Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Keep Apple Container support compatible with the latest command execution behavior.
- Remove unused extension stdout capture and the Terraform output action.

## [0.200.3]
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
30 changes: 27 additions & 3 deletions docs/architecture/container-engines.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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 <target-hash>
run --rm --name terrabuild-<node-slug>-<nonce>
```

Then it adds:
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/architecture/graph-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions src/Terrabuild.Tests/Core/Configuration.fs
Original file line number Diff line number Diff line change
Expand Up @@ -749,3 +749,22 @@ target build {
"""

Assert.That(Action(fun () -> Configuration.read (baseOptions root (Set [ "build" ])) |> ignore), Throws.TypeOf<TerrabuildException>()))

[<TestCase(true)>]
[<TestCase(false)>]
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))
8 changes: 8 additions & 0 deletions src/Terrabuild.Tests/Core/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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"

[<Test>]
let ``CLI accepts apple engine for run and explain`` () =
let parser = ArgumentParser.Create<CLI.TerrabuildArgs>(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
52 changes: 52 additions & 0 deletions src/Terrabuild.Tests/Core/Runner.fs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,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")

[<Test>]
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))

[<Test>]
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<Errors.TerrabuildException>
let node = { node with Operations = [ { operation with Image = None } ] }
let _, _, command, _, _, _, _ =
Runner.buildCommandsForRuntime linuxRuntime node options "." workspace workspace |> List.exactlyOne
command |> should equal "echo")

[<Test>]
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

[<Test>]
let ``buildCommands mounts docker socket only for docker client commands`` () =
withTempWorkspace (fun workspace ->
Expand Down
1 change: 1 addition & 0 deletions src/Terrabuild.UI/src/components/BuildControlsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down
5 changes: 3 additions & 2 deletions src/Terrabuild/CLI.fs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ open Argu
type Engine =
| Docker
| Podman
| Apple
| Host

[<RequireQualifiedAccess>]
Expand Down Expand Up @@ -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."

[<RequireQualifiedAccess>]
Expand Down Expand Up @@ -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)."

[<RequireQualifiedAccess>]
type ImpactArgs =
Expand Down
1 change: 1 addition & 0 deletions src/Terrabuild/Contracts/ConfigOptions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ open Contracts
type Engine =
| Docker
| Podman
| Apple
| Host

[<RequireQualifiedAccess>]
Expand Down
1 change: 1 addition & 0 deletions src/Terrabuild/Core/Configuration.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"

Expand Down
34 changes: 34 additions & 0 deletions src/Terrabuild/Core/Runner.fs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type private ApiBuildLifecycle(api: Contracts.IApiClient option) =
type private EngineRequestPath =
| Docker
| Podman
| Apple
| Host

module private Native =
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -334,7 +349,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 =
Expand Down
12 changes: 8 additions & 4 deletions src/Terrabuild/Helpers/Exec.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -199,17 +203,17 @@ 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 () =
let profile = FS.combinePath ("HOME" |> Environment.envVar |> Option.get) ".terrabuild"
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
Expand Down
1 change: 1 addition & 0 deletions src/Terrabuild/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ let processCommandLine (parser: ArgumentParser<TerrabuildArgs>) (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
Expand Down
1 change: 1 addition & 0 deletions src/Terrabuild/Web/GraphServer.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/apple-container/PROJECT
Original file line number Diff line number Diff line change
@@ -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'" }
}
15 changes: 15 additions & 0 deletions tests/apple-container/WORKSPACE
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading