diff --git a/README.md b/README.md index a994006..685d2cc 100644 --- a/README.md +++ b/README.md @@ -222,10 +222,11 @@ handlers** for non-arm64/non-amd64 architectures. `linux/amd64` works only becau Silicon ships hardware Rosetta 2 translation. Tracking upstream: [apple/container#1496](https://github.com/apple/container/issues/1496). -Until Apple ships QEMU support, two workarounds exist: +Until Apple ships QEMU support, three workarounds exist: | Path | Tradeoff | |------|----------| +| **Remote builder.** Point mocker at a Linux host that already has QEMU `binfmt` registered: `mocker build --builder --platform linux/ppc64le …`. The `--builder` value is forwarded to `container build --builder`, which can target a remote BuildKit node (e.g. one registered via `docker buildx create --driver remote ssh://host`). The `RUN` steps then execute on the remote host's emulation, not the local arm64 VM. | Needs a reachable remote Linux host (native ppc64le, or x86/arm with `qemu-user-static`) and a one-time builder registration. | | **Run a Podman machine** alongside mocker. Its Fedora CoreOS VM has `qemu-user-static` registered, so `podman build --platform linux/ppc64le` handles `RUN` steps. Use `mocker manifest create` afterwards to assemble per-arch images into a list. | Requires a persistent QEMU VM — extra memory and reliability surface. | | **`container run --virtualization`** a Linux VM, install `qemu-user-static` + Docker inside, build there, then `container image save` / `mocker manifest add` the result. | Manual setup; one-time per arch you need. | diff --git a/Sources/Mocker/Commands/Build.swift b/Sources/Mocker/Commands/Build.swift index 8a7d34b..77c61de 100644 --- a/Sources/Mocker/Commands/Build.swift +++ b/Sources/Mocker/Commands/Build.swift @@ -130,7 +130,8 @@ struct Build: AsyncParsableCommand { let image = try await manager.build( tag: tag, context: context, dockerfile: file, noCache: noCache, buildArgs: buildArg, platforms: platform, target: target, - labels: label, quiet: quiet, progress: progress, output: output + labels: label, quiet: quiet, progress: progress, output: output, + builder: builder ) if quiet { print(image.shortID) diff --git a/Sources/MockerKit/Image/ImageManager.swift b/Sources/MockerKit/Image/ImageManager.swift index 2a565f7..a1581ff 100644 --- a/Sources/MockerKit/Image/ImageManager.swift +++ b/Sources/MockerKit/Image/ImageManager.swift @@ -115,9 +115,37 @@ public actor ImageManager { private static let containerCLI = CLIResolver.resolve() + /// Construct the argument vector for `container build`. + /// + /// Pure and side-effect-free so it can be unit-tested without spawning a + /// process. The `builder` value maps to `container build --builder`, the + /// manual escape hatch for exotic architectures (ppc64le/s390x/riscv64) that + /// the local arm64 BuildKit VM cannot emulate — see README and apple/container#1496. + static func makeBuildArguments( + tag: String, dockerfilePath: String, context: String, noCache: Bool = false, + buildArgs: [String] = [], platforms: [String] = [], target: String? = nil, + labels: [String] = [], quiet: Bool = false, progress: String? = nil, + output: [String] = [], builder: String? = nil + ) -> [String] { + var args = ["build", "-t", tag, "-f", dockerfilePath] + if noCache { args.append("--no-cache") } + for arg in buildArgs { args += ["--build-arg", arg] } + for p in platforms { args += ["--platform", p] } + if let target { args += ["--target", target] } + for l in labels { args += ["-l", l] } + if quiet { args.append("-q") } + if let progress { args += ["--progress", progress] } + for o in output { args += ["-o", o] } + if let builder, !builder.isEmpty { args += ["--builder", builder] } + args.append(context) + return args + } + /// Build an image from a Dockerfile using the `container` CLI. /// - Parameter platforms: pass multiple values to build a multi-arch manifest list (e.g. `["linux/amd64", "linux/arm64"]`). - public func build(tag: String, context: String, dockerfile: String = "Dockerfile", noCache: Bool = false, buildArgs: [String] = [], platforms: [String] = [], target: String? = nil, labels: [String] = [], quiet: Bool = false, progress: String? = nil, output: [String] = []) async throws -> ImageInfo { + /// - Parameter builder: optional named builder instance forwarded to `container build --builder`, + /// enabling a remote BuildKit node for exotic architectures (apple/container#1496). + public func build(tag: String, context: String, dockerfile: String = "Dockerfile", noCache: Bool = false, buildArgs: [String] = [], platforms: [String] = [], target: String? = nil, labels: [String] = [], quiet: Bool = false, progress: String? = nil, output: [String] = [], builder: String? = nil) async throws -> ImageInfo { let contextURL: URL if context.hasPrefix("/") { contextURL = URL(fileURLWithPath: context) @@ -132,16 +160,11 @@ public actor ImageManager { throw MockerError.buildError("Dockerfile not found at \(dockerfilePath)") } - var args = ["build", "-t", tag, "-f", dockerfilePath] - if noCache { args.append("--no-cache") } - for arg in buildArgs { args += ["--build-arg", arg] } - for p in platforms { args += ["--platform", p] } - if let target { args += ["--target", target] } - for l in labels { args += ["-l", l] } - if quiet { args.append("-q") } - if let progress { args += ["--progress", progress] } - for o in output { args += ["-o", o] } - args.append(context) + let args = Self.makeBuildArguments( + tag: tag, dockerfilePath: dockerfilePath, context: context, noCache: noCache, + buildArgs: buildArgs, platforms: platforms, target: target, labels: labels, + quiet: quiet, progress: progress, output: output, builder: builder + ) let process = Process() process.executableURL = URL(fileURLWithPath: Self.containerCLI) diff --git a/Tests/MockerKitTests/ImageManagerBuildArgsTests.swift b/Tests/MockerKitTests/ImageManagerBuildArgsTests.swift new file mode 100644 index 0000000..e4e3c96 --- /dev/null +++ b/Tests/MockerKitTests/ImageManagerBuildArgsTests.swift @@ -0,0 +1,98 @@ +import Testing +@testable import MockerKit + +/// Tests for `ImageManager.makeBuildArguments` — the pure argument-vector builder +/// behind `mocker build`. Covers the `--builder` wiring added for the exotic-arch +/// remote-builder workaround (apple/container#1496, issue #10). +@Suite("ImageManager build arguments") +struct ImageManagerBuildArgsTests { + + @Test("base arguments are well-formed") + func baseArgs() { + let args = ImageManager.makeBuildArguments( + tag: "myapp:latest", dockerfilePath: "/ctx/Dockerfile", context: "." + ) + #expect(args.first == "build") + #expect(args.contains("-t")) + #expect(args.contains("myapp:latest")) + #expect(args.contains("-f")) + #expect(args.contains("/ctx/Dockerfile")) + // Context is always the trailing positional argument. + #expect(args.last == ".") + } + + @Test("--builder is omitted when not provided") + func builderOmittedByDefault() { + let args = ImageManager.makeBuildArguments( + tag: "myapp:latest", dockerfilePath: "/ctx/Dockerfile", context: "." + ) + #expect(!args.contains("--builder")) + } + + @Test("--builder is omitted when empty") + func builderOmittedWhenEmpty() { + let args = ImageManager.makeBuildArguments( + tag: "myapp:latest", dockerfilePath: "/ctx/Dockerfile", context: ".", builder: "" + ) + #expect(!args.contains("--builder")) + } + + @Test("--builder is forwarded with its value") + func builderForwarded() { + let args = ImageManager.makeBuildArguments( + tag: "myapp:latest", dockerfilePath: "/ctx/Dockerfile", context: ".", + builder: "remote-ppc64le" + ) + // The flag and its value must appear adjacently. + guard let idx = args.firstIndex(of: "--builder") else { + Issue.record("--builder flag missing from arguments: \(args)") + return + } + #expect(args.indices.contains(idx + 1)) + #expect(args[idx + 1] == "remote-ppc64le") + // …and the value must not be swallowed by the trailing context positional. + #expect(args.last == ".") + } + + @Test("--builder composes with --platform for exotic-arch builds") + func builderWithExoticPlatform() { + let args = ImageManager.makeBuildArguments( + tag: "myapp:latest", dockerfilePath: "/ctx/Dockerfile", context: ".", + platforms: ["linux/ppc64le"], builder: "remote" + ) + #expect(args.contains("--platform")) + #expect(args.contains("linux/ppc64le")) + #expect(args.contains("--builder")) + #expect(args.contains("remote")) + } + + @Test("all optional flags are emitted in the expected shape") + func fullArgs() { + let args = ImageManager.makeBuildArguments( + tag: "myapp:1.0", dockerfilePath: "/ctx/Dockerfile", context: "ctx", + noCache: true, buildArgs: ["KEY=val"], platforms: ["linux/arm64", "linux/amd64"], + target: "builder-stage", labels: ["a=b"], quiet: true, progress: "plain", + output: ["type=docker"], builder: "myremote" + ) + #expect(args.contains("--no-cache")) + #expect(adjacent(args, "--build-arg", "KEY=val")) + #expect(adjacent(args, "--target", "builder-stage")) + #expect(adjacent(args, "-l", "a=b")) + #expect(args.contains("-q")) + #expect(adjacent(args, "--progress", "plain")) + #expect(adjacent(args, "-o", "type=docker")) + #expect(adjacent(args, "--builder", "myremote")) + // Both platforms must be present. + #expect(args.contains("linux/arm64")) + #expect(args.contains("linux/amd64")) + #expect(args.last == "ctx") + } + + /// Helper: assert `flag` is immediately followed by `value` somewhere in `args`. + private func adjacent(_ args: [String], _ flag: String, _ value: String) -> Bool { + for (i, a) in args.enumerated() where a == flag { + if i + 1 < args.count, args[i + 1] == value { return true } + } + return false + } +}