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
215 changes: 189 additions & 26 deletions .github/workflows/docker-sandboxes-images.yml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ flowchart LR
Remove --> Start
```

## See it in action

[![EPAR demo — disposable GitHub Actions runners with Docker Sandboxes](https://img.youtube.com/vi/5xDEFpf6iZc/maxresdefault.jpg)](https://www.youtube.com/watch?v=5xDEFpf6iZc)

Watch EPAR take a GitHub Actions job from a warm runner, execute it inside a Docker Sandboxes microVM, retire the runner after the job, and bring a clean replacement back to ready state.

[Watch the demo on YouTube →](https://www.youtube.com/watch?v=5xDEFpf6iZc)

## Why EPAR

- **Put spare compute to work** — run long-running E2E, integration, and Docker-heavy CI on machines you already operate.
Expand Down
98 changes: 93 additions & 5 deletions cmd/epar-prebuilt-publisher/workflow_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,94 @@ func TestWorkflowRepairsCatalogFirstPromotionBeforeNoop(t *testing.T) {
}
}

func TestWorkflowVerifiesMatchingPackageBeforeNoop(t *testing.T) {
workflow := strings.ReplaceAll(readPublisherWorkflow(t), "\r\n", "\n")
noop := strings.Index(workflow, " - name: Determine whether the immutable tuple is already active\n")
build := strings.Index(workflow, "\n build:\n")
if noop < 0 || build <= noop {
t.Fatalf("cannot isolate metadata-first no-op step: noop=%d build=%d", noop, build)
}
noopStep := workflow[noop:build]
for _, required := range []string{
`matching_entries="$RUNNER_TEMP/epar-catalog/matching-entries.json"`,
`matching_entry="$RUNNER_TEMP/epar-catalog/matching-entry.json"`,
`($effectiveStatus == "candidate" or $effectiveStatus == "active")`,
`$entry.gates.sourceRechecked == true`,
`$entry.gates.attestationVerified == true`,
`go run ./cmd/epar-prebuilt-publisher verify-package`,
`--reference "$package_reference"`,
`> "$RUNNER_TEMP/epar-catalog/package-verification.json"`,
`Catalog contains $match_count complete matching entries; refusing an ambiguous no-op.`,
} {
if !strings.Contains(noopStep, required) {
t.Fatalf("metadata-first no-op contract is missing %q", required)
}
}
if strings.Contains(noopStep, `$effectiveStatus == "superseded"`) {
t.Fatal("superseded catalog entries must not suppress a rebuild")
}
verify := strings.Index(noopStep, `go run ./cmd/epar-prebuilt-publisher verify-package`)
noOpAssignment := strings.Index(noopStep, "noop=true")
if verify < 0 || noOpAssignment < 0 || verify >= noOpAssignment {
t.Fatalf("immutable package verification must precede noop=true: verify=%d noop=%d", verify, noOpAssignment)
}
}

func TestWorkflowGuardsCatalogPointerAndPublishesCandidateLedgerOnMain(t *testing.T) {
workflow := strings.ReplaceAll(readPublisherWorkflow(t), "\r\n", "\n")
promote := strings.Index(workflow, " - name: Verify signed catalog and move only authorized aliases\n")
manual := strings.Index(workflow, "\n prepare-promotion-review:\n")
if promote < 0 || manual <= promote {
t.Fatalf("cannot isolate automatic catalog publication step: promote=%d manual=%d", promote, manual)
}
promoteStep := workflow[promote:manual]
for _, required := range []string{
`EXPECTED_CATALOG_MANIFEST: ${{ needs.resolve.outputs.catalog_manifest_digest }}`,
`old_catalog_digest" != "$expected_catalog_manifest"`,
`if [[ "$ALLOW_ALIAS" == true ]]; then`,
`catalog-v1 was moved; the profile alias was not moved`,
`catalog rollback skipped because the catalog pointer changed after this publication`,
} {
if !strings.Contains(promoteStep, required) {
t.Fatalf("catalog publication safety contract is missing %q", required)
}
}
reconcile := strings.Index(workflow, " - name: Reconcile an interrupted catalog-first alias promotion\n")
noop := strings.Index(workflow, " - name: Determine whether the immutable tuple is already active\n")
if reconcile < 0 || noop <= reconcile || !strings.Contains(workflow[reconcile:noop], `current_alias_digest" == "$observed"`) {
t.Fatal("alias reconciliation must compare the observed alias head again before repair")
}
}

