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:
J Logan
2026-06-01 21:42:10 -07:00
committed by GitHub
parent 34cff5925f
commit e20aca8203
19 changed files with 207 additions and 60 deletions
+3 -3
View File
@@ -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(