From 5f8985c5ff063a88f0196c74266dfeb1a5ceb39f Mon Sep 17 00:00:00 2001 From: Natanael Copa Date: Fri, 24 Jul 2026 13:34:25 +0200 Subject: [PATCH] Add --iidfile option to container build Writes the built image's digest to a file after a successful build, mirroring the existing --cidfile convention used by container run/create. Only supported with the default OCI output (type=oci), since that is the only export that produces a loaded image digest. Fixes https://github.com/apple/container/issues/1998 --- Sources/ContainerCommands/BuildCommand.swift | 33 ++++++++++++++ .../ContainerFixture+ImageHelpers.swift | 6 ++- .../Build/TestCLIBuilder.swift | 45 +++++++++++++++++++ docs/command-reference.md | 1 + 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 34ec4356f..0bd843b37 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -81,6 +81,9 @@ extension Application { var dockerfile: String = "-" + @Option(name: .long, help: ArgumentHelp("Write the image ID to the file", valueName: "path")) + var iidfile: String = "" + @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) var label: [String] = [] @@ -350,6 +353,7 @@ extension Application { group.addTask { [ terminal, buildArg, secretsData, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, log, + iidfile, ] in let config = Builder.BuildConfig( buildID: buildID, @@ -389,6 +393,7 @@ extension Application { unpackProgress.start() var finalMessage = imageNames.joined(separator: "\n") + var builtImageDigest: String? let taskManager = ProgressTaskCoordinator() // Currently, only a single export can be specified. for exp in exports { @@ -408,6 +413,7 @@ extension Application { for image in result.images { try Task.checkCancellation() try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) + builtImageDigest = image.digest // Tag the unpacked image with all requested tags for tagName in imageNames { @@ -439,6 +445,19 @@ extension Application { } await taskManager.finish() unpackProgress.finish() + + if !iidfile.isEmpty { + guard let builtImageDigest else { + throw ContainerizationError(.internalError, message: "no image digest available to write to iidfile") + } + let data = builtImageDigest.data(using: .utf8) + var attributes = [FileAttributeKey: Any]() + attributes[.posixPermissions] = 0o644 + guard FileManager.default.createFile(atPath: iidfile, contents: data, attributes: attributes) else { + throw ContainerizationError(.internalError, message: "failed to create iidfile at \(iidfile)") + } + } + print(finalMessage) } @@ -460,6 +479,20 @@ extension Application { } } + if !iidfile.isEmpty { + for exportSpec in output { + let export: Builder.BuildExport + do { + export = try Builder.BuildExport(from: exportSpec) + } catch { + throw ValidationError("invalid output \(exportSpec): \(error)") + } + guard export.type == "oci" else { + throw ValidationError("--iidfile requires the default OCI output (type=oci)") + } + } + } + switch file { case "-": dockerfile = "-" diff --git a/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift index 4dc3a1999..e1f64c60f 100644 --- a/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+ImageHelpers.swift @@ -22,7 +22,11 @@ import Testing extension ContainerFixture { /// Decoded output of `container image inspect` or `container image list --format json`. public struct ImageInspectOutput: Codable { - public struct Configuration: Codable { public let name: String } + public struct Configuration: Codable { + public struct Descriptor: Codable { public let digest: String } + public let name: String + public let descriptor: Descriptor + } public struct Variant: Codable { public struct Platform: Codable { public let os: String diff --git a/Tests/IntegrationTests/Build/TestCLIBuilder.swift b/Tests/IntegrationTests/Build/TestCLIBuilder.swift index 7fc250f10..75251a8c1 100644 --- a/Tests/IntegrationTests/Build/TestCLIBuilder.swift +++ b/Tests/IntegrationTests/Build/TestCLIBuilder.swift @@ -403,6 +403,51 @@ struct TestCLIBuilder { } } + @Test func testBuildIidfile() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let tag = "registry.local/iidfile-test:\(UUID().uuidString)" + let iidfile = dir.appending("image.id") + + try f.buildWithPaths(tags: [tag], contextDir: dir, otherArgs: ["--iidfile", iidfile.string]) + + #expect(FileManager.default.fileExists(atPath: iidfile.string), "iidfile should be created") + let writtenDigest = try String(contentsOfFile: iidfile.string, encoding: .utf8) + #expect(!writtenDigest.hasSuffix("\n"), "iidfile should not contain a trailing newline") + #expect(writtenDigest.hasPrefix("sha256:"), "iidfile should contain a digest") + + let inspected = try f.doInspectImages(tag) + #expect(inspected.count == 1) + #expect(writtenDigest == inspected[0].configuration.descriptor.digest) + } + } + + @Test func testBuildIidfileRejectsNonOCIOutput() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let iidfile = dir.appending("image.id") + let exportPath = dir.appending("export.tar") + + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportPath.string)", + "--iidfile", iidfile.string, + dir.appending("context").string, + ]) + #expect(result.status != 0, "build should reject --iidfile combined with a non-OCI output") + #expect(!FileManager.default.fileExists(atPath: iidfile.string), "iidfile should not be created") + } + } + @Test func testBuildAfterContextChange() async throws { try await ContainerFixture.with { f in let dir = try f.createTempDir() diff --git a/docs/command-reference.md b/docs/command-reference.md index c7d854806..339891682 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -147,6 +147,7 @@ container build [] [] * `--dns-option