func TestWorkflowCarriesManualCatalogHeadThroughProtectedPromotion(t *testing.T) {
workflow := strings.ReplaceAll(readPublisherWorkflow(t), "\r\n", "\n")
prepare := strings.Index(workflow, " prepare-promotion-review:\n")
manual := strings.Index(workflow, "\n manual-promote:\n")
if prepare < 0 || manual <= prepare {
t.Fatalf("cannot isolate protected promotion preparation: prepare=%d manual=%d", prepare, manual)
}
prepareJob := workflow[prepare:manual]
manualJob := workflow[manual:]
for _, required := range []string{
`catalog_manifest: ${{ steps.review.outputs.catalog_manifest }}`,
`echo "catalog_manifest=$expected_catalog_manifest" >> "$GITHUB_OUTPUT"`,
`Current catalog-v1 manifest at review`,
} {
if !strings.Contains(prepareJob, required) {
t.Fatalf("protected review must record the catalog head: missing %q", required)
}
}
for _, required := range []string{
`EXPECTED_CATALOG_MANIFEST: ${{ needs.prepare-promotion-review.outputs.catalog_manifest }}`,
`catalog-v1 changed after reviewer preparation`,
`[[ "$current_catalog_digest" == "$old_catalog_digest" ]]`,
} {
if !strings.Contains(manualJob, required) {
t.Fatalf("protected promotion must guard the reviewed catalog head: missing %q", required)
}
}
}

