From b14a76c80156952a0c98a86e0704500dde1351cc Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Fri, 18 Sep 2026 09:32:41 +0200 Subject: [PATCH 1/2] feat: add Apple Container execution engine --- CHANGELOG.md | 2 + Makefile | 5 + docs/architecture/container-engines.md | 30 ++++- docs/architecture/graph-pipeline.md | 2 +- src/Terrabuild.Tests/Core/Configuration.fs | 19 +++ src/Terrabuild.Tests/Core/Program.fs | 8 ++ src/Terrabuild.Tests/Core/Runner.fs | 52 +++++++++ .../src/components/BuildControlsPanel.tsx | 1 + src/Terrabuild/CLI.fs | 5 +- src/Terrabuild/Contracts/ConfigOptions.fs | 1 + src/Terrabuild/Core/Configuration.fs | 1 + src/Terrabuild/Core/Runner.fs | 34 ++++++ src/Terrabuild/Helpers/Exec.fs | 12 +- src/Terrabuild/Program.fs | 1 + src/Terrabuild/Web/GraphServer.fs | 1 + tests/apple-container/PROJECT | 16 +++ tests/apple-container/WORKSPACE | 15 +++ tests/apple-container/smoke.py | 109 ++++++++++++++++++ tests/apple-container/verify.sh | 17 +++ website/pages/index.mdx | 2 +- website/site-docs/extensibility/container.md | 37 +++++- website/site-docs/extensibility/script.md | 2 +- .../site-docs/getting-started/environments.md | 2 +- website/site-docs/workspace/workspace.md | 2 +- 24 files changed, 360 insertions(+), 16 deletions(-) create mode 100644 tests/apple-container/PROJECT create mode 100644 tests/apple-container/WORKSPACE create mode 100644 tests/apple-container/smoke.py create mode 100644 tests/apple-container/verify.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 96250a5b..320a63b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Terrabuild are documented in this file. ## [Unreleased] +- Run containerized builds with Apple Container on Apple silicon Macs using the apple engine. + - Rebuild cached targets when Insights reports an unfinished or missing artifact instead of aborting the build. ## [0.200.2] diff --git a/Makefile b/Makefile index 824d407b..4255751b 100644 --- a/Makefile +++ b/Makefile @@ -220,6 +220,11 @@ smoke-indirect-target: smoke-test-phases: $(call run_integration_test, tests/phases, run build --project app --force --debug --parallel 2 --log --engine docker --local-only) +.PHONY: smoke-test-apple +smoke-test-apple: + dotnet build -c $(config) $(dotnet_props) src/Terrabuild/Terrabuild.fsproj + python3 tests/apple-container/smoke.py src/Terrabuild/bin/$(config)/net10.0/terrabuild.dll + smoke-test-dotnet-cache: dotnet test -c $(config) $(dotnet_props) src/Terrabuild.Tests/Terrabuild.Tests.fsproj --filter "TestCategory=integration" diff --git a/docs/architecture/container-engines.md b/docs/architecture/container-engines.md index bc5f3eab..5f582711 100644 --- a/docs/architecture/container-engines.md +++ b/docs/architecture/container-engines.md @@ -1,10 +1,11 @@ # Container Engines -Terrabuild supports three execution paths in `Runner.fs`: +Terrabuild supports four execution paths in `Runner.fs`: - host execution - Docker-backed container execution - Podman-backed container execution +- Apple Container-backed execution through the `container` CLI This document describes the current container runtime arguments used by each engine. @@ -20,7 +21,7 @@ This document describes the current container runtime arguments used by each eng For containerized operations, Terrabuild always starts from: ```text -run --rm --name +run --rm --name terrabuild-- ``` Then it adds: @@ -104,6 +105,29 @@ Terrabuild runs the command directly on the host when the effective engine is `h - command is the operation command - arguments are the operation arguments -Operations without an `image` always use this path, even when the selected engine is Docker or Podman. +Operations without an `image` always use this path, even when the selected engine is Docker, Podman, or Apple Container. No container-specific arguments are added in this path. + +## Apple Container arguments and lifecycle + +The `apple` engine invokes `container run` on Apple silicon with macOS 26 or later. +It uses the shared command shape, CPU/platform options, environment forwarding and +three bind mounts described above. Mounts use `-v source:target`. No host namespace, +Linux user mapping, or Docker socket options are added. + +Before a run that executes Apple container operations, Terrabuild checks the host +and calls `container list --quiet` to verify the runtime is available. Dry runs and +runs containing only host operations or cached results do not require the service. +The service must be installed and started separately. Container 1.4.1 is the tested +baseline. + +Named Apple containers participate in the same cleanup records as Docker and +Podman. Cancellation, failed processes and abandoned records use `container rm -f`; +already-removed containers count as successful cleanup. Only Terrabuild's recorded +containers are removed; the shared service remains running. + +Apple uses its own image store. Engine selection does not translate Docker build +commands. Host networking and automatic port publication are not provided. +`make smoke-test-apple` exercises this backend explicitly; the existing smoke suite +and repository self-build retain their Docker configuration. diff --git a/docs/architecture/graph-pipeline.md b/docs/architecture/graph-pipeline.md index 4c413eb1..7533435c 100644 --- a/docs/architecture/graph-pipeline.md +++ b/docs/architecture/graph-pipeline.md @@ -137,7 +137,7 @@ Executing nodes acquire their named locks in deterministic order before commands Every command holds a process lease in the global profile. Cache clearing acquires the profile gate and fails safely when any process lease is still active; stale leases from terminated processes are reclaimed. This prevents clearing directories or lock inodes out from under an executing graph. -Container executions keep an exclusively leased record in the global profile. Cancellation removes the daemon-owned Docker or Podman container before terminating its local CLI process. If Terrabuild is killed abruptly, the next invocation reaps unlocked records before configuration recovery or hashing, preventing an orphan from continuing to mutate mounted workspace or cache files. A cleanup record is deleted only after the engine confirms removal or reports that the container is already absent; daemon errors and timeouts retain it for a later invocation. +Container executions keep an exclusively leased record in the global profile. Cancellation removes the daemon-owned Docker, Podman, or Apple Container container before terminating its local CLI process. If Terrabuild is killed abruptly, the next invocation reaps unlocked records before configuration recovery or hashing, preventing an orphan from continuing to mutate mounted workspace or cache files. A cleanup record is deleted only after the engine confirms removal or reports that the container is already absent; daemon errors and timeouts retain it for a later invocation. Local cache publication keeps the previous completed entry as a sibling backup until the replacement directory is visible. The next locked lookup restores that backup if publication was interrupted, or removes it if the replacement was already committed. diff --git a/src/Terrabuild.Tests/Core/Configuration.fs b/src/Terrabuild.Tests/Core/Configuration.fs index 489362b1..0c7fa4fa 100644 --- a/src/Terrabuild.Tests/Core/Configuration.fs +++ b/src/Terrabuild.Tests/Core/Configuration.fs @@ -749,3 +749,22 @@ target build { """ Assert.That(Action(fun () -> Configuration.read (baseOptions root (Set [ "build" ])) |> ignore), Throws.TypeOf())) + +[] +[] +let ``apple engine selection is exposed to expressions and workspace overrides cli`` workspaceOverride = + withTempWorkspace (fun root -> + let engineSetting = if workspaceOverride then "engine = ~apple" else "" + let workspaceText = "workspace { " + engineSetting + " }\ntarget build {\n build = terrabuild.engine == ~apple ? ~always : ~lazy\n}" + writeFile root "WORKSPACE" workspaceText + writeFile root "app/PROJECT" """ +project app { @shell {} } +target build { @shell echo { arguments = "app" } } +""" + let options = + { baseOptions root (Set [ "build" ]) with + Engine = if workspaceOverride then ConfigOptions.Engine.Docker else ConfigOptions.Engine.Apple } + let effective, config = Configuration.read options + effective.Engine |> should equal ConfigOptions.Engine.Apple + config.Projects["workspace/path#app"].Targets["build"].Build + |> should equal (Some GraphDef.BuildMode.Always)) diff --git a/src/Terrabuild.Tests/Core/Program.fs b/src/Terrabuild.Tests/Core/Program.fs index 1a47264f..57b1136b 100644 --- a/src/Terrabuild.Tests/Core/Program.fs +++ b/src/Terrabuild.Tests/Core/Program.fs @@ -511,3 +511,11 @@ let ``buildImpactResult marks missing base nodes as changed and skips unnamed no impactResult.Impacts.Count |> should equal 1 impactResult.Impacts["root:build"] |> should equal "changed" + +[] +let ``CLI accepts apple engine for run and explain`` () = + let parser = ArgumentParser.Create(programName = "terrabuild") + let run = parser.ParseCommandLine([| "run"; "build"; "--engine"; "apple" |], raiseOnUsage = true) + run.GetResult(TerrabuildArgs.Run).GetResult(RunArgs.Engine) |> should equal CLI.Engine.Apple + let explain = parser.ParseCommandLine([| "explain"; "build"; "--engine"; "apple" |], raiseOnUsage = true) + explain.GetResult(TerrabuildArgs.Explain).GetResult(ExplainArgs.Engine) |> should equal CLI.Engine.Apple diff --git a/src/Terrabuild.Tests/Core/Runner.fs b/src/Terrabuild.Tests/Core/Runner.fs index 2ff491e4..6af28f43 100644 --- a/src/Terrabuild.Tests/Core/Runner.fs +++ b/src/Terrabuild.Tests/Core/Runner.fs @@ -481,6 +481,58 @@ let ``buildCommands formats podman container requests through podman path on lin args |> should contain $"--mount type=bind,src={workspace},target=/terrabuild" args |> should contain "restore App.csproj") +[] +let ``apple commands preserve mounts arguments and environment without docker host flags`` () = + withTempWorkspace (fun root -> + withEnvironmentVariable "TB_SAMPLE" "$TERRABUILD_HOME/cache" (fun () -> + let workspace = Path.Combine(root, "workspace with spaces") + let operation = buildOperation "docker" "build \"Project With Spaces\"" (Some "tool:image") + let node = buildNode "apple-build" "src/My App" "build" GraphDef.RunAction.Exec [ operation ] + let options = { baseOptions workspace with Engine = ConfigOptions.Engine.Apple } + let _, workDir, command, arguments, _, _, envs, _ = + Runner.buildCommandsForRuntime macRuntime node options node.ProjectDir workspace workspace + |> List.exactlyOne + command |> should equal "container" + workDir |> should equal workspace + envs["TB_SAMPLE"] |> should equal "/terrabuild-home/cache" + envs["FROM_ENV_MAP"] |> should equal "set-by-terrabuild" + match arguments with + | Exec.Arguments.Raw _ -> Assert.Fail("Expected structured arguments") + | Exec.Arguments.List args -> + for expected in [ workspace + ":/terrabuild"; workspace + ":/terrabuild-home" + workspace + ":/terrabuild-tmp"; "/terrabuild/src/My App" + "Project With Spaces"; "--cpus=2"; "--platform=linux/amd64" + "TB_SAMPLE"; "FROM_ENV_MAP"; "HOME=/terrabuild-home" ] do + args |> should contain expected + for unexpected in [ "--net=host"; "--pid=host"; "--ipc=host"; "--user" + "/var/run/docker.sock:/var/run/docker.sock"; "/terrabuild-home/cache" ] do + args |> should not' (contain unexpected) + Exec.tryContainerIdentity command arguments |> Option.isSome |> should equal true)) + +[] +let ``apple engine rejects container execution on linux but permits imageless operations`` () = + withTempWorkspace (fun workspace -> + let operation = buildOperation "echo" "hello" (Some "tool:image") + let node = buildNode "apple-linux" "." "build" GraphDef.RunAction.Exec [ operation ] + let options = { baseOptions workspace with Engine = ConfigOptions.Engine.Apple } + (fun () -> Runner.buildCommandsForRuntime linuxRuntime node options "." workspace workspace |> ignore) + |> should throw typeof + let node = { node with Operations = [ { operation with Image = None } ] } + let _, _, command, _, _, _, _, _ = + Runner.buildCommandsForRuntime linuxRuntime node options "." workspace workspace |> List.exactlyOne + command |> should equal "echo") + +[] +let ``apple cleanup recognizes absent containers without swallowing other failures`` () = + Exec.containerIsAbsent "container" "Error: internalError: failed to delete container (cause: notFound: container with ID terrabuild-test not found)" + |> should equal true + Exec.containerIsAbsent "container" "notFound: service not found" |> should equal false + Exec.containerIsAbsent "container" "permission denied" |> should equal false + Exec.tryContainerIdentity "container" (Exec.Arguments.List [ "run"; "--name"; "terrabuild-test"; "alpine" ]) + |> should equal (Some ("container", "terrabuild-test")) + Exec.tryContainerIdentity "container" (Exec.Arguments.List [ "build"; "--name"; "unrelated" ]) + |> should equal None + [] let ``buildCommands mounts docker socket only for docker client commands`` () = withTempWorkspace (fun workspace -> diff --git a/src/Terrabuild.UI/src/components/BuildControlsPanel.tsx b/src/Terrabuild.UI/src/components/BuildControlsPanel.tsx index e3af4dec..73c39b7c 100644 --- a/src/Terrabuild.UI/src/components/BuildControlsPanel.tsx +++ b/src/Terrabuild.UI/src/components/BuildControlsPanel.tsx @@ -22,6 +22,7 @@ const engineOptions = [ { value: "default", label: "Default" }, { value: "docker", label: "Docker" }, { value: "podman", label: "Podman" }, + { value: "apple", label: "Apple" }, { value: "host", label: "Host" }, ]; diff --git a/src/Terrabuild/CLI.fs b/src/Terrabuild/CLI.fs index 97ba9c41..9d6152a6 100644 --- a/src/Terrabuild/CLI.fs +++ b/src/Terrabuild/CLI.fs @@ -5,6 +5,7 @@ open Argu type Engine = | Docker | Podman + | Apple | Host [] @@ -83,7 +84,7 @@ with | Note _ -> "Note for the build." | Group _ -> "Group identifier for related builds." | Tag _ -> "Tag for build." - | Engine _ -> "Container engine to use (docker, podman or host)." + | Engine _ -> "Container engine to use (docker, podman, apple or host)." | Dry_Run -> "Prepare the action but do not apply." [] @@ -115,7 +116,7 @@ with | Force -> "Explain the forced execution decision." | Retry -> "Explain retry behavior for failed cached tasks." | Local_Only -> "Use local cache only." - | Engine _ -> "Container engine to use (docker, podman or host)." + | Engine _ -> "Container engine to use (docker, podman, apple or host)." [] type ImpactArgs = diff --git a/src/Terrabuild/Contracts/ConfigOptions.fs b/src/Terrabuild/Contracts/ConfigOptions.fs index 8b18cb5d..5e6026c8 100644 --- a/src/Terrabuild/Contracts/ConfigOptions.fs +++ b/src/Terrabuild/Contracts/ConfigOptions.fs @@ -7,6 +7,7 @@ open Contracts type Engine = | Docker | Podman + | Apple | Host [] diff --git a/src/Terrabuild/Core/Configuration.fs b/src/Terrabuild/Core/Configuration.fs index 9db630ca..e98e594f 100644 --- a/src/Terrabuild/Core/Configuration.fs +++ b/src/Terrabuild/Core/Configuration.fs @@ -1092,6 +1092,7 @@ let read (options: ConfigOptions.Options) = | None -> options.Engine | Some "docker" -> ConfigOptions.Engine.Docker | Some "podman" -> ConfigOptions.Engine.Podman + | Some "apple" -> ConfigOptions.Engine.Apple | Some "host" -> ConfigOptions.Engine.Host | Some x -> raiseInvalidArg $"Invalid engine option '{x}'" diff --git a/src/Terrabuild/Core/Runner.fs b/src/Terrabuild/Core/Runner.fs index f7424e28..1622bef8 100644 --- a/src/Terrabuild/Core/Runner.fs +++ b/src/Terrabuild/Core/Runner.fs @@ -87,6 +87,7 @@ type private ApiBuildLifecycle(api: Contracts.IApiClient option) = type private EngineRequestPath = | Docker | Podman + | Apple | Host module private Native = @@ -197,10 +198,22 @@ let private buildPodmanPolicy (runtime: HostRuntime) (operation: GraphDef.Contai ExtraArgs = extraArgs MountArgs = mountArgs } +let private buildApplePolicy (runtime: HostRuntime) homeDir tmpDir wsDir = + if runtime.Platform <> Environment.HostPlatform.MacOS then + raiseInvalidArg "The apple engine requires macOS on Apple silicon." + + { EngineCommand = "container" + ExtraArgs = [] + MountArgs = + [ yield! formatDockerMount homeDir containerHome + yield! formatDockerMount tmpDir containerTmp + yield! formatDockerMount wsDir "/terrabuild" ] } + let private buildContainerPolicy runtime engineRequestPath operation homeDir tmpDir wsDir = match engineRequestPath with | EngineRequestPath.Docker -> buildDockerPolicy runtime operation homeDir tmpDir wsDir | EngineRequestPath.Podman -> buildPodmanPolicy runtime operation homeDir tmpDir wsDir + | EngineRequestPath.Apple -> buildApplePolicy runtime homeDir tmpDir wsDir | EngineRequestPath.Host -> invalidArg "engineRequestPath" "Host engine does not support container policy" let private buildContainerCommand runtime engineRequestPath (node: GraphDef.Node) (operation: GraphDef.ContaineredShellOperation) (options: ConfigOptions.Options) projectDirectory homeDir tmpDir : BuiltCommand = @@ -245,6 +258,8 @@ and internal buildCommandsForRuntime (runtime: HostRuntime) (node: GraphDef.Node buildContainerCommand runtime EngineRequestPath.Docker node operation options projectDirectory homeDir tmpDir | ConfigOptions.Engine.Podman, Some _ -> buildContainerCommand runtime EngineRequestPath.Podman node operation options projectDirectory homeDir tmpDir + | ConfigOptions.Engine.Apple, Some _ -> + buildContainerCommand runtime EngineRequestPath.Apple node operation options projectDirectory homeDir tmpDir | _ -> buildHostCommand operation projectDirectory) @@ -351,7 +366,26 @@ let buildBatchSchedule flattenBatchProgress (graph: GraphDef.Graph) (targetNode: | None -> (targetNode.Id, $"{targetNode.Target} {targetNode.ProjectDir}") ] +let private checkAppleRuntime workspace = + if not (OperatingSystem.IsMacOSVersionAtLeast(26)) + || RuntimeInformation.OSArchitecture <> Architecture.Arm64 then + raiseInvalidArg "The apple engine requires macOS 26 or later on Apple silicon." + try + match Exec.execCaptureOutput workspace "container" "list --quiet" Map.empty with + | Exec.Success _ -> () + | Exec.Error (message, _) -> + raiseInvalidArg $"Apple Container is unavailable. Run 'container system start' and retry. {message.Trim()}" + with + | :? System.ComponentModel.Win32Exception as ex -> + forwardExternalError("Unable to start Apple Container. Install it with 'brew install container' and ensure 'container' is on PATH.", ex) + let run (options: ConfigOptions.Options) (cache: Cache.ICache) (api: Contracts.IApiClient option) (uploadGraph: GraphDef.Graph) (graph: GraphDef.Graph) = + if options.Engine = ConfigOptions.Engine.Apple && not options.DryRun + && (graph.Nodes.Values |> Seq.exists (fun node -> + node.Action = GraphDef.RunAction.Exec + && (node.Operations |> List.exists (fun operation -> operation.Image.IsSome)))) then + checkAppleRuntime options.Workspace + let startedAt = DateTime.UtcNow let graphEnvironment = options.Environment |> Option.defaultValue "" let repository = diff --git a/src/Terrabuild/Helpers/Exec.fs b/src/Terrabuild/Helpers/Exec.fs index 36303fc3..82928ad0 100644 --- a/src/Terrabuild/Helpers/Exec.fs +++ b/src/Terrabuild/Helpers/Exec.fs @@ -178,9 +178,13 @@ let private abandonContainerRecord (lease: IDisposable) = | :? ContainerRecordLease as containerLease -> containerLease.Abandon() | _ -> lease.Dispose() -let private containerIsAbsent (diagnostic: string) = +let internal containerIsAbsent engine (diagnostic: string) = diagnostic.Contains("no such container", StringComparison.OrdinalIgnoreCase) || diagnostic.Contains("no container with name or ID", StringComparison.OrdinalIgnoreCase) + || (engine = "container" + && diagnostic.Contains("notFound:", StringComparison.Ordinal) + && diagnostic.Contains("container with ID ", StringComparison.Ordinal) + && diagnostic.Contains(" not found", StringComparison.Ordinal)) let private forceRemoveContainer engine name = let psi = ProcessStartInfo(FileName = engine, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true) @@ -199,7 +203,7 @@ let private forceRemoveContainer engine name = let stderr = stderr.GetAwaiter().GetResult() if not exited then raise (TimeoutException($"{engine} timed out while removing container '{name}'")) - if proc.ExitCode <> 0 && not (containerIsAbsent $"{stdout}\n{stderr}") then + if proc.ExitCode <> 0 && not (containerIsAbsent engine $"{stdout}\n{stderr}") then failwithf "%s failed to remove container '%s' (exit %d): %s" engine name proc.ExitCode stderr let reapContainers () = @@ -207,9 +211,9 @@ let reapContainers () = reapContainerRecordsAt profile forceRemoveContainer |> ignore -let private tryContainerIdentity command arguments = +let internal tryContainerIdentity command arguments = match command, arguments with - | ("docker" | "podman"), Arguments.List args -> + | ("docker" | "podman" | "container"), Arguments.List ("run" :: args) -> args |> List.windowed 2 |> List.tryPick (function diff --git a/src/Terrabuild/Program.fs b/src/Terrabuild/Program.fs index 02b3a2e6..0f4b59c5 100644 --- a/src/Terrabuild/Program.fs +++ b/src/Terrabuild/Program.fs @@ -344,6 +344,7 @@ let processCommandLine (parser: ArgumentParser) (result: ParseRe match runOptions.Engine with | None | Some Engine.Docker -> ConfigOptions.Engine.Docker | Some Engine.Podman -> ConfigOptions.Engine.Podman + | Some Engine.Apple -> ConfigOptions.Engine.Apple | Some Engine.Host -> ConfigOptions.Engine.Host ConfigOptions.Options.HeadCommit = sourceControl.HeadCommit ConfigOptions.Options.CommitLog = sourceControl.CommitLog diff --git a/src/Terrabuild/Web/GraphServer.fs b/src/Terrabuild/Web/GraphServer.fs index 5c3a459c..c16f2ed5 100644 --- a/src/Terrabuild/Web/GraphServer.fs +++ b/src/Terrabuild/Web/GraphServer.fs @@ -174,6 +174,7 @@ let private buildConfig match engine |> normalizeEngineOption with | None | Some "docker" -> ConfigOptions.Engine.Docker | Some "podman" -> ConfigOptions.Engine.Podman + | Some "apple" -> ConfigOptions.Engine.Apple | Some "host" -> ConfigOptions.Engine.Host | _ -> failwith $"Invalid engine option {engine}" ConfigOptions.Options.HeadCommit = sourceControl.HeadCommit diff --git a/tests/apple-container/PROJECT b/tests/apple-container/PROJECT new file mode 100644 index 00000000..8cfebca2 --- /dev/null +++ b/tests/apple-container/PROJECT @@ -0,0 +1,16 @@ +project { + outputs = [ ".out" ] + @shell {} +} + +target build { + @shell sh { args = "verify.sh 'argument with spaces'" } +} + +target fail { + @shell sh { args = "-c 'exit 7'" } +} + +target wait { + @shell sh { args = "-c 'mkdir -p .out; touch .out/started; sleep 120'" } +} diff --git a/tests/apple-container/WORKSPACE b/tests/apple-container/WORKSPACE new file mode 100644 index 00000000..98f57543 --- /dev/null +++ b/tests/apple-container/WORKSPACE @@ -0,0 +1,15 @@ +workspace {} + +target build {} +target fail {} +target wait {} + +extension @shell { + image = "docker.io/library/alpine:3.23" + platform = "linux/arm64" + cpus = 1 + variables = [ "TB_APPLE_SAMPLE" ] + env { + TB_APPLE_EXPLICIT = "explicit value" + } +} diff --git a/tests/apple-container/smoke.py b/tests/apple-container/smoke.py new file mode 100644 index 00000000..657e2543 --- /dev/null +++ b/tests/apple-container/smoke.py @@ -0,0 +1,109 @@ +"""Exercise the real Apple runtime through Terrabuild, including cancellation. + +Run with make smoke-test-apple on an Apple silicon Mac with container running. +All workspaces are temporary; cleanup removes only containers created by this test. +""" + +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time + + +def run(args, **kwargs): + return subprocess.run(args, check=True, text=True, capture_output=True, **kwargs) + + +def wait_for(predicate, description, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.2) + raise AssertionError(f"Timed out waiting for {description}") + + +def main(): + binary = Path(sys.argv[1]).resolve() + fixture = Path(__file__).resolve().parent + run(["container", "list", "--quiet"]) + with tempfile.TemporaryDirectory(prefix="terrabuild apple ") as directory: + workspace = Path(directory).resolve() + profile = workspace / "home" + profile.mkdir() + shutil.copy(fixture / "WORKSPACE", workspace) + for name in ["app one", "app two"]: + project = workspace / name + project.mkdir() + for filename in ["PROJECT", "verify.sh"]: + shutil.copy(fixture / filename, project) + run(["git", "init", "-q"], cwd=workspace) + run(["git", "add", "."], cwd=workspace) + run(["git", "-c", "user.name=Smoke test", "-c", "user.email=smoke@example.invalid", + "commit", "-qm", "Fixture"], cwd=workspace) + env = dict(os.environ, HOME=str(profile), TB_APPLE_SAMPLE="$TERRABUILD_HOME/cache with spaces") + command = ["dotnet", str(binary), "run"] + options = ["--engine", "apple", "--local-only", "--force", "--debug", "--parallel", "2"] + + def execute(target): + result = subprocess.run(command + [target] + options, cwd=workspace, env=env, + text=True, capture_output=True, timeout=120) + print(result.stdout) + print(result.stderr, file=sys.stderr) + return result + + def records(): + return list((profile / ".terrabuild" / "containers").glob("*.json")) + + def cleanup_records(): + for record in records(): + data = json.loads(record.read_text(encoding="utf-8-sig")) + subprocess.run(["container", "rm", "-f", data["name"]], capture_output=True) + + try: + assert execute("build").returncode == 0, "Apple build failed" + for name in ["app one", "app two"]: + assert (workspace / name / ".out/result with spaces.txt").read_text() == "apple container output\n" + debug = json.loads((workspace / "terrabuild-debug.json").read_text()) + operations = [op for execution in debug["executions"] for op in execution["operations"]] + assert len(operations) == 2 + assert all(op["command"] == "container" and op["exitCode"] == 0 for op in operations) + wait_for(lambda: not records(), "successful container cleanup") + + assert execute("fail").returncode != 0, "Failed container reported success" + debug = json.loads((workspace / "terrabuild-debug.json").read_text()) + operations = [op for execution in debug["executions"] for op in execution["operations"]] + assert operations and all(op["exitCode"] == 7 for op in operations) + wait_for(lambda: not records(), "failed container cleanup") + + with (workspace / "cancel.log").open("w") as log: + process = subprocess.Popen(command + ["wait"] + options, cwd=workspace, env=env, + stdout=log, stderr=log) + try: + wait_for(lambda: all((workspace / name / ".out/started").exists() + for name in ["app one", "app two"]), "both containers to start") + names = [json.loads(record.read_text(encoding="utf-8-sig"))["name"] for record in records()] + assert len(names) == 2 + process.send_signal(signal.SIGINT) + process.wait(timeout=30) + assert process.returncode != 0 + wait_for(lambda: not records(), "cancelled container cleanup") + remaining = run(["container", "list", "--all", "--quiet"]).stdout.splitlines() + assert not set(names).intersection(remaining), "Cancelled containers still exist" + finally: + if process.poll() is None: + process.kill() + process.wait() + print((workspace / "cancel.log").read_text()) + finally: + cleanup_records() + print("Apple container smoke tests passed") + + +if __name__ == "__main__": + main() diff --git a/tests/apple-container/verify.sh b/tests/apple-container/verify.sh new file mode 100644 index 00000000..8fb34a7b --- /dev/null +++ b/tests/apple-container/verify.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu +test "$1" = "argument with spaces" +test "$TB_APPLE_SAMPLE" = "/terrabuild-home/cache with spaces" +test "$TB_APPLE_EXPLICIT" = "explicit value" +test "$HOME" = "/terrabuild-home" +test "$TERRABUILD_HOME" = "$HOME" +test "$TMPDIR" = "/terrabuild-tmp" +test "$(uname -m)" = "aarch64" +test "$(pwd)" = "/terrabuild/$(basename "$PWD")" +home_probe=$(mktemp "$HOME/apple-smoke.XXXXXX") +tmp_probe=$(mktemp "$TMPDIR/apple-smoke.XXXXXX") +rm "$home_probe" "$tmp_probe" +mkdir -p .out +printf 'apple container output\n' > '.out/result with spaces.txt' +printf 'apple stdout\n' +printf 'apple stderr\n' >&2 diff --git a/website/pages/index.mdx b/website/pages/index.mdx index ccd28a50..33db8031 100644 --- a/website/pages/index.mdx +++ b/website/pages/index.mdx @@ -148,7 +148,7 @@ import Link from '@docusaurus/Link';
CONTAINERS

