diff --git a/Sources/ContainerCommands/Image/ImageInspect.swift b/Sources/ContainerCommands/Image/ImageInspect.swift index 7cf8fc2b..34d998f6 100644 --- a/Sources/ContainerCommands/Image/ImageInspect.swift +++ b/Sources/ContainerCommands/Image/ImageInspect.swift @@ -50,7 +50,7 @@ extension Application { ) } - var printable: [ImageDetail] = [] + var printable: [ImageResource] = [] for image in result.images { guard !Utility.isInfraImage( @@ -59,10 +59,15 @@ extension Application { initImage: containerSystemConfig.vminit.image ) else { continue } - printable.append(try await image.details()) + let resolved = try await image.resolvedManifests() + printable.append(ImageResource(config: image.description, index: resolved.index, manifests: resolved.manifests)) } - try Output.emit(Output.renderJSON(printable)) + let options = JSONOptions( + outputFormatting: [.prettyPrinted, .sortedKeys], + dateEncodingStrategy: .iso8601 + ) + try Output.emit(Output.renderJSON(printable, options: options)) } } } diff --git a/Sources/ContainerCommands/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift index b01245bb..1a1d17cb 100644 --- a/Sources/ContainerCommands/Image/ImageList.swift +++ b/Sources/ContainerCommands/Image/ImageList.swift @@ -18,11 +18,11 @@ import ArgumentParser import ContainerAPIClient import ContainerPersistence import ContainerPlugin +import ContainerResource import Containerization import ContainerizationError import ContainerizationOCI import Foundation -import SwiftProtobuf extension Application { public struct ImageList: AsyncLoggableCommand { @@ -53,12 +53,9 @@ extension Application { } images.sort { $0.reference < $1.reference } - if format == .json { - try await Self.emitJSON(images: images) - return - } - - if quiet { + // Quiet mode prints references directly and skips the more expensive + // per-image manifest resolution. `--format json` takes precedence. + if quiet && format != .json { for image in images { let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig) print(processedReferenceString) @@ -66,14 +63,20 @@ extension Application { return } - if verbose { - let items = try await Self.buildVerboseItems(images: images, containerSystemConfig: containerSystemConfig) - Output.emit(Output.renderTable(items)) + let resources = try await Self.buildResources(images: images, containerSystemConfig: containerSystemConfig) + + if format == .json { + try Self.emitJSON(resources: resources) return } - let items = try await Self.buildTableItems(images: images, containerSystemConfig: containerSystemConfig) - Output.emit(Output.renderTable(items)) + if verbose { + let rows = resources.flatMap { VerboseImageRow.rows(for: $0) } + Output.emit(Output.renderTable(rows)) + return + } + + Output.emit(Output.renderTable(resources)) } private static func validate(quiet: Bool, verbose: Bool) throws { @@ -82,111 +85,27 @@ extension Application { } } - private static func emitJSON(images: [ClientImage]) async throws { - let formatter = ByteCountFormatter() - var printableImages: [PrintableImage] = [] + /// Builds the resource for each image, denormalizing the reference so the + /// display name omits the default registry. + private static func buildResources(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [ImageResource] { + var resources: [ImageResource] = [] for image in images { - let size = try await ClientImage.getFullImageSize(image: image) - let formattedSize = formatter.string(fromByteCount: size) - printableImages.append( - PrintableImage(reference: image.reference, fullSize: formattedSize, descriptor: image.descriptor) - ) + let resolved = try await image.resolvedManifests() + let displayReference = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig) + resources.append( + ImageResource(config: image.description, index: resolved.index, manifests: resolved.manifests, displayReference: displayReference)) } - try Output.emit(Output.renderJSON(printableImages)) + return resources } - private static func buildTableItems(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [ImageRow] { - var items: [ImageRow] = [] - for image in images { - let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - let digest = try await image.resolved().digest - items.append( - ImageRow( - name: reference.name, - tag: reference.tag ?? "", - trimmedDigest: Utility.trimDigest(digest: digest) - )) - } - return items - } - - private static func buildVerboseItems(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [VerboseImageRow] { - let formatter = ByteCountFormatter() - var items: [VerboseImageRow] = [] - for image in images { - let imageDigest = try await image.resolved().digest - let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - for descriptor in try await image.index().manifests { - if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], - referenceType == "attestation-manifest" - { - continue - } - - guard let platform = descriptor.platform else { - continue - } - - var config: ContainerizationOCI.Image - var manifest: ContainerizationOCI.Manifest - do { - config = try await image.config(for: platform) - manifest = try await image.manifest(for: platform) - } catch { - continue - } - - let created = config.created ?? "" - let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0) { $0 + $1.size } - let formattedSize = formatter.string(fromByteCount: size) - - items.append( - VerboseImageRow( - name: reference.name, - tag: reference.tag ?? "", - indexDigest: Utility.trimDigest(digest: imageDigest), - os: platform.os, - arch: platform.architecture, - variant: platform.variant ?? "", - fullSize: formattedSize, - created: created, - manifestDigest: Utility.trimDigest(digest: descriptor.digest) - )) - } - } - return items - } - - struct PrintableImage: Codable { - let reference: String - let fullSize: String - let descriptor: Descriptor + private static func emitJSON(resources: [ImageResource]) throws { + let options = JSONOptions(dateEncodingStrategy: .iso8601) + try Output.emit(Output.renderJSON(resources, options: options)) } } } -private struct ImageRow: ListDisplayable { - let name: String - let tag: String - let trimmedDigest: String - - static var tableHeader: [String] { - ["NAME", "TAG", "DIGEST"] - } - - var tableRow: [String] { - [name, tag, trimmedDigest] - } - - // Required by ListDisplayable but unused — ImageList handles quiet mode - // separately to avoid expensive digest resolution. - var quietValue: String { - name - } -} - +/// A single row of the verbose image listing — one per platform variant. private struct VerboseImageRow: ListDisplayable { let name: String let tag: String @@ -209,4 +128,30 @@ private struct VerboseImageRow: ListDisplayable { var quietValue: String { name } + + /// Flattens an ImageResource into one verbose image row entry per platform variant. + static func rows(for resource: ImageResource) -> [VerboseImageRow] { + let formatter = ByteCountFormatter() + let reference = try? ContainerizationOCI.Reference.parse(resource.displayReference) + let name = reference?.name ?? resource.displayReference + let tag = reference?.tag ?? "" + let indexDigest = Utility.trimDigest(digest: resource.index.digest) + return + resource.variants + // Skip attestation manifests, which use the `unknown/unknown` platform. + .filter { !($0.platform.os == "unknown" && $0.platform.architecture == "unknown") } + .map { variant in + VerboseImageRow( + name: name, + tag: tag, + indexDigest: indexDigest, + os: variant.platform.os, + arch: variant.platform.architecture, + variant: variant.platform.variant ?? "", + fullSize: formatter.string(fromByteCount: variant.size), + created: variant.config.created ?? "", + manifestDigest: Utility.trimDigest(digest: variant.digest) + ) + } + } } diff --git a/Sources/ContainerResource/Image/ImageDetail.swift b/Sources/ContainerCommands/Image/ImageResource+ListDisplayable.swift similarity index 55% rename from Sources/ContainerResource/Image/ImageDetail.swift rename to Sources/ContainerCommands/Image/ImageResource+ListDisplayable.swift index cf2d672d..200e4bba 100644 --- a/Sources/ContainerResource/Image/ImageDetail.swift +++ b/Sources/ContainerCommands/Image/ImageResource+ListDisplayable.swift @@ -14,29 +14,26 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import Containerization +import ContainerAPIClient +import ContainerResource import ContainerizationOCI -public struct ImageDetail: Codable { - public let name: String - public let index: Descriptor - public let variants: [Variants] - - public struct Variants: Codable { - public let platform: Platform - public let config: ContainerizationOCI.Image - public let size: Int64 - - public init(platform: Platform, size: Int64, config: ContainerizationOCI.Image) { - self.platform = platform - self.config = config - self.size = size - } +extension ImageResource: ListDisplayable { + public static var tableHeader: [String] { + ["NAME", "TAG", "DIGEST"] } - public init(name: String, index: Descriptor, variants: [Variants]) { - self.name = name - self.index = index - self.variants = variants + public var tableRow: [String] { + // `displayReference` is already denormalized by the caller. + let reference = try? ContainerizationOCI.Reference.parse(displayReference) + return [ + reference?.name ?? displayReference, + reference?.tag ?? "", + Utility.trimDigest(digest: index.digest), + ] + } + + public var quietValue: String { + name } } diff --git a/Sources/ContainerResource/Image/ImageResource.swift b/Sources/ContainerResource/Image/ImageResource.swift new file mode 100644 index 00000000..78ce2118 --- /dev/null +++ b/Sources/ContainerResource/Image/ImageResource.swift @@ -0,0 +1,207 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationOCI +import Foundation + +/// An image resource, representing an OCI image managed by the system. +/// +/// `ImageResource` conforms to `ManagedResource` and wraps the image's +/// ``ImageDescription`` (its reference and index descriptor) alongside the +/// resolved index descriptor and the per-platform variants that make up the +/// image. +public struct ImageResource: ManagedResource { + /// A single platform-specific variant of an image. + public struct Variant: Sendable, Codable { + /// The platform this variant targets. + public let platform: Platform + /// The digest of this variant's manifest. + public let digest: String + /// The total size of this variant in bytes. + public let size: Int64 + /// The OCI image config for this variant. + public let config: ContainerizationOCI.Image + + public init(platform: Platform, digest: String, size: Int64, config: ContainerizationOCI.Image) { + self.platform = platform + self.digest = digest + self.size = size + self.config = config + } + } + + /// Already-resolved OCI content for a single platform manifest, used as + /// input when building an ``ImageResource``. The variant's total size is + /// computed from these pieces during initialization. + public struct ManifestContent { + /// The manifest descriptor as listed in the image index. + public let descriptor: Descriptor + /// The platform manifest. + public let manifest: ContainerizationOCI.Manifest + /// The OCI image config for the platform. + public let config: ContainerizationOCI.Image + + public init(descriptor: Descriptor, manifest: ContainerizationOCI.Manifest, config: ContainerizationOCI.Image) { + self.descriptor = descriptor + self.manifest = manifest + self.config = config + } + } + + /// The image's description — its reference and index descriptor. + public let config: ImageDescription + + /// The resolved index descriptor for the image. + public let index: Descriptor + + /// The platform-specific variants contained in the image. + public let variants: [Variant] + + /// The reference to show in human-facing listings, with default-registry + /// information removed (e.g. `alpine` rather than `docker.io/library/alpine`). + /// Computed by the caller, which has access to the system configuration. + /// Defaults to the full ``name`` when not supplied. + public let displayReference: String + + /// The creation date resolved from the OCI image config, if available. + private let created: Date? + + // MARK: ManagedResource + + /// The unique identifier for this image. Identical to the image's index digest. + public var id: String { config.digest } + + /// The user-facing reference (`name:tag`) for this image. + public var name: String { config.reference } + + /// The time at which the image was created, resolved from the OCI image + /// config. Falls back to the Unix epoch when no creation date is recorded. + public var creationDate: Date { created ?? Date(timeIntervalSince1970: 0) } + + /// Key-value labels for this image, derived from the index descriptor's + /// annotations. Returns an empty label set if the annotations fail + /// ``ResourceLabels`` validation. + public var labels: ResourceLabels { + (try? ResourceLabels(config.descriptor.annotations ?? [:])) ?? ResourceLabels() + } + + // MARK: Initialization + + /// Creates an image resource. + /// + /// - Parameters: + /// - config: The image's description (reference and index descriptor). + /// - index: The resolved index descriptor. + /// - variants: The per-platform variants contained in the image. + /// - created: The creation date resolved from the OCI image config, if any. + /// - displayReference: The denormalized reference for human-facing + /// listings. Defaults to the full reference when `nil`. + public init(config: ImageDescription, index: Descriptor, variants: [Variant], created: Date? = nil, displayReference: String? = nil) { + self.config = config + self.index = index + self.variants = variants + self.created = created + self.displayReference = displayReference ?? config.reference + } +} + +extension ImageResource { + /// Creates an image resource from already-resolved index and manifest + /// content. + /// + /// This initializer performs the variant resolution: it computes each + /// platform variant's total size (manifest descriptor + config + layers) + /// and derives the image's creation date from the earliest variant's OCI + /// config `created` timestamp. + /// + /// - Parameters: + /// - config: The image's description (reference and index descriptor). + /// - index: The resolved index descriptor. + /// - manifests: The already-resolved per-platform manifest content. + /// - displayReference: The denormalized reference for human-facing + /// listings. Defaults to the full reference when `nil`. + public init(config: ImageDescription, index: Descriptor, manifests: [ManifestContent], displayReference: String? = nil) { + var variants: [Variant] = [] + var created: Date? + for content in manifests { + guard let platform = content.descriptor.platform else { + continue + } + let size = + content.descriptor.size + content.manifest.config.size + + content.manifest.layers.reduce(0) { $0 + $1.size } + variants.append(Variant(platform: platform, digest: content.descriptor.digest, size: size, config: content.config)) + // Use the earliest variant's creation timestamp as the image's date. + if let createdString = content.config.created, let date = Self.parseCreated(createdString) { + created = created.map { min($0, date) } ?? date + } + } + self.init(config: config, index: index, variants: variants, created: created, displayReference: displayReference) + } + + private static func parseCreated(_ value: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: value) { + return date + } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +extension ImageResource { + /// Returns `true` if `name` is a syntactically valid image reference. + public static func nameValid(_ name: String) -> Bool { + (try? Reference.parse(name)) != nil + } +} + +// MARK: - Codable + +extension ImageResource { + enum CodingKeys: String, CodingKey { + case id + case name + case creationDate + case labels + case configuration + case index + case variants + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(creationDate, forKey: .creationDate) + try container.encode(labels, forKey: .labels) + try container.encode(config, forKey: .configuration) + try container.encode(index, forKey: .index) + try container.encode(variants, forKey: .variants) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.config = try container.decode(ImageDescription.self, forKey: .configuration) + self.index = try container.decode(Descriptor.self, forKey: .index) + self.variants = try container.decode([Variant].self, forKey: .variants) + self.created = try container.decodeIfPresent(Date.self, forKey: .creationDate) + // `displayReference` is a display-only value and is not serialized. + self.displayReference = self.config.reference + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage+ImageResource.swift b/Sources/Services/ContainerAPIService/Client/ClientImage+ImageResource.swift new file mode 100644 index 00000000..543f09b8 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientImage+ImageResource.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerResource +import ContainerizationOCI + +extension ClientImage { + /// Resolves, from the content store, the index descriptor and per-platform + /// manifest content needed to build an ``ImageResource``. + /// + /// Manifests without a platform, or whose config/manifest cannot be + /// fetched, are skipped. The returned content is the input expected by + /// ``ImageResource/init(config:index:manifests:)``. + public func resolvedManifests() async throws -> (index: Descriptor, manifests: [ImageResource.ManifestContent]) { + let index = try await self.resolved() + var manifests: [ImageResource.ManifestContent] = [] + for desc in try await self.index().manifests { + guard let platform = desc.platform else { + continue + } + let config: ContainerizationOCI.Image + let manifest: ContainerizationOCI.Manifest + do { + config = try await self.config(for: platform) + manifest = try await self.manifest(for: platform) + } catch { + continue + } + manifests.append(.init(descriptor: desc, manifest: manifest, config: config)) + } + return (index, manifests) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage.swift b/Sources/Services/ContainerAPIService/Client/ClientImage.swift index 49e7b54c..3516d0ef 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientImage.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientImage.swift @@ -550,27 +550,3 @@ extension ImageDescription { return name } } - -extension ClientImage { - public func details() async throws -> ImageDetail { - let descriptor = try await self.resolved() - let reference = self.reference - var variants: [ImageDetail.Variants] = [] - for desc in try await self.index().manifests { - guard let platform = desc.platform else { - continue - } - let config: ContainerizationOCI.Image - let manifest: ContainerizationOCI.Manifest - do { - config = try await self.config(for: platform) - manifest = try await self.manifest(for: platform) - } catch { - continue - } - let size = desc.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) - variants.append(.init(platform: platform, size: size, config: config)) - } - return ImageDetail(name: reference, index: descriptor, variants: variants) - } -} diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift index 307b4076..2b6ef908 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift @@ -522,7 +522,7 @@ class TestCLIImagesCommand: CLITest { } } - @Test func testImageFullSizeFieldExists() throws { + @Test func testImageVariantSizeFieldExists() throws { // 1. pull image try doPull(imageName: alpine) @@ -541,9 +541,11 @@ class TestCLIImagesCommand: CLITest { return } - // 4. check that the output has a non-empty 'fullSize' field - let size = image["fullSize"] as? String ?? "" - #expect(!size.isEmpty, "expected image to have non-empty 'fullSize' field: \(image)") + // 4. check that the image reports at least one variant with a non-zero size + let variants = image["variants"] as? [[String: Any]] ?? [] + #expect(!variants.isEmpty, "expected image to report at least one variant: \(image)") + let hasSize = variants.contains { ($0["size"] as? Int ?? 0) > 0 } + #expect(hasSize, "expected at least one variant to have a non-zero 'size' field: \(image)") } @Test func testImageListTableFormat() throws { diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index abc6fb8e..a6247481 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -30,7 +30,8 @@ import Testing class CLITest { private static let commandSeq = Mutex(0) struct Image: Codable { - let reference: String + let name: String + var reference: String { name } } // These structs need to track their counterpart presentation structs in CLI.