func TestWorkflowForceCandidatePreservesVerifiedEvidence(t *testing.T) {
workflow := readPublisherWorkflow(t)
for _, required := range []string{
Expand Down Expand Up @@ -62,7 +150,7 @@ func TestWorkflowUsesHostedBuildsAndExternalEPARAcceptance(t *testing.T) {
`runnerName:$amd64DockerHubRunner`,
`runnerName:$arm64PlaywrightRunner`,
`runnerName:$arm64DockerHubRunner`,
`catalog-v1 and the profile alias were not moved`,
`catalog-v1 was moved; the profile alias was not moved`,
} {
if !strings.Contains(workflow, required) {
t.Fatalf("publisher candidate acceptance contract is missing %q", required)
Expand Down Expand Up @@ -217,10 +305,10 @@ func TestWorkflowPreparesReviewSummaryBeforeProtectedPromotion(t *testing.T) {
func TestWorkflowBuildsAndPromotesFullWithoutPersistentNativeRunners(t *testing.T) {
workflow := readPublisherWorkflow(t)
for _, required := range []string{
`- cron: '37 */6 * * *'`,
`- cron: '7 1,7,13,19 * * *'`,
`'37 */6 * * *') profile=act`,
`'7 1,7,13,19 * * *') profile=full`,
`- cron: '37 23 */7 * *'`,
`- cron: '57 23 */7 * *'`,
`'37 23 */7 * *') profile=full`,
`'57 23 */7 * *') profile=act`,
`act|full) ;;`,
`if: needs.resolve.outputs.profile == 'full'`,
`Full publication requires at least 40 GiB free`,
Expand Down
4 changes: 3 additions & 1 deletion cmd/ephemeral-action-runner/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ func runInitWithOptions(opts initOptions) error {
return err
}
}
fmt.Fprintln(opts.Out, "EPAR first-run setup")
fmt.Fprintln(opts.Out, "Welcome to EPAR first-run setup wizard.")
fmt.Fprintln(opts.Out, "")
fmt.Fprintln(opts.Out, "This creates an EPAR runner configuration.")
fmt.Fprintln(opts.Out, "Before continuing, create a GitHub App with organization self-hosted runner read/write access.")
Expand Down Expand Up @@ -2059,6 +2059,8 @@ dockerSandboxes:
policyGeneration: %s
networkBaseline: open
architectureEmulation: %s
recoveryMode: exclusive-auto
recoveryQuiescenceSeconds: 60
stagingRoot: .local/cache/docker-sandboxes/staging
cpus: 4
memory: 8GiB
Expand Down
30 changes: 23 additions & 7 deletions cmd/ephemeral-action-runner/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1125,11 +1125,20 @@ func TestInitDockerSandboxesGeneratesDesiredImageConfigAndProvisionsTemplate(t *
if got, want := cfg.DockerSandboxes.NetworkBaseline, config.DockerSandboxesNetworkBaselineOpen; got != want {
t.Fatalf("dockerSandboxes.networkBaseline = %q, want %q", got, want)
}
if got, want := cfg.DockerSandboxes.ArchitectureEmulation, config.DockerSandboxesArchitectureEmulationBestEffort; got != want {
if got, want := cfg.DockerSandboxes.ArchitectureEmulation, config.DockerSandboxesArchitectureEmulationNativeOnly; got != want {
t.Fatalf("dockerSandboxes.architectureEmulation = %q, want wizard value %q", got, want)
}
if !strings.Contains(string(configContent), "architectureEmulation: best-effort") {
t.Fatalf("wizard config omitted best-effort architecture emulation:\n%s", configContent)
if got, want := cfg.DockerSandboxes.RecoveryMode, config.DockerSandboxesRecoveryModeExclusiveAuto; got != want {
t.Fatalf("dockerSandboxes.recoveryMode = %q, want wizard value %q", got, want)
}
if got, want := cfg.DockerSandboxes.RecoveryQuiescenceSeconds, config.DockerSandboxesDefaultRecoveryQuiescenceSeconds; got != want {
t.Fatalf("dockerSandboxes.recoveryQuiescenceSeconds = %d, want wizard value %d", got, want)
}
if !strings.Contains(string(configContent), "architectureEmulation: native-only") {
t.Fatalf("wizard config omitted native-only architecture emulation:\n%s", configContent)
}
if !strings.Contains(string(configContent), "recoveryMode: exclusive-auto") || !strings.Contains(string(configContent), "recoveryQuiescenceSeconds: 60") {
t.Fatalf("wizard config omitted exclusive automatic recovery defaults:\n%s", configContent)
}
for key, values := range map[string]struct{ got, want string }{
"rootDisk": {cfg.DockerSandboxes.RootDisk, "auto"},
Expand All @@ -1144,11 +1153,14 @@ func TestInitDockerSandboxesGeneratesDesiredImageConfigAndProvisionsTemplate(t *
t.Fatalf("init output omitted %q:\n%s", want, out.String())
}
}
for _, want := range []string{"Architecture emulation: best-effort", "QEMU/binfmt will be attempted; unsupported hosts continue with verified native containers and a warning."} {
for _, want := range []string{"Architecture emulation: native-only", "QEMU/binfmt is disabled by default; Docker Sandboxes runs native containers only."} {
if !strings.Contains(out.String(), want) {
t.Fatalf("wizard review omitted %q:\n%s", want, out.String())
}
}
if strings.Contains(out.String(), "QEMU/binfmt will be attempted") {
t.Fatalf("wizard review unexpectedly enabled the QEMU attempt by default:\n%s", out.String())
}
setupHints := " Choose how EPAR should provision the reusable runner artifact during startup.\n Docker Sandboxes profiles must include a private Docker daemon; specialized and custom tags are not admitted.\n Image catalog: https://github.com/catthehacker/docker_images#images-available\n\nRunner base image:"
if !strings.Contains(out.String(), setupHints) {
t.Fatalf("Docker Sandboxes setup hints were not grouped before the option list:\n%s", out.String())
Expand Down Expand Up @@ -1288,6 +1300,7 @@ func TestDockerSandboxesPrebuiltConfigRendersExplicitDistributionAndReference(t
"distribution: prebuilt",
"prebuiltReference: " + config.DockerSandboxesPrebuiltActReference,
"sourceImage: ghcr.io/catthehacker/ubuntu:act-latest",
"architectureEmulation: best-effort",
"updateFrequency: weekly",
"updateTime: \"07:00\"",
} {
Expand Down Expand Up @@ -2082,7 +2095,7 @@ func TestInitPromotedDockerSandboxesDefaultsOnlyAfterPassingPreflight(t *testing
if got, want := cfg.DockerSandboxes.PolicyGeneration, record.PolicyFingerprint; got != want {
t.Fatalf("dockerSandboxes.policyGeneration = %q, want %q", got, want)
}
if got, want := cfg.DockerSandboxes.ArchitectureEmulation, config.DockerSandboxesArchitectureEmulationBestEffort; got != want {
if got, want := cfg.DockerSandboxes.ArchitectureEmulation, config.DockerSandboxesArchitectureEmulationNativeOnly; got != want {
t.Fatalf("dockerSandboxes.architectureEmulation = %q, want Windows wizard value %q", got, want)
}
for key, values := range map[string]struct {
Expand All @@ -2100,7 +2113,7 @@ func TestInitPromotedDockerSandboxesDefaultsOnlyAfterPassingPreflight(t *testing
if err != nil {
t.Fatal(err)
}
for _, required := range []string{"type: docker-sandboxes", "dockerSandboxes:", "epar-docker-sandboxes", "policyGeneration: " + record.PolicyFingerprint, "architectureEmulation: best-effort"} {
for _, required := range []string{"type: docker-sandboxes", "dockerSandboxes:", "epar-docker-sandboxes", "policyGeneration: " + record.PolicyFingerprint, "architectureEmulation: native-only", "recoveryMode: exclusive-auto", "recoveryQuiescenceSeconds: 60"} {
if !strings.Contains(string(configText), required) {
t.Fatalf("generated Docker Sandboxes config omitted %q:\n%s", required, configText)
}
Expand All @@ -2113,11 +2126,14 @@ func TestInitPromotedDockerSandboxesDefaultsOnlyAfterPassingPreflight(t *testing
if !strings.Contains(out.String(), "PASS: the exact promoted platform") || !strings.Contains(out.String(), "Docker Sandboxes — recommended (default)") {
t.Fatalf("init output did not explain the promoted default:\n%s", out.String())
}
for _, want := range []string{"Architecture emulation: best-effort", "QEMU/binfmt will be attempted; unsupported hosts continue with verified native containers and a warning."} {
for _, want := range []string{"Architecture emulation: native-only", "QEMU/binfmt is disabled by default; Docker Sandboxes runs native containers only."} {
if !strings.Contains(out.String(), want) {
t.Fatalf("Windows wizard review omitted %q:\n%s", want, out.String())
}
}
if strings.Contains(out.String(), "QEMU/binfmt will be attempted") {
t.Fatalf("Windows wizard review unexpectedly enabled the QEMU attempt by default:\n%s", out.String())
}
}

func TestPromotedDockerSandboxesPlatformUsesSharedHostGuestMapping(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion cmd/ephemeral-action-runner/init_wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,8 @@ func promptInitReview(out io.Writer, reader *bufio.Reader, draft initWizardDraft
fmt.Fprintf(out, " Architecture emulation: %s\n", architectureEmulation)
if architectureEmulation == config.DockerSandboxesArchitectureEmulationBestEffort {
fmt.Fprintln(out, " QEMU/binfmt will be attempted; unsupported hosts continue with verified native containers and a warning.")
} else if architectureEmulation == config.DockerSandboxesArchitectureEmulationNativeOnly {
fmt.Fprintln(out, " QEMU/binfmt is disabled by default; Docker Sandboxes runs native containers only.")
}
}
default:
Expand Down Expand Up @@ -576,5 +578,5 @@ func renderInitWizardConfig(draft initWizardDraft) (string, error) {
}

func initDockerSandboxesArchitectureEmulation() string {
return config.DockerSandboxesArchitectureEmulationBestEffort
return config.DockerSandboxesArchitectureEmulationNativeOnly
}
9 changes: 6 additions & 3 deletions configs/docker-sandboxes.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,12 @@ dockerSandboxes:
# The wizard reads and records the exact active host-global policy fingerprint.
policyGeneration: sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
networkBaseline: open
# best-effort attempts bundled QEMU/binfmt, then continues with a warning when the sandbox supports native containers only.
# Use required only when every runner must execute foreign-architecture containers.
architectureEmulation: best-effort
# native-only disables QEMU/binfmt by default and verifies the native guest and private Docker architecture.
# Use best-effort or required only after validating the exact foreign-architecture workload.
architectureEmulation: native-only
# exclusive-auto permits one bounded stop-wait-start recovery per create-admission incident; inventory recovery keeps its bounded backoff retries, and observe never mutates sbx.
recoveryMode: exclusive-auto
recoveryQuiescenceSeconds: 60
additionalAllow:
- api.github.com
- '*.githubusercontent.com:443'
Expand Down
4 changes: 2 additions & 2 deletions docs/advanced/cross-architecture-containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The setup helper is privileged. Use it only in trusted workflows and treat its p
| Docker Container | Run the setup action inside the disposable job before Docker or Compose uses a foreign image. No EPAR configuration key enables universal emulation. |
| WSL | Run the setup action inside the WSL runner if its Linux Docker daemon needs a foreign image. An x64 WSL runner does not gain ARM64 execution merely by pulling an ARM64 image. |
| Tart on Apple Silicon (retired) | The guest is ARM64. The optional Rosetta path is retained for existing configurations and is not equivalent to QEMU/binfmt. Use a distinct label and validate the exact image/workload. |
| Docker Sandboxes | Keep `provider.platform` native. The default `best-effort` mode tries the bundled QEMU/binfmt handlers and warns when only native execution is available. Set `required` only when foreign execution must be an admission requirement; `native-only` skips the attempt. |
| Docker Sandboxes | Keep `provider.platform` native. The default `native-only` mode skips QEMU/binfmt. Prefer a native or multi-platform image; `best-effort` and `required` are explicit opt-ins and are not a complete cross-architecture guarantee in `sbx` v0.39. |
| GitHub-hosted Windows or macOS | These labels do not replace a Linux Docker daemon for container actions or service containers. Use a suitable Linux execution surface. |

Keep architecture-specific jobs on a distinct `runs-on` label. Do not label an ARM64 runner as `ubuntu-latest`: GitHub's `ubuntu-latest` is a GitHub-managed environment, and x64 assumptions can fail on ARM64.
Expand All @@ -65,7 +65,7 @@ For an amd64-only service on an ARM64 host, first try a published ARM64 or multi

For an ARM64 image on an x64 Linux runner, follow the same process with `platforms: arm64` and `--platform linux/arm64`. Never treat a successful `docker pull` as the proof; run a container and check both the expected architecture output and the real workload.

Docker Sandboxes has no fixed advertised target matrix. `best-effort` and `required` install every handler available from the immutable host-platform `tonistiigi/binfmt` artifact and let QEMU handle an executable when a registered ELF signature matches. In `best-effort`, missing sandbox-kernel support produces a controller warning and foreign images fail normally at execution time while native jobs continue. `native-only` deliberately makes no foreign-execution claim.
Docker Sandboxes has no fixed advertised target matrix. The default `native-only` mode deliberately makes no foreign-execution claim. `best-effort` and `required` install every handler available from the immutable host-platform `tonistiigi/binfmt` artifact and let QEMU handle an executable when a registered ELF signature matches, but `sbx` v0.39 does not fully support this path. In `best-effort`, missing sandbox-kernel support produces a controller warning and foreign images fail normally at execution time while native jobs continue. For a job that requires another architecture, use a native target-architecture EPAR machine with Docker Sandboxes or a separate Docker Container configuration with trusted Docker-in-Docker QEMU support.

## References

Expand Down
2 changes: 1 addition & 1 deletion docs/advanced/docker-sandboxes-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ The direct build does not create a Docker staging image. Once the imported templ

The source lock pins build tooling, Tini, helper inputs, and platform-specific inputs. The default policy checks mutable source and Actions runner selectors weekly at 07:00 local time; `./start image update` checks immediately. EPAR activates a new immutable template only after build, import, and exact readback succeed.

Docker Sandboxes native-platform operation is current on Linux, macOS, and Windows hosts based on completed cross-platform build, import, lifecycle, and cleanup testing. Architecture capability is separate: the wizard uses `best-effort`, which enables QEMU where sandbox-local `binfmt_misc` works and otherwise admits only the verified native platform with a warning. Run local admission and workload validation for the exact host and workload; this status does not claim independent certification. Any future certification record should bind the reviewed native-controller source/build, full template identity, cache ID, metadata/archive digests, architecture mode, and reviewed evidence.
Docker Sandboxes native-platform operation is current on Linux, macOS, and Windows hosts based on completed cross-platform build, import, lifecycle, and cleanup testing. Architecture capability is separate: the wizard uses `native-only`, which skips QEMU and admits only the verified native platform. Run local admission and workload validation for the exact host and workload; this status does not claim independent cross-architecture certification. Any future certification record should bind the reviewed native-controller source/build, full template identity, cache ID, metadata/archive digests, architecture mode, and reviewed evidence.
Loading
Loading