mirror of
https://github.com/apple/container.git
synced 2026-09-28 09:56:11 -04:00
Normalize JSON output for network and volume resources. (#1624)
Normalize JSON output for network and volume resources. (#1624) - Reworks both JSON output presets to use sorted keys, ISO timestamps. `compact` is used for `ls` output, and `pretty` is used for `inspect`. - Extracts non-DRY option configuration into presets. - Closes #1623.
This commit is contained in:
@@ -80,7 +80,7 @@ extension APIServer {
|
||||
await containersService.setNetworksService(networkService)
|
||||
initializeHealthCheckService(log: log, routes: &routes)
|
||||
try initializeKernelService(log: log, routes: &routes)
|
||||
let volumesService = try initializeVolumeService(containersService: containersService, log: log, routes: &routes)
|
||||
let volumesService = try await initializeVolumeService(containersService: containersService, log: log, routes: &routes)
|
||||
try initializeDiskUsageService(
|
||||
containersService: containersService,
|
||||
volumesService: volumesService,
|
||||
@@ -359,11 +359,11 @@ extension APIServer {
|
||||
containersService: ContainersService,
|
||||
log: Logger,
|
||||
routes: inout [XPCRoute: XPCServer.RouteHandler]
|
||||
) throws -> VolumesService {
|
||||
) async throws -> VolumesService {
|
||||
log.info("initializing volume service")
|
||||
|
||||
let resourceRoot = appRoot.appending(FilePath.Component("volumes"))
|
||||
let service = try VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log)
|
||||
let service = try await VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log)
|
||||
let harness = VolumesHarness(service: service, log: log)
|
||||
|
||||
routes[XPCRoute.volumeCreate] = XPCServer.route(harness.create)
|
||||
|
||||
@@ -50,7 +50,7 @@ extension Application {
|
||||
)
|
||||
}
|
||||
|
||||
try Output.emit(Output.renderJSON(containers.map { PrintableContainer($0) }))
|
||||
try Output.emit(Output.renderJSON(containers.map { PrintableContainer($0) }, options: .pretty))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,11 +63,7 @@ extension Application {
|
||||
printable.append(ImageResource(config: image.description, index: resolved.index, manifests: resolved.manifests))
|
||||
}
|
||||
|
||||
let options = JSONOptions(
|
||||
outputFormatting: [.prettyPrinted, .sortedKeys],
|
||||
dateEncodingStrategy: .iso8601
|
||||
)
|
||||
try Output.emit(Output.renderJSON(printable, options: options))
|
||||
try Output.emit(Output.renderJSON(printable, options: .pretty))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +99,7 @@ extension Application {
|
||||
}
|
||||
|
||||
private static func emitJSON(resources: [ImageResource]) throws {
|
||||
let options = JSONOptions(dateEncodingStrategy: .iso8601)
|
||||
try Output.emit(Output.renderJSON(resources, options: options))
|
||||
try Output.emit(Output.renderJSON(resources, options: .compact))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ extension Application {
|
||||
)
|
||||
}
|
||||
|
||||
try Output.emit(Output.renderJSON(items))
|
||||
try Output.emit(Output.renderJSON(items, options: .pretty))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ public struct JSONOptions: Sendable {
|
||||
public var outputFormatting: JSONEncoder.OutputFormatting = []
|
||||
public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||
|
||||
public static let compact = JSONOptions()
|
||||
public static let prettySorted = JSONOptions(outputFormatting: [.prettyPrinted, .sortedKeys])
|
||||
public static let compact = JSONOptions(outputFormatting: [.sortedKeys], dateEncodingStrategy: .iso8601)
|
||||
public static let pretty = JSONOptions(outputFormatting: [.prettyPrinted, .sortedKeys], dateEncodingStrategy: .iso8601)
|
||||
|
||||
public init(
|
||||
outputFormatting: JSONEncoder.OutputFormatting = [],
|
||||
@@ -85,10 +85,10 @@ public enum Output {
|
||||
/// The JSON and display models may be the same type (e.g., `PrintableContainer`)
|
||||
/// or different types.
|
||||
public static func render<J: Encodable, D: ListDisplayable>(
|
||||
json: J, display: [D], format: ListFormat, quiet: Bool
|
||||
json: J, display: [D], format: ListFormat, quiet: Bool, jsonOptions: JSONOptions = .compact
|
||||
) throws {
|
||||
switch format {
|
||||
case .json: try emit(renderJSON(json))
|
||||
case .json: try emit(renderJSON(json, options: jsonOptions))
|
||||
case .yaml: try emit(renderYAML(json))
|
||||
case .table: emit(renderList(display, quiet: quiet))
|
||||
case .toml: try emit(renderTOML(json))
|
||||
|
||||
@@ -37,7 +37,7 @@ extension Application {
|
||||
let stats = try await ClientDiskUsage.get()
|
||||
|
||||
if format == .json {
|
||||
try Output.emit(Output.renderJSON(stats, options: .prettySorted))
|
||||
try Output.emit(Output.renderJSON(stats, options: .pretty))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ extension Application.VolumeCommand {
|
||||
public func run() async throws {
|
||||
let uniqueNames = Set(names)
|
||||
let volumes = try await ClientVolume.list().filter { uniqueNames.contains($0.id) }
|
||||
let volumeResources = volumes.map { VolumeResource(config: $0) }
|
||||
let volumeResources = volumes.map { VolumeResource(configuration: $0) }
|
||||
|
||||
if volumes.count != uniqueNames.count {
|
||||
let found = Set(volumes.map { $0.id })
|
||||
@@ -49,11 +49,7 @@ extension Application.VolumeCommand {
|
||||
)
|
||||
}
|
||||
|
||||
let options = JSONOptions(
|
||||
outputFormatting: [.prettyPrinted, .sortedKeys],
|
||||
dateEncodingStrategy: .iso8601
|
||||
)
|
||||
try Output.emit(Output.renderJSON(volumeResources, options: options))
|
||||
try Output.emit(Output.renderJSON(volumeResources, options: .pretty))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,14 +41,7 @@ extension Application.VolumeCommand {
|
||||
|
||||
public func run() async throws {
|
||||
let volumes = try await ClientVolume.list()
|
||||
let volumeResources = volumes.map { VolumeResource(config: $0) }
|
||||
|
||||
if format == .json {
|
||||
let options = JSONOptions(dateEncodingStrategy: .iso8601)
|
||||
try Output.emit(Output.renderJSON(volumeResources, options: options))
|
||||
return
|
||||
}
|
||||
|
||||
let volumeResources = volumes.map { VolumeResource(configuration: $0) }
|
||||
try Output.render(json: volumeResources, display: volumeResources, format: format, quiet: quiet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ extension VolumeResource: ListDisplayable {
|
||||
[
|
||||
name,
|
||||
isAnonymous ? "anonymous" : "named",
|
||||
config.driver,
|
||||
config.options.isEmpty ? "" : config.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ","),
|
||||
configuration.driver,
|
||||
configuration.options.isEmpty ? "" : configuration.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ","),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -101,8 +101,8 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
|
||||
self.plugin = plugin
|
||||
self.options = try container.decodeIfPresent([String: String].self, forKey: .options) ?? [:]
|
||||
} else if let legacy = try container.decodeIfPresent(_LegacyPluginInfo.self, forKey: .pluginInfo) {
|
||||
// - Deprecated: As of 1.0.0. Use ``plugin`` and ``options`` instead.
|
||||
// - Note: Will be removed in a later release.
|
||||
// Deprecated: As of 1.0.0. Use ``plugin`` and ``options`` instead.
|
||||
// Note: Will be removed in a later release.
|
||||
self.plugin = legacy.plugin
|
||||
var opts: [String: String] = [:]
|
||||
if let variant = legacy.variant { opts["variant"] = variant }
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import Foundation
|
||||
|
||||
/// A named or anonymous volume that can be mounted in containers.
|
||||
public struct VolumeConfiguration: Sendable, Codable, Equatable, Identifiable {
|
||||
public struct VolumeConfiguration: Sendable, Equatable, Identifiable {
|
||||
// id of the volume.
|
||||
public var id: String { name }
|
||||
// Name of the volume.
|
||||
@@ -29,7 +29,7 @@ public struct VolumeConfiguration: Sendable, Codable, Equatable, Identifiable {
|
||||
// The mount point of the volume on the host.
|
||||
public var source: String
|
||||
// Timestamp when the volume was created.
|
||||
public var createdAt: Date
|
||||
public var creationDate: Date
|
||||
// User-defined key/value metadata.
|
||||
public var labels: [String: String]
|
||||
// Driver-specific options.
|
||||
@@ -42,7 +42,7 @@ public struct VolumeConfiguration: Sendable, Codable, Equatable, Identifiable {
|
||||
driver: String = "local",
|
||||
format: String = "ext4",
|
||||
source: String,
|
||||
createdAt: Date = Date(),
|
||||
creationDate: Date = Date(),
|
||||
labels: [String: String] = [:],
|
||||
options: [String: String] = [:],
|
||||
sizeInBytes: UInt64? = nil
|
||||
@@ -51,11 +51,49 @@ public struct VolumeConfiguration: Sendable, Codable, Equatable, Identifiable {
|
||||
self.driver = driver
|
||||
self.format = format
|
||||
self.source = source
|
||||
self.createdAt = createdAt
|
||||
self.creationDate = creationDate
|
||||
self.labels = labels
|
||||
self.options = options
|
||||
self.sizeInBytes = sizeInBytes
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, driver, format, source, labels, options, sizeInBytes
|
||||
case creationDate
|
||||
// TODO: retain for deserialization compatibility, remove in next major version
|
||||
case createdAt
|
||||
}
|
||||
}
|
||||
|
||||
extension VolumeConfiguration: Codable {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
driver = try container.decode(String.self, forKey: .driver)
|
||||
format = try container.decode(String.self, forKey: .format)
|
||||
source = try container.decode(String.self, forKey: .source)
|
||||
// Deprecated: As of 1.0.0. Use ``creationDate`` instead of ``createdAt``.
|
||||
// Note: Will be removed in a later release.
|
||||
creationDate =
|
||||
try container.decodeIfPresent(Date.self, forKey: .creationDate)
|
||||
?? container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||
?? Date(timeIntervalSince1970: 0)
|
||||
labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
|
||||
options = try container.decodeIfPresent([String: String].self, forKey: .options) ?? [:]
|
||||
sizeInBytes = try container.decodeIfPresent(UInt64.self, forKey: .sizeInBytes)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(driver, forKey: .driver)
|
||||
try container.encode(format, forKey: .format)
|
||||
try container.encode(source, forKey: .source)
|
||||
try container.encode(creationDate, forKey: .creationDate)
|
||||
try container.encode(labels, forKey: .labels)
|
||||
try container.encode(options, forKey: .options)
|
||||
try container.encodeIfPresent(sizeInBytes, forKey: .sizeInBytes)
|
||||
}
|
||||
}
|
||||
|
||||
extension VolumeConfiguration {
|
||||
|
||||
@@ -19,37 +19,37 @@ import Foundation
|
||||
/// A volume resource, representing a configured volume.
|
||||
public struct VolumeResource: ManagedResource {
|
||||
/// The volume's configuration — its persistent, intrinsic properties.
|
||||
public let config: VolumeConfiguration
|
||||
public let configuration: VolumeConfiguration
|
||||
|
||||
// MARK: ManagedResource
|
||||
|
||||
/// The unique identifier for this volume. Identical to ``VolumeConfiguration/name``.
|
||||
public var id: String { config.name }
|
||||
public var id: String { configuration.name }
|
||||
|
||||
/// The user-assigned name for this volume. For volumes, name and ID are the same.
|
||||
public var name: String { config.name }
|
||||
public var name: String { configuration.name }
|
||||
|
||||
/// The time at which this volume was created.
|
||||
public var creationDate: Date { config.createdAt }
|
||||
public var creationDate: Date { configuration.creationDate }
|
||||
|
||||
/// Key-value labels for this volume. If the underlying
|
||||
/// ``VolumeConfiguration/labels`` dictionary contains values that fail
|
||||
/// ``ResourceLabels`` validation, this returns an empty label set.
|
||||
public var labels: ResourceLabels {
|
||||
(try? ResourceLabels(config.labels)) ?? ResourceLabels()
|
||||
(try? ResourceLabels(configuration.labels)) ?? ResourceLabels()
|
||||
}
|
||||
|
||||
/// Whether this is an anonymous volume (detected via the configuration's labels).
|
||||
public var isAnonymous: Bool { config.isAnonymous }
|
||||
public var isAnonymous: Bool { configuration.isAnonymous }
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a volume resource.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - config: The volume's intrinsic configuration.
|
||||
public init(config: VolumeConfiguration) {
|
||||
self.config = config
|
||||
/// - configuration: The volume's intrinsic configuration.
|
||||
public init(configuration: VolumeConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,17 +74,17 @@ extension VolumeResource {
|
||||
extension VolumeResource {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case config
|
||||
case configuration
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(config, forKey: .config)
|
||||
try container.encode(configuration, forKey: .configuration)
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.config = try container.decode(VolumeConfiguration.self, forKey: .config)
|
||||
self.configuration = try container.decode(VolumeConfiguration.self, forKey: .configuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,29 @@ public actor VolumesService {
|
||||
private static let entityFile = "entity.json"
|
||||
private static let blockFile = "volume.img"
|
||||
|
||||
public init(resourceRoot: FilePath, containersService: ContainersService, log: Logger) throws {
|
||||
public init(resourceRoot: FilePath, containersService: ContainersService, log: Logger) async throws {
|
||||
try FileManager.default.createDirectory(atPath: resourceRoot.string, withIntermediateDirectories: true)
|
||||
self.resourceRoot = resourceRoot
|
||||
self.store = try FilesystemEntityStore<VolumeConfiguration>(path: resourceRoot, type: "volumes", log: log)
|
||||
self.containersService = containersService
|
||||
self.log = log
|
||||
|
||||
// Migrate configs stored with the old `createdAt` key to `creationDate`.
|
||||
// Deprecated: As of 1.0.0. Use ``creationDate`` instead of ``createdAt``.
|
||||
// Note: Will be removed in a later release.
|
||||
let configurations = try await store.list()
|
||||
for configuration in configurations {
|
||||
do {
|
||||
try await store.update(configuration)
|
||||
} catch {
|
||||
log.error(
|
||||
"failed to migrate volume configuration",
|
||||
metadata: [
|
||||
"name": "\(configuration.name)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func create(
|
||||
|
||||
Reference in New Issue
Block a user