Create ImageResource conforming to ManagedResource (#1619)

- Closes #1625.
- For now, the ImageResource compliance happens
  at the CLI level. We will work on pushing that into or
  closer to the APIServer at a later time.

## Testing
- [x] Tested locally

---------

Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
This commit is contained in:
Kathryn Baldauf
2026-06-01 17:01:20 -07:00
committed by GitHub
parent 7358102c68
commit 55f1e4fdf4
8 changed files with 340 additions and 161 deletions
@@ -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))
}
}
}
+54 -109
View File
@@ -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 ?? "<none>",
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 ?? "<none>",
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 ?? "<none>"
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)
)
}
}
}
@@ -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 ?? "<none>",
Utility.trimDigest(digest: index.digest),
]
}
public var quietValue: String {
name
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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 {
+2 -1
View File
@@ -30,7 +30,8 @@ import Testing
class CLITest {
private static let commandSeq = Mutex<Int>(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.