mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-20
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user