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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --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. |

Expand Down
3 changes: 2 additions & 1 deletion Sources/Mocker/Commands/Build.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 34 additions & 11 deletions Sources/MockerKit/Image/ImageManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions Tests/MockerKitTests/ImageManagerBuildArgsTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading