Skip to content

Commit a8c779f

Browse files
authored
Uniform support for --platform, --os, --arch. (#545)
- Fixes #231. - Extends #313 (@dcantah) so that all of `container create`, `container run`, `container build`, `container image pull`, and `container image save` accept the three options. - `container build` now processes comma-separated lists for `--platform`, `--arch`, and `--os`. It first checks `--platform`, assembling the union of all platform values. If that set is non-empty, the builder builds the values in the set. Otherwise, the set consists of all combinations of the specified architecture and os values, finally defaulting to `linux` and the host architecture if no options are provided. - All other commands work accept a single platform, preferring the `--platform` option over `--arch` and `--os` when both are specified. `--os` defaults to `linux`, and `--arch` defaults to the host architecture. - Clarify help messages and present the args in consistent order, with platform first since it takes precedence if present. - Deduplicate redundant platform options for `container build`.
1 parent df1cc1b commit a8c779f

File tree

13 files changed

+243
-70
lines changed

13 files changed

+243
-70
lines changed

Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/CLI/BuildCommand.swift

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,29 @@ extension Application {
7777
[]
7878
}()
7979

80-
@Option(name: .long, help: ArgumentHelp("set the build architecture", valueName: "value"))
81-
var arch: [String] = {
82-
["arm64"]
80+
@Option(
81+
name: .long,
82+
help: "add the platform to the build",
83+
transform: { val in val.split(separator: ",").map { String($0) } }
84+
)
85+
var platform: [[String]] = [[]]
86+
87+
@Option(
88+
name: .long,
89+
help: ArgumentHelp("add the OS type to the build", valueName: "value"),
90+
transform: { val in val.split(separator: ",").map { String($0) } }
91+
)
92+
var os: [[String]] = {
93+
[["linux"]]
8394
}()
8495

85-
@Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value"))
86-
var os: [String] = {
87-
["linux"]
96+
@Option(
97+
name: [.long, .short],
98+
help: ArgumentHelp("add the architecture type to the build", valueName: "value"),
99+
transform: { val in val.split(separator: ",").map { String($0) } }
100+
)
101+
var arch: [[String]] = {
102+
[[Arch.hostArchitecture().rawValue]]
88103
}()
89104

90105
@Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type"))
@@ -217,14 +232,25 @@ extension Application {
217232
throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)")
218233
}
219234
}
220-
let platforms: [Platform] = try {
221-
var results: [Platform] = []
222-
for o in self.os {
223-
for a in self.arch {
235+
let platforms: Set<Platform> = try {
236+
var results: Set<Platform> = []
237+
for platform in (self.platform.flatMap { $0 }) {
238+
guard let p = try? Platform(from: platform) else {
239+
throw ValidationError("invalid platform specified \(platform)")
240+
}
241+
results.insert(p)
242+
}
243+
244+
if !results.isEmpty {
245+
return results
246+
}
247+
248+
for o in (self.os.flatMap { $0 }) {
249+
for a in (self.arch.flatMap { $0 }) {
224250
guard let platform = try? Platform(from: "\(o)/\(a)") else {
225251
throw ValidationError("invalid os/architecture combination \(o)/\(a)")
226252
}
227-
results.append(platform)
253+
results.insert(platform)
228254
}
229255
}
230256
return results
@@ -238,7 +264,7 @@ extension Application {
238264
dockerfile: dockerfile,
239265
labels: label,
240266
noCache: noCache,
241-
platforms: platforms,
267+
platforms: [Platform](platforms),
242268
terminal: terminal,
243269
tag: imageName,
244270
target: target,

Sources/CLI/Image/ImagePull.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,21 @@ extension Application {
3737
@OptionGroup
3838
var progressFlags: Flags.Progress
3939

40-
@Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String?
40+
@Option(
41+
help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'. This takes precedence over --os and --arch"
42+
)
43+
var platform: String?
44+
45+
@Option(
46+
help: "Set OS if image can target multiple operating systems"
47+
)
48+
var os: String = "linux"
49+
50+
@Option(
51+
name: [.customLong("arch"), .customShort("a")],
52+
help: "Set arch if image can target multiple architectures"
53+
)
54+
var arch: String = Arch.hostArchitecture().rawValue
4155

4256
@Argument var reference: String
4357

@@ -55,6 +69,8 @@ extension Application {
5569
var p: Platform?
5670
if let platform {
5771
p = try Platform(from: platform)
72+
} else {
73+
p = try Platform(from: "\(os)/\(arch)")
5874
}
5975

6076
let scheme = try RequestScheme(registry.scheme)

Sources/CLI/Image/ImageSave.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,21 @@ extension Application {
3131
@OptionGroup
3232
var global: Flags.Global
3333

34-
@Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String?
34+
@Option(
35+
help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'. This takes precedence over --os and --arch"
36+
)
37+
var platform: String?
38+
39+
@Option(
40+
help: "Set OS if image can target multiple operating systems"
41+
)
42+
var os: String = "linux"
43+
44+
@Option(
45+
name: [.customLong("arch"), .customShort("a")],
46+
help: "Set arch if image can target multiple architectures"
47+
)
48+
var arch: String = Arch.hostArchitecture().rawValue
3549

3650
@Option(
3751
name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(),
@@ -46,6 +60,8 @@ extension Application {
4660
var p: Platform?
4761
if let platform {
4862
p = try Platform(from: platform)
63+
} else {
64+
p = try Platform(from: "\(os)/\(arch)")
4965
}
5066

5167
let progressConfig = try ProgressConfig(

Sources/CLI/System/SystemStart.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ extension Application {
112112

113113
private func installInitialFilesystem() async throws {
114114
let dep = Dependencies.initFs
115-
let pullCommand = ImagePull(reference: dep.source)
115+
var pullCommand = try ImagePull.parse()
116+
pullCommand.reference = dep.source
116117
print("Installing base container filesystem...")
117118
do {
118119
try await pullCommand.run()

Sources/ContainerClient/Flags.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,14 @@ public struct Flags {
108108
@Flag(name: [.customLong("remove"), .customLong("rm")], help: "Remove the container after it stops")
109109
public var remove = false
110110

111+
@Option(name: .customLong("platform"), help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch")
112+
public var platform: String?
113+
111114
@Option(name: .customLong("os"), help: "Set OS if image can target multiple operating systems")
112115
public var os = "linux"
113116

114117
@Option(
115-
name: [.customLong("arch"), .short], help: "Set arch if image can target multiple architectures")
118+
name: [.long, .short], help: "Set arch if image can target multiple architectures")
116119
public var arch: String = Arch.hostArchitecture().rawValue
117120

118121
@Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container")

Sources/ContainerClient/Parser.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ public struct Parser {
5050
user: String?, uid: UInt32?, gid: UInt32?,
5151
defaultUser: ProcessConfiguration.User = .id(uid: 0, gid: 0)
5252
) -> (user: ProcessConfiguration.User, groups: [UInt32]) {
53-
5453
var supplementalGroups: [UInt32] = []
5554
let user: ProcessConfiguration.User = {
5655
if let user = user, !user.isEmpty {
@@ -79,6 +78,10 @@ public struct Parser {
7978
.init(arch: arch, os: os)
8079
}
8180

81+
public static func platform(from platform: String) throws -> ContainerizationOCI.Platform {
82+
try .init(from: platform)
83+
}
84+
8285
public static func resources(cpus: Int64?, memory: String?) throws -> ContainerConfiguration.Resources {
8386
var resource = ContainerConfiguration.Resources()
8487
if let cpus {

Sources/ContainerClient/Utility.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ public struct Utility {
7272
registry: Flags.Registry,
7373
progressUpdate: @escaping ProgressUpdateHandler
7474
) async throws -> (ContainerConfiguration, Kernel) {
75-
let requestedPlatform = Parser.platform(os: management.os, arch: management.arch)
75+
var requestedPlatform = Parser.platform(os: management.os, arch: management.arch)
76+
// Prefer --platform
77+
if let platform = management.platform {
78+
requestedPlatform = try Parser.platform(from: platform)
79+
}
7680
let scheme = try RequestScheme(registry.scheme)
7781

7882
await progressUpdate([

Tests/CLITests/Subcommands/Build/CLIBuildBase.swift

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,32 +78,49 @@ class TestCLIBuildBase: CLITest {
7878
}
7979

8080
@discardableResult
81-
func build(tag: String, tempDir: URL, args: [String]? = nil) throws -> String {
82-
try buildWithPaths(tag: tag, tempContext: tempDir, tempDockerfileContext: tempDir, args: args)
81+
func build(
82+
tag: String,
83+
tempDir: URL,
84+
buildArgs: [String] = [],
85+
otherArgs: [String] = []
86+
) throws -> String {
87+
try buildWithPaths(
88+
tag: tag,
89+
tempContext: tempDir,
90+
tempDockerfileContext: tempDir,
91+
buildArgs: buildArgs,
92+
otherArgs: otherArgs
93+
)
8394
}
8495

8596
// buildWithPaths is a helper function for calling build with different paths for the build context and
8697
// the dockerfile path. If both paths are the same, use `build` func above.
8798
@discardableResult
88-
func buildWithPaths(tag: String, tempContext: URL, tempDockerfileContext: URL, args: [String]? = nil) throws -> String {
99+
func buildWithPaths(
100+
tag: String,
101+
tempContext: URL,
102+
tempDockerfileContext: URL,
103+
buildArgs: [String] = [],
104+
otherArgs: [String] = []
105+
) throws -> String {
89106
let contextDir: URL = tempContext.appendingPathComponent("context")
90107
let contextDirPath = contextDir.absoluteURL.path
91-
var buildArgs = [
108+
var args = [
92109
"build",
93110
"-f",
94111
tempDockerfileContext.appendingPathComponent("Dockerfile").path,
95112
"-t",
96113
tag,
97114
]
98-
if let args = args {
99-
for arg in args {
100-
buildArgs.append("--build-arg")
101-
buildArgs.append(arg)
102-
}
115+
for arg in buildArgs {
116+
args.append("--build-arg")
117+
args.append(arg)
103118
}
104-
buildArgs.append(contextDirPath)
119+
args.append(contextDirPath)
120+
121+
args.append(contentsOf: otherArgs)
105122

106-
let response = try run(arguments: buildArgs)
123+
let response = try run(arguments: args)
107124
if response.status != 0 {
108125
throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)")
109126
}

Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
//
1818

19+
import ContainerizationOCI
1920
import Foundation
2021
import Testing
2122

@@ -139,7 +140,7 @@ extension TestCLIBuildBase {
139140
"""
140141
try createContext(tempDir: tempDir, dockerfile: dockerfile)
141142
let imageName: String = "registry.local/build-arg:\(UUID().uuidString)"
142-
try self.build(tag: imageName, tempDir: tempDir, args: ["TAG=3.20"])
143+
try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["TAG=3.20"])
143144
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
144145
}
145146

@@ -159,7 +160,7 @@ extension TestCLIBuildBase {
159160
if let proxyAddr = proxyEnv {
160161
address = String(proxyAddr.trimmingPrefix("http://"))
161162
}
162-
try self.build(tag: imageName, tempDir: tempDir, args: ["ADDRESS=\(address)"])
163+
try self.build(tag: imageName, tempDir: tempDir, buildArgs: ["ADDRESS=\(address)"])
163164
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
164165
}
165166

@@ -374,5 +375,43 @@ extension TestCLIBuildBase {
374375
}
375376
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
376377
}
378+
379+
@Test func testBuildMultiArch() throws {
380+
let tempDir: URL = try createTempDir()
381+
let dockerfile: String =
382+
"""
383+
FROM ghcr.io/linuxcontainers/alpine:3.20
384+
385+
ADD . .
386+
387+
RUN cat emptyFile
388+
RUN cat Test/testempty
389+
"""
390+
let context: [FileSystemEntry] = [
391+
.directory("Test"),
392+
.file("Test/testempty", content: .zeroFilled(size: 1)),
393+
.file("emptyFile", content: .zeroFilled(size: 1)),
394+
]
395+
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)
396+
let imageName: String = "registry.local/multi-arch:\(UUID().uuidString)"
397+
try self.build(tag: imageName, tempDir: tempDir, otherArgs: ["--arch", "amd64,arm64"])
398+
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
399+
400+
let output = try doInspectImages(image: imageName)
401+
#expect(output.count == 1, "expected a single image inspect output, got \(output)")
402+
403+
let expected = Set([
404+
Platform(arch: "amd64", os: "linux", variant: nil),
405+
Platform(arch: "arm64", os: "linux", variant: nil),
406+
])
407+
let actual = Set(
408+
output[0].variants.map { v in
409+
Platform(arch: v.platform.architecture, os: v.platform.os, variant: nil)
410+
})
411+
#expect(
412+
actual == expected,
413+
"expected platforms \(expected), got \(actual)"
414+
)
415+
}
377416
}
378417
}

0 commit comments

Comments
 (0)