Pin the execution environment.

-

Set an image on an extension to run its actions in the same container locally and in CI. Terrabuild supports Docker and Podman.

+

Set an image on an extension to run its actions in the same container locally and in CI. Terrabuild supports Docker, Podman, and Apple Container.

extension @dotnet {'{'}
image = "mcr.microsoft.com/dotnet/sdk:9.0"
platform = "linux/arm64"
{'}'}
Containerize an extension
diff --git a/website/site-docs/extensibility/container.md b/website/site-docs/extensibility/container.md index 58f18a71..017e571a 100644 --- a/website/site-docs/extensibility/container.md +++ b/website/site-docs/extensibility/container.md @@ -3,7 +3,7 @@ title: Container --- -An extension can run actions in a container when `image` is specified. Terrabuild uses the workspace engine (`docker` or `podman`) to launch it: +An extension can run actions in a container when `image` is specified. Terrabuild uses the workspace engine (`docker`, `podman`, or `apple`) to launch it: ```terrabuild extension @terraform { @@ -38,7 +38,40 @@ Terrabuild configures container actions as follows: * The selected platform and CPU limit are applied when provided. * The workspace, Terrabuild home, and temporary directories are mounted into the container. * The working directory is the current project directory. -* Network, IPC, and PID namespaces use host mode. +* Docker and Podman use host network, IPC, and PID namespaces. Apple Container uses its own VM and default network. * Declared environment variables are forwarded to the container. * With Docker, the Docker socket is mounted only when the action command itself is `docker`. * On Linux, Docker uses the host user and group IDs; Podman uses `keep-id` user namespaces. + +## Apple Container on macOS + +On Apple silicon with macOS 26 or later, install and start Apple's Container tool: + +```bash +brew install container +container system start +terrabuild run build --engine apple +``` + +Accept the recommended Linux kernel installation when prompted. The Apple engine +has been tested with Container 1.4.1. Terrabuild requires the service to be running; +it does not install, start, stop, or upgrade the service automatically. + +You can also select `engine = ~apple` in the `workspace` block. A workspace engine +setting overrides CLI and Graph UI selection. Docker remains the default when no +engine is selected. Actions without an `image` still run on the host. + +Apple Container uses its own image store and registry credentials. An image built +locally by Docker is not automatically available to Apple Container. Build or +import it with `container`, or pull it from a registry. Selecting `apple` does not +rewrite `@docker` actions or shell commands that invoke Docker. + +Apple containers do not receive Docker's host network, PID, IPC, or socket options. +Host `localhost` and automatic development-server port exposure are therefore not +provided. Terrabuild currently uses Apple's default networking without publishing +ports. Prefer native `linux/arm64` images; other platforms depend on Apple's runtime +and any required emulation setup. + +To validate the engine on a configured Mac, run `make smoke-test-apple`. This checks +parallel builds, bind-mounted files, environment forwarding, exit codes and Ctrl+C +cleanup using temporary workspaces. diff --git a/website/site-docs/extensibility/script.md b/website/site-docs/extensibility/script.md index de96dc6f..6f277fb7 100644 --- a/website/site-docs/extensibility/script.md +++ b/website/site-docs/extensibility/script.md @@ -195,5 +195,5 @@ Imports in local scripts are resolved to workspace files. Imports in remote scri - [Protocol Types](./types) lists every context and result shape. - [Host Functions](./functions) lists the functions Terrabuild exposes to scripts. -- [Container](./container) explains how an extension runs its returned operations in Docker or Podman. +- [Container](./container) explains how an extension runs its returned operations in Docker, Podman, or Apple Container. - The normative protocol is maintained in [`docs/architecture/fscript-extension-protocol.md`](https://github.com/MagnusOpera/terrabuild/blob/main/docs/architecture/fscript-extension-protocol.md). diff --git a/website/site-docs/getting-started/environments.md b/website/site-docs/getting-started/environments.md index 361a1477..34572beb 100644 --- a/website/site-docs/getting-started/environments.md +++ b/website/site-docs/getting-started/environments.md @@ -146,7 +146,7 @@ extension @terraform { Choose the tool version and platform supported by your repository. This example forwards Azure credential variables to Terraform; adapt the variable list for your provider. The extension configuration makes those selected values part of -the target's input fingerprint. Container execution requires Docker or Podman. +the target's input fingerprint. Container execution requires Docker, Podman, or Apple Container. Use [extension specialization](../project/extension.md) for project-specific images or additional settings. Scalar settings can replace inherited values; diff --git a/website/site-docs/workspace/workspace.md b/website/site-docs/workspace/workspace.md index be434cae..dfa1eded 100644 --- a/website/site-docs/workspace/workspace.md +++ b/website/site-docs/workspace/workspace.md @@ -95,7 +95,7 @@ The following arguments are supported: Paths are evaluated from workspace root and used by extension script sandboxing. * `version` - (Optional) Minimal Terrabuild version required by this workspace. Default is `nothing`. -* `engine` - (Optional) Execution engine to use. Allowed values are `~docker`, `~podman`, and `~host`. Default is `~docker`. +* `engine` - (Optional) Execution engine to use. Allowed values are `~docker`, `~podman`, `~apple`, and `~host`. Default is `~docker`. * `configuration` - (Optional) Default configuration value exposed to the workspace. Default is `nothing`. * `environment` - (Optional) Default environment value exposed to the workspace. Default is `nothing`. From b44bcc0d6d051c22d4809a0e59f6024204dcd39a Mon Sep 17 00:00:00 2001 From: Pierre Chalamet Date: Tue, 22 Sep 2026 21:59:10 +0200 Subject: [PATCH 2/2] fix: update apple container tests after merge --- CHANGELOG.md | 1 + src/Terrabuild.Tests/Core/Runner.fs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54351561..47cbba46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Terrabuild are documented in this file. ## [Unreleased] - Run containerized builds with Apple Container on Apple silicon Macs using the apple engine. +- Keep Apple Container support compatible with the latest command execution behavior. - Remove unused extension stdout capture and the Terraform output action. ## [0.200.3] diff --git a/src/Terrabuild.Tests/Core/Runner.fs b/src/Terrabuild.Tests/Core/Runner.fs index 2e2c8de8..ab3d0ab4 100644 --- a/src/Terrabuild.Tests/Core/Runner.fs +++ b/src/Terrabuild.Tests/Core/Runner.fs @@ -487,7 +487,7 @@ let ``apple commands preserve mounts arguments and environment without docker ho let operation = buildOperation "docker" "build \"Project With Spaces\"" (Some "tool:image") let node = buildNode "apple-build" "src/My App" "build" GraphDef.RunAction.Exec [ operation ] let options = { baseOptions workspace with Engine = ConfigOptions.Engine.Apple } - let _, workDir, command, arguments, _, _, envs, _ = + let _, workDir, command, arguments, _, _, envs = Runner.buildCommandsForRuntime macRuntime node options node.ProjectDir workspace workspace |> List.exactlyOne command |> should equal "container" @@ -516,7 +516,7 @@ let ``apple engine rejects container execution on linux but permits imageless op (fun () -> Runner.buildCommandsForRuntime linuxRuntime node options "." workspace workspace |> ignore) |> should throw typeof let node = { node with Operations = [ { operation with Image = None } ] } - let _, _, command, _, _, _, _, _ = + let _, _, command, _, _, _, _ = Runner.buildCommandsForRuntime linuxRuntime node options "." workspace workspace |> List.exactlyOne command |> should equal "echo")