Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Sources/ContainerBuild/Builder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ public struct Builder: Sendable {
public let noCache: Bool
public let platforms: [Platform]
public let terminal: Terminal?
public let tag: String
public let tags: [String]
public let target: String
public let quiet: Bool
public let exports: [BuildExport]
Expand All @@ -260,7 +260,7 @@ public struct Builder: Sendable {
noCache: Bool,
platforms: [Platform],
terminal: Terminal?,
tag: String,
tags: [String],
target: String,
quiet: Bool,
exports: [BuildExport],
Expand All @@ -276,7 +276,7 @@ public struct Builder: Sendable {
self.noCache = noCache
self.platforms = platforms
self.terminal = terminal
self.tag = tag
self.tags = tags
self.target = target
self.quiet = quiet
self.exports = exports
Expand Down Expand Up @@ -312,9 +312,11 @@ extension CallOptions {
("context", URL(filePath: config.contextDir).path(percentEncoded: false)),
("dockerfile", config.dockerfile.base64EncodedString()),
("progress", config.terminal != nil ? "tty" : "plain"),
("tag", config.tag),
("target", config.target),
]
for tag in config.tags {
headers.append(("tag", tag))
}
for platform in config.platforms {
headers.append(("platforms", platform.description))
}
Expand Down
26 changes: 18 additions & 8 deletions Sources/ContainerCommands/BuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ extension Application {
var quiet: Bool = false

@Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name"))
var targetImageName: String = UUID().uuidString.lowercased()
var targetImageNames: [String] = {
[UUID().uuidString.lowercased()]
}()

@Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage"))
var target: String = ""
Expand Down Expand Up @@ -195,11 +197,11 @@ extension Application {
try? FileManager.default.removeItem(at: tempURL)
}

let imageName: String = try {
let parsedReference = try Reference.parse(targetImageName)
let imageNames: [String] = try targetImageNames.map { name in
let parsedReference = try Reference.parse(name)
parsedReference.normalize()
return parsedReference.description
}()
}

var terminal: Terminal?
switch self.progress {
Expand Down Expand Up @@ -267,7 +269,7 @@ extension Application {
noCache: noCache,
platforms: [Platform](platforms),
terminal: terminal,
tag: imageName,
tags: imageNames,
target: target,
quiet: quiet,
exports: exports,
Expand All @@ -294,7 +296,7 @@ extension Application {
}
unpackProgress.start()

var finalMessage = "Successfully built \(imageName)"
var finalMessage = "Successfully built \(imageNames.joined(separator: ", "))"
let taskManager = ProgressTaskCoordinator()
// Currently, only a single export can be specified.
for exp in exports {
Expand All @@ -311,6 +313,12 @@ extension Application {
for image in loaded {
try Task.checkCancellation()
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler))

// Tag the unpacked image with all requested tags
for tagName in imageNames {
try Task.checkCancellation()
_ = try await image.tag(new: tagName)
}
}
case "tar":
guard let dest = exp.destination else {
Expand Down Expand Up @@ -349,8 +357,10 @@ extension Application {
guard FileManager.default.fileExists(atPath: contextDir) else {
throw ValidationError("context dir does not exist \(contextDir)")
}
guard let _ = try? Reference.parse(targetImageName) else {
throw ValidationError("invalid reference \(targetImageName)")
for name in targetImageNames {
guard let _ = try? Reference.parse(name) else {
throw ValidationError("invalid reference \(name)")
}
}
}
}
Expand Down
26 changes: 22 additions & 4 deletions Tests/CLITests/Subcommands/Build/CLIBuildBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,23 @@ class TestCLIBuildBase: CLITest {
otherArgs: [String] = []
) throws -> String {
try buildWithPaths(
tag: tag,
tags: [tag],
tempContext: tempDir,
tempDockerfileContext: tempDir,
buildArgs: buildArgs,
otherArgs: otherArgs
)
}

@discardableResult
func build(
tags: [String],
tempDir: URL,
buildArgs: [String] = [],
otherArgs: [String] = []
) throws -> String {
try buildWithPaths(
tags: tags,
tempContext: tempDir,
tempDockerfileContext: tempDir,
buildArgs: buildArgs,
Expand All @@ -95,7 +111,7 @@ class TestCLIBuildBase: CLITest {
// the dockerfile path. If both paths are the same, use `build` func above.
@discardableResult
func buildWithPaths(
tag: String,
tags: [String],
tempContext: URL,
tempDockerfileContext: URL,
buildArgs: [String] = [],
Expand All @@ -107,9 +123,11 @@ class TestCLIBuildBase: CLITest {
"build",
"-f",
tempDockerfileContext.appendingPathComponent("Dockerfile").path,
"-t",
tag,
]
for tag in tags {
args.append("-t")
args.append(tag)
}
for arg in buildArgs {
args.append("--build-arg")
args.append(arg)
Expand Down
26 changes: 25 additions & 1 deletion Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ extension TestCLIBuildBase {

let imageName = "registry.local/build-diff-context:\(UUID().uuidString)"
#expect(throws: Never.self) {
try self.buildWithPaths(tag: imageName, tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir)
try self.buildWithPaths(tags: [imageName], tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir)
}
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
Expand Down Expand Up @@ -432,5 +432,29 @@ extension TestCLIBuildBase {
"expected platforms \(expected), got \(actual)"
)
}

@Test func testBuildMultipleTags() throws {
let tempDir: URL = try createTempDir()
let dockerfile: String =
"""
FROM scratch

ADD emptyFile /
"""
let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)

let uuid = UUID().uuidString
let tag1 = "registry.local/multi-tag-test:\(uuid)"
let tag2 = "registry.local/multi-tag-test:latest"
let tag3 = "registry.local/multi-tag-test:v1.0.0"

try self.build(tags: [tag1, tag2, tag3], tempDir: tempDir)

// Verify all three tags exist and point to the same image
#expect(try self.inspectImage(tag1) == tag1, "expected to have successfully built \(tag1)")
#expect(try self.inspectImage(tag2) == tag2, "expected to have successfully built \(tag2)")
#expect(try self.inspectImage(tag3) == tag3, "expected to have successfully built \(tag3)")
}
}
}
5 changes: 4 additions & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ container build [OPTIONS] [CONTEXT-DIR]
* `--platform <platform>`: Add the platform to the build (takes precedence over --os and --arch)
* `--progress <type>`: Progress type (format: auto|plain|tty)] (default: auto)
* `-q, --quiet`: Suppress build output
* `-t, --tag <name>`: Name for the built image
* `-t, --tag <name>`: Name for the built image (can be specified multiple times)
* `--target <stage>`: Set the target build stage
* `--vsock-port <port>`: Builder shim vsock port (default: 8088)
* `--version`: Show the version.
Expand All @@ -122,6 +122,9 @@ container build --build-arg NODE_VERSION=18 -t my-app .

# build the production stage only and disable cache
container build --target production --no-cache -t my-app:prod .

# build with multiple tags
container build -t my-app:latest -t my-app:v1.0.0 -t my-app:stable .
```

## Container Management
Expand Down