From f38b64d5dc499d21bc071d4698cba3a6702351f8 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 22 Jul 2026 11:24:41 +0800 Subject: [PATCH 01/22] Harden runner group selection and preflight --- README.md | 3 +- cmd/ephemeral-action-runner/init.go | 378 +++++++++++++++++++++- cmd/ephemeral-action-runner/init_test.go | 351 +++++++++++++++++++- cmd/ephemeral-action-runner/start.go | 8 + cmd/ephemeral-action-runner/start_test.go | 66 +++- configs/docker-dind.act.example.yml | 9 + configs/docker-dind.core.example.yml | 8 + configs/docker-dind.example.yml | 9 + configs/docker-dind.web-e2e.example.yml | 9 + configs/tart.example.yml | 9 + configs/tart.web-e2e.example.yml | 9 + configs/wsl.example.yml | 9 + configs/wsl.lean.example.yml | 9 + configs/wsl.web-e2e.example.yml | 9 + docs/configuration.md | 21 +- docs/core-runner-verification.md | 13 +- docs/github-app.md | 3 +- docs/runner-groups.md | 54 ++++ docs/security.md | 2 +- docs/troubleshooting.md | 24 ++ docs/usage.md | 4 +- internal/config/config.go | 127 +++++++- internal/config/config_test.go | 93 +++++- internal/github/runner_groups.go | 175 ++++++++++ internal/github/runner_groups_test.go | 279 ++++++++++++++++ internal/pool/manager.go | 49 +++ internal/pool/manager_test.go | 70 ++++ scripts/ci/core-runner-controller.sh | 10 + scripts/run-with-docker.ps1 | 39 ++- scripts/test/no-go-first-run-smoke.ps1 | 29 +- 30 files changed, 1814 insertions(+), 64 deletions(-) create mode 100644 docs/runner-groups.md create mode 100644 internal/github/runner_groups.go create mode 100644 internal/github/runner_groups_test.go diff --git a/README.md b/README.md index 7dd66e2..7a8c0a7 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ That's it. #### What Happens -EPAR initializes `.local/config.yml` for you if it does not exist. Docker-DinD is the default. The wizard asks whether new Docker-DinD runners should inherit the host's trusted TLS roots and defaults to yes. On native Windows, it also offers WSL2 when `wsl.exe --status` confirms default version 2. On macOS, it offers experimental Tart mode when `tart --version` succeeds. Press Enter to keep Docker-DinD. Existing configs do not enable host trust inheritance automatically. You can customize the config afterward; see [Configuration](docs/configuration.md). +EPAR initializes `.local/config.yml` for you if it does not exist. The wizard reads your organization's runner groups, requires an explicit selection, and writes an enforced safety policy; see [Runner Group Security](docs/runner-groups.md). Docker-DinD is the default. The wizard asks whether new Docker-DinD runners should inherit the host's trusted TLS roots and defaults to yes. On native Windows, it also offers WSL2 when `wsl.exe --status` confirms default version 2. On macOS, it offers experimental Tart mode when `tart --version` succeeds. Press Enter to keep Docker-DinD. Existing configs do not enable host trust inheritance automatically. You can customize the config afterward; see [Configuration](docs/configuration.md). Then EPAR checks the configured runner image, builds or replaces it when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. @@ -154,6 +154,7 @@ GitHub also warns against using self-hosted runners with public repositories tha - [Usage](docs/usage.md): setup, image builds, verification, and pool commands. - [Configuration](docs/configuration.md): config file sections and common edits. - [GitHub App Setup](docs/github-app.md): required GitHub App permissions and fields. +- [Runner Group Security](docs/runner-groups.md): repository access, wizard choices, and registration preflight policy. - [Docker-DinD Provider](docs/providers/docker-dind.md): default Docker runner mode. - [WSL Provider](docs/providers/wsl.md): Windows WSL2 runners. - [Tart Provider (experimental)](docs/providers/tart.md): Apple Silicon ARM64 Linux VM runners and Rosetta compatibility limits. diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index f47271f..d154505 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -14,12 +14,14 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strconv" "strings" "time" "unicode/utf16" "github.com/solutionforest/ephemeral-action-runner/internal/config" + gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" ) @@ -41,6 +43,15 @@ var initTartVersion = tartVersion var initResolveHostTrust = hosttrust.Resolve +type initRunnerGroupClient interface { + ListRunnerGroups(context.Context) ([]gh.RunnerGroup, error) + ListRunnerGroupRepositories(context.Context, int64) ([]gh.RunnerGroupRepository, error) +} + +var newInitRunnerGroupClient = func(cfg config.GitHubConfig) initRunnerGroupClient { + return gh.New(cfg) +} + func detectedInitHostTrustOS() string { if hostOS := strings.TrimSpace(os.Getenv("EPAR_CONTROLLER_HOST_OS")); hostOS != "" { return hostOS @@ -49,6 +60,7 @@ func detectedInitHostTrustOS() string { } type initOptions struct { + Context context.Context ProjectRoot string ConfigPath string Force bool @@ -80,6 +92,7 @@ func runInit(args []string) error { } return runInitWithOptions(initOptions{ + Context: interruptContext(), ProjectRoot: projectRoot, ConfigPath: configPath, Force: *force, @@ -91,6 +104,9 @@ func runInit(args []string) error { } func runInitWithOptions(opts initOptions) error { + if opts.Context == nil { + opts.Context = context.Background() + } if opts.In == nil { opts.In = os.Stdin } @@ -127,6 +143,17 @@ func runInitWithOptions(opts initOptions) error { if err != nil { return err } + githubConfig := config.GitHubConfig{ + AppID: appID, + Organization: organization, + PrivateKeyPath: resolveInitPrivateKeyPath(opts.ProjectRoot, privateKeyPath), + APIBaseURL: "https://api.github.com", + WebBaseURL: "https://github.com", + } + runnerGroup, err := promptRunnerGroup(opts.Context, opts.Out, reader, newInitRunnerGroupClient(githubConfig)) + if err != nil { + return err + } providerType := "docker-dind" if wsl2Available() { providerType, err = promptProviderType(opts.Out, reader, "wsl") @@ -185,12 +212,12 @@ func runInitWithOptions(opts initOptions) error { } } - content := defaultDockerDindConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes) + content := defaultDockerDindConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup) switch providerType { case "wsl": - content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix) + content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup) case "tart": - content = defaultTartConfig(appID, organization, privateKeyPath, poolNamePrefix) + content = defaultTartConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup) } if err := os.MkdirAll(filepath.Dir(opts.ConfigPath), 0755); err != nil { return err @@ -213,6 +240,312 @@ Manual/advanced: return nil } +type initRunnerGroupSelection struct { + Group gh.RunnerGroup + Policy config.RunnerGroupSecurityConfig +} + +func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, client initRunnerGroupClient) (initRunnerGroupSelection, error) { + var groups []gh.RunnerGroup + var repositories map[int64][]gh.RunnerGroupRepository + for { + if groups == nil { + var err error + groups, repositories, err = loadInitRunnerGroups(ctx, client) + if err != nil { + return initRunnerGroupSelection{}, fmt.Errorf("load GitHub runner groups: %w", err) + } + } + fmt.Fprintln(out, "") + fmt.Fprintln(out, "GitHub runner group:") + fmt.Fprintln(out, " Choose which repositories GitHub may route to these self-hosted runners.") + fmt.Fprintln(out, " Groups are ordered from the most restrictive policy to the least restrictive policy; the first item is not an automatic default.") + fmt.Fprintln(out, "") + fmt.Fprintln(out, " Repository access meanings:") + fmt.Fprintln(out, " Selected repositories: Only repositories explicitly added to the group can use its runners. This is recommended.") + fmt.Fprintln(out, " All private repositories: Every current and future private repository in the organization can use its runners.") + fmt.Fprintln(out, " All repositories: Every current and future repository allowed by the public-repository setting can use its runners.") + fmt.Fprintln(out, "") + fmt.Fprintln(out, " Recommended policy: a non-default group, Selected repositories, trusted repositories only, and public repository access disabled.") + fmt.Fprintln(out, " See docs/runner-groups.md for details.") + for i, group := range groups { + printRunnerGroupChoice(out, i+1, group, repositories[group.ID]) + } + fmt.Fprintln(out, " R. Refresh runner groups") + fmt.Fprintln(out, " Q. Quit without writing a config") + + choice, err := promptRequired(out, reader, "Runner group choice") + if err != nil { + return initRunnerGroupSelection{}, err + } + switch strings.ToLower(choice) { + case "r", "refresh": + groups = nil + repositories = nil + continue + case "q", "quit": + return initRunnerGroupSelection{}, fmt.Errorf("runner-group selection cancelled; no config was written") + } + index, parseErr := strconv.Atoi(choice) + if parseErr != nil || index < 1 || index > len(groups) { + fmt.Fprintf(out, "Choose a runner group number, R to refresh, or Q to quit.\n") + continue + } + group := groups[index-1] + selectedRepositories := repositories[group.ID] + _, publicCount := repositoryPrivacyCounts(selectedRepositories) + if runnerGroupVisibilityRank(group.Visibility) == 3 { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "*** SECURITY BLOCK: GITHUB RETURNED AN UNKNOWN REPOSITORY-ACCESS POLICY ***") + fmt.Fprintf(out, "Runner group %q uses repository access %q, which this EPAR version cannot evaluate safely.\n", group.Name, group.Visibility) + fmt.Fprintln(out, "RECOMMENDED ACTION: Do not select this group. Review its policy in GitHub, update EPAR if support is available, and choose Refresh; otherwise choose another documented group.") + refresh, err := promptBackRefreshQuit(out, reader) + if err != nil { + return initRunnerGroupSelection{}, err + } + if refresh { + groups = nil + repositories = nil + } + continue + } + if group.AllowsPublicRepositories || publicCount > 0 { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "*** SECURITY BLOCK: THIS RUNNER GROUP IS NOT ALLOWED BY EPAR'S SAFE DEFAULTS ***") + fmt.Fprintf(out, "Runner group %q permits public repository access. A public repository or fork-triggered workflow may run untrusted code on a self-hosted runner and reach the runner host or connected services.\n", group.Name) + fmt.Fprintln(out, "RECOMMENDED ACTION: Do not use this group for a normal EPAR deployment. Follow docs/runner-groups.md to create a dedicated non-default group, allow only explicitly selected trusted repositories, and disable public repository access. Then return here and choose that group.") + fmt.Fprintln(out, "If you intentionally operate a separately reviewed public-project deployment, finish initialization with a secure group first and document any manual policy override afterward.") + refresh, err := promptBackRefreshQuit(out, reader) + if err != nil { + return initRunnerGroupSelection{}, err + } + if refresh { + groups = nil + repositories = nil + } + continue + } + + warnings := runnerGroupSelectionWarnings(group) + if len(warnings) > 0 { + fmt.Fprintln(out, "") + notRecommended := runnerGroupDoesNotMeetRecommendedPolicy(group) + if notRecommended { + fmt.Fprintln(out, "*** SECURITY WARNING: THIS RUNNER GROUP IS NOT RECOMMENDED ***") + } else { + fmt.Fprintln(out, "*** SECURITY ADVISORY: ENTERPRISE-MANAGED RUNNER GROUP ***") + } + fmt.Fprintf(out, "Runner group %q requires explicit review:\n", group.Name) + for _, warning := range warnings { + fmt.Fprintf(out, " - %s\n", warning) + } + continueLabel := "Continue after confirming the enterprise-managed policy" + if notRecommended { + fmt.Fprintln(out, "RECOMMENDED ACTION: Choose Back. Follow docs/runner-groups.md to create a dedicated non-default group with Selected repositories and public repository access disabled, then select that safer group.") + fmt.Fprintln(out, "Continuing will deliberately relax the generated policy to match this broader group. Future repositories may gain access without another EPAR configuration change.") + continueLabel = "Continue anyway and generate a relaxed policy" + } + continueSelection, err := promptContinueOrBack(out, reader, continueLabel) + if err != nil { + return initRunnerGroupSelection{}, err + } + if !continueSelection { + continue + } + } + return initRunnerGroupSelection{ + Group: group, + Policy: config.RunnerGroupSecurityConfig{ + Enforcement: config.RunnerGroupEnforcementEnforce, + RequireExplicitGroup: true, + RequireNonDefaultGroup: !group.Default, + RequiredRepositoryAccess: group.Visibility, + RequirePublicRepositoriesDisabled: true, + }, + }, nil + } +} + +func loadInitRunnerGroups(ctx context.Context, client initRunnerGroupClient) ([]gh.RunnerGroup, map[int64][]gh.RunnerGroupRepository, error) { + groups, err := client.ListRunnerGroups(ctx) + if err != nil { + return nil, nil, err + } + if len(groups) == 0 { + return nil, nil, fmt.Errorf("GitHub returned no runner groups for the organization") + } + groups = sortRunnerGroupsForWizard(groups) + repositories := make(map[int64][]gh.RunnerGroupRepository) + for _, group := range groups { + if group.Visibility != config.RunnerGroupRepositoryAccessSelected { + continue + } + selected, err := client.ListRunnerGroupRepositories(ctx, group.ID) + if err != nil { + return nil, nil, err + } + repositories[group.ID] = selected + } + return groups, repositories, nil +} + +func sortRunnerGroupsForWizard(groups []gh.RunnerGroup) []gh.RunnerGroup { + ordered := append([]gh.RunnerGroup(nil), groups...) + sort.SliceStable(ordered, func(i, j int) bool { + left, right := ordered[i], ordered[j] + if left.AllowsPublicRepositories != right.AllowsPublicRepositories { + return !left.AllowsPublicRepositories + } + if leftRank, rightRank := runnerGroupVisibilityRank(left.Visibility), runnerGroupVisibilityRank(right.Visibility); leftRank != rightRank { + return leftRank < rightRank + } + if left.Default != right.Default { + return !left.Default + } + if left.Inherited != right.Inherited { + return !left.Inherited + } + return strings.ToLower(left.Name) < strings.ToLower(right.Name) + }) + return ordered +} + +func runnerGroupVisibilityRank(visibility string) int { + switch visibility { + case config.RunnerGroupRepositoryAccessSelected: + return 0 + case config.RunnerGroupRepositoryAccessPrivate: + return 1 + case config.RunnerGroupRepositoryAccessAll: + return 2 + default: + return 3 + } +} + +func printRunnerGroupChoice(out io.Writer, number int, group gh.RunnerGroup, repositories []gh.RunnerGroupRepository) { + privateCount, publicCount := repositoryPrivacyCounts(repositories) + fmt.Fprintf(out, "\n %d. %s\n", number, group.Name) + switch group.Visibility { + case config.RunnerGroupRepositoryAccessSelected: + fmt.Fprintf(out, " Repository access: Selected repositories — only the %d private and %d public repositories explicitly selected in GitHub can use this group.\n", privateCount, publicCount) + case config.RunnerGroupRepositoryAccessPrivate: + fmt.Fprintln(out, " Repository access: All private repositories — every current and future private repository in the organization can use this group.") + case config.RunnerGroupRepositoryAccessAll: + fmt.Fprintln(out, " Repository access: All repositories — every current and future repository permitted by the public-repository setting can use this group.") + default: + fmt.Fprintf(out, " Repository access: Unknown GitHub value %q — do not select this group until its policy can be understood.\n", group.Visibility) + } + if group.AllowsPublicRepositories || publicCount > 0 { + fmt.Fprintln(out, " Public repositories: ALLOWED — public or fork-triggered workflows may reach self-hosted runners.") + } else { + fmt.Fprintln(out, " Public repositories: Disabled — public repositories cannot use this group.") + } + groupTypes := []string{"organization-managed", "non-default"} + if group.Default { + groupTypes = []string{"GitHub default group"} + } + if group.Inherited { + groupTypes = append(groupTypes, "inherited from the enterprise") + } + fmt.Fprintf(out, " Group type: %s.\n", strings.Join(groupTypes, ", ")) + switch { + case group.AllowsPublicRepositories || publicCount > 0: + fmt.Fprintln(out, " Assessment: BLOCKED BY WIZARD — does not satisfy the public-repository safety requirement.") + case runnerGroupDoesNotMeetRecommendedPolicy(group): + fmt.Fprintln(out, " Assessment: NOT RECOMMENDED — requires an explicit warning and a relaxed generated policy.") + case group.Inherited: + fmt.Fprintln(out, " Assessment: REVIEW REQUIRED — access is restrictive, but policy changes are controlled at enterprise level.") + default: + fmt.Fprintln(out, " Assessment: RECOMMENDED — matches EPAR's strict generated policy.") + } +} + +func repositoryPrivacyCounts(repositories []gh.RunnerGroupRepository) (privateCount, publicCount int) { + for _, repository := range repositories { + if repository.Private { + privateCount++ + } else { + publicCount++ + } + } + return privateCount, publicCount +} + +func runnerGroupSelectionWarnings(group gh.RunnerGroup) []string { + var warnings []string + if group.Default { + warnings = append(warnings, "This is GitHub's default runner group. New or unintended repositories may gain access as organization policy changes.") + } + switch group.Visibility { + case config.RunnerGroupRepositoryAccessPrivate: + warnings = append(warnings, "This group is available to every private repository in the organization, including repositories created later.") + case config.RunnerGroupRepositoryAccessAll: + warnings = append(warnings, "This group is available to every repository allowed by its public-repository setting, including repositories created later.") + } + if group.Inherited { + warnings = append(warnings, "This group is inherited from the enterprise. Its policy must be reviewed and changed at enterprise level.") + } + return warnings +} + +func runnerGroupDoesNotMeetRecommendedPolicy(group gh.RunnerGroup) bool { + return group.Default || group.Visibility != config.RunnerGroupRepositoryAccessSelected +} + +func promptContinueOrBack(out io.Writer, reader *bufio.Reader, continueLabel string) (bool, error) { + fmt.Fprintf(out, " 1. %s\n", continueLabel) + fmt.Fprintln(out, " 2. Back to group selection") + for { + choice, err := promptRequired(out, reader, "Choice") + if err != nil { + return false, err + } + switch strings.ToLower(choice) { + case "1", "continue": + return true, nil + case "2", "back": + return false, nil + default: + fmt.Fprintln(out, "Choose 1 to continue or 2 to go back.") + } + } +} + +func promptBackRefreshQuit(out io.Writer, reader *bufio.Reader) (bool, error) { + fmt.Fprintln(out, " 1. Back to group selection") + fmt.Fprintln(out, " 2. Refresh runner groups") + fmt.Fprintln(out, " 3. Quit without writing a config") + for { + choice, err := promptRequired(out, reader, "Choice") + if err != nil { + return false, err + } + switch strings.ToLower(choice) { + case "1", "back": + return false, nil + case "2", "refresh": + return true, nil + case "3", "quit": + return false, fmt.Errorf("runner-group selection cancelled; no config was written") + default: + fmt.Fprintln(out, "Choose 1 to go back, 2 to refresh, or 3 to quit.") + } + } +} + +func resolveInitPrivateKeyPath(projectRoot, path string) string { + if path == "~" || strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + if path == "~" { + return home + } + return filepath.Join(home, path[2:]) + } + } + return config.ProjectPath(projectRoot, path) +} + func promptRequired(out io.Writer, reader *bufio.Reader, label string) (string, error) { for { fmt.Fprintf(out, "%s: ", label) @@ -499,7 +832,7 @@ func (b *boundedBuffer) Write(p []byte) (int, error) { return b.Buffer.Write(p) } -func defaultDockerDindConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string) string { +func defaultDockerDindConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection) string { return fmt.Sprintf(`github: appId: %d organization: %s @@ -546,10 +879,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: %s labels: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: %s + requireExplicitGroup: %t + requireNonDefaultGroup: %t + requiredRepositoryAccess: %s + requirePublicRepositoriesDisabled: %t + provider: type: docker-dind sourceImage: epar-docker-dind-catthehacker-ubuntu @@ -563,10 +905,10 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix) +`, appID, organization, privateKeyPath, hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } -func defaultWSLConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string) string { +func defaultWSLConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection) string { return fmt.Sprintf(`github: appId: %d organization: %s @@ -614,10 +956,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: %s labels: [self-hosted, linux, X64, epar-wsl-catthehacker-ubuntu] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: %s + requireExplicitGroup: %t + requireNonDefaultGroup: %t + requiredRepositoryAccess: %s + requirePublicRepositoriesDisabled: %t + provider: type: wsl sourceImage: work/images/epar-wsl-catthehacker-ubuntu.tar @@ -632,10 +983,10 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, poolNamePrefix) +`, appID, organization, privateKeyPath, poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } -func defaultTartConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string) string { +func defaultTartConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection) string { return fmt.Sprintf(`# Experimental: this default is a basic Ubuntu ARM64 Tart VM, not a GitHub-hosted runner image. # It does not include the broad dependency set from https://github.com/actions/runner-images. github: @@ -683,10 +1034,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: %s labels: [self-hosted, linux, ARM64, epar-tart-ubuntu-24.04-base] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: %s + requireExplicitGroup: %t + requireNonDefaultGroup: %t + requiredRepositoryAccess: %s + requirePublicRepositoriesDisabled: %t + provider: type: tart sourceImage: epar-ubuntu-24-arm64 @@ -700,7 +1060,7 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, poolNamePrefix) +`, appID, organization, privateKeyPath, poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } var stdinIsInteractive = func() bool { diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index 641f65d..252bc6d 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -13,6 +13,7 @@ import ( "unicode/utf16" "github.com/solutionforest/ephemeral-action-runner/internal/config" + gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" ) @@ -29,7 +30,7 @@ func TestInitCreatesDefaultDockerDindConfig(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -42,6 +43,13 @@ func TestInitCreatesDefaultDockerDindConfig(t *testing.T) { if cfg.GitHub.AppID != 123456 || cfg.GitHub.Organization != "solutionforest" || cfg.GitHub.PrivateKeyPath != ".local/github-app.pem" { t.Fatalf("unexpected GitHub config: %+v", cfg.GitHub) } + if cfg.Runner.Group != "restricted group" { + t.Fatalf("runner.group = %q, want selected group", cfg.Runner.Group) + } + policy := cfg.Security.RunnerGroup + if policy.Enforcement != config.RunnerGroupEnforcementEnforce || !policy.RequireExplicitGroup || !policy.RequireNonDefaultGroup || policy.RequiredRepositoryAccess != config.RunnerGroupRepositoryAccessSelected || !policy.RequirePublicRepositoriesDisabled { + t.Fatalf("unexpected generated runner-group policy: %+v", policy) + } if got, want := cfg.Provider.Type, "docker-dind"; got != want { t.Fatalf("provider.type = %q, want %q", got, want) } @@ -98,6 +106,282 @@ func TestInitCreatesDefaultDockerDindConfig(t *testing.T) { if !strings.Contains(out.String(), "Pool name prefix (press Enter to use build-box-01-a4f9c2):") { t.Fatalf("init output did not explain default prefix acceptance:\n%s", out.String()) } + for _, want := range []string{"Repository access meanings:", "Selected repositories: Only repositories explicitly added", "Assessment: RECOMMENDED"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("init output did not explain runner-group policy term %q:\n%s", want, out.String()) + } + } +} + +func TestInitAllowsWarnedDefaultGroupAndWritesMatchingPolicy(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + setInitRunnerGroupClient(t, &fakeInitRunnerGroupClient{groups: []gh.RunnerGroup{{ID: 1, Name: "Default", Visibility: config.RunnerGroupRepositoryAccessAll, Default: true}}}) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123\nexample\nkey.pem\n1\n1\n\nn\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + policy := cfg.Security.RunnerGroup + if cfg.Runner.Group != "Default" || policy.RequireNonDefaultGroup || policy.RequiredRepositoryAccess != config.RunnerGroupRepositoryAccessAll || !policy.RequirePublicRepositoriesDisabled { + t.Fatalf("unexpected default-group config: group=%q policy=%+v", cfg.Runner.Group, policy) + } + if !strings.Contains(out.String(), "*** SECURITY WARNING: THIS RUNNER GROUP IS NOT RECOMMENDED ***") || !strings.Contains(out.String(), "New or unintended repositories") || !strings.Contains(out.String(), "RECOMMENDED ACTION: Choose Back") || !strings.Contains(out.String(), "docs/runner-groups.md") || !strings.Contains(out.String(), "Continue anyway and generate a relaxed policy") || strings.Count(out.String(), "requires explicit review") != 1 { + t.Fatalf("default-group warning/back option missing:\n%s", out.String()) + } +} + +func TestInitCanBackFromBroadGroupAndChooseRestrictedGroup(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + setInitRunnerGroupClient(t, &fakeInitRunnerGroupClient{ + groups: []gh.RunnerGroup{ + {ID: 1, Name: "broad", Visibility: config.RunnerGroupRepositoryAccessPrivate}, + {ID: 2, Name: "restricted", Visibility: config.RunnerGroupRepositoryAccessSelected}, + }, + repositories: map[int64][]gh.RunnerGroupRepository{2: {{FullName: "example/private", Private: true}}}, + }) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123\nexample\nkey.pem\n2\n2\n1\n\nn\n"), + Out: &bytes.Buffer{}, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Runner.Group != "restricted" || cfg.Security.RunnerGroup.RequiredRepositoryAccess != config.RunnerGroupRepositoryAccessSelected { + t.Fatalf("unexpected selection after back: group=%q policy=%+v", cfg.Runner.Group, cfg.Security.RunnerGroup) + } +} + +func TestInitRejectsPublicGroupAndAllowsAnotherSelection(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + client := &fakeInitRunnerGroupClient{ + groups: []gh.RunnerGroup{ + {ID: 1, Name: "public", Visibility: config.RunnerGroupRepositoryAccessSelected, AllowsPublicRepositories: true}, + {ID: 2, Name: "restricted", Visibility: config.RunnerGroupRepositoryAccessSelected}, + }, + repositories: map[int64][]gh.RunnerGroupRepository{ + 1: {{FullName: "example/public", Private: false}}, + 2: {{FullName: "example/private", Private: true}}, + }, + } + setInitRunnerGroupClient(t, client) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123\nexample\nkey.pem\n2\n1\n1\n\nn\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Runner.Group != "restricted" { + t.Fatalf("runner.group = %q, want restricted", cfg.Runner.Group) + } + if !strings.Contains(out.String(), "*** SECURITY BLOCK: THIS RUNNER GROUP IS NOT ALLOWED BY EPAR'S SAFE DEFAULTS ***") || !strings.Contains(out.String(), "public repository or fork-triggered workflow") || !strings.Contains(out.String(), "RECOMMENDED ACTION: Do not use this group") || !strings.Contains(out.String(), "docs/runner-groups.md") { + t.Fatalf("public-repository warning missing:\n%s", out.String()) + } + if client.groupCalls != 1 { + t.Fatalf("ListRunnerGroups calls = %d, want 1 when choosing Back", client.groupCalls) + } +} + +func TestInitBlocksUnknownRunnerGroupRepositoryAccess(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + setInitRunnerGroupClient(t, &fakeInitRunnerGroupClient{groups: []gh.RunnerGroup{{ID: 1, Name: "future-policy", Visibility: "future-value"}}}) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123\nexample\nkey.pem\n1\n3\n"), + Out: &out, + }) + if err == nil || !strings.Contains(err.Error(), "selection cancelled") { + t.Fatalf("init error = %v, want cancellation after unknown policy block", err) + } + if !strings.Contains(out.String(), "*** SECURITY BLOCK: GITHUB RETURNED AN UNKNOWN REPOSITORY-ACCESS POLICY ***") { + t.Fatalf("unknown access policy was not blocked clearly:\n%s", out.String()) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("config exists after unknown runner-group policy: %v", statErr) + } +} + +func TestSortRunnerGroupsForWizardPutsRestrictiveGroupsFirst(t *testing.T) { + groups := []gh.RunnerGroup{ + {ID: 1, Name: "Default", Visibility: config.RunnerGroupRepositoryAccessAll, Default: true}, + {ID: 2, Name: "public-selected", Visibility: config.RunnerGroupRepositoryAccessSelected, AllowsPublicRepositories: true}, + {ID: 3, Name: "all-private-repositories", Visibility: config.RunnerGroupRepositoryAccessPrivate}, + {ID: 4, Name: "recommended", Visibility: config.RunnerGroupRepositoryAccessSelected}, + {ID: 5, Name: "inherited-recommended", Visibility: config.RunnerGroupRepositoryAccessSelected, Inherited: true}, + } + ordered := sortRunnerGroupsForWizard(groups) + got := make([]string, len(ordered)) + for i, group := range ordered { + got[i] = group.Name + } + want := []string{"recommended", "inherited-recommended", "all-private-repositories", "Default", "public-selected"} + if !slices.Equal(got, want) { + t.Fatalf("runner-group order = %#v, want %#v", got, want) + } + if groups[0].Name != "Default" { + t.Fatalf("sort mutated API response order: %#v", groups) + } +} + +func TestInitRefreshesRunnerGroupsOnlyWhenRequested(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + client := &fakeInitRunnerGroupClient{ + groupResponses: [][]gh.RunnerGroup{ + {{ID: 1, Name: "before-refresh", Visibility: config.RunnerGroupRepositoryAccessSelected}}, + {{ID: 2, Name: "after-refresh", Visibility: config.RunnerGroupRepositoryAccessSelected}}, + }, + repositories: map[int64][]gh.RunnerGroupRepository{ + 1: {{FullName: "example/private", Private: true}}, + 2: {{FullName: "example/private", Private: true}}, + }, + } + setInitRunnerGroupClient(t, client) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123\nexample\nkey.pem\nr\n1\n\n"), + Out: &bytes.Buffer{}, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Runner.Group != "after-refresh" || client.groupCalls != 2 { + t.Fatalf("refresh result: group=%q ListRunnerGroups calls=%d", cfg.Runner.Group, client.groupCalls) + } +} + +func TestInitAllowsInheritedGroupWithAdvisory(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + setInitRunnerGroupClient(t, &fakeInitRunnerGroupClient{ + groups: []gh.RunnerGroup{{ID: 1, Name: "enterprise-restricted", Visibility: config.RunnerGroupRepositoryAccessSelected, Inherited: true}}, + repositories: map[int64][]gh.RunnerGroupRepository{1: {{FullName: "example/private", Private: true}}}, + }) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123\nexample\nkey.pem\n1\n1\n\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Runner.Group != "enterprise-restricted" || !cfg.Security.RunnerGroup.RequireNonDefaultGroup || !strings.Contains(out.String(), "enterprise level") { + t.Fatalf("inherited selection missing advisory or strict policy: group=%q policy=%+v output=%s", cfg.Runner.Group, cfg.Security.RunnerGroup, out.String()) + } +} + +func TestInitRunnerGroupAPIFailureDoesNotWriteConfig(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + setInitRunnerGroupClient(t, &fakeInitRunnerGroupClient{err: errors.New("permission denied")}) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123\nexample\nkey.pem\n"), + Out: &bytes.Buffer{}, + }) + if err == nil || !strings.Contains(err.Error(), "load GitHub runner groups") || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("init error = %v, want runner-group API failure", err) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("config exists after runner-group API failure: %v", statErr) + } +} + +func TestInitRunnerGroupAPIFailureDoesNotOverwriteExistingConfig(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + setInitRunnerGroupClient(t, &fakeInitRunnerGroupClient{err: errors.New("permission denied")}) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + const original = "existing config must remain\n" + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + Force: true, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123\nexample\nkey.pem\n"), + Out: &bytes.Buffer{}, + }) + if err == nil || !strings.Contains(err.Error(), "load GitHub runner groups") { + t.Fatalf("init error = %v, want runner-group API failure", err) + } + contents, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if string(contents) != original { + t.Fatalf("existing config changed after API failure: %q", contents) + } } func TestDetectedInitHostTrustOSUsesWrapperHost(t *testing.T) { @@ -117,7 +401,7 @@ func TestInitCanDisableHostTrustOverlay(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\nn\n"), Out: &bytes.Buffer{}, }); err != nil { t.Fatal(err) @@ -145,7 +429,7 @@ func TestInitDoesNotWriteEnabledConfigWhenHostTrustPreflightFails(t *testing.T) ProjectRoot: dir, ConfigPath: path, SkipDockerCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n"), Out: &bytes.Buffer{}, }) if err == nil || !strings.Contains(err.Error(), "collector unavailable") { @@ -167,7 +451,7 @@ func TestInitAcceptsCustomPoolNamePrefix(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\ncustom-prefix\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\ncustom-prefix\n"), Out: &bytes.Buffer{}, }); err != nil { t.Fatal(err) @@ -193,7 +477,7 @@ func TestInitRepromptsInvalidPoolNamePrefix(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n-bad\nfixed-prefix\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n-bad\nfixed-prefix\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -301,6 +585,7 @@ func TestInitRefusesExistingConfig(t *testing.T) { } func TestInitChecksDockerByDefault(t *testing.T) { + stubInitRunnerGroupClient(t) oldDockerAvailable := dockerAvailable t.Cleanup(func() { dockerAvailable = oldDockerAvailable @@ -312,7 +597,7 @@ func TestInitChecksDockerByDefault(t *testing.T) { err := runInitWithOptions(initOptions{ ProjectRoot: t.TempDir(), ConfigPath: filepath.Join(t.TempDir(), ".local", "config.yml"), - In: strings.NewReader("123\norg\nkey.pem\n"), + In: strings.NewReader("123\norg\nkey.pem\n1\n"), Out: &bytes.Buffer{}, }) if err == nil || !strings.Contains(err.Error(), "Docker is required") { @@ -332,7 +617,7 @@ func TestInitOffersWSL2ConfigWhenAvailable(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n2\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n2\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -351,6 +636,7 @@ func TestInitOffersWSL2ConfigWhenAvailable(t *testing.T) { "organization: your-org", "organization: solutionforest", "privateKeyPath: ~/.config/ephemeral-action-runner/github-app.pem", "privateKeyPath: .local/github-app.pem", "namePrefix: CHANGE-ME-unique-machine-prefix", "namePrefix: build-box-01-a4f9c2", + "group: your-runner-group", "group: \"restricted group\"", ).Replace(string(want)) wantText = strings.ReplaceAll(wantText, "\r\n", "\n") if string(got) != wantText { @@ -378,7 +664,7 @@ func TestInitWSL2ChoiceDefaultsToDockerDindAndRepromptsInvalidValues(t *testing. ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\ninvalid\n\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\ninvalid\n\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -412,7 +698,7 @@ func TestInitOffersTartConfigWhenAvailable(t *testing.T) { ProjectRoot: dir, ConfigPath: path, SkipHostTrustCheck: true, - In: strings.NewReader("654321\nexample\n.local/github-app.pem\n2\n\n"), + In: strings.NewReader("654321\nexample\n.local/github-app.pem\n1\n2\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -431,6 +717,7 @@ func TestInitOffersTartConfigWhenAvailable(t *testing.T) { "organization: your-org", "organization: example", "privateKeyPath: ~/.config/ephemeral-action-runner/github-app.pem", "privateKeyPath: .local/github-app.pem", "namePrefix: CHANGE-ME-unique-machine-prefix", "namePrefix: build-box-01-a4f9c2", + "group: your-runner-group", "group: \"restricted group\"", ).Replace(string(want)) wantText = strings.ReplaceAll(wantText, "\r\n", "\n") if string(got) != wantText { @@ -531,12 +818,58 @@ func stubInitHostAndRandom(t *testing.T, hostname string, random []byte) { oldRandomRead := initRandomRead initHostname = func() (string, error) { return hostname, nil } initRandomRead = fixedRandomRead(random) + stubInitRunnerGroupClient(t) t.Cleanup(func() { initHostname = oldHostname initRandomRead = oldRandomRead }) } +type fakeInitRunnerGroupClient struct { + groups []gh.RunnerGroup + groupResponses [][]gh.RunnerGroup + repositories map[int64][]gh.RunnerGroupRepository + err error + groupCalls int +} + +func (f *fakeInitRunnerGroupClient) ListRunnerGroups(context.Context) ([]gh.RunnerGroup, error) { + f.groupCalls++ + if len(f.groupResponses) > 0 { + index := f.groupCalls - 1 + if index >= len(f.groupResponses) { + index = len(f.groupResponses) - 1 + } + return append([]gh.RunnerGroup(nil), f.groupResponses[index]...), f.err + } + return append([]gh.RunnerGroup(nil), f.groups...), f.err +} + +func (f *fakeInitRunnerGroupClient) ListRunnerGroupRepositories(_ context.Context, groupID int64) ([]gh.RunnerGroupRepository, error) { + return append([]gh.RunnerGroupRepository(nil), f.repositories[groupID]...), f.err +} + +func stubInitRunnerGroupClient(t *testing.T) { + t.Helper() + oldFactory := newInitRunnerGroupClient + newInitRunnerGroupClient = func(config.GitHubConfig) initRunnerGroupClient { + return &fakeInitRunnerGroupClient{ + groups: []gh.RunnerGroup{{ID: 1, Name: "restricted group", Visibility: config.RunnerGroupRepositoryAccessSelected}}, + repositories: map[int64][]gh.RunnerGroupRepository{ + 1: {{ID: 1, FullName: "example/private", Private: true}}, + }, + } + } + t.Cleanup(func() { newInitRunnerGroupClient = oldFactory }) +} + +func setInitRunnerGroupClient(t *testing.T, client initRunnerGroupClient) { + t.Helper() + oldFactory := newInitRunnerGroupClient + newInitRunnerGroupClient = func(config.GitHubConfig) initRunnerGroupClient { return client } + t.Cleanup(func() { newInitRunnerGroupClient = oldFactory }) +} + func fixedRandomRead(random []byte) func([]byte) (int, error) { return func(data []byte) (int, error) { copy(data, random) diff --git a/cmd/ephemeral-action-runner/start.go b/cmd/ephemeral-action-runner/start.go index a22efe8..df91ee3 100644 --- a/cmd/ephemeral-action-runner/start.go +++ b/cmd/ephemeral-action-runner/start.go @@ -15,6 +15,7 @@ import ( ) type starterManager interface { + PreflightRunnerGroup(context.Context) error EnsureImage(context.Context) error RunPool(context.Context, pool.RunOptions) error } @@ -131,6 +132,12 @@ func runStartWithOptions(opts startOptions) (err error) { timingManager.FinishStartupTiming(err) }() } + if opts.Register { + fmt.Fprintf(opts.Out, "Checking GitHub runner-group security policy for %s\n", configPath) + if err = manager.PreflightRunnerGroup(opts.Context); err != nil { + return err + } + } poolLockHeld := false if lockingManager, ok := manager.(poolLockingStarterManager); ok { controllerLock, err := lockingManager.AcquirePoolControllerLock() @@ -190,6 +197,7 @@ func ensureConfigForStart(opts startOptions) (string, error) { } fmt.Fprintf(opts.Out, "No EPAR config found. Starting first-run setup.\n\n") if err := runInitWithOptions(initOptions{ + Context: opts.Context, ProjectRoot: projectRootOrCwd(opts.ProjectRoot), ConfigPath: path, In: opts.In, diff --git a/cmd/ephemeral-action-runner/start_test.go b/cmd/ephemeral-action-runner/start_test.go index 3bc1d56..1efd3a3 100644 --- a/cmd/ephemeral-action-runner/start_test.go +++ b/cmd/ephemeral-action-runner/start_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "errors" "os" "path/filepath" "strings" @@ -80,6 +81,7 @@ func TestStartPropagatesConfigAndInstances(t *testing.T) { func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { dir := t.TempDir() stubNoWSL2(t) + stubInitRunnerGroupClient(t) oldInteractive := stdinIsInteractive oldDocker := dockerAvailable oldResolveHostTrust := initResolveHostTrust @@ -99,7 +101,7 @@ func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n"), Out: &out, ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { if path != filepath.Join(dir, ".local", "config.yml") { @@ -140,7 +142,7 @@ func TestStartInteractiveMissingConfigCanSelectWSL2(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n2\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n2\n\n"), Out: &out, ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { if path != filepath.Join(dir, ".local", "config.yml") { @@ -188,7 +190,7 @@ func TestStartInteractiveMissingConfigCanSelectTartWithoutDocker(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n2\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n2\n\n"), Out: &out, ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { if path != filepath.Join(dir, ".local", "config.yml") { @@ -244,18 +246,70 @@ func TestStartRejectsNonPositiveInstancesOverride(t *testing.T) { } } +func TestStartPreflightsBeforeImageAndPool(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yml") + if err := os.WriteFile(configPath, []byte("config"), 0600); err != nil { + t.Fatal(err) + } + fake := &fakeStarterManager{} + err := runStartWithOptions(startOptions{ + Context: context.Background(), + ProjectRoot: dir, + ConfigPath: configPath, + Register: true, + Out: &bytes.Buffer{}, + ManagerFactory: func(string, string, bool, bool) (starterManager, error) { + return fake, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(fake.calls, ","); got != "preflight,image,pool" { + t.Fatalf("call order = %q, want preflight,image,pool", got) + } + + fake = &fakeStarterManager{preflightErr: errors.New("unsafe group")} + err = runStartWithOptions(startOptions{ + Context: context.Background(), + ProjectRoot: dir, + ConfigPath: configPath, + Register: true, + Out: &bytes.Buffer{}, + ManagerFactory: func(string, string, bool, bool) (starterManager, error) { + return fake, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "unsafe group") { + t.Fatalf("start error = %v, want preflight failure", err) + } + if got := strings.Join(fake.calls, ","); got != "preflight" { + t.Fatalf("call order after rejection = %q, want preflight only", got) + } +} + type fakeStarterManager struct { - ensureCalls int - runCalls int - runOptions pool.RunOptions + preflightErr error + ensureCalls int + runCalls int + runOptions pool.RunOptions + calls []string +} + +func (m *fakeStarterManager) PreflightRunnerGroup(context.Context) error { + m.calls = append(m.calls, "preflight") + return m.preflightErr } func (m *fakeStarterManager) EnsureImage(context.Context) error { + m.calls = append(m.calls, "image") m.ensureCalls++ return nil } func (m *fakeStarterManager) RunPool(_ context.Context, opts pool.RunOptions) error { + m.calls = append(m.calls, "pool") m.runCalls++ m.runOptions = opts return nil diff --git a/configs/docker-dind.act.example.yml b/configs/docker-dind.act.example.yml index 3afb483..8436aba 100644 --- a/configs/docker-dind.act.example.yml +++ b/configs/docker-dind.act.example.yml @@ -47,10 +47,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: your-runner-group labels: [self-hosted, linux, epar-docker-dind-catthehacker-act] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + provider: type: docker-dind sourceImage: epar-docker-dind-catthehacker-act diff --git a/configs/docker-dind.core.example.yml b/configs/docker-dind.core.example.yml index f4fdfbc..8d641a1 100644 --- a/configs/docker-dind.core.example.yml +++ b/configs/docker-dind.core.example.yml @@ -53,6 +53,14 @@ runner: noDefaultLabels: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: false + provider: type: docker-dind sourceImage: epar-ci-core diff --git a/configs/docker-dind.example.yml b/configs/docker-dind.example.yml index 30c6655..2b73c0e 100644 --- a/configs/docker-dind.example.yml +++ b/configs/docker-dind.example.yml @@ -47,10 +47,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: your-runner-group labels: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + provider: type: docker-dind sourceImage: epar-docker-dind-catthehacker-ubuntu diff --git a/configs/docker-dind.web-e2e.example.yml b/configs/docker-dind.web-e2e.example.yml index 70da56e..2cfb599 100644 --- a/configs/docker-dind.web-e2e.example.yml +++ b/configs/docker-dind.web-e2e.example.yml @@ -48,10 +48,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: your-runner-group labels: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu-web-e2e] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + provider: type: docker-dind sourceImage: epar-docker-dind-catthehacker-ubuntu-web-e2e diff --git a/configs/tart.example.yml b/configs/tart.example.yml index efe103e..58e910a 100644 --- a/configs/tart.example.yml +++ b/configs/tart.example.yml @@ -45,10 +45,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: your-runner-group labels: [self-hosted, linux, ARM64, epar-tart-ubuntu-24.04-base] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + provider: type: tart sourceImage: epar-ubuntu-24-arm64 diff --git a/configs/tart.web-e2e.example.yml b/configs/tart.web-e2e.example.yml index ad7fce8..8a49681 100644 --- a/configs/tart.web-e2e.example.yml +++ b/configs/tart.web-e2e.example.yml @@ -43,10 +43,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: your-runner-group labels: [self-hosted, linux, ARM64, epar-tart-ubuntu-24.04-web-e2e, epar-tart-rosetta-amd64] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + provider: type: tart sourceImage: epar-ubuntu-24-arm64-web-e2e diff --git a/configs/wsl.example.yml b/configs/wsl.example.yml index 2caf870..2d734bb 100644 --- a/configs/wsl.example.yml +++ b/configs/wsl.example.yml @@ -45,10 +45,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: your-runner-group labels: [self-hosted, linux, X64, epar-wsl-catthehacker-ubuntu] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + provider: type: wsl sourceImage: work/images/epar-wsl-catthehacker-ubuntu.tar diff --git a/configs/wsl.lean.example.yml b/configs/wsl.lean.example.yml index 6fdd411..910bfa0 100644 --- a/configs/wsl.lean.example.yml +++ b/configs/wsl.lean.example.yml @@ -44,10 +44,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: your-runner-group labels: [self-hosted, linux, X64, epar-wsl-ubuntu-24.04-base] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + provider: type: wsl sourceImage: work/images/epar-ubuntu-24-wsl.tar diff --git a/configs/wsl.web-e2e.example.yml b/configs/wsl.web-e2e.example.yml index b454272..2ae9c84 100644 --- a/configs/wsl.web-e2e.example.yml +++ b/configs/wsl.web-e2e.example.yml @@ -44,10 +44,19 @@ logging: retentionIntervalMinutes: 60 runner: + group: your-runner-group labels: [self-hosted, linux, X64, epar-wsl-ubuntu-24.04-web-e2e] includeHostLabel: true ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + provider: type: wsl sourceImage: work/images/epar-ubuntu-24-wsl-web-e2e.tar diff --git a/docs/configuration.md b/docs/configuration.md index 120b57f..026ae5a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,6 +23,7 @@ EPAR looks for config in this order: | `pool` | Runner count, instance name prefix, and replacement retry policy. | | `logging` | Manager and transcript sinks, formats, rotation, retention, and log directory. | | `runner` | GitHub Actions labels, runner group, default-label policy, and whether to add the host-machine label. | +| `security` | Runner-group policy requirements checked before runner registration. | | `docker` | Optional Docker registry mirrors and Docker-DinD daemon proxy settings. | | `timeouts` | Boot, GitHub online, and command timeout values in seconds. | @@ -77,21 +78,27 @@ runner: includeHostLabel: false ``` -Register runners in an organization runner group and omit GitHub's automatic -`self-hosted`, operating-system, and architecture labels: +Register runners in an organization runner group and omit GitHub's automatic `self-hosted`, operating-system, and architecture labels: ```yaml runner: - group: epar-ci-canary + group: your-runner-group labels: [epar-core-unique-label] includeHostLabel: false noDefaultLabels: true + +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true ``` -`runner.group` is optional. The group must already exist and allow the target -repository to use it. `runner.noDefaultLabels` defaults to `false`; when it is -`true`, workflows must target labels explicitly configured under -`runner.labels` (and may also target the runner group). +The group must already exist and allow the target repository to use it. `requiredRepositoryAccess` is a maximum breadth: `selected` allows only selected-repository groups, `private` also allows all-private groups, and `all` accepts any repository visibility. The public-repository requirement remains independent. Existing configs without `security.runnerGroup` use strict recommended requirements in `warn` mode; new wizard configs use `enforce`. See [Runner Group Security](runner-groups.md) for default-group choices, enterprise inheritance, overrides, and failure behavior. + +`runner.noDefaultLabels` defaults to `false`; when it is `true`, workflows must target labels explicitly configured under `runner.labels` and may also target the runner group. Use a different config file: diff --git a/docs/core-runner-verification.md b/docs/core-runner-verification.md index bdf3c61..98e609a 100644 --- a/docs/core-runner-verification.md +++ b/docs/core-runner-verification.md @@ -39,14 +39,11 @@ the Go version declared by `go.mod` before building EPAR. ### Restricted ephemeral-runner group -In the organization settings, create a runner group named -`epar-ci-canary` and restrict its repository access to -`solutionforest/ephemeral-action-runner`. - -EPAR registers the temporary runners in this group with no GitHub default -labels and only a per-run label such as `epar-core-123456-1`. The canary jobs -target both the group and that unique label, so unrelated self-hosted runners -cannot accept them. +In the organization settings, create a runner group named `epar-ci-canary` and restrict its repository access to `solutionforest/ephemeral-action-runner`. + +EPAR registers the temporary runners in this group with no GitHub default labels and only a per-run label such as `epar-core-123456-1`. The canary jobs target both the group and that unique label, so unrelated self-hosted runners cannot accept them. + +This repository is public, so its canary config explicitly sets only `security.runnerGroup.requirePublicRepositoriesDisabled: false`. It keeps enforcement, explicit naming, non-default-group use, and selected-repository access enabled. This narrow exception depends on the trusted controller and protected workflow conditions below and is not the recommended deployment model for public repositories. ### GitHub App and protected environment diff --git a/docs/github-app.md b/docs/github-app.md index 347f303..5415e01 100644 --- a/docs/github-app.md +++ b/docs/github-app.md @@ -30,10 +30,11 @@ github: `github.organization` must be the organization where the app is installed. `privateKeyPath` is resolved relative to the project root unless it is absolute. -Image-only commands do not use GitHub credentials. Runner registration, GitHub-backed status, and GitHub cleanup do. +Image-only commands do not use GitHub credentials. The initializer reads runner groups and selected repositories. Runner registration, GitHub-backed status, and GitHub cleanup also use the App. The existing **Self-hosted runners** read and write permission covers these operations. References: - [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) - [Managing private keys for GitHub Apps](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps) - [Organization self-hosted runner registration token API](https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization) +- [Organization runner-group API](https://docs.github.com/en/rest/actions/self-hosted-runner-groups) diff --git a/docs/runner-groups.md b/docs/runner-groups.md new file mode 100644 index 0000000..9f3ad4c --- /dev/null +++ b/docs/runner-groups.md @@ -0,0 +1,54 @@ +# Runner Group Security + +GitHub decides which repositories can route jobs to a self-hosted runner through its runner group before EPAR receives a job. EPAR therefore verifies the configured group policy before creating a registration token; it does not accept a job first and reject it afterward. + +## Recommended GitHub Setup + +1. Open the organization’s **Settings**, then **Actions**, **Runners**, and **Runner groups**. +2. Create a dedicated group for EPAR runners. +3. Choose **Selected repositories** and add only repositories whose workflows are trusted to run on the EPAR host. +4. Keep public repository access disabled. +5. Run `./start` or `ephemeral-action-runner init` and select that group when the wizard lists the organization’s live runner groups. + +See GitHub’s [runner-group access documentation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access) for the organization and enterprise controls. + +Any repository with access to the group can route a matching job to its runners. Broad access also applies to repositories created later, and public repositories can expose self-hosted runners to untrusted pull request or fork workflows. EPAR is intended for trusted jobs and is not a hostile-code sandbox. + +## Wizard Decisions + +The wizard requires an explicit numbered selection; pressing Enter does not select the default group. It blocks groups that permit public repositories under the generated safety policy. + +GitHub's runner-group API does not provide a creation timestamp, so the wizard cannot order groups by age. It orders them by security posture instead: groups with public repository access disabled appear first, then selected-repository access before all-private access before all-repository access. Within equivalent policies, non-default organization-managed groups appear before default or inherited alternatives. The wizard explains each access mode in plain language and labels groups as recommended, requiring review, not recommended, or blocked. + +The default group and groups available to all private or all repositories are not automatically forbidden. The wizard explains their broader and potentially future access, then offers **Continue with this group** or **Back to group selection**. Continuing records that deliberate choice in the policy. Inherited enterprise groups receive an additional warning because their policy must be managed at enterprise level. + +The wizard must read the live GitHub policy. It has no offline or unchecked group-name fallback, and it does not write or overwrite a config if the API call or selection fails. + +## Configuration + +```yaml +runner: + group: your-runner-group + +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true +``` + +`enforcement` accepts `enforce` or `warn`. Enforcement blocks new runner registrations when GitHub cannot be checked or the policy violates a requirement. Warning mode reports the same conditions and continues; configs created before this feature use strict recommended requirements in warning mode until migrated. + +`requiredRepositoryAccess` is the maximum permitted breadth. `selected` permits only selected-repository access, `private` permits selected or all-private access, and `all` permits any GitHub repository-access setting. Tightening GitHub policy remains valid; broadening beyond the configured ceiling blocks registration. + +`requirePublicRepositoriesDisabled` is independent of repository breadth. Setting it to `false` deliberately allows public repositories but produces a runtime advisory. This override should be exceptional and should be paired with a documented trusted-workflow threat model. + +Setting `requireNonDefaultGroup` to `false` permits the default group but still produces an advisory when it is used. Setting `requireExplicitGroup` to `false` permits an empty `runner.group`; EPAR then resolves and checks the group GitHub marks as default. + +## Runtime Behavior + +EPAR checks policy before startup image work, before registration-enabled pool provisioning, and again immediately before each runner registration token request. Enforcement failures do not delete existing runners or interrupt jobs. EPAR does not modify GitHub policy and does not run a periodic policy audit. + +An administrator can change GitHub policy between the final check and registration because GitHub does not provide an atomic policy-check-and-register operation. Keeping group administration restricted remains necessary. diff --git a/docs/security.md b/docs/security.md index c8c277a..b914979 100644 --- a/docs/security.md +++ b/docs/security.md @@ -22,7 +22,7 @@ A workflow controls the runner environment while it runs and can access any secr Do not mount host source directories, Docker sockets, private keys, or long-lived cloud credentials into runner instances unless that is inside your trust boundary. -Use GitHub runner groups, repository restrictions, environment protections, and minimal secrets. Avoid routing public pull request workflows, forked contributions, or unknown third-party workflow code to EPAR runners. +Use GitHub runner groups, repository restrictions, environment protections, and minimal secrets. EPAR's [runner-group security preflight](runner-groups.md) checks the configured routing policy before registration, but it does not make public pull request workflows, forked contributions, or unknown third-party workflow code trustworthy. ## Provider Notes diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 993f380..a51e8ac 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -84,6 +84,30 @@ docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / If the container-visible disk is full, adjust or clean the Docker/OrbStack storage from that product's settings. Finder free space by itself may not reflect the Linux VM storage available to containers. +## Windows No-Go Startup Prints An HTTP/2 Named-Pipe Diagnostic + +### Symptoms + +During the containerized Go-toolchain bootstrap, Docker Desktop may print a line like: + +```text +2026/07/22 02:36:46 http2: server: error reading preface from client //./pipe/dockerDesktopLinuxEngine: file has already been closed +``` + +This is a Docker Desktop/Windows named-pipe transport diagnostic that can be emitted when a client connection closes. It is not an EPAR runner-group or GitHub API error. If the bootstrap `docker build` exits successfully and the EPAR wizard or command continues, the line does not indicate that EPAR failed. + +The Windows no-Go wrapper suppresses only this exact diagnostic when the bootstrap build succeeds. If the Docker build fails, the wrapper preserves the complete Docker stderr, including this line, because it may then be relevant context. + +If startup stops instead of continuing, verify the active Docker Desktop engine and context: + +```powershell +docker version +docker info +docker context show +``` + +Resolve any failed command or unhealthy engine reported by those checks. Do not treat every named-pipe message as harmless when Docker also returns a nonzero exit code. + ## Docker Container Fails Because Its Architecture Does Not Match The Runner ### Symptoms diff --git a/docs/usage.md b/docs/usage.md index 15df358..1818678 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -52,7 +52,7 @@ Equivalent without the wrapper: go run ./cmd/ephemeral-action-runner ``` -If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then writes `.local/config.yml`. Docker-DinD is the default. For a new Docker-DinD config, the wizard asks whether to inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. On native Windows, when `wsl.exe --status` successfully confirms default version 2, the wizard also offers a WSL2 config. On macOS, when `tart --version` succeeds, it offers an experimental Tart config. Press Enter to retain Docker-DinD. The Docker preflight applies to Docker-DinD and the default WSL image, which uses Docker for its one-time rootfs export, but not to Tart. EPAR then checks the configured image, builds or replaces it when the image is missing or no longer matches the config, and starts the configured number of runners. The default config uses `pool.instances: 1`. +If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then lists the organization's live runner groups. The group choice is explicit and has no Enter-for-default or offline fallback. Default and broad-access groups require warning confirmation; public-enabled groups are blocked by the generated safety policy. See [Runner Group Security](runner-groups.md). Docker-DinD is the default. For a new Docker-DinD config, the wizard asks whether to inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. On native Windows, when `wsl.exe --status` successfully confirms default version 2, the wizard also offers a WSL2 config. On macOS, when `tart --version` succeeds, it offers an experimental Tart config. Press Enter to retain Docker-DinD. The Docker preflight applies to Docker-DinD and the default WSL image, which uses Docker for its one-time rootfs export, but not to Tart. EPAR then checks runner-group policy and the configured image, builds or replaces the image when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. Pass flags through `./start` to choose a config or runner count: @@ -96,7 +96,7 @@ On Windows PowerShell: go run ./cmd/ephemeral-action-runner init ``` -For other WSL or Tart variants, or for custom labels, copy one example config into `.local/config.yml`, then edit the GitHub App fields and any labels you want to expose to workflows. +For other WSL or Tart variants, or for custom labels, copy one example config into `.local/config.yml`, then edit the GitHub App fields, `runner.group`, and any labels you want to expose to workflows. Example group names are placeholders. | Host and image | Example config | | --- | --- | diff --git a/internal/config/config.go b/internal/config/config.go index 71c1a1a..7f9e748 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { Pool PoolConfig Logging LoggingConfig Runner RunnerConfig + Security SecurityConfig Provider ProviderConfig Docker DockerConfig Timeouts TimeoutConfig @@ -98,6 +99,27 @@ type RunnerConfig struct { NoDefaultLabels bool } +type SecurityConfig struct { + RunnerGroup RunnerGroupSecurityConfig +} + +type RunnerGroupSecurityConfig struct { + Enforcement string + RequireExplicitGroup bool + RequireNonDefaultGroup bool + RequiredRepositoryAccess string + RequirePublicRepositoriesDisabled bool +} + +const ( + RunnerGroupEnforcementEnforce = "enforce" + RunnerGroupEnforcementWarn = "warn" + + RunnerGroupRepositoryAccessSelected = "selected" + RunnerGroupRepositoryAccessPrivate = "private" + RunnerGroupRepositoryAccessAll = "all" +) + type ProviderConfig struct { Type string SourceImage string @@ -176,6 +198,15 @@ func Default() Config { IncludeHostLabel: true, Ephemeral: true, }, + Security: SecurityConfig{ + RunnerGroup: RunnerGroupSecurityConfig{ + Enforcement: RunnerGroupEnforcementWarn, + RequireExplicitGroup: true, + RequireNonDefaultGroup: true, + RequiredRepositoryAccess: RunnerGroupRepositoryAccessSelected, + RequirePublicRepositoriesDisabled: true, + }, + }, Provider: ProviderConfig{ Type: "tart", SourceImage: "epar-ubuntu-24-arm64", @@ -203,6 +234,8 @@ func Load(path string) (Config, error) { defer file.Close() section := "" + subsection := "" + subsectionIndent := 0 scanner := bufio.NewScanner(file) lineNo := 0 var pendingList *pendingListKey @@ -241,6 +274,8 @@ func Load(path string) (Config, error) { return cfg, fmt.Errorf("%s:%d: unknown section %q", path, lineNo, key) } section = key + subsection = "" + subsectionIndent = 0 continue } if indent == 0 { @@ -249,24 +284,42 @@ func Load(path string) (Config, error) { if section == "" { return cfg, fmt.Errorf("%s:%d: key %q must be under a section", path, lineNo, key) } - if section == "pool" && key == "logDir" { + if section == "security" && value == "" { + if key != "runnerGroup" { + return cfg, fmt.Errorf("%s:%d: unknown subsection security.%s", path, lineNo, key) + } + subsection = key + subsectionIndent = indent + continue + } + effectiveSection := section + if section == "security" { + if subsection == "" { + return cfg, fmt.Errorf("%s:%d: key %q must be under security.runnerGroup", path, lineNo, key) + } + if indent <= subsectionIndent { + return cfg, fmt.Errorf("%s:%d: key %q must be nested under security.%s", path, lineNo, key, subsection) + } + effectiveSection = section + "." + subsection + } + if effectiveSection == "pool" && key == "logDir" { legacyLogDir = trimQuotes(value) legacyLogDirLine = lineNo explicit["pool.logDir"] = true continue } - if value == "" && isListKey(section, key) { - if err := setListValue(&cfg, section, key, nil); err != nil { + if value == "" && isListKey(effectiveSection, key) { + if err := setListValue(&cfg, effectiveSection, key, nil); err != nil { return cfg, fmt.Errorf("%s:%d: %w", path, lineNo, err) } - explicit[section+"."+key] = true - pendingList = &pendingListKey{section: section, key: key, indent: indent} + explicit[effectiveSection+"."+key] = true + pendingList = &pendingListKey{section: effectiveSection, key: key, indent: indent} continue } - if err := apply(&cfg, section, key, value); err != nil { + if err := apply(&cfg, effectiveSection, key, value); err != nil { return cfg, fmt.Errorf("%s:%d: %w", path, lineNo, err) } - explicit[section+"."+key] = true + explicit[effectiveSection+"."+key] = true } if err := scanner.Err(); err != nil { return cfg, err @@ -278,6 +331,13 @@ func Load(path string) (Config, error) { cfg.Logging.Directory = legacyLogDir cfg.warnings = append(cfg.warnings, fmt.Sprintf("%s:%d: pool.logDir is deprecated; using its value as logging.directory (move it to the top-level logging section)", path, legacyLogDirLine)) } + if !explicit["security.runnerGroup.enforcement"] && + !explicit["security.runnerGroup.requireExplicitGroup"] && + !explicit["security.runnerGroup.requireNonDefaultGroup"] && + !explicit["security.runnerGroup.requiredRepositoryAccess"] && + !explicit["security.runnerGroup.requirePublicRepositoriesDisabled"] { + cfg.warnings = append(cfg.warnings, fmt.Sprintf("%s: runner-group security policy is not configured; using strict recommended checks in warn mode (add security.runnerGroup.enforcement: enforce after reviewing the policy)", path)) + } applyProviderDefaults(&cfg, explicit) applyRunnerHostLabel(&cfg) cfg.GitHub.PrivateKeyPath = expandHome(cfg.GitHub.PrivateKeyPath) @@ -478,6 +538,33 @@ func apply(cfg *Config, section, key, value string) error { default: return unknownKey(section, key) } + case "security.runnerGroup": + switch key { + case "enforcement": + cfg.Security.RunnerGroup.Enforcement = strings.ToLower(value) + case "requireExplicitGroup": + v, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid security.runnerGroup.requireExplicitGroup: %w", err) + } + cfg.Security.RunnerGroup.RequireExplicitGroup = v + case "requireNonDefaultGroup": + v, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid security.runnerGroup.requireNonDefaultGroup: %w", err) + } + cfg.Security.RunnerGroup.RequireNonDefaultGroup = v + case "requiredRepositoryAccess": + cfg.Security.RunnerGroup.RequiredRepositoryAccess = strings.ToLower(value) + case "requirePublicRepositoriesDisabled": + v, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid security.runnerGroup.requirePublicRepositoriesDisabled: %w", err) + } + cfg.Security.RunnerGroup.RequirePublicRepositoriesDisabled = v + default: + return unknownKey(section, key) + } case "provider": switch key { case "type": @@ -535,7 +622,7 @@ func unknownKey(section, key string) error { func isKnownSection(section string) bool { switch section { - case "github", "image", "pool", "logging", "runner", "provider", "docker", "timeouts": + case "github", "image", "pool", "logging", "runner", "security", "provider", "docker", "timeouts": return true default: return false @@ -787,6 +874,9 @@ func appendListValue(cfg *Config, section, key, value string) error { } func Validate(cfg Config) error { + if err := ValidateRunnerGroupSecurity(cfg.Security.RunnerGroup); err != nil { + return err + } if err := ValidateLogging(cfg.Logging); err != nil { return err } @@ -888,6 +978,20 @@ func Validate(cfg Config) error { return nil } +func ValidateRunnerGroupSecurity(policy RunnerGroupSecurityConfig) error { + switch policy.Enforcement { + case RunnerGroupEnforcementEnforce, RunnerGroupEnforcementWarn: + default: + return fmt.Errorf("unsupported security.runnerGroup.enforcement %q; supported values are enforce and warn", policy.Enforcement) + } + switch policy.RequiredRepositoryAccess { + case RunnerGroupRepositoryAccessSelected, RunnerGroupRepositoryAccessPrivate, RunnerGroupRepositoryAccessAll: + default: + return fmt.Errorf("unsupported security.runnerGroup.requiredRepositoryAccess %q; supported values are selected, private, and all", policy.RequiredRepositoryAccess) + } + return nil +} + func ValidateLogging(logging LoggingConfig) error { if strings.TrimSpace(logging.Directory) == "" { return fmt.Errorf("logging.directory is required") @@ -1284,7 +1388,12 @@ func stripComment(s string) string { func trimQuotes(s string) string { s = strings.TrimSpace(s) if len(s) >= 2 { - if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + if s[0] == '"' && s[len(s)-1] == '"' { + if value, err := strconv.Unquote(s); err == nil { + return value + } + } + if s[0] == '\'' && s[len(s)-1] == '\'' { return s[1 : len(s)-1] } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f1c2407..1aba7f9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -112,6 +112,94 @@ func TestRunnerRegistrationControlsDefaultToDisabled(t *testing.T) { } } +func TestRunnerGroupSecurityDefaultsToStrictWarnings(t *testing.T) { + policy := Default().Security.RunnerGroup + if policy.Enforcement != RunnerGroupEnforcementWarn || + !policy.RequireExplicitGroup || + !policy.RequireNonDefaultGroup || + policy.RequiredRepositoryAccess != RunnerGroupRepositoryAccessSelected || + !policy.RequirePublicRepositoriesDisabled { + t.Fatalf("unexpected runner-group security defaults: %+v", policy) + } +} + +func TestLoadNestedRunnerGroupSecurity(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + content := `security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: false + requireNonDefaultGroup: false + requiredRepositoryAccess: all + requirePublicRepositoriesDisabled: false +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + policy := cfg.Security.RunnerGroup + if policy.Enforcement != RunnerGroupEnforcementEnforce || policy.RequireExplicitGroup || policy.RequireNonDefaultGroup || policy.RequiredRepositoryAccess != RunnerGroupRepositoryAccessAll || policy.RequirePublicRepositoriesDisabled { + t.Fatalf("unexpected parsed runner-group policy: %+v", policy) + } + for _, warning := range cfg.Warnings() { + if strings.Contains(warning, "runner-group security policy is not configured") { + t.Fatalf("unexpected migration warning for configured policy: %q", warning) + } + } +} + +func TestLoadLegacyConfigWarnsAboutRunnerGroupSecurity(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + if err := os.WriteFile(path, []byte("runner:\n group: existing-group\n"), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if !slices.ContainsFunc(cfg.Warnings(), func(warning string) bool { + return strings.Contains(warning, "runner-group security policy is not configured") && strings.Contains(warning, "warn mode") + }) { + t.Fatalf("Warnings() = %#v, want runner-group migration warning", cfg.Warnings()) + } +} + +func TestLoadRejectsInvalidRunnerGroupSecurityNesting(t *testing.T) { + for _, content := range []string{ + "security:\n unknown:\n enforcement: enforce\n", + "security:\n enforcement: enforce\n", + "security:\n runnerGroup:\n enforcement: enforce\n", + "security:\n runnerGroup:\n unknown: true\n", + } { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatalf("Load(%q) succeeded, want nesting/key error", content) + } + } +} + +func TestValidateRunnerGroupSecurityRejectsInvalidValues(t *testing.T) { + for _, mutate := range []func(*RunnerGroupSecurityConfig){ + func(policy *RunnerGroupSecurityConfig) { policy.Enforcement = "off" }, + func(policy *RunnerGroupSecurityConfig) { policy.RequiredRepositoryAccess = "repositories" }, + } { + cfg := Default() + mutate(&cfg.Security.RunnerGroup) + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "security.runnerGroup") { + t.Fatalf("Validate() error = %v, want runner-group policy error", err) + } + } +} + func TestLoggingDefaults(t *testing.T) { got := Default().Logging if got.Directory != "work/logs" || !slices.Equal(got.ManagerSinks, []string{"console"}) || got.ManagerConsoleFormat != "text" || got.ManagerFileFormat != "json" || !slices.Equal(got.TranscriptSinks, []string{"file"}) || got.TranscriptConsoleFormat != "text" { @@ -228,7 +316,9 @@ func TestLoadMigratesPoolLogDirInMemoryWithWarning(t *testing.T) { t.Fatalf("logging.directory = %q, want legacy value %q", got, want) } warnings := cfg.Warnings() - if len(warnings) != 1 || !strings.Contains(warnings[0], "pool.logDir is deprecated") || !strings.Contains(warnings[0], "logging.directory") { + if !slices.ContainsFunc(warnings, func(warning string) bool { + return strings.Contains(warning, "pool.logDir is deprecated") && strings.Contains(warning, "logging.directory") + }) { t.Fatalf("Warnings() = %#v, want migration warning", warnings) } if err := Validate(cfg); err != nil { @@ -256,6 +346,7 @@ func TestLoadRejectsUnknownSectionsAndKeys(t *testing.T) { "pool:\n unknown: value\n", "logging:\n unknown: value\n", "runner:\n unknown: value\n", + "security:\n runnerGroup:\n unknown: value\n", "provider:\n unknown: value\n", "docker:\n unknown: value\n", "timeouts:\n unknown: 1\n", diff --git a/internal/github/runner_groups.go b/internal/github/runner_groups.go new file mode 100644 index 0000000..59f3953 --- /dev/null +++ b/internal/github/runner_groups.go @@ -0,0 +1,175 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" +) + +type RunnerGroup struct { + ID int64 `json:"id"` + Name string `json:"name"` + Visibility string `json:"visibility"` + Default bool `json:"default"` + Inherited bool `json:"inherited"` + AllowsPublicRepositories bool `json:"allows_public_repositories"` +} + +type RunnerGroupRepository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Private bool `json:"private"` +} + +type RunnerGroupPolicyResult struct { + Group RunnerGroup + Resolved bool + Repositories []RunnerGroupRepository + Violations []string + Advisories []string +} + +func (r RunnerGroupPolicyResult) Allowed() bool { + return len(r.Violations) == 0 +} + +func (c *Client) ListRunnerGroups(ctx context.Context) ([]RunnerGroup, error) { + var all []RunnerGroup + for page := 1; ; page++ { + var response struct { + TotalCount int `json:"total_count"` + RunnerGroups []RunnerGroup `json:"runner_groups"` + } + path := fmt.Sprintf("/orgs/%s/actions/runner-groups?per_page=100&page=%d", url.PathEscape(c.cfg.Organization), page) + if err := c.installationRequest(ctx, http.MethodGet, path, nil, &response); err != nil { + return nil, fmt.Errorf("list organization runner groups: %w", err) + } + all = append(all, response.RunnerGroups...) + if len(response.RunnerGroups) < 100 { + return all, nil + } + } +} + +func (c *Client) ListRunnerGroupRepositories(ctx context.Context, groupID int64) ([]RunnerGroupRepository, error) { + var all []RunnerGroupRepository + for page := 1; ; page++ { + var response struct { + TotalCount int `json:"total_count"` + Repositories []RunnerGroupRepository `json:"repositories"` + } + path := fmt.Sprintf("/orgs/%s/actions/runner-groups/%d/repositories?per_page=100&page=%d", url.PathEscape(c.cfg.Organization), groupID, page) + if err := c.installationRequest(ctx, http.MethodGet, path, nil, &response); err != nil { + return nil, fmt.Errorf("list repositories for runner group id %d: %w", groupID, err) + } + all = append(all, response.Repositories...) + if len(response.Repositories) < 100 { + return all, nil + } + } +} + +func (c *Client) EvaluateRunnerGroupPolicy(ctx context.Context, configuredGroup string, policy config.RunnerGroupSecurityConfig) (RunnerGroupPolicyResult, error) { + groups, err := c.ListRunnerGroups(ctx) + if err != nil { + return RunnerGroupPolicyResult{}, err + } + result := ResolveRunnerGroup(configuredGroup, policy, groups) + if !result.Resolved || result.Group.Visibility != config.RunnerGroupRepositoryAccessSelected { + return result, nil + } + repositories, err := c.ListRunnerGroupRepositories(ctx, result.Group.ID) + if err != nil { + return RunnerGroupPolicyResult{}, err + } + return EvaluateRunnerGroupPolicy(result.Group, repositories, policy), nil +} + +func ResolveRunnerGroup(configuredGroup string, policy config.RunnerGroupSecurityConfig, groups []RunnerGroup) RunnerGroupPolicyResult { + name := configuredGroup + if strings.TrimSpace(name) == "" { + if policy.RequireExplicitGroup { + return RunnerGroupPolicyResult{Violations: []string{"runner.group is required by security.runnerGroup.requireExplicitGroup"}} + } + for _, group := range groups { + if group.Default { + result := EvaluateRunnerGroupPolicy(group, nil, policy) + result.Advisories = append(result.Advisories, "runner.group is empty; GitHub's default runner group will be used") + return result + } + } + return RunnerGroupPolicyResult{Violations: []string{"runner.group is empty and GitHub did not return a default runner group"}} + } + for _, group := range groups { + if group.Name == name { + return EvaluateRunnerGroupPolicy(group, nil, policy) + } + } + return RunnerGroupPolicyResult{Violations: []string{fmt.Sprintf("runner group %q was not found in the organization", name)}} +} + +func EvaluateRunnerGroupPolicy(group RunnerGroup, repositories []RunnerGroupRepository, policy config.RunnerGroupSecurityConfig) RunnerGroupPolicyResult { + result := RunnerGroupPolicyResult{ + Group: group, + Resolved: true, + Repositories: append([]RunnerGroupRepository(nil), repositories...), + } + if group.Default { + if policy.RequireNonDefaultGroup { + result.Violations = append(result.Violations, fmt.Sprintf("runner group %q is GitHub's default group but security.runnerGroup.requireNonDefaultGroup is true", group.Name)) + } else { + result.Advisories = append(result.Advisories, fmt.Sprintf("runner group %q is GitHub's default group; repository access may broaden as organization policy changes", group.Name)) + } + } + if group.Inherited { + result.Advisories = append(result.Advisories, fmt.Sprintf("runner group %q is inherited from the enterprise; its policy must be managed at enterprise level", group.Name)) + } + if !repositoryAccessAllows(policy.RequiredRepositoryAccess, group.Visibility) { + result.Violations = append(result.Violations, fmt.Sprintf("runner group %q has repository access %q, broader than security.runnerGroup.requiredRepositoryAccess %q", group.Name, group.Visibility, policy.RequiredRepositoryAccess)) + } + switch group.Visibility { + case config.RunnerGroupRepositoryAccessPrivate: + result.Advisories = append(result.Advisories, fmt.Sprintf("runner group %q is available to all private repositories in the organization", group.Name)) + case config.RunnerGroupRepositoryAccessAll: + result.Advisories = append(result.Advisories, fmt.Sprintf("runner group %q is available to all repositories allowed by its public-repository setting", group.Name)) + case config.RunnerGroupRepositoryAccessSelected: + if len(repositories) == 0 { + result.Advisories = append(result.Advisories, fmt.Sprintf("runner group %q has no selected repositories and cannot receive jobs", group.Name)) + } + default: + result.Violations = append(result.Violations, fmt.Sprintf("runner group %q returned unsupported repository access %q", group.Name, group.Visibility)) + } + if policy.RequirePublicRepositoriesDisabled { + if group.AllowsPublicRepositories { + result.Violations = append(result.Violations, fmt.Sprintf("runner group %q allows public repositories but security.runnerGroup.requirePublicRepositoriesDisabled is true", group.Name)) + } + for _, repository := range repositories { + if !repository.Private { + name := repository.FullName + if name == "" { + name = repository.Name + } + result.Violations = append(result.Violations, fmt.Sprintf("runner group %q includes public repository %q while public repositories are required to be disabled", group.Name, name)) + } + } + } else if group.AllowsPublicRepositories { + result.Advisories = append(result.Advisories, fmt.Sprintf("runner group %q allows public repositories; untrusted public or fork workflows may reach self-hosted runners", group.Name)) + } + return result +} + +func repositoryAccessAllows(maximum, actual string) bool { + rank := map[string]int{ + config.RunnerGroupRepositoryAccessSelected: 0, + config.RunnerGroupRepositoryAccessPrivate: 1, + config.RunnerGroupRepositoryAccessAll: 2, + } + maximumRank, maximumKnown := rank[maximum] + actualRank, actualKnown := rank[actual] + return maximumKnown && actualKnown && actualRank <= maximumRank +} diff --git a/internal/github/runner_groups_test.go b/internal/github/runner_groups_test.go new file mode 100644 index 0000000..9a0d9cd --- /dev/null +++ b/internal/github/runner_groups_test.go @@ -0,0 +1,279 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" +) + +func TestRunnerGroupRepositoryAccessUsesMaximumBreadth(t *testing.T) { + for _, test := range []struct { + maximum string + actual string + allowed bool + }{ + {maximum: "selected", actual: "selected", allowed: true}, + {maximum: "selected", actual: "private", allowed: false}, + {maximum: "selected", actual: "all", allowed: false}, + {maximum: "private", actual: "selected", allowed: true}, + {maximum: "private", actual: "private", allowed: true}, + {maximum: "private", actual: "all", allowed: false}, + {maximum: "all", actual: "selected", allowed: true}, + {maximum: "all", actual: "private", allowed: true}, + {maximum: "all", actual: "all", allowed: true}, + } { + t.Run(test.maximum+"_allows_"+test.actual, func(t *testing.T) { + if got := repositoryAccessAllows(test.maximum, test.actual); got != test.allowed { + t.Fatalf("repositoryAccessAllows(%q, %q) = %t, want %t", test.maximum, test.actual, got, test.allowed) + } + }) + } +} + +func TestEvaluateRunnerGroupPolicy(t *testing.T) { + strict := config.Default().Security.RunnerGroup + for _, test := range []struct { + name string + group RunnerGroup + repositories []RunnerGroupRepository + mutate func(*config.RunnerGroupSecurityConfig) + wantAllowed bool + wantViolation string + wantAdvisory string + }{ + { + name: "strict selected private repositories", + group: RunnerGroup{ID: 1, Name: "restricted", Visibility: "selected"}, + repositories: []RunnerGroupRepository{{FullName: "example/private", Private: true}}, + wantAllowed: true, + }, + { + name: "default rejected when required", + group: RunnerGroup{ID: 1, Name: "Default", Visibility: "selected", Default: true}, + wantViolation: "requireNonDefaultGroup is true", + }, + { + name: "default allowed with advisory", + group: RunnerGroup{ID: 1, Name: "Default", Visibility: "all", Default: true}, + mutate: func(policy *config.RunnerGroupSecurityConfig) { + policy.RequireNonDefaultGroup = false + policy.RequiredRepositoryAccess = "all" + }, + wantAllowed: true, + wantAdvisory: "default group", + }, + { + name: "broader access rejected", + group: RunnerGroup{ID: 1, Name: "broad", Visibility: "private"}, + wantViolation: "broader than", + }, + { + name: "public group rejected", + group: RunnerGroup{ID: 1, Name: "public", Visibility: "selected", AllowsPublicRepositories: true}, + wantViolation: "allows public repositories", + }, + { + name: "public repository defensively rejected", + group: RunnerGroup{ID: 1, Name: "inconsistent", Visibility: "selected"}, + repositories: []RunnerGroupRepository{{FullName: "example/public", Private: false}}, + wantViolation: "includes public repository", + }, + { + name: "public exception remains advisory", + group: RunnerGroup{ID: 1, Name: "public", Visibility: "selected", AllowsPublicRepositories: true}, + mutate: func(policy *config.RunnerGroupSecurityConfig) { policy.RequirePublicRepositoriesDisabled = false }, + wantAllowed: true, + wantAdvisory: "untrusted public or fork workflows", + }, + { + name: "inherited group allowed with advisory", + group: RunnerGroup{ID: 1, Name: "enterprise", Visibility: "selected", Inherited: true}, + wantAllowed: true, + wantAdvisory: "enterprise level", + }, + } { + t.Run(test.name, func(t *testing.T) { + policy := strict + if test.mutate != nil { + test.mutate(&policy) + } + result := EvaluateRunnerGroupPolicy(test.group, test.repositories, policy) + if result.Allowed() != test.wantAllowed { + t.Fatalf("Allowed() = %t, violations=%#v", result.Allowed(), result.Violations) + } + if test.wantViolation != "" && !containsText(result.Violations, test.wantViolation) { + t.Fatalf("violations = %#v, want text %q", result.Violations, test.wantViolation) + } + if test.wantAdvisory != "" && !containsText(result.Advisories, test.wantAdvisory) { + t.Fatalf("advisories = %#v, want text %q", result.Advisories, test.wantAdvisory) + } + }) + } +} + +func TestResolveRunnerGroupRequiresExactOrDefault(t *testing.T) { + groups := []RunnerGroup{{ID: 1, Name: "Restricted", Visibility: "selected"}, {ID: 2, Name: "Default", Visibility: "all", Default: true}} + policy := config.Default().Security.RunnerGroup + if result := ResolveRunnerGroup("restricted", policy, groups); result.Allowed() || !containsText(result.Violations, "was not found") { + t.Fatalf("case-mismatched group result = %+v, want not found", result) + } + policy.RequireExplicitGroup = false + policy.RequireNonDefaultGroup = false + policy.RequiredRepositoryAccess = "all" + result := ResolveRunnerGroup("", policy, groups) + if !result.Allowed() || !result.Resolved || result.Group.Name != "Default" || !containsText(result.Advisories, "runner.group is empty") { + t.Fatalf("default result = %+v", result) + } +} + +func TestListRunnerGroupsAndRepositoriesPaginates(t *testing.T) { + keyPath := writeKey(t) + client := New(config.GitHubConfig{AppID: 123, Organization: "example", PrivateKeyPath: keyPath, APIBaseURL: "https://api.github.test"}) + client.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + body := "{}" + switch r.URL.Path { + case "/orgs/example/installation": + body = `{"id":42}` + case "/app/installations/42/access_tokens": + body = `{"token":"installation-token","expires_at":"2099-01-01T00:00:00Z"}` + case "/orgs/example/actions/runner-groups": + page := r.URL.Query().Get("page") + if page == "1" { + items := make([]string, 100) + for i := range items { + items[i] = fmt.Sprintf(`{"id":%d,"name":"group-%d","visibility":"selected"}`, i+1, i+1) + } + body = `{"total_count":101,"runner_groups":[` + strings.Join(items, ",") + `]}` + } else { + body = `{"total_count":101,"runner_groups":[{"id":101,"name":"group-101","visibility":"selected"}]}` + } + case "/orgs/example/actions/runner-groups/1/repositories": + page := r.URL.Query().Get("page") + if page == "1" { + items := make([]string, 100) + for i := range items { + items[i] = fmt.Sprintf(`{"id":%d,"name":"repo-%d","full_name":"example/repo-%d","private":true}`, i+1, i+1, i+1) + } + body = `{"total_count":101,"repositories":[` + strings.Join(items, ",") + `]}` + } else { + body = `{"total_count":101,"repositories":[{"id":101,"name":"repo-101","full_name":"example/repo-101","private":true}]}` + } + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + })} + groups, err := client.ListRunnerGroups(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(groups) != 101 { + t.Fatalf("groups = %d, want 101", len(groups)) + } + repositories, err := client.ListRunnerGroupRepositories(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if len(repositories) != 101 { + t.Fatalf("repositories = %d, want 101", len(repositories)) + } +} + +func TestLiveRunnerGroupPolicy(t *testing.T) { + configPath := os.Getenv("EPAR_LIVE_CONFIG") + safeGroup := os.Getenv("EPAR_LIVE_SAFE_GROUP") + publicGroup := os.Getenv("EPAR_LIVE_PUBLIC_GROUP") + if configPath == "" || safeGroup == "" || publicGroup == "" { + t.Skip("set EPAR_LIVE_CONFIG, EPAR_LIVE_SAFE_GROUP, and EPAR_LIVE_PUBLIC_GROUP to run live GitHub policy validation") + } + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(configPath) { + configPath = config.ProjectPath(repositoryRoot, configPath) + } + cfg, err := config.Load(configPath) + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(cfg.GitHub.PrivateKeyPath) { + cfg.GitHub.PrivateKeyPath = config.ProjectPath(repositoryRoot, cfg.GitHub.PrivateKeyPath) + } + client := New(cfg.GitHub) + strict := config.Default().Security.RunnerGroup + strict.Enforcement = config.RunnerGroupEnforcementEnforce + + safeResult, err := client.EvaluateRunnerGroupPolicy(context.Background(), safeGroup, strict) + if err != nil { + t.Fatal(err) + } + if !safeResult.Allowed() { + t.Fatalf("safe group violations = %#v", safeResult.Violations) + } + + groups, err := client.ListRunnerGroups(context.Background()) + if err != nil { + t.Fatal(err) + } + var defaultName string + for _, group := range groups { + if group.Default { + defaultName = group.Name + break + } + } + if defaultName == "" { + t.Fatal("GitHub returned no default runner group") + } + defaultStrict, err := client.EvaluateRunnerGroupPolicy(context.Background(), defaultName, strict) + if err != nil { + t.Fatal(err) + } + if defaultStrict.Allowed() { + t.Fatalf("default group unexpectedly passed strict policy: %+v", defaultStrict) + } + defaultPolicy := strict + defaultPolicy.RequireNonDefaultGroup = false + defaultPolicy.RequiredRepositoryAccess = config.RunnerGroupRepositoryAccessAll + defaultAllowed, err := client.EvaluateRunnerGroupPolicy(context.Background(), defaultName, defaultPolicy) + if err != nil { + t.Fatal(err) + } + if !defaultAllowed.Allowed() || !containsText(defaultAllowed.Advisories, "default group") { + t.Fatalf("default group deliberate policy result = %+v", defaultAllowed) + } + + publicStrict, err := client.EvaluateRunnerGroupPolicy(context.Background(), publicGroup, strict) + if err != nil { + t.Fatal(err) + } + if publicStrict.Allowed() || !containsText(publicStrict.Violations, "allows public repositories") { + t.Fatalf("public group strict result = %+v", publicStrict) + } + publicPolicy := strict + publicPolicy.RequirePublicRepositoriesDisabled = false + publicAllowed, err := client.EvaluateRunnerGroupPolicy(context.Background(), publicGroup, publicPolicy) + if err != nil { + t.Fatal(err) + } + if !publicAllowed.Allowed() || !containsText(publicAllowed.Advisories, "untrusted public or fork workflows") { + t.Fatalf("public group deliberate policy result = %+v", publicAllowed) + } +} + +func containsText(values []string, text string) bool { + for _, value := range values { + if strings.Contains(value, text) { + return true + } + } + return false +} diff --git a/internal/pool/manager.go b/internal/pool/manager.go index af81fb5..ecaaf15 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -45,6 +45,7 @@ type Manager struct { type GitHubClient interface { OrganizationURL() string + EvaluateRunnerGroupPolicy(ctx context.Context, configuredGroup string, policy config.RunnerGroupSecurityConfig) (gh.RunnerGroupPolicyResult, error) RegistrationToken(ctx context.Context) (gh.RegistrationToken, error) ListRunners(ctx context.Context) ([]gh.Runner, error) RunnerByName(ctx context.Context, name string) (gh.Runner, bool, error) @@ -101,6 +102,11 @@ const ( ) func (m *Manager) Verify(ctx context.Context, opts VerifyOptions) error { + if opts.RegisterOnly { + if err := m.PreflightRunnerGroup(ctx); err != nil { + return err + } + } poolLock, err := m.AcquirePoolControllerLock() if err != nil { return err @@ -174,6 +180,11 @@ func (m *Manager) Verify(ctx context.Context, opts VerifyOptions) error { } func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { + if opts.Register { + if err := m.PreflightRunnerGroup(ctx); err != nil { + return err + } + } if !opts.PoolLockHeld { controllerLock, err := m.AcquirePoolControllerLock() if err != nil { @@ -1114,6 +1125,9 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register } return vm, fmt.Errorf("github client is required for registration") } + if err := m.PreflightRunnerGroup(ctx); err != nil { + return vm, err + } var ( token gh.RegistrationToken runner gh.Runner @@ -1162,6 +1176,41 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register return vm, nil } +func (m *Manager) PreflightRunnerGroup(ctx context.Context) error { + if m.DryRun { + m.infof("[dry-run] would verify GitHub runner-group security policy before registration\n") + return nil + } + if m.GitHub == nil { + return fmt.Errorf("runner-group security preflight requires a GitHub client") + } + policy := m.Config.Security.RunnerGroup + result, err := m.GitHub.EvaluateRunnerGroupPolicy(ctx, m.Config.Runner.Group, policy) + if err != nil { + message := fmt.Sprintf("runner-group security preflight could not read GitHub policy: %v", err) + if policy.Enforcement == config.RunnerGroupEnforcementWarn { + m.warnf("warning: %s; continuing because security.runnerGroup.enforcement is warn\n", message) + return nil + } + return fmt.Errorf("%s", message) + } + for _, advisory := range result.Advisories { + m.warnf("warning: runner-group security advisory: %s\n", advisory) + } + if len(result.Violations) > 0 { + message := strings.Join(result.Violations, "; ") + if policy.Enforcement == config.RunnerGroupEnforcementWarn { + m.warnf("warning: runner-group security policy violation: %s; continuing because security.runnerGroup.enforcement is warn\n", message) + return nil + } + return fmt.Errorf("runner-group security preflight failed: %s", message) + } + if result.Resolved { + m.infof("runner-group security preflight passed for %q\n", result.Group.Name) + } + return nil +} + func (m *Manager) startupInstanceStartStage() string { if m.Config.Provider.Type == "docker-dind" { return "instance_start_and_inner_docker_ready" diff --git a/internal/pool/manager_test.go b/internal/pool/manager_test.go index 2e0c076..06cd884 100644 --- a/internal/pool/manager_test.go +++ b/internal/pool/manager_test.go @@ -42,6 +42,65 @@ func TestRunnerAliveKeepsBusyGitHubRunnerWithoutServiceCheck(t *testing.T) { } } +func TestRunnerGroupPreflightEnforcesAndWarns(t *testing.T) { + violation := gh.RunnerGroupPolicyResult{Violations: []string{"runner group allows public repositories"}} + for _, test := range []struct { + name string + enforcement string + result gh.RunnerGroupPolicyResult + apiErr error + wantErr bool + }{ + {name: "enforce violation", enforcement: config.RunnerGroupEnforcementEnforce, result: violation, wantErr: true}, + {name: "warn violation", enforcement: config.RunnerGroupEnforcementWarn, result: violation}, + {name: "enforce API failure", enforcement: config.RunnerGroupEnforcementEnforce, apiErr: errors.New("forbidden"), wantErr: true}, + {name: "warn API failure", enforcement: config.RunnerGroupEnforcementWarn, apiErr: errors.New("forbidden")}, + {name: "enforce allowed", enforcement: config.RunnerGroupEnforcementEnforce, result: gh.RunnerGroupPolicyResult{Resolved: true, Group: gh.RunnerGroup{Name: "restricted"}}}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := config.Default() + cfg.Runner.Group = "restricted" + cfg.Security.RunnerGroup.Enforcement = test.enforcement + github := &fakeGitHub{policyResult: test.result, policyErr: test.apiErr} + manager := Manager{Config: cfg, GitHub: github} + err := manager.PreflightRunnerGroup(context.Background()) + if (err != nil) != test.wantErr { + t.Fatalf("PreflightRunnerGroup() error = %v, wantErr=%t", err, test.wantErr) + } + if got := atomic.LoadInt32(&github.policyCalls); got != 1 { + t.Fatalf("policy calls = %d, want 1", got) + } + if got := atomic.LoadInt32(&github.registrationCalls); got != 0 { + t.Fatalf("registration-token calls = %d, want 0", got) + } + if got := atomic.LoadInt32(&github.deleteCalls); got != 0 { + t.Fatalf("runner delete calls = %d, want 0", got) + } + }) + } +} + +func TestRegistrationPreflightRejectsBeforeTokenRequest(t *testing.T) { + provider := &fakeProvider{ip: "127.0.0.1"} + github := &fakeGitHub{policyResult: gh.RunnerGroupPolicyResult{Violations: []string{"unsafe group"}}} + manager := newRegisteredTestManager(t, provider, github) + manager.Config.Security = config.Default().Security + manager.Config.Security.RunnerGroup.Enforcement = config.RunnerGroupEnforcementEnforce + _, err := manager.provisionOne(context.Background(), "epar-test-policy", true, false) + if err == nil || !strings.Contains(err.Error(), "unsafe group") { + t.Fatalf("provisionOne() error = %v, want policy rejection", err) + } + if got := atomic.LoadInt32(&github.policyCalls); got != 1 { + t.Fatalf("policy calls = %d, want 1", got) + } + if got := atomic.LoadInt32(&github.registrationCalls); got != 0 { + t.Fatalf("registration-token calls = %d, want 0", got) + } + if got := atomic.LoadInt32(&github.deleteCalls); got != 0 { + t.Fatalf("remote runner delete calls = %d, want 0", got) + } +} + func TestRetiredInstanceTranscriptsBecomeRetentionEligibleWhileLiveInstanceStaysProtected(t *testing.T) { root := t.TempDir() runtime, err := logging.NewRuntime(logging.Options{Directory: root, TranscriptSinks: logging.SinkFile}) @@ -283,6 +342,7 @@ func TestRunPoolReplacesCompletedRunnerAfterBusyProvisioning(t *testing.T) { Pool: config.PoolConfig{Instances: 1, NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}, Runner: config.RunnerConfig{Labels: []string{"self-hosted"}, Ephemeral: true}, + Security: config.Default().Security, }, Provider: provider, GitHub: github, @@ -1344,6 +1404,10 @@ type fakeGitHub struct { listFunc func(context.Context) ([]gh.Runner, error) registrationErr error registrationToken string + policyResult gh.RunnerGroupPolicyResult + policyErr error + policyCalls int32 + registrationCalls int32 deleteCalls int32 deletedIDs []int64 mu sync.Mutex @@ -1355,7 +1419,13 @@ func (g *fakeGitHub) OrganizationURL() string { return "https://github.test/example" } +func (g *fakeGitHub) EvaluateRunnerGroupPolicy(context.Context, string, config.RunnerGroupSecurityConfig) (gh.RunnerGroupPolicyResult, error) { + atomic.AddInt32(&g.policyCalls, 1) + return g.policyResult, g.policyErr +} + func (g *fakeGitHub) RegistrationToken(context.Context) (gh.RegistrationToken, error) { + atomic.AddInt32(&g.registrationCalls, 1) token := g.registrationToken if token == "" { token = "token" diff --git a/scripts/ci/core-runner-controller.sh b/scripts/ci/core-runner-controller.sh index 5aae410..da7f9c1 100644 --- a/scripts/ci/core-runner-controller.sh +++ b/scripts/ci/core-runner-controller.sh @@ -287,6 +287,16 @@ runner: includeHostLabel: false ephemeral: true +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + # This public project uses a protected trusted-workflow canary. Public + # repository access is the only runner-group safety requirement relaxed. + requirePublicRepositoriesDisabled: false + provider: type: docker-dind sourceImage: ${EPAR_OUTPUT_IMAGE} diff --git a/scripts/run-with-docker.ps1 b/scripts/run-with-docker.ps1 index 7310c4c..1b04889 100644 --- a/scripts/run-with-docker.ps1 +++ b/scripts/run-with-docker.ps1 @@ -49,6 +49,34 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { exit 1 } +function Test-EparBenignDockerDesktopPrefaceDiagnostic { + param([Parameter(Mandatory = $true)][string] $Line) + return $Line -match '^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} http2: server: error reading preface from client //\./pipe/(?:dockerDesktopLinuxEngine|docker_engine): file has already been closed$' +} + +function Invoke-EparBootstrapDockerBuild { + $stderrPath = [System.IO.Path]::GetTempFileName() + try { + docker build --quiet ` + --build-arg "GO_IMAGE=$Image" ` + -t $DevImage ` + -f (Join-Path $RepoRoot "scripts\docker\dev.Dockerfile") ` + (Join-Path $RepoRoot "scripts\docker") 2> $stderrPath | Out-Null + $buildExitCode = $LASTEXITCODE + if (Test-Path -LiteralPath $stderrPath) { + foreach ($line in Get-Content -LiteralPath $stderrPath) { + if ($buildExitCode -eq 0 -and (Test-EparBenignDockerDesktopPrefaceDiagnostic -Line ([string]$line))) { + continue + } + [Console]::Error.WriteLine([string]$line) + } + } + return $buildExitCode + } finally { + Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue + } +} + $RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) . (Join-Path $RepoRoot "scripts\host-trust\wrapper-lib.ps1") $EparCommand = if ($EparArgs -and $EparArgs.Count -gt 0) { [string] $EparArgs[0] } else { "start" } @@ -70,14 +98,9 @@ try { $ExitCode = 0 $bridge = $null try { - docker build --quiet ` - --build-arg "GO_IMAGE=$Image" ` - -t $DevImage ` - -f (Join-Path $RepoRoot "scripts\docker\dev.Dockerfile") ` - (Join-Path $RepoRoot "scripts\docker") | Out-Null - - if ($LASTEXITCODE -ne 0) { - $ExitCode = $LASTEXITCODE + $ExitCode = Invoke-EparBootstrapDockerBuild + if ($ExitCode -ne 0) { + # Invoke-EparBootstrapDockerBuild already preserved the complete Docker stderr on failure. } else { if ($ImplicitInit) { $InitArgs = @(Get-EparHostTrustInitArguments -Arguments $EparArgs) diff --git a/scripts/test/no-go-first-run-smoke.ps1 b/scripts/test/no-go-first-run-smoke.ps1 index a7c4be2..262f244 100644 --- a/scripts/test/no-go-first-run-smoke.ps1 +++ b/scripts/test/no-go-first-run-smoke.ps1 @@ -10,6 +10,8 @@ $oldLocalAppData = $env:LOCALAPPDATA $oldFakeProject = $env:FAKE_PROJECT $oldFakeDockerLog = $env:FAKE_DOCKER_LOG $oldFakeInitFail = $env:FAKE_INIT_FAIL +$oldFakeBuildDiagnostic = $env:FAKE_BUILD_DIAGNOSTIC +$oldFakeBuildFail = $env:FAKE_BUILD_FAIL try { $hostPowerShell = (Get-Process -Id $PID).Path $project = Join-Path $temporary 'project' @@ -26,7 +28,16 @@ try { $ErrorActionPreference = 'Stop' $line = 'CALL' + (($args | ForEach-Object { ' <' + $_ + '>' }) -join '') Add-Content -LiteralPath $env:FAKE_DOCKER_LOG -Value $line -Encoding utf8 -if ($args.Count -gt 0 -and $args[0] -eq 'build') { exit 0 } +if ($args.Count -gt 0 -and $args[0] -eq 'build') { + if ($env:FAKE_BUILD_DIAGNOSTIC -eq '1') { + [Console]::Error.WriteLine('2026/07/22 02:36:46 http2: server: error reading preface from client //./pipe/dockerDesktopLinuxEngine: file has already been closed') + } + if ($env:FAKE_BUILD_FAIL -eq '1') { + [Console]::Error.WriteLine('fake Docker build failure') + exit 24 + } + exit 0 +} if ($args -contains 'init') { if ($env:FAKE_INIT_FAIL -eq '1') { exit 23 } $local = Join-Path $env:FAKE_PROJECT '.local' @@ -45,9 +56,12 @@ exit 0 $env:FAKE_PROJECT = $project $env:FAKE_DOCKER_LOG = Join-Path $temporary 'docker.log' Remove-Item Env:FAKE_INIT_FAIL -ErrorAction SilentlyContinue + $env:FAKE_BUILD_DIAGNOSTIC = '1' + Remove-Item Env:FAKE_BUILD_FAIL -ErrorAction SilentlyContinue - & $hostPowerShell -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $project 'scripts\run-with-docker.ps1') start + $firstRunOutput = @(& $hostPowerShell -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $project 'scripts\run-with-docker.ps1') start *>&1) if ($LASTEXITCODE -ne 0) { throw "first-run wrapper exited $LASTEXITCODE" } + if (($firstRunOutput | Out-String) -match 'http2: server: error reading preface') { throw "successful bootstrap leaked the benign Docker Desktop diagnostic: $($firstRunOutput -join [Environment]::NewLine)" } $calls = @(Get-Content -LiteralPath $env:FAKE_DOCKER_LOG | Where-Object { $_ -like '* *' }) if ($calls.Count -ne 2) { throw "expected two controller runs, got $($calls.Count)" } if ($calls[0] -notlike '* *' -or $calls[0] -notlike '* *' -or $calls[0] -notlike '* *') { @@ -57,6 +71,15 @@ exit 0 throw "second start did not receive the read-only host-trust feed: $($calls[1])" } + $env:FAKE_BUILD_FAIL = '1' + $failedBuildOutput = @(& $hostPowerShell -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $project 'scripts\run-with-docker.ps1') start *>&1) + if ($LASTEXITCODE -ne 24) { throw "failing bootstrap build exited $LASTEXITCODE, want 24" } + $failedBuildText = $failedBuildOutput | Out-String + if ($failedBuildText -notmatch 'http2: server: error reading preface' -or $failedBuildText -notmatch 'fake Docker build failure') { + throw "failing bootstrap build did not preserve complete Docker stderr: $failedBuildText" + } + Remove-Item Env:FAKE_BUILD_FAIL -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $project '.local') -Recurse -Force Remove-Item -LiteralPath $env:LOCALAPPDATA -Recurse -Force -ErrorAction SilentlyContinue [System.IO.File]::WriteAllText($env:FAKE_DOCKER_LOG, '', [System.Text.UTF8Encoding]::new($false)) @@ -73,5 +96,7 @@ finally { if ($null -eq $oldFakeProject) { Remove-Item Env:FAKE_PROJECT -ErrorAction SilentlyContinue } else { $env:FAKE_PROJECT = $oldFakeProject } if ($null -eq $oldFakeDockerLog) { Remove-Item Env:FAKE_DOCKER_LOG -ErrorAction SilentlyContinue } else { $env:FAKE_DOCKER_LOG = $oldFakeDockerLog } if ($null -eq $oldFakeInitFail) { Remove-Item Env:FAKE_INIT_FAIL -ErrorAction SilentlyContinue } else { $env:FAKE_INIT_FAIL = $oldFakeInitFail } + if ($null -eq $oldFakeBuildDiagnostic) { Remove-Item Env:FAKE_BUILD_DIAGNOSTIC -ErrorAction SilentlyContinue } else { $env:FAKE_BUILD_DIAGNOSTIC = $oldFakeBuildDiagnostic } + if ($null -eq $oldFakeBuildFail) { Remove-Item Env:FAKE_BUILD_FAIL -ErrorAction SilentlyContinue } else { $env:FAKE_BUILD_FAIL = $oldFakeBuildFail } Remove-Item -LiteralPath $temporary -Recurse -Force -ErrorAction SilentlyContinue } From 544392cd343c216fc134df9ef0249a8211c3b4a8 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 22 Jul 2026 14:15:45 +0800 Subject: [PATCH 02/22] Restore Docker pull progress and startup timing output --- internal/pool/docker_pull.go | 75 ++++++++------- internal/pool/docker_pull_test.go | 138 +++++++++++++++++++++++++++ internal/pool/startup_timing.go | 21 +++- internal/pool/startup_timing_test.go | 72 ++++++++++++++ 4 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 internal/pool/docker_pull_test.go diff --git a/internal/pool/docker_pull.go b/internal/pool/docker_pull.go index dbf327e..b293193 100644 --- a/internal/pool/docker_pull.go +++ b/internal/pool/docker_pull.go @@ -41,6 +41,12 @@ type dockerLayerProgress struct { // image preparation tests. Production always uses Manager.pullDockerSource. var pullDockerSourceCommand = (*Manager).pullDockerSource +var dockerPullProgressTerminal = func() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +var dockerPullProgressConsole io.Writer = os.Stdout + func (m *Manager) pullDockerSource(ctx context.Context, opts dockerSourcePullOptions) error { cli, err := client.New(client.FromEnv) if err != nil { @@ -81,7 +87,6 @@ func (m *Manager) pullDockerSource(ctx context.Context, opts dockerSourcePullOpt } func (m *Manager) pullDockerSourceWithCLI(ctx context.Context, opts dockerSourcePullOptions, apiErr error) error { - m.warnf("warning: %v; falling back to docker pull CLI\n", apiErr) m.writeDockerPullNotice(opts.LogPath, "warning: "+sanitizeTimingError(apiErr)+"; falling back to docker pull CLI") args := []string{"pull"} if opts.Platform != "" { @@ -214,11 +219,6 @@ func (m *Manager) renderDockerPullProgress(ctx context.Context, response client. if err != nil { return err } - interactive := term.IsTerminal(int(os.Stdout.Fd())) && containsString(m.Config.Logging.TranscriptSinks, "console") && m.Config.Logging.TranscriptConsoleFormat == "text" - eventWriter := transcript.Stdout - if interactive { - eventWriter = transcript.File - } layers := map[string]dockerLayerProgress{} lastRender := time.Time{} rendered := false @@ -226,7 +226,7 @@ func (m *Manager) renderDockerPullProgress(ctx context.Context, response client. if streamErr != nil { return streamErr } - writeDockerPullEvent(eventWriter, message.ID, message.Status, message.Progress, message.Stream, message.Error) + writeDockerPullEvent(transcript.Stdout, message.ID, message.Status, message.Progress, message.Stream, message.Error) if message.Error != nil { return message.Error } @@ -247,29 +247,27 @@ func (m *Manager) renderDockerPullProgress(ctx context.Context, response client. layers[message.ID] = layer } if time.Since(lastRender) >= dockerPullProgressInterval { - if interactive { - writeDockerPullSummary(os.Stdout, true, layers) - } else { - writeDockerPullSummary(transcript.Stdout, false, layers) - } + m.writeDockerPullProgress(logPath, layers) lastRender = time.Now() rendered = true } } if rendered { - if interactive { - writeDockerPullSummary(os.Stdout, true, layers) - } else { - writeDockerPullSummary(transcript.Stdout, false, layers) - } - if interactive { - fmt.Fprintln(os.Stdout) + m.writeDockerPullProgress(logPath, layers) + if m.dockerPullProgressIsInteractive() { + fmt.Fprintln(dockerPullProgressConsole) } } return nil } func (m *Manager) writeDockerPullNotice(logPath, message string) { + attributes := []any{"provider", m.Config.Provider.Type, "operation", "docker-pull", "logPath", logPath} + if strings.HasPrefix(message, "warning:") { + m.logger().Warn(strings.TrimSpace(strings.TrimPrefix(message, "warning:")), attributes...) + } else { + m.logger().Info(message, attributes...) + } transcript, err := m.transcript(logPath, "", "docker-pull") if err != nil { m.logger().Warn("docker pull transcript unavailable", "operation", "docker-pull", "logPath", logPath, "error", err) @@ -278,15 +276,6 @@ func (m *Manager) writeDockerPullNotice(logPath, message string) { _, _ = fmt.Fprintf(transcript.Stdout, "%s\n", message) } -func containsString(values []string, wanted string) bool { - for _, value := range values { - if value == wanted { - return true - } - } - return false -} - func writeDockerPullEvent(logFile io.Writer, id, status string, progress *jsonstream.Progress, stream string, pullErr error) { if logFile == nil { return @@ -315,7 +304,29 @@ func dockerPullLayerComplete(status string) bool { return status == "pull complete" || status == "already exists" || status == "exists" } -func writeDockerPullSummary(w io.Writer, interactive bool, layers map[string]dockerLayerProgress) { +func (m *Manager) writeDockerPullProgress(logPath string, layers map[string]dockerLayerProgress) { + line := dockerPullProgressSummary(layers) + if m.dockerPullProgressIsInteractive() { + _, _ = fmt.Fprintf(dockerPullProgressConsole, "\r\033[2K%s", line) + return + } + m.logger().Info(line, "provider", m.Config.Provider.Type, "operation", "docker-pull", "logPath", logPath) +} + +func (m *Manager) dockerPullProgressIsInteractive() bool { + return dockerPullProgressTerminal() && containsString(m.Config.Logging.ManagerSinks, "console") && m.Config.Logging.ManagerConsoleFormat == "text" +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func dockerPullProgressSummary(layers map[string]dockerLayerProgress) string { var complete, known int var currentBytes, totalBytes int64 for _, layer := range layers { @@ -335,11 +346,7 @@ func writeDockerPullSummary(w io.Writer, interactive bool, layers map[string]doc if known < len(layers) { line += fmt.Sprintf("; %d layer(s) size pending", len(layers)-known) } - if interactive { - fmt.Fprintf(w, "\r\033[2K%s", line) - return - } - fmt.Fprintf(w, "%s %s\n", time.Now().UTC().Format(time.RFC3339Nano), line) + return line } func formatDockerPullBytes(value int64) string { diff --git a/internal/pool/docker_pull_test.go b/internal/pool/docker_pull_test.go new file mode 100644 index 0000000..b81a254 --- /dev/null +++ b/internal/pool/docker_pull_test.go @@ -0,0 +1,138 @@ +package pool + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/logging" +) + +func TestDockerPullProgressUsesManagerLoggerAndPreservesSourceTranscript(t *testing.T) { + root := t.TempDir() + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: root, + ManagerSinks: logging.SinkConsole, + TranscriptSinks: logging.SinkFile, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + cfg := config.Default() + cfg.Logging.Directory = root + manager := Manager{Config: cfg, Logging: runtime} + logPath := filepath.Join(root, "builds", "source.docker-pull.log") + previousTerminal := dockerPullProgressTerminal + dockerPullProgressTerminal = func() bool { return false } + t.Cleanup(func() { dockerPullProgressTerminal = previousTerminal }) + manager.writeDockerPullProgress(logPath, map[string]dockerLayerProgress{ + "layer-a": {current: 512, total: 1024, completed: false}, + "layer-b": {completed: true}, + }) + transcriptWriter, err := manager.transcript(logPath, "", "docker-pull") + if err != nil { + t.Fatal(err) + } + writeDockerPullEvent(transcriptWriter.Stdout, "layer-a", "Downloading", nil, "", nil) + manager.writeDockerPullNotice(logPath, "Docker source pull complete: example.invalid/source:latest") + if err := manager.releaseTranscript(logPath); err != nil { + t.Fatal(err) + } + + consoleText := console.String() + if !strings.Contains(consoleText, "Docker source pull: 1/2 layers complete; 512 B/1.0 KiB (50%); 1 layer(s) size pending") { + t.Fatalf("manager console did not receive pull progress: %q", consoleText) + } + if !strings.Contains(consoleText, "Docker source pull complete: example.invalid/source:latest") { + t.Fatalf("manager console did not receive pull completion notice: %q", consoleText) + } + transcript, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(transcript), "Docker source pull complete: example.invalid/source:latest") { + t.Fatalf("source transcript did not retain pull completion notice: %q", transcript) + } + if !strings.Contains(string(transcript), "layer-a Downloading") { + t.Fatalf("source transcript did not retain raw pull event: %q", transcript) + } +} + +func TestDockerPullProgressHonorsManagerJSONConsoleFormat(t *testing.T) { + root := t.TempDir() + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: root, + ManagerSinks: logging.SinkConsole, + ManagerConsoleFormat: logging.FormatJSON, + TranscriptSinks: logging.SinkFile, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + cfg := config.Default() + cfg.Logging.Directory = root + cfg.Logging.ManagerConsoleFormat = "json" + cfg.Provider.Type = "docker-dind" + manager := Manager{Config: cfg, Logging: runtime} + previousTerminal := dockerPullProgressTerminal + dockerPullProgressTerminal = func() bool { return true } + t.Cleanup(func() { dockerPullProgressTerminal = previousTerminal }) + logPath := filepath.Join(root, "builds", "source.docker-pull.log") + manager.writeDockerPullProgress(logPath, map[string]dockerLayerProgress{"layer-a": {current: 1, total: 2}}) + + var record map[string]any + if err := json.Unmarshal(console.Bytes(), &record); err != nil { + t.Fatalf("decode manager JSON console: %v: %q", err, console.String()) + } + if record["msg"] != "Docker source pull: 0/1 layers complete; 1 B/2 B (50%)" || record["provider"] != "docker-dind" || record["operation"] != "docker-pull" || record["logPath"] != logPath { + t.Fatalf("manager JSON console missing pull context: %#v", record) + } +} + +func TestDockerPullProgressUsesSingleLineTerminalDisplayForTextManagerConsole(t *testing.T) { + root := t.TempDir() + var managerConsole, terminalConsole bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: root, + ManagerSinks: logging.SinkConsole, + TranscriptSinks: logging.SinkFile, + Stdout: &managerConsole, + Stderr: &managerConsole, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + previousTerminal, previousConsole := dockerPullProgressTerminal, dockerPullProgressConsole + dockerPullProgressTerminal = func() bool { return true } + dockerPullProgressConsole = &terminalConsole + t.Cleanup(func() { + dockerPullProgressTerminal = previousTerminal + dockerPullProgressConsole = previousConsole + }) + + manager := Manager{Config: config.Default(), Logging: runtime} + manager.writeDockerPullProgress("source.docker-pull.log", map[string]dockerLayerProgress{"layer-a": {current: 1, total: 2}}) + + if got, want := terminalConsole.String(), "\r\033[2KDocker source pull: 0/1 layers complete; 1 B/2 B (50%)"; got != want { + t.Fatalf("terminal pull progress = %q, want %q", got, want) + } + if got := managerConsole.String(); got != "" { + t.Fatalf("interactive pull progress was duplicated through manager logger: %q", got) + } +} diff --git a/internal/pool/startup_timing.go b/internal/pool/startup_timing.go index adb065d..9f30a76 100644 --- a/internal/pool/startup_timing.go +++ b/internal/pool/startup_timing.go @@ -2,6 +2,7 @@ package pool import ( "encoding/json" + "fmt" "log/slog" "os" "path/filepath" @@ -151,14 +152,28 @@ func (t *startupTiming) finish(err error) { } else { t.stages = append(t.stages, startupTimingStage{name: "total_startup", elapsed: elapsed}) t.eventLocked("total_startup", "completed", elapsed, nil) - for _, stage := range t.stages { - t.logger.Info("startup timing", "provider", t.provider, "operation", "startup", "stage", stage.name, "duration", stage.elapsed.Round(time.Millisecond), "logPath", t.path) - } + t.logger.Info(startupTimingSummary(t.provider, t.stages, t.path), "provider", t.provider, "operation", "startup", "stages", slog.GroupValue(startupTimingStageAttributes(t.stages)...), "logPath", t.path) } t.closed = true _ = t.file.Close() } +func startupTimingSummary(provider string, stages []startupTimingStage, path string) string { + parts := make([]string, 0, len(stages)) + for _, stage := range stages { + parts = append(parts, fmt.Sprintf("%s=%s", stage.name, stage.elapsed.Round(time.Millisecond))) + } + return fmt.Sprintf("%s startup timing: %s; log: %s", startupTimingLabel(provider), strings.Join(parts, ", "), path) +} + +func startupTimingStageAttributes(stages []startupTimingStage) []slog.Attr { + attributes := make([]slog.Attr, 0, len(stages)) + for _, stage := range stages { + attributes = append(attributes, slog.Duration(stage.name, stage.elapsed.Round(time.Millisecond))) + } + return attributes +} + func (t *startupTiming) eventLocked(stage, outcome string, elapsed time.Duration, err error) { event := startupTimingEvent{ Timestamp: time.Now().UTC().Format(time.RFC3339Nano), diff --git a/internal/pool/startup_timing_test.go b/internal/pool/startup_timing_test.go index 10f98bb..d27c9a8 100644 --- a/internal/pool/startup_timing_test.go +++ b/internal/pool/startup_timing_test.go @@ -1,9 +1,16 @@ package pool import ( + "bytes" + "encoding/json" "errors" + "os" + "path/filepath" "strings" "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/logging" ) func TestSanitizeTimingErrorRedactsSecretAssignments(t *testing.T) { @@ -16,3 +23,68 @@ func TestSanitizeTimingErrorRedactsSecretAssignments(t *testing.T) { t.Fatalf("sanitizeTimingError did not retain sanitized keys: %q", got) } } + +func TestStartupTimingWritesOneReadableConsoleSummaryAndStructuredRecord(t *testing.T) { + root := t.TempDir() + logDirectory := filepath.Join(root, "logs") + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: logDirectory, + ManagerSinks: logging.SinkBoth, + ManagerFileFormat: logging.FormatJSON, + TranscriptSinks: logging.SinkNone, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatalf("create logging runtime: %v", err) + } + manager := Manager{ + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}, Logging: config.LoggingConfig{Directory: "logs"}}, + ProjectRoot: root, + Logging: runtime, + } + path, err := manager.StartStartupTiming() + if err != nil { + t.Fatalf("start startup timing: %v", err) + } + for _, stage := range []string{"source_image_pull", "instance_container_create"} { + if err := manager.timeStartupStage(stage, func() error { return nil }); err != nil { + t.Fatalf("measure %s: %v", stage, err) + } + } + manager.FinishStartupTiming(nil) + if err := runtime.Close(); err != nil { + t.Fatalf("close logging runtime: %v", err) + } + + output := console.String() + if count := strings.Count(output, "\n"); count != 1 { + t.Fatalf("console emitted %d timing records, want 1: %q", count, output) + } + for _, want := range []string{"DinD startup timing:", "source_image_pull=", "instance_container_create=", "total_startup=", "log: " + path} { + if !strings.Contains(output, want) { + t.Fatalf("console summary missing %q: %q", want, output) + } + } + + content, err := os.ReadFile(filepath.Join(logDirectory, logging.ManagerFilename)) + if err != nil { + t.Fatalf("read structured manager log: %v", err) + } + var record map[string]any + if err := json.Unmarshal(content, &record); err != nil { + t.Fatalf("decode structured manager record: %v\n%s", err, content) + } + message, ok := record["msg"].(string) + if !ok || !strings.Contains(message, "DinD startup timing:") || !strings.Contains(message, "source_image_pull=") || !strings.Contains(message, "instance_container_create=") || !strings.Contains(message, "total_startup=") || !strings.Contains(message, "log: "+path) { + t.Fatalf("unexpected structured message: %#v", record["msg"]) + } + if record["provider"] != "docker-dind" || record["operation"] != "startup" || record["logPath"] != path { + t.Fatalf("structured record missing context: %#v", record) + } + stages, ok := record["stages"].(map[string]any) + if !ok || stages["source_image_pull"] == nil || stages["instance_container_create"] == nil || stages["total_startup"] == nil { + t.Fatalf("structured record missing stage durations: %#v", record["stages"]) + } +} From 115c9687e7c165813cada296ee7b48cb236c5a92 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 23 Jul 2026 10:07:31 +0800 Subject: [PATCH 03/22] Rename Docker-DinD provider to Docker Container --- README.md | 18 +++---- SUPPORT.md | 2 +- cmd/ephemeral-action-runner/init.go | 26 ++++----- cmd/ephemeral-action-runner/init_test.go | 16 +++--- cmd/ephemeral-action-runner/main.go | 6 +-- cmd/ephemeral-action-runner/provider_test.go | 12 ++--- ...e.yml => docker-container.act.example.yml} | 8 +-- ....yml => docker-container.core.example.yml} | 2 +- ...ample.yml => docker-container.example.yml} | 8 +-- ...l => docker-container.web-e2e.example.yml} | 8 +-- docs/advanced/docker-registry-mirrors.md | 10 ++-- docs/advanced/macos-startup.md | 2 +- docs/advanced/no-go-install.md | 2 +- docs/advanced/windows-startup.md | 2 +- docs/background.md | 6 +-- docs/configuration.md | 40 ++++++++------ docs/core-runner-verification.md | 14 ++--- docs/design.md | 12 ++--- docs/image-build.md | 45 ++++++++-------- docs/operations.md | 20 +++---- docs/providers/adding-provider.md | 2 +- .../{docker-dind.md => docker-container.md} | 54 +++++++++---------- docs/providers/wsl.md | 2 +- docs/security.md | 6 +-- docs/troubleshooting.md | 26 ++++----- docs/usage.md | 48 ++++++++--------- internal/config/config.go | 22 ++++---- internal/config/config_test.go | 52 +++++++++--------- internal/image/README.md | 2 +- internal/logging/paths.go | 2 +- internal/logging/paths_test.go | 8 +-- internal/logging/recognition.go | 4 +- internal/logging/runtime_test.go | 6 +-- internal/pool/controller_lock_test.go | 16 +++--- internal/pool/docker_pull_test.go | 26 ++++----- internal/pool/host_trust_test.go | 8 +-- internal/pool/image.go | 38 ++++++------- internal/pool/image_manifest.go | 12 ++--- internal/pool/image_manifest_test.go | 4 +- internal/pool/image_test.go | 22 ++++---- internal/pool/manager.go | 2 +- internal/pool/manager_test.go | 22 ++++---- internal/pool/startup_timing.go | 8 +-- internal/pool/startup_timing_test.go | 20 +++---- internal/pool/trusted_ca_test.go | 4 +- .../docker_container.go} | 8 +-- .../docker_container_test.go} | 20 +++---- internal/provider/factory.go | 2 +- scripts/ci/core-runner-controller.sh | 2 +- scripts/ci/core-runner-controller_test.sh | 8 +-- scripts/container/ubuntu/entrypoint.sh | 4 +- scripts/run-with-docker.ps1 | 2 +- scripts/run-with-docker.sh | 2 +- scripts/sync-wiki.sh | 10 ++-- scripts/test/host-trust-docker-e2e.sh | 2 +- scripts/test/no-go-first-run-smoke.ps1 | 2 +- scripts/test/no-go-first-run-smoke.sh | 2 +- 57 files changed, 370 insertions(+), 369 deletions(-) rename configs/{docker-dind.act.example.yml => docker-container.act.example.yml} (90%) rename configs/{docker-dind.core.example.yml => docker-container.core.example.yml} (98%) rename configs/{docker-dind.example.yml => docker-container.example.yml} (89%) rename configs/{docker-dind.web-e2e.example.yml => docker-container.web-e2e.example.yml} (88%) rename docs/providers/{docker-dind.md => docker-container.md} (72%) rename internal/provider/{dockerdind/docker_dind.go => dockercontainer/docker_container.go} (96%) rename internal/provider/{dockerdind/docker_dind_test.go => dockercontainer/docker_container_test.go} (89%) diff --git a/README.md b/README.md index 7a8c0a7..1fbd582 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,13 @@ A normal long-lived self-hosted runner can leave dependencies, files, containers - **Warm pool:** keep ready self-hosted runners online after setup. - **Disposable jobs:** each runner is cleaned up after one job. -- **Great default image:** Docker-DinD and WSL use Catthehacker's full Ubuntu runner image by default. -- **Docker-friendly isolation:** Docker-DinD gives each runner its own private Docker daemon. +- **Great default image:** Docker Container and WSL use Catthehacker's full Ubuntu runner image by default. +- **Docker-friendly isolation:** Docker Container gives each runner its own private Docker daemon. - **Simple host use:** run Linux GitHub Actions jobs from a Windows, macOS, Linux, or Docker-capable host. ## Quick Start -The easiest path is the default **Docker-DinD** mode. It works well for most Linux GitHub Actions jobs, especially Docker and Docker Compose jobs. +The easiest path is the default **Docker Container** mode. It works well for most Linux GitHub Actions jobs, especially Docker and Docker Compose jobs. ### 1. Install Docker @@ -75,7 +75,7 @@ That's it. #### What Happens -EPAR initializes `.local/config.yml` for you if it does not exist. The wizard reads your organization's runner groups, requires an explicit selection, and writes an enforced safety policy; see [Runner Group Security](docs/runner-groups.md). Docker-DinD is the default. The wizard asks whether new Docker-DinD runners should inherit the host's trusted TLS roots and defaults to yes. On native Windows, it also offers WSL2 when `wsl.exe --status` confirms default version 2. On macOS, it offers experimental Tart mode when `tart --version` succeeds. Press Enter to keep Docker-DinD. Existing configs do not enable host trust inheritance automatically. You can customize the config afterward; see [Configuration](docs/configuration.md). +EPAR initializes `.local/config.yml` for you if it does not exist. The wizard reads your organization's runner groups, requires an explicit selection, and writes an enforced safety policy; see [Runner Group Security](docs/runner-groups.md). Docker Container is the default. The wizard asks whether new Docker Container runners should inherit the host's trusted TLS roots and defaults to yes. On native Windows, it also offers WSL2 when `wsl.exe --status` confirms default version 2. On macOS, it offers experimental Tart mode when `tart --version` succeeds. Press Enter to keep Docker Container. Existing configs do not enable host trust inheritance automatically. You can customize the config afterward; see [Configuration](docs/configuration.md). Then EPAR checks the configured runner image, builds or replaces it when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. @@ -104,18 +104,18 @@ runs-on: [self-hosted] If you have multiple self-hosted runners and want this job to run on a specific kind of EPAR runner, add one of its extra labels to the list, e.g.: ```yaml -runs-on: [self-hosted, epar-docker-dind-catthehacker-ubuntu] +runs-on: [self-hosted, epar-docker-container-catthehacker-ubuntu] ``` EPAR also adds an `epar-host-` label by default, so you can see which host registered each runner. You only need to include that label in `runs-on` when you intentionally want a job to target one machine. ## Other Modes -Docker-DinD is the default first choice. Other providers are available when they fit your host better: +Docker Container is the default first choice. Other providers are available when they fit your host better: | Provider | Use when | | --- | --- | -| Docker-DinD | You have a Docker-compatible daemon on Windows, macOS, or Linux, and want a private Docker daemon per runner. | +| Docker Container | You have a Docker-compatible daemon on Windows, macOS, or Linux, and want a private Docker daemon per runner. | | WSL2 | You are on Windows and want runners as disposable WSL distros. | | Tart (experimental) | You are on Apple Silicon macOS and want to experiment with native ARM64 Linux VMs. The default Tart image is a basic Ubuntu OS image and does not include the normal GitHub-hosted runner dependency set. | @@ -141,7 +141,7 @@ Yes. EPAR registers disposable ephemeral runners. After a job finishes, EPAR del ### Can jobs use Docker, Docker Compose, and Buildx? -Yes, with the default Docker-DinD mode. Each runner gets its own private Docker daemon, so job-created containers, networks, and volumes stay inside that disposable runner. +Yes, with the default Docker Container mode. Each runner gets its own private Docker daemon, so job-created containers, networks, and volumes stay inside that disposable runner. ## Safety @@ -155,7 +155,7 @@ GitHub also warns against using self-hosted runners with public repositories tha - [Configuration](docs/configuration.md): config file sections and common edits. - [GitHub App Setup](docs/github-app.md): required GitHub App permissions and fields. - [Runner Group Security](docs/runner-groups.md): repository access, wizard choices, and registration preflight policy. -- [Docker-DinD Provider](docs/providers/docker-dind.md): default Docker runner mode. +- [Docker Container Provider](docs/providers/docker-container.md): default Docker runner mode. - [WSL Provider](docs/providers/wsl.md): Windows WSL2 runners. - [Tart Provider (experimental)](docs/providers/tart.md): Apple Silicon ARM64 Linux VM runners and Rosetta compatibility limits. - [Image Build](docs/image-build.md): image internals and customization. diff --git a/SUPPORT.md b/SUPPORT.md index 72f7890..78c8a0c 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,6 +1,6 @@ # Support -Start with the [troubleshooting guide](docs/troubleshooting.md). It provides symptom-first diagnostics for Docker-DinD, WSL, Tart, host trust, image builds, storage, and cross-architecture containers. +Start with the [troubleshooting guide](docs/troubleshooting.md). It provides symptom-first diagnostics for Docker Container, WSL, Tart, host trust, image builds, storage, and cross-architecture containers. Before asking for help, search the repository's existing [issues](https://github.com/solutionforest/ephemeral-action-runner/issues) and collect: diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index d154505..baf5f8e 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -154,7 +154,7 @@ func runInitWithOptions(opts initOptions) error { if err != nil { return err } - providerType := "docker-dind" + providerType := "docker-container" if wsl2Available() { providerType, err = promptProviderType(opts.Out, reader, "wsl") if err != nil { @@ -188,7 +188,7 @@ func runInitWithOptions(opts initOptions) error { hostTrustMode := config.HostTrustModeDisabled hostTrustScopes := []string{config.HostTrustScopeSystem} - if providerType == "docker-dind" { + if providerType == "docker-container" { enabled, promptErr := promptYesNo(opts.Out, reader, "Inherit this host's trusted TLS roots into disposable runners?", true) if promptErr != nil { return promptErr @@ -212,7 +212,7 @@ func runInitWithOptions(opts initOptions) error { } } - content := defaultDockerDindConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup) + content := defaultDockerContainerConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup) switch providerType { case "wsl": content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup) @@ -602,7 +602,7 @@ func promptProviderType(out io.Writer, reader *bufio.Reader, alternative string) alternativeLabel := providerDisplayName(alternative) fmt.Fprintln(out, "") fmt.Fprintln(out, "Runner provider:") - fmt.Fprintln(out, " 1. Docker-DinD (default)") + fmt.Fprintln(out, " 1. Docker Container — private daemon (default)") fmt.Fprintf(out, " 2. %s\n", alternativeLabel) for { value, hitEOF, err := promptDefault(out, reader, "Runner provider", "1") @@ -610,8 +610,8 @@ func promptProviderType(out io.Writer, reader *bufio.Reader, alternative string) return "", err } switch strings.ToLower(value) { - case "1", "docker", "docker-dind": - return "docker-dind", nil + case "1", "docker", "docker-container": + return "docker-container", nil case "2", alternative: return alternative, nil case "wsl2": @@ -621,7 +621,7 @@ func promptProviderType(out io.Writer, reader *bufio.Reader, alternative string) default: // Continue below so aliases that belong to an unavailable provider are rejected. } - fmt.Fprintf(out, "Runner provider must be 1 (Docker-DinD) or 2 (%s).\n", alternativeLabel) + fmt.Fprintf(out, "Runner provider must be 1 (Docker Container — private daemon) or 2 (%s).\n", alternativeLabel) if hitEOF { return "", fmt.Errorf("invalid runner provider %q", value) } @@ -635,7 +635,7 @@ func providerDisplayName(providerType string) string { case "tart": return "Tart (experimental)" default: - return "Docker-DinD" + return "Docker Container — private daemon" } } @@ -832,7 +832,7 @@ func (b *boundedBuffer) Write(p []byte) (int, error) { return b.Buffer.Write(p) } -func defaultDockerDindConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection) string { +func defaultDockerContainerConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection) string { return fmt.Sprintf(`github: appId: %d organization: %s @@ -843,7 +843,7 @@ func defaultDockerDindConfig(appID int64, organization, privateKeyPath string, p image: sourceType: docker-image sourceImage: ghcr.io/catthehacker/ubuntu:full-latest - outputImage: epar-docker-dind-catthehacker-ubuntu + outputImage: epar-docker-container-catthehacker-ubuntu upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest @@ -880,7 +880,7 @@ logging: runner: group: %s - labels: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu] + labels: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu] includeHostLabel: true ephemeral: true @@ -893,8 +893,8 @@ security: requirePublicRepositoriesDisabled: %t provider: - type: docker-dind - sourceImage: epar-docker-dind-catthehacker-ubuntu + type: docker-container + sourceImage: epar-docker-container-catthehacker-ubuntu network: default docker: diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index 252bc6d..5556fff 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -17,7 +17,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" ) -func TestInitCreatesDefaultDockerDindConfig(t *testing.T) { +func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) stubNoWSL2(t) @@ -50,13 +50,13 @@ func TestInitCreatesDefaultDockerDindConfig(t *testing.T) { if policy.Enforcement != config.RunnerGroupEnforcementEnforce || !policy.RequireExplicitGroup || !policy.RequireNonDefaultGroup || policy.RequiredRepositoryAccess != config.RunnerGroupRepositoryAccessSelected || !policy.RequirePublicRepositoriesDisabled { t.Fatalf("unexpected generated runner-group policy: %+v", policy) } - if got, want := cfg.Provider.Type, "docker-dind"; got != want { + if got, want := cfg.Provider.Type, "docker-container"; got != want { t.Fatalf("provider.type = %q, want %q", got, want) } if got, want := cfg.Image.SourceImage, "ghcr.io/catthehacker/ubuntu:full-latest"; got != want { t.Fatalf("image.sourceImage = %q, want %q", got, want) } - if got, want := cfg.Image.OutputImage, "epar-docker-dind-catthehacker-ubuntu"; got != want { + if got, want := cfg.Image.OutputImage, "epar-docker-container-catthehacker-ubuntu"; got != want { t.Fatalf("image.outputImage = %q, want %q", got, want) } if got, want := cfg.Image.HostTrustMode, config.HostTrustModeOverlay; got != want { @@ -97,7 +97,7 @@ func TestInitCreatesDefaultDockerDindConfig(t *testing.T) { if !strings.Contains(string(configText), "replacementRetryInitialSeconds: 15\n replacementRetryMaxSeconds: 1800\n replacementRetryMultiplier: 2\n replacementRetryJitterPercent: 20\n") { t.Fatalf("generated config did not include replacement retry settings:\n%s", configText) } - if got := strings.Join(cfg.Runner.Labels, ","); !strings.Contains(got, "epar-docker-dind-catthehacker-ubuntu") { + if got := strings.Join(cfg.Runner.Labels, ","); !strings.Contains(got, "epar-docker-container-catthehacker-ubuntu") { t.Fatalf("runner labels = %q", got) } if !strings.Contains(out.String(), "start") || !strings.Contains(out.String(), "pool up --instances 2") { @@ -652,7 +652,7 @@ func TestInitOffersWSL2ConfigWhenAvailable(t *testing.T) { } } -func TestInitWSL2ChoiceDefaultsToDockerDindAndRepromptsInvalidValues(t *testing.T) { +func TestInitWSL2ChoiceDefaultsToDockerContainerAndRepromptsInvalidValues(t *testing.T) { stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) stubWSL2Available(t) @@ -673,10 +673,10 @@ func TestInitWSL2ChoiceDefaultsToDockerDindAndRepromptsInvalidValues(t *testing. if err != nil { t.Fatal(err) } - if !strings.Contains(string(configBytes), "type: docker-dind") { - t.Fatalf("config did not use the default Docker-DinD provider:\n%s", configBytes) + if !strings.Contains(string(configBytes), "type: docker-container") { + t.Fatalf("config did not use the default Docker Container provider:\n%s", configBytes) } - if !strings.Contains(out.String(), "Runner provider must be 1 (Docker-DinD) or 2 (WSL2).") { + if !strings.Contains(out.String(), "Runner provider must be 1 (Docker Container — private daemon) or 2 (WSL2).") { t.Fatalf("init output did not explain invalid provider input:\n%s", out.String()) } } diff --git a/cmd/ephemeral-action-runner/main.go b/cmd/ephemeral-action-runner/main.go index 7c5c680..4f76710 100644 --- a/cmd/ephemeral-action-runner/main.go +++ b/cmd/ephemeral-action-runner/main.go @@ -16,7 +16,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/logging" "github.com/solutionforest/ephemeral-action-runner/internal/pool" "github.com/solutionforest/ephemeral-action-runner/internal/provider" - dockerdindprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockerdind" + dockercontainerprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockercontainer" tartprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/tart" wslprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/wsl" ) @@ -528,14 +528,14 @@ func newProvider(cfg config.Config, projectRoot string, dryRun bool) (provider.P return tartprovider.New("", dryRun), nil case "wsl": return wslprovider.New("", config.ProjectPath(projectRoot, cfg.Provider.InstallRoot), projectRoot, dryRun), nil - case "docker-dind": + case "docker-container": hostGateway := config.DockerConfigNeedsHostGateway(cfg.Docker) environment := map[string]string{ "HTTP_PROXY": cfg.Docker.HTTPProxy, "HTTPS_PROXY": cfg.Docker.HTTPSProxy, "NO_PROXY": cfg.Docker.NoProxy, } - return dockerdindprovider.NewWithOptions("", cfg.Provider.Platform, hostGateway, environment, dryRun), nil + return dockercontainerprovider.NewWithOptions("", cfg.Provider.Platform, hostGateway, environment, dryRun), nil default: return nil, provider.UnsupportedTypeError(cfg.Provider.Type) } diff --git a/cmd/ephemeral-action-runner/provider_test.go b/cmd/ephemeral-action-runner/provider_test.go index 7ab3206..9df9505 100644 --- a/cmd/ephemeral-action-runner/provider_test.go +++ b/cmd/ephemeral-action-runner/provider_test.go @@ -4,12 +4,12 @@ import ( "testing" "github.com/solutionforest/ephemeral-action-runner/internal/config" - dockerdindprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockerdind" + dockercontainerprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockercontainer" ) func TestNewProviderWiresDockerDaemonProxy(t *testing.T) { cfg := config.Default() - cfg.Provider.Type = "docker-dind" + cfg.Provider.Type = "docker-container" cfg.Provider.Platform = "linux/amd64" cfg.Docker.HTTPProxy = "http://host.docker.internal:3128" cfg.Docker.HTTPSProxy = "http://host.docker.internal:3128" @@ -19,11 +19,11 @@ func TestNewProviderWiresDockerDaemonProxy(t *testing.T) { if err != nil { t.Fatal(err) } - dind, ok := created.(*dockerdindprovider.Provider) + dockerContainer, ok := created.(*dockercontainerprovider.Provider) if !ok { - t.Fatalf("newProvider() type = %T, want Docker-DinD provider", created) + t.Fatalf("newProvider() type = %T, want Docker Container provider", created) } - if !dind.HostGateway { + if !dockerContainer.HostGateway { t.Fatal("host.docker.internal proxy did not enable host gateway") } for key, want := range map[string]string{ @@ -31,7 +31,7 @@ func TestNewProviderWiresDockerDaemonProxy(t *testing.T) { "HTTPS_PROXY": cfg.Docker.HTTPSProxy, "NO_PROXY": cfg.Docker.NoProxy, } { - if got := dind.Environment[key]; got != want { + if got := dockerContainer.Environment[key]; got != want { t.Errorf("provider environment %s = %q, want %q", key, got, want) } } diff --git a/configs/docker-dind.act.example.yml b/configs/docker-container.act.example.yml similarity index 90% rename from configs/docker-dind.act.example.yml rename to configs/docker-container.act.example.yml index 8436aba..7a2682d 100644 --- a/configs/docker-dind.act.example.yml +++ b/configs/docker-container.act.example.yml @@ -8,7 +8,7 @@ github: image: sourceType: docker-image sourceImage: ghcr.io/catthehacker/ubuntu:act-latest - outputImage: epar-docker-dind-catthehacker-act + outputImage: epar-docker-container-catthehacker-act upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest @@ -48,7 +48,7 @@ logging: runner: group: your-runner-group - labels: [self-hosted, linux, epar-docker-dind-catthehacker-act] + labels: [self-hosted, linux, epar-docker-container-catthehacker-act] includeHostLabel: true ephemeral: true @@ -61,8 +61,8 @@ security: requirePublicRepositoriesDisabled: true provider: - type: docker-dind - sourceImage: epar-docker-dind-catthehacker-act + type: docker-container + sourceImage: epar-docker-container-catthehacker-act network: default docker: diff --git a/configs/docker-dind.core.example.yml b/configs/docker-container.core.example.yml similarity index 98% rename from configs/docker-dind.core.example.yml rename to configs/docker-container.core.example.yml index 8d641a1..9b7e60b 100644 --- a/configs/docker-dind.core.example.yml +++ b/configs/docker-container.core.example.yml @@ -62,7 +62,7 @@ security: requirePublicRepositoriesDisabled: false provider: - type: docker-dind + type: docker-container sourceImage: epar-ci-core network: default platform: linux/amd64 diff --git a/configs/docker-dind.example.yml b/configs/docker-container.example.yml similarity index 89% rename from configs/docker-dind.example.yml rename to configs/docker-container.example.yml index 2b73c0e..8b97f92 100644 --- a/configs/docker-dind.example.yml +++ b/configs/docker-container.example.yml @@ -8,7 +8,7 @@ github: image: sourceType: docker-image sourceImage: ghcr.io/catthehacker/ubuntu:full-latest - outputImage: epar-docker-dind-catthehacker-ubuntu + outputImage: epar-docker-container-catthehacker-ubuntu upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest @@ -48,7 +48,7 @@ logging: runner: group: your-runner-group - labels: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu] + labels: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu] includeHostLabel: true ephemeral: true @@ -61,8 +61,8 @@ security: requirePublicRepositoriesDisabled: true provider: - type: docker-dind - sourceImage: epar-docker-dind-catthehacker-ubuntu + type: docker-container + sourceImage: epar-docker-container-catthehacker-ubuntu network: default docker: diff --git a/configs/docker-dind.web-e2e.example.yml b/configs/docker-container.web-e2e.example.yml similarity index 88% rename from configs/docker-dind.web-e2e.example.yml rename to configs/docker-container.web-e2e.example.yml index 2cfb599..0ed8c97 100644 --- a/configs/docker-dind.web-e2e.example.yml +++ b/configs/docker-container.web-e2e.example.yml @@ -8,7 +8,7 @@ github: image: sourceType: docker-image sourceImage: ghcr.io/catthehacker/ubuntu:act-latest - outputImage: epar-docker-dind-catthehacker-ubuntu-web-e2e + outputImage: epar-docker-container-catthehacker-ubuntu-web-e2e upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest @@ -49,7 +49,7 @@ logging: runner: group: your-runner-group - labels: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu-web-e2e] + labels: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu-web-e2e] includeHostLabel: true ephemeral: true @@ -62,8 +62,8 @@ security: requirePublicRepositoriesDisabled: true provider: - type: docker-dind - sourceImage: epar-docker-dind-catthehacker-ubuntu-web-e2e + type: docker-container + sourceImage: epar-docker-container-catthehacker-ubuntu-web-e2e network: default docker: diff --git a/docs/advanced/docker-registry-mirrors.md b/docs/advanced/docker-registry-mirrors.md index e28f056..bfd8913 100644 --- a/docs/advanced/docker-registry-mirrors.md +++ b/docs/advanced/docker-registry-mirrors.md @@ -48,7 +48,7 @@ The generated daemon config is equivalent to: } ``` -The same config surface works for Docker-DinD, Tart, and WSL when Docker is installed in the runner instance. +The same config surface works for Docker Container, Tart, and WSL when Docker is installed in the runner instance. ## What EPAR Does Not Run @@ -107,7 +107,7 @@ docker: - http://host.docker.internal:5050 ``` -For Docker-DinD, EPAR adds Docker's `host.docker.internal:host-gateway` alias when any configured mirror uses `host.docker.internal`. On macOS Docker Desktop and OrbStack this name is usually already available; on Linux Docker Engine the alias helps runner containers reach a host-published mirror. +For Docker Container, EPAR adds Docker's `host.docker.internal:host-gateway` alias when any configured mirror uses `host.docker.internal`. On macOS Docker Desktop and OrbStack this name is usually already available; on Linux Docker Engine the alias helps runner containers reach a host-published mirror. For Tart and WSL, `host.docker.internal` may not resolve the way it does in Docker containers. Use a LAN address, DNS name, or other route that is reachable from the guest. @@ -117,7 +117,7 @@ The mirror URL must be valid from the runner instance's point of view: | Provider | Same-host mirror URL guidance | | --- | --- | -| Docker-DinD | `http://host.docker.internal:5050` is a good one-machine choice when the local cache publishes host port `5050`. EPAR adds Docker's `host-gateway` alias for this name when needed. | +| Docker Container | `http://host.docker.internal:5050` is a good one-machine choice when the local cache publishes host port `5050`. EPAR adds Docker's `host-gateway` alias for this name when needed. | | Tart | Use an IP address or DNS name reachable from inside the VM, such as the host's LAN IP or an intranet DNS name. `host.docker.internal` is not guaranteed. | | WSL | Use an address reachable from inside the WSL distro. Depending on Windows and WSL networking, this may be the Windows host address, a LAN IP, or an intranet DNS name. `host.docker.internal` is not guaranteed. | @@ -129,7 +129,7 @@ docker: - http://docker-cache.office.example:5000 ``` -For one-machine Docker-DinD development, a host-published local cache is usually enough: +For one-machine Docker Container development, a host-published local cache is usually enough: ```yaml docker: @@ -168,7 +168,7 @@ Start a runner instance with mirrors configured: ./bin/ephemeral-action-runner pool verify --instances 1 --cleanup ``` -For Docker-DinD, inspect the inner daemon: +For Docker Container, inspect the inner daemon: ```bash docker exec docker info diff --git a/docs/advanced/macos-startup.md b/docs/advanced/macos-startup.md index f29a858..953d75c 100644 --- a/docs/advanced/macos-startup.md +++ b/docs/advanced/macos-startup.md @@ -125,5 +125,5 @@ launchctl bootout "gui/$(id -u)" ~/Library/LaunchAgents/com.example.epar.plist - `start` cleans up prefixed instances when it exits. Use `--keep-on-exit` only for debugging. - The first run can take a while because `start` may build or refresh the configured image before starting runners. - If Docker Desktop, OrbStack, or Docker Engine cannot start, the script exits before EPAR starts. -- For Docker-DinD, the host Docker runtime must support privileged containers. +- For Docker Container, the host Docker runtime must support privileged containers. - For Tart-only pools that do not use host Docker or a local registry mirror, disable the Docker wait in your local copy. diff --git a/docs/advanced/no-go-install.md b/docs/advanced/no-go-install.md index a9ec253..7c439a6 100644 --- a/docs/advanced/no-go-install.md +++ b/docs/advanced/no-go-install.md @@ -1,6 +1,6 @@ # Running EPAR Without Installing Go -The standard path is to download GitHub's automatic **Source code (zip)** or **Source code (tar.gz)** from the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases) and run `go run ./cmd/ephemeral-action-runner` from the extracted source folder. If you do not want Go installed on the host, use EPAR's Docker-based source runner instead. The default Docker-DinD provider still needs Docker. +The standard path is to download GitHub's automatic **Source code (zip)** or **Source code (tar.gz)** from the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases) and run `go run ./cmd/ephemeral-action-runner` from the extracted source folder. If you do not want Go installed on the host, use EPAR's Docker-based source runner instead. The default Docker Container provider still needs Docker. ## Run With Docker diff --git a/docs/advanced/windows-startup.md b/docs/advanced/windows-startup.md index 325deb0..976072f 100644 --- a/docs/advanced/windows-startup.md +++ b/docs/advanced/windows-startup.md @@ -84,6 +84,6 @@ Unregister-ScheduledTask -TaskName "EPAR" -Confirm:$false ## Notes - Stop the foreground EPAR process or scheduled task to trigger normal cleanup. -- For Docker-DinD, the host Docker runtime must support privileged Linux containers. +- For Docker Container, the host Docker runtime must support privileged Linux containers. - For WSL, make sure WSL2 is installed and the configured WSL image has been built or can be built by `start`. - If the selected provider needs Docker, use a logon trigger with a delay so Docker has time to become ready. diff --git a/docs/background.md b/docs/background.md index b70b89b..985c33e 100644 --- a/docs/background.md +++ b/docs/background.md @@ -10,11 +10,11 @@ runs-on: [self-hosted, linux, ARM64, m3-ubuntu-24.04-docker] Do not label these runners as `ubuntu-latest`. GitHub-hosted `ubuntu-latest` is a GitHub-managed image environment, and x64 assumptions may break on ARM64. -For Docker-heavy Linux CI, Docker-DinD is the recommended first path when the host already has a Docker runtime that supports privileged containers. It keeps workflow Docker resources inside a private inner daemon per runner instance, so existing Compose stacks with fixed project names or ports usually need fewer repository changes. +For Docker-heavy Linux CI, Docker Container is the recommended first path when the host already has a Docker runtime that supports privileged containers. It keeps workflow Docker resources inside a private inner daemon per runner instance, so existing Compose stacks with fixed project names or ports usually need fewer repository changes. -WSL on Windows x64 remains a good EPAR provider for workflows that need native x64 Linux Docker images. Tart on Apple Silicon remains useful when you specifically want VM-based runners on a Mac host, but the VM is ARM64 unless you opt into and validate Rosetta support. Workflows that depend on amd64-only images should target a distinct label such as a Docker-DinD label with verified amd64 emulation, `epar-tart-rosetta-amd64`, a WSL x64 label, or another x64 Linux runner label. +WSL on Windows x64 remains a good EPAR provider for workflows that need native x64 Linux Docker images. Tart on Apple Silicon remains useful when you specifically want VM-based runners on a Mac host, but the VM is ARM64 unless you opt into and validate Rosetta support. Workflows that depend on amd64-only images should target a distinct label such as a Docker Container label with verified amd64 emulation, `epar-tart-rosetta-amd64`, a WSL x64 label, or another x64 Linux runner label. -On Apple Silicon hosts, amd64 containers inside Docker-DinD depend on the host runtime's emulation support; validate `docker run --platform linux/amd64 alpine:3.20 uname -m` inside a running EPAR instance before routing amd64-only workflows there. +On Apple Silicon hosts, amd64 containers inside Docker Container depend on the host runtime's emulation support; validate `docker run --platform linux/amd64 alpine:3.20 uname -m` inside a running EPAR instance before routing amd64-only workflows there. ## OCI Clarification diff --git a/docs/configuration.md b/docs/configuration.md index 026ae5a..d0112a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,20 @@ # Configuration -EPAR stores local settings in `.local/config.yml` by default. The first run creates that file for the default Docker-DinD setup when it does not exist. +EPAR stores local settings in `.local/config.yml` by default. The first run creates that file for the default Docker Container setup when it does not exist. + +## Pre-release provider rename + +The Docker Container provider has an intentional pre-release naming migration. EPAR does not retain a compatibility alias for the pre-rename provider identifier. + +Before editing an existing configuration: + +1. Stop the current controller. +2. Use the previous release to remove its managed runners and runner containers. +3. Verify that no prior managed GitHub runner records or host containers remain. +4. Update `provider.type`, `image.outputImage`, `provider.sourceImage`, the runner label, and pool prefix to the `docker-container` values in the current example configuration. +5. Rebuild the renamed image, then start the renamed provider. + +The old and renamed controllers must not overlap: their provider labels and controller-lock identity differ, so concurrent operation can create duplicate runners or leave resources outside the active controller's ownership boundary. Use `.local/config.yml` for real GitHub App values, local paths, labels, and runner counts. Tracked files under `configs/` are examples. @@ -18,13 +32,13 @@ EPAR looks for config in this order: | Section | Purpose | | --- | --- | | `github` | GitHub App ID, organization, private key path, and optional GitHub API/web URLs. | -| `provider` | How EPAR creates disposable runners: `docker-dind`, `wsl`, or `tart`. | +| `provider` | How EPAR creates disposable runners: `docker-container`, `wsl`, or `tart`. | | `image` | Source image/rootfs, output image, runner version, and optional install scripts. | | `pool` | Runner count, instance name prefix, and replacement retry policy. | | `logging` | Manager and transcript sinks, formats, rotation, retention, and log directory. | | `runner` | GitHub Actions labels, runner group, default-label policy, and whether to add the host-machine label. | | `security` | Runner-group policy requirements checked before runner registration. | -| `docker` | Optional Docker registry mirrors and Docker-DinD daemon proxy settings. | +| `docker` | Optional Docker registry mirrors and private Docker daemon proxy settings. | | `timeouts` | Boot, GitHub online, and command timeout values in seconds. | ## Common Edits @@ -68,7 +82,7 @@ runner: labels: - self-hosted - linux - - epar-docker-dind-catthehacker-ubuntu + - epar-docker-container-catthehacker-ubuntu ``` Disable the automatic host-machine label: @@ -110,7 +124,7 @@ Configure logging and retention in the top-level `logging` section. The complete ### Host trust inheritance -Docker-DinD runners can inherit the host's trusted TLS root anchors: +Docker Container runners can inherit the host's trusted TLS root anchors: ```yaml image: @@ -118,11 +132,7 @@ image: hostTrustScopes: [system, user] ``` -`image.hostTrustMode` accepts `disabled` or `overlay`. Existing configs default -to `disabled`. A new interactive Docker-DinD initialization asks whether to -enable host trust inheritance; pressing Enter accepts the displayed `yes` -default. Enabling the policy is the one-time consent for EPAR to follow later -host root additions, removals, and rotations automatically. +`image.hostTrustMode` accepts `disabled` or `overlay`. Existing configs default to `disabled`. A new interactive Docker Container initialization asks whether to enable host trust inheritance; pressing Enter accepts the displayed `yes` default. Enabling the policy is the one-time consent for EPAR to follow later host root additions, removals, and rotations automatically. The supported scopes are: @@ -134,7 +144,7 @@ The supported scopes are: Use `[system, user]` on Windows or macOS when the runner should inherit the same two trust scopes as the account running EPAR. Linux configs must use -`[system]`. Overlay mode is supported only for `provider.type: docker-dind` and +`[system]`. Overlay mode is supported only for `provider.type: docker-container` and requires `runner.ephemeral: true`. If macOS has disabled user-level Trust Settings, the `user` scope contributes no certificates until that host policy is enabled again. @@ -169,7 +179,7 @@ or DER X.509 CA certificates before building, normalizes them to deterministic independent of host trust inheritance and remain trusted until removed from the config. -Route the private Docker-DinD daemon through an enterprise network proxy: +Route the private Docker daemon through an enterprise network proxy: ```yaml docker: @@ -179,13 +189,13 @@ docker: ``` These optional values become `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` on the -outer Docker-DinD container, so its inner `dockerd` inherits them at first +outer runner container, so its inner `dockerd` inherits them at first startup. Proxy URLs must not contain credentials. Keep machine-specific proxy addresses in ignored `.local/config.yml`, not tracked example files. ## Provider Defaults -For `provider.type: docker-dind`, EPAR defaults to Catthehacker's full Ubuntu runner image and creates a Docker-DinD image named `epar-docker-dind-catthehacker-ubuntu`. +For `provider.type: docker-container`, EPAR defaults to Catthehacker's full Ubuntu runner image and creates a Docker Container image named `epar-docker-container-catthehacker-ubuntu`. For `provider.type: wsl`, EPAR defaults to Catthehacker's full Ubuntu runner image, converts it into a WSL rootfs, and stores the output under `work/images/`. @@ -193,6 +203,6 @@ For the experimental `provider.type: tart`, EPAR defaults to `ghcr.io/cirruslabs See the provider docs for details: -- [Docker-DinD Provider](providers/docker-dind.md) +- [Docker Container Provider](providers/docker-container.md) - [WSL Provider](providers/wsl.md) - [Tart Provider](providers/tart.md) diff --git a/docs/core-runner-verification.md b/docs/core-runner-verification.md index 98e609a..7d7386c 100644 --- a/docs/core-runner-verification.md +++ b/docs/core-runner-verification.md @@ -1,9 +1,6 @@ # Level 1 Core Runner Verification -The `Core runner verification` GitHub Actions workflow proves EPAR's central -contract against GitHub: create an isolated Docker-DinD runner, register it for -one job, replace it after that job, run a second job on the replacement, and -clean up the runner records and outer containers. +The `Core runner verification` GitHub Actions workflow proves EPAR's central contract against GitHub: create an isolated Docker Container runner, register it for one job, replace it after that job, run a second job on the replacement, and clean up the runner records and outer containers. This is an infrastructure canary, not a language or framework compatibility matrix. The workload checks checkout and artifact transfer, then exercises the @@ -88,12 +85,12 @@ the artifact, Buildx, Compose, container health, and HTTP checks. The controller performs cleanup before and after the canaries. It stops the pool supervisor, deletes GitHub runner registrations within the exact -`epar-ci-core` prefix boundary, removes matching outer Docker-DinD containers, +`epar-ci-core` prefix boundary, removes matching outer runner containers, and deletes its temporary key, generated config, and logs. Before failed-run cleanup deletes those logs, the controller prints a sanitized final 200 lines from the pool-supervisor log and each available runner log. Runner launch or online readiness failures first append bounded process state, `run.log`, -latest `Runner_*.log`, and Docker-DinD daemon tails to the host guest log, so +latest `Runner_*.log`, and private Docker daemon tails to the host guest log, so those diagnostics pass through the same sanitizer before cleanup. A controller failure then attempts cleanup before canceling the workflow so queued canary jobs do not remain indefinitely. The next run also pre-cleans the same boundary. @@ -129,8 +126,7 @@ runner registrations within the same prefix boundary. - Check controller disk space and Docker health. - Confirm outbound access to the pinned Catthehacker and BusyBox images. -- For nested-Docker startup failures, inspect the grouped Docker-DinD runner-log - tail in the controller output. +- For nested-Docker startup failures, inspect the grouped Docker Container runner-log tail in the controller output. ### Workflow is canceled after a controller error @@ -142,7 +138,7 @@ cancellation itself as the root cause. ## Manual Cleanup Use a local, untracked config based on -`configs/docker-dind.core.example.yml`, with the same organization, GitHub App +`configs/docker-container.core.example.yml`, with the same organization, GitHub App key path, and `pool.namePrefix: epar-ci-core`, then run: ```bash diff --git a/docs/design.md b/docs/design.md index 5126491..791ba64 100644 --- a/docs/design.md +++ b/docs/design.md @@ -3,7 +3,7 @@ EPAR has three main layers: - `cmd/ephemeral-action-runner`: CLI for image builds, pool lifecycle, verification, cleanup, and status. -- `internal/provider`: a local instance provider interface. Tart, WSL, and Docker-DinD are implemented providers. +- `internal/provider`: a local instance provider interface. Tart, WSL, and Docker Container are implemented providers. - `internal/github`: GitHub App authentication and self-hosted runner API calls. ```mermaid @@ -13,7 +13,7 @@ flowchart LR Manager --> GitHub["GitHub App client"] Provider --> Tart["Tart"] Provider --> WSL["WSL2"] - Provider --> DinD["Docker-DinD"] + Provider --> DockerContainer["Docker Container"] GitHub --> API["GitHub Actions runner APIs"] ``` @@ -28,7 +28,7 @@ For each runner instance: 5. Run `/opt/epar/validate-runtime.sh`. 6. Fetch a short-lived GitHub registration token on the host. 7. Run `config.sh --ephemeral --unattended` inside the instance. -8. Start the runner process. VM and WSL images use systemd; Docker-DinD falls back to a PID-file managed background process. +8. Start the runner process. VM and WSL images use systemd; Docker Container falls back to a PID-file managed background process. 9. Poll GitHub until the runner is ready. Verification-only flows require online/idle; supervised replacement pools also accept an online runner that is already busy with a queued job. 10. Monitor the runner service and GitHub runner record. 11. Delete the instance after the ephemeral runner exits, then create a replacement to maintain pool size. @@ -71,12 +71,12 @@ The foreground supervisor checks each instance every 15 seconds by default. A ru - The matching GitHub runner record exists and reports `online`. - A GitHub runner with `busy=true` is kept alive even if the local service check is temporarily inconclusive. -- When the runner is idle, `/opt/epar/check-runner.sh` reports the runner process is active. The script checks `actions-runner.service` on systemd instances and `/var/run/actions-runner.pid` on non-systemd instances such as Docker-DinD containers. +- When the runner is idle, `/opt/epar/check-runner.sh` reports the runner process is active. The script checks `actions-runner.service` on systemd instances and `/var/run/actions-runner.pid` on non-systemd instances such as Docker runner containers. The instance is retired when an idle runner process exits, the runner record disappears, or the runner reports a non-online status. Runner process exit is expected after an ephemeral runner finishes one job. ## Provider Boundary -The controller depends on provider operations for clone/create, start, exec, address discovery, stop, delete, and list. Provider implementations own host-specific details such as Tart VM names, WSL distro names, Docker-DinD container names, or future Hyper-V VM names. +The controller depends on provider operations for clone/create, start, exec, address discovery, stop, delete, and list. Provider implementations own host-specific details such as Tart VM names, WSL distro names, Docker runner container names, or future Hyper-V VM names. -Docker-DinD is intentionally modeled as an instance provider, not a host Docker socket shortcut. Each instance is a privileged outer container that starts its own Docker daemon. Workflow `docker compose` resources are created inside that private daemon and disappear when EPAR removes the runner container with its volumes. +Docker Container is intentionally modeled as an instance provider, not a host Docker socket shortcut. Each instance is a privileged outer container that starts its own Docker daemon. Workflow `docker compose` resources are created inside that private daemon and disappear when EPAR removes the runner container with its volumes. diff --git a/docs/image-build.md b/docs/image-build.md index 7c0bedb..209c560 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -21,13 +21,13 @@ For WSL, the image build produces a rootfs tar. It can start from either a Docke For `docker-image` sources, EPAR pulls the source image, creates a temporary container, exports that container filesystem to an intermediate rootfs tar, and captures the image environment metadata. Later builds reuse the intermediate rootfs only when the cached source manifest still matches the source image, platform, and digest. The WSL build then imports the rootfs into a temporary distro, enables systemd, installs the runner runtime, writes the captured image env under `/opt/epar`, validates it, exports the reusable tar, and unregisters the temporary distro. Pool instances import from `provider.sourceImage`, which should point at the built reusable tar. -For Docker-DinD, the image build uses Docker images: +For Docker Container, the image build uses Docker images: - `image.sourceType`: `docker-image`. - `image.sourceImage`: maintained Catthehacker Ubuntu runner image, default `ghcr.io/catthehacker/ubuntu:full-latest`. -- `image.outputImage`: reusable EPAR runner container image tag, default `epar-docker-dind-catthehacker-ubuntu`. +- `image.outputImage`: reusable EPAR runner container image tag, default `epar-docker-container-catthehacker-ubuntu`. -Docker-DinD builds a thin wrapper over the source image, installs the GitHub Actions runner, reuses Docker Engine/CLI/Compose/Buildx from the base image when they are already present, adds a container entrypoint that starts `dockerd`, then runs configured install scripts and validation. Pool instances are privileged containers created from `provider.sourceImage`, which should match the built reusable Docker image tag. +Docker Container builds a thin wrapper over the source image, installs the GitHub Actions runner, reuses Docker Engine/CLI/Compose/Buildx from the base image when they are already present, adds a container entrypoint that starts `dockerd`, then runs configured install scripts and validation. Pool instances are privileged containers created from `provider.sourceImage`, which should match the built reusable Docker image tag. Every build writes an EPAR image manifest. The `start` command compares that manifest with the current config and source image identity before creating runners. If the image is missing, has no manifest, or no longer matches, `start` rebuilds it with replace enabled. The lower-level `image build` command keeps its explicit safety behavior and still requires `--replace` when the output already exists. @@ -37,8 +37,8 @@ flowchart TD Build --> Scripts["EPAR guest scripts"] Scripts --> Runner["GitHub Actions runner"] Runner --> WSLFull["WSL Docker-source env and Docker validation"] - WSLFull --> DockerDind["Docker-DinD-only private daemon layer"] - DockerDind --> Rosetta["Optional Tart Rosetta layer"] + WSLFull --> DockerContainer["Docker Container private-daemon runtime"] + DockerContainer --> Rosetta["Optional Tart Rosetta layer"] Rosetta --> Custom["Optional custom install scripts"] Custom --> Validate["Runtime validation"] Validate --> Output["Reusable runner image"] @@ -49,13 +49,13 @@ flowchart TD Several layers control what is pre-installed in the Ubuntu image: -1. `image.hostTrustMode: overlay`, when enabled for Docker-DinD, snapshots and installs the controller host's trusted root anchors. +1. `image.hostTrustMode: overlay`, when enabled for Docker Container, snapshots and installs the controller host's trusted root anchors. 2. `image.trustedCaCertificatePaths`, when configured, installs additional explicitly trusted enterprise CA certificates. 3. EPAR installs both CA sources before guest install steps access the network. 4. `/opt/epar/install-base.sh` is intentionally lean. It does not install Docker, browsers, language runtimes, or project tools. 5. `/opt/epar/install-runner.sh` always installs the GitHub Actions runner. 6. WSL builds from Docker-image sources validate Docker Engine from the base image and preserve source image environment metadata for runner jobs. -7. Docker-DinD builds validate or install Docker Engine and add the private `dockerd` entrypoint. +7. Docker Container builds validate or install Docker Engine and add the private `dockerd` entrypoint. 8. Tart builds with `provider.rosettaTag` install the optional Rosetta amd64 container layer. 9. `image.customInstallScripts` adds optional tool layers. @@ -63,15 +63,15 @@ Several layers control what is pre-installed in the Ubuntu image: flowchart LR Base["Runner-only base"] --> Runner["GitHub Actions runner"] Runner --> WSLFull["WSL Docker-source env and Docker validation"] - WSLFull --> DinD["Docker-DinD-only daemon layer"] - DinD --> Rosetta["Optional Tart Rosetta layer"] + WSLFull --> DockerContainer["Docker Container private-daemon runtime"] + DockerContainer --> Rosetta["Optional Tart Rosetta layer"] Rosetta --> Optional["customInstallScripts"] Optional --> Docker["Docker/browser layer"] Optional --> Web["web/E2E layer"] Optional --> Yours["your scripts"] ``` -The public WSL and Docker-DinD default examples start from `ghcr.io/catthehacker/ubuntu:full-latest`, so they inherit Docker plus the broader Catthehacker runner tool stack. Tart and the WSL lean examples leave `image.customInstallScripts` empty, producing a runner-only Ubuntu image. Docker-DinD always needs Docker Engine because the provider depends on a private inner Docker daemon. +The public WSL and Docker Container default examples start from `ghcr.io/catthehacker/ubuntu:full-latest`, so they inherit Docker plus the broader Catthehacker runner tool stack. Tart and the WSL lean examples leave `image.customInstallScripts` empty, producing a runner-only Ubuntu image. Docker Container always needs Docker Engine because the provider depends on a private inner Docker daemon. EPAR ships reusable install scripts for common cases: @@ -82,8 +82,7 @@ Built-in install scripts call `scripts/guest/ubuntu/wait-apt-ready.sh` before pa ### Host trust overlay -Docker-DinD can snapshot all trusted root anchors visible in configured host -scopes and add them to Ubuntu's default CA store: +Docker Container can snapshot all trusted root anchors visible in configured host scopes and add them to Ubuntu's default CA store: ```yaml image: @@ -112,7 +111,7 @@ mounted into job containers. EPAR does not alter a running job's CA store. This is an additive Ubuntu overlay. It does not reproduce every Windows or macOS trust-policy constraint and does not remove an independently bundled -Ubuntu root. See [Docker-DinD Host Trust Inheritance](providers/docker-dind.md#host-trust-inheritance). +Ubuntu root. See [Docker Container Host Trust Inheritance](providers/docker-container.md#host-trust-inheritance). ### Enterprise CA certificates @@ -181,7 +180,7 @@ apt-get install -y --no-install-recommends \ shellcheck ``` -The same script can be used by Tart, WSL, and Docker-DinD because it runs inside Ubuntu. If the customized image changes workflow capabilities, give it distinct image names and labels so workflows can opt into it explicitly: +The same script can be used by Tart, WSL, and Docker Container because it runs inside Ubuntu. If the customized image changes workflow capabilities, give it distinct image names and labels so workflows can opt into it explicitly: ```yaml image: @@ -203,7 +202,7 @@ Docker registry mirrors are runtime configuration, not image content. Keep them ## Upstream Runner Images -Runner-only Tart images and the default WSL and Docker-DinD images do not require EPAR's pinned `actions/runner-images` checkout. The default WSL and Docker-DinD images start from `ghcr.io/catthehacker/ubuntu:full-latest`, which already includes Docker Engine, Compose, Buildx, Node/npm, and the broader Catthehacker runner tool stack. +Runner-only Tart images and the default WSL and Docker Container images do not require EPAR's pinned `actions/runner-images` checkout. The default WSL and Docker Container images start from `ghcr.io/catthehacker/ubuntu:full-latest`, which already includes Docker Engine, Compose, Buildx, Node/npm, and the broader Catthehacker runner tool stack. The built-in Docker/browser and web/E2E scripts require a pinned checkout of `actions/runner-images`: @@ -221,21 +220,21 @@ When one of those built-in scripts is selected, the build copies only the requir - `images/ubuntu/scripts/build/install-nodejs.sh` - `images/ubuntu/toolsets` -## Docker-DinD Images +## Docker Container Images -Use `configs/docker-dind.example.yml` for the default full Catthehacker runner container with a private Docker daemon. For smaller Docker-focused workloads, use `configs/docker-dind.act.example.yml`, which starts from `ghcr.io/catthehacker/ubuntu:act-latest` without custom install scripts. It includes Node plus Docker Engine/CLI/Compose/Buildx, but does not guarantee browser dependencies. Use `configs/docker-dind.web-e2e.example.yml` when workflows need Playwright or another browser workload; it starts from the same Act base and layers the web/E2E add-on. +Use `configs/docker-container.example.yml` for the default full Catthehacker runner container with a private Docker daemon. For smaller Docker-focused workloads, use `configs/docker-container.act.example.yml`, which starts from `ghcr.io/catthehacker/ubuntu:act-latest` without custom install scripts. It includes Node plus Docker Engine/CLI/Compose/Buildx, but does not guarantee browser dependencies. Use `configs/docker-container.web-e2e.example.yml` when workflows need Playwright or another browser workload; it starts from the same Act base and layers the web/E2E add-on. ```bash -cp configs/docker-dind.example.yml .local/config.yml +cp configs/docker-container.example.yml .local/config.yml ./bin/ephemeral-action-runner image build --replace ``` -Run `image update-upstream` first when using `configs/docker-dind.web-e2e.example.yml`, because that optional layer installs browser and Node.js pieces from the pinned upstream runner-images scripts. +Run `image update-upstream` first when using `configs/docker-container.web-e2e.example.yml`, because that optional layer installs browser and Node.js pieces from the pinned upstream runner-images scripts. The output image is a Docker image tag: ```bash -docker image ls epar-docker-dind-catthehacker-ubuntu +docker image ls epar-docker-container-catthehacker-ubuntu ``` The provider creates each runner instance with `docker create --privileged` and no host socket mount. The image entrypoint starts a private `dockerd`, waits for `docker info`, and keeps the container alive while EPAR configures and monitors the GitHub runner process. Workflow Docker resources live inside that inner daemon. The inner daemon defaults to the `vfs` storage driver because it is reliable for nested Docker across Docker Desktop, OrbStack, and Linux Docker hosts; users can bake a different `EPAR_DOCKERD_STORAGE_DRIVER` into a derived image after validating the host. @@ -268,13 +267,13 @@ work/images/epar-wsl-catthehacker-ubuntu.tar.epar-manifest.json Later builds reuse that source cache when its source manifest still matches. Delete those files when you intentionally want to reconvert the Docker image. -WSL runner startup sources `/opt/epar/source-image.env` before launching the GitHub Actions runner. That preserves image metadata such as `ImageOS`, `ImageVersion`, runner tool cache paths, browser paths, and Java paths from the Docker image source. WSL does not use Docker-DinD's container entrypoint; it keeps the systemd and keepalive model used by other WSL images. +WSL runner startup sources `/opt/epar/source-image.env` before launching the GitHub Actions runner. That preserves image metadata such as `ImageOS`, `ImageVersion`, runner tool cache paths, browser paths, and Java paths from the Docker image source. WSL does not use the Docker Container entrypoint; it keeps the systemd and keepalive model used by other WSL images. Use `configs/wsl.lean.example.yml` when you want the old smaller rootfs-tar path. That config expects you to export a clean Ubuntu 24.04 WSL distro to `work/images/ubuntu-24.04-clean.rootfs.tar`. ## Installed Runtime -The default WSL and Docker-DinD builds use `ghcr.io/catthehacker/ubuntu:full-latest` as the source image. It is larger than the medium Catthehacker act image, but it is the recommended default for public users because common tools such as Node/npm are already present. The WSL lean and web/E2E examples keep demonstrating smaller custom paths that layer only selected dependencies. +The default WSL and Docker Container builds use `ghcr.io/catthehacker/ubuntu:full-latest` as the source image. It is larger than the medium Catthehacker act image, but it is the recommended default for public users because common tools such as Node/npm are already present. The WSL lean and web/E2E examples keep demonstrating smaller custom paths that layer only selected dependencies. Catthehacker's `full-latest` and `act-latest` tags are rolling references. EPAR records the resolved source digest in its image manifest so a built image remains auditable, but a later `image build --replace` can consume a newer upstream digest. Pin `image.sourceImage` to a tested digest when rebuild reproducibility is required. @@ -290,7 +289,7 @@ The optional `install-docker-browser.sh` layer installs: - upstream Google Chrome on x64 - Playwright-managed Chromium on ARM64, exposed as `epar-browser`, `chromium`, and `chromium-browser` -The WSL default and Docker-DinD provider validate Docker Engine/CLI/Compose/Buildx through `scripts/guest/ubuntu/install-docker-engine.sh`. Docker-DinD then starts the daemon at container runtime from `/opt/epar/container-entrypoint.sh`; WSL starts Docker through systemd inside the distro. Set `EPAR_FORCE_UPSTREAM_DOCKER_INSTALL=true` inside non-WSL-default image builds only if you intentionally want to replace the base image's Docker packages with the pinned upstream `actions/runner-images` Docker install harness. +The WSL default and Docker Container provider validate Docker Engine/CLI/Compose/Buildx through `scripts/guest/ubuntu/install-docker-engine.sh`. Docker Container then starts the daemon at container runtime from `/opt/epar/container-entrypoint.sh`; WSL starts Docker through systemd inside the distro. Set `EPAR_FORCE_UPSTREAM_DOCKER_INSTALL=true` inside non-WSL-default image builds only if you intentionally want to replace the base image's Docker packages with the pinned upstream `actions/runner-images` Docker install harness. The ARM64 Docker harness prefers upstream `toolset-2404-arm64.json`. If an older upstream checkout does not contain that file, EPAR falls back to a minimal ARM-aware Docker toolset. diff --git a/docs/operations.md b/docs/operations.md index 132d9fd..5e55c30 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -9,14 +9,14 @@ Host-side logs go under `work/logs` by default. Run `ephemeral-action-runner log Guest provisioning command output is streamed to `work/logs/instances/.guest.log`. If runner launch or GitHub online readiness fails, EPAR first appends bounded diagnostics to that host guest log: runner PID/process state, tails from `run.log` and the latest `Runner_*.log`, -and the Docker-DinD daemon log when present. Diagnostic collection is +and the private Docker daemon log when present. Diagnostic collection is best-effort and does not replace the original readiness error. -On systemd instances, the runner process is launched with `systemd-run` as `actions-runner.service` so provider `exec` calls return immediately after the service starts. On non-systemd instances such as Docker-DinD containers, EPAR starts `run.sh` in the background, writes `/var/run/actions-runner.pid`, and appends output to `/var/log/actions-runner/run.log`. +On systemd instances, the runner process is launched with `systemd-run` as `actions-runner.service` so provider `exec` calls return immediately after the service starts. On non-systemd instances such as Docker runner containers, EPAR starts `run.sh` in the background, writes `/var/run/actions-runner.pid`, and appends output to `/var/log/actions-runner/run.log`. -Docker-DinD containers also write inner Docker daemon logs to `/var/log/epar-dockerd.log` inside the runner container. Host-side Docker commands only show the outer runner container; job-created Compose resources live in the inner daemon. +Docker runner containers also write inner Docker daemon logs to `/var/log/epar-dockerd.log` inside the runner container. Host-side Docker commands only show the outer runner container; job-created Compose resources live in the inner daemon. -When `docker.registryMirrors` is configured, EPAR writes `/etc/docker/daemon.json` inside each instance before runtime validation. For Docker-DinD, inspect both `/etc/docker/daemon.json` and `/var/log/epar-dockerd.log` inside the outer runner container. +When `docker.registryMirrors` is configured, EPAR writes `/etc/docker/daemon.json` inside each instance before runtime validation. For Docker Container, inspect both `/etc/docker/daemon.json` and `/var/log/epar-dockerd.log` inside the outer runner container. ## Supervisor Exit @@ -50,7 +50,7 @@ epar-tart-20260703-010500-001 Do not set `namePrefix` to a broad value such as `ubuntu` or `runner`. Keep it within 40 characters so EPAR can append its generated runner-name suffix. Also do not reuse the same `namePrefix` on different machines or for separate EPAR supervisors in the same GitHub organization. GitHub cleanup is prefix-based, so a shared prefix lets one machine delete another machine's runner records. -For Docker-DinD, cleanup removes the outer runner container with `docker rm -f -v`. That also removes the private inner Docker daemon's containers, networks, volumes, and image cache for that EPAR instance. +For Docker Container, cleanup removes the outer runner container with `docker rm -f -v`. That also removes the private inner Docker daemon's containers, networks, volumes, and image cache for that EPAR instance. ## Troubleshooting @@ -60,8 +60,8 @@ This section is a compact checklist. For symptom-first diagnostics with host/pro - If an image build fails with `E: You don't have enough free space in /var/cache/apt/archives/.`, check the Docker daemon or VM storage with `docker system df` and `docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h /`. On Windows Docker Desktop with WSL2, the container-visible disk can be much smaller than Windows Explorer free space; see [Windows Docker Desktop WSL2 Disk Is Smaller Than Expected](troubleshooting.md#windows-docker-desktop-wsl2-disk-is-smaller-than-expected). - If Docker validation fails for a Docker-enabled image, inspect `work/logs/builds/.guest.log`. - If browser validation fails on ARM64, confirm `epar-browser` exists inside the guest and inspect `/opt/epar/browser`. -- If a Docker Compose job uses an amd64-only runtime image on an ARM64 Tart runner and fails with `exec format error` or repeated container exits such as status `139`, use a runner label that supports that image instead of changing application runtime settings only for runner compatibility. Suitable targets include Docker-DinD with verified `linux/amd64` emulation, WSL x64, an x64 Linux host, or a Tart image with Rosetta enabled and validated. -- If a workflow uses fixed Compose project names, fixed container names, or fixed ports, Docker-DinD is often a better fit than a shared host Docker socket because each runner gets a private inner Docker daemon. Verify by starting two unregistered instances, running the same compose stack in both, and confirming host Docker only shows the outer EPAR runner containers. +- If a Docker Compose job uses an amd64-only runtime image on an ARM64 Tart runner and fails with `exec format error` or repeated container exits such as status `139`, use a runner label that supports that image instead of changing application runtime settings only for runner compatibility. Suitable targets include Docker Container with verified `linux/amd64` emulation, WSL x64, an x64 Linux host, or a Tart image with Rosetta enabled and validated. +- If a workflow uses fixed Compose project names, fixed container names, or fixed ports, Docker Container is often a better fit than a shared host Docker socket because each runner gets a private inner Docker daemon. Verify by starting two unregistered instances, running the same compose stack in both, and confirming host Docker only shows the outer EPAR runner containers. - If repeated jobs still pull slowly after configuring a registry mirror, verify the mirror is reachable from inside the runner instance and that it supports the requested registry, image platform, and authentication model. Docker daemon mirrors primarily target Docker Hub; other registry caches may require workflow image references to use the cache registry URL. - If a mirrored workflow only improves modestly, check where the time is going. Registry mirrors mainly reduce image pull time; container startup, Compose health checks, database initialization, volume sync, browser tests, private image authentication, and CPU-bound or emulated workloads can still dominate the total job time. - If GitHub registration fails, confirm the app has permission to manage organization self-hosted runners and that the private key path is readable by the host user. @@ -71,6 +71,6 @@ This section is a compact checklist. For symptom-first diagnostics with host/pro - If using Tart `softnet`, verify the host has the privileges Tart requires. - If default WSL image build fails before import, confirm Docker Desktop, Docker Engine, or another Docker daemon is reachable so EPAR can export `ghcr.io/catthehacker/ubuntu:full-latest` into a rootfs tar. For lean WSL configs, confirm the clean Ubuntu rootfs was exported from an Ubuntu 24.04 WSL distro. - If WSL image build fails before systemd is ready, confirm WSL2 is enabled and inspect `work/logs/builds/.guest.log`. -- If Docker-DinD startup fails, confirm the host Docker runtime supports privileged containers and inspect `/var/log/epar-dockerd.log` inside the runner container. -- If Docker-DinD `docker run` fails with nested overlay mount errors, keep the default `EPAR_DOCKERD_STORAGE_DRIVER=vfs`. Only switch to `overlay2` or `auto` in a derived image after proving that storage driver works on the exact host runtime. -- If the default WSL or Docker-DinD build cannot validate Docker, confirm the source image still provides `docker`, `dockerd`, Compose, Buildx, and `iptables`. If you intentionally use a clean Ubuntu source image instead of Catthehacker's runner image, run `image update-upstream` first and use a config that installs Docker from EPAR's pinned `actions/runner-images` Docker install harness. +- If Docker Container startup fails, confirm the host Docker runtime supports privileged containers and inspect `/var/log/epar-dockerd.log` inside the runner container. +- If Docker Container `docker run` fails with nested overlay mount errors, keep the default `EPAR_DOCKERD_STORAGE_DRIVER=vfs`. Only switch to `overlay2` or `auto` in a derived image after proving that storage driver works on the exact host runtime. +- If the default WSL or Docker Container build cannot validate Docker, confirm the source image still provides `docker`, `dockerd`, Compose, Buildx, and `iptables`. If you intentionally use a clean Ubuntu source image instead of Catthehacker's runner image, run `image update-upstream` first and use a config that installs Docker from EPAR's pinned `actions/runner-images` Docker install harness. diff --git a/docs/providers/adding-provider.md b/docs/providers/adding-provider.md index f3e5e49..5bd235d 100644 --- a/docs/providers/adding-provider.md +++ b/docs/providers/adding-provider.md @@ -1,6 +1,6 @@ # Adding A Provider -Providers implement the shared interface in `internal/provider`. The controller expects a provider to clone or create an instance, start it, execute guest commands, return an address when available, stop/delete it, and list existing instances for prefix-safe cleanup. Tart, WSL, and Docker-DinD are the current examples of that boundary. +Providers implement the shared interface in `internal/provider`. The controller expects a provider to clone or create an instance, start it, execute guest commands, return an address when available, stop/delete it, and list existing instances for prefix-safe cleanup. Tart, WSL, and Docker Container are the current examples of that boundary. Keep provider behavior idempotent where possible. Cleanup must only remove instances whose names match `pool.namePrefix`. diff --git a/docs/providers/docker-dind.md b/docs/providers/docker-container.md similarity index 72% rename from docs/providers/docker-dind.md rename to docs/providers/docker-container.md index cde6373..adc9d67 100644 --- a/docs/providers/docker-dind.md +++ b/docs/providers/docker-container.md @@ -1,6 +1,6 @@ -# Docker-DinD Provider +# Docker Container Provider -The Docker-DinD provider creates one privileged Ubuntu-based runner container per EPAR instance. That outer container starts its own private Docker daemon, and the GitHub Actions runner executes inside the same container. +The Docker Container provider creates one privileged Ubuntu-based runner container per EPAR instance. That outer container starts its own private Docker daemon, and the GitHub Actions runner executes inside the same container. This is useful when a host already has a reliable Docker runtime and you want disposable runner environments without creating full VMs. It is also useful for Docker Compose-heavy jobs because each runner instance has a separate inner Docker daemon. Deleting the EPAR container deletes that instance's job containers, networks, volumes, and inner image cache. @@ -8,57 +8,57 @@ EPAR does not support a host Docker socket provider. ## When To Choose It -Choose Docker-DinD first for Docker-heavy Linux workflows when privileged containers are acceptable on the host. It is especially useful when the target repository already has Compose scripts, fixed project names, fixed internal ports, or amd64-only runtime images. In those cases, selecting a compatible runner label and Docker platform is usually cleaner than changing application runtime settings for CI compatibility. +Choose Docker Container first for Docker-heavy Linux workflows when privileged containers are acceptable on the host. It is especially useful when the target repository already has Compose scripts, fixed project names, fixed internal ports, or amd64-only runtime images. In those cases, selecting a compatible runner label and Docker platform is usually cleaner than changing application runtime settings for CI compatibility. Choose Tart or WSL instead when you specifically need their host model: Tart for VM-based Apple Silicon runners, WSL for Windows-hosted Linux runners, and x64 WSL/Linux hosts for native amd64 performance. ## Configuration -Use `configs/docker-dind.example.yml` for a base runner image: +Use `configs/docker-container.example.yml` for a base runner image: ```yaml image: sourceType: docker-image sourceImage: ghcr.io/catthehacker/ubuntu:full-latest - outputImage: epar-docker-dind-catthehacker-ubuntu + outputImage: epar-docker-container-catthehacker-ubuntu provider: - type: docker-dind - sourceImage: epar-docker-dind-catthehacker-ubuntu + type: docker-container + sourceImage: epar-docker-container-catthehacker-ubuntu network: default ``` -Use `configs/docker-dind.act.example.yml` for a smaller Docker-focused runner. Its Catthehacker Act base includes Node plus Docker Engine/CLI/Compose/Buildx, but does not guarantee a browser runtime: +Use `configs/docker-container.act.example.yml` for a smaller Docker-focused runner. Its Catthehacker Act base includes Node plus Docker Engine/CLI/Compose/Buildx, but does not guarantee a browser runtime: ```yaml image: sourceType: docker-image sourceImage: ghcr.io/catthehacker/ubuntu:act-latest - outputImage: epar-docker-dind-catthehacker-act + outputImage: epar-docker-container-catthehacker-act runner: - labels: [self-hosted, linux, epar-docker-dind-catthehacker-act] + labels: [self-hosted, linux, epar-docker-container-catthehacker-act] provider: - sourceImage: epar-docker-dind-catthehacker-act + sourceImage: epar-docker-container-catthehacker-act ``` -Use `configs/docker-dind.web-e2e.example.yml` as a smaller customized-image example. It starts from `ghcr.io/catthehacker/ubuntu:act-latest` and layers only the web/E2E add-on: +Use `configs/docker-container.web-e2e.example.yml` as a smaller customized-image example. It starts from `ghcr.io/catthehacker/ubuntu:act-latest` and layers only the web/E2E add-on: ```yaml image: sourceType: docker-image sourceImage: ghcr.io/catthehacker/ubuntu:act-latest - outputImage: epar-docker-dind-catthehacker-ubuntu-web-e2e + outputImage: epar-docker-container-catthehacker-ubuntu-web-e2e customInstallScripts: - scripts/guest/ubuntu/install-web-e2e.sh runner: - labels: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu-web-e2e] + labels: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu-web-e2e] includeHostLabel: true provider: - sourceImage: epar-docker-dind-catthehacker-ubuntu-web-e2e + sourceImage: epar-docker-container-catthehacker-ubuntu-web-e2e ``` `provider.platform` is optional and maps to Docker's `--platform` flag for image builds and runner containers. Use a label that reflects the actual platform your workflows should target. @@ -71,7 +71,7 @@ docker: - http://host.docker.internal:5050 ``` -When a Docker-DinD mirror URL uses `host.docker.internal`, EPAR adds Docker's `host-gateway` alias to the outer runner container so the inner daemon can reach a host-published mirror on Linux Docker Engine. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). +When a Docker Container mirror URL uses `host.docker.internal`, EPAR adds Docker's `host-gateway` alias to the outer runner container so the inner daemon can reach a host-published mirror on Linux Docker Engine. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). If the inner daemon must use an enterprise HTTP proxy, configure its startup environment explicitly: @@ -93,8 +93,7 @@ trust inheritance or configure the authorized root under ## Host Trust Inheritance -On Windows, macOS, and Linux controller hosts, Docker-DinD can add the host's -trusted TLS root anchors to each disposable Ubuntu runner: +On Windows, macOS, and Linux controller hosts, Docker Container can add the host's trusted TLS root anchors to each disposable Ubuntu runner: ```yaml image: @@ -102,10 +101,7 @@ image: hostTrustScopes: [system, user] ``` -Use `[system, user]` on Windows or macOS. Use `[system]` on Linux; Linux has no -portable per-user TLS root store. Overlay mode requires ephemeral Docker-DinD -runners. Existing configs remain disabled. New interactive Docker-DinD setup -shows a yes/no prompt and defaults to enabling inheritance. +Use `[system, user]` on Windows or macOS. Use `[system]` on Linux; Linux has no portable per-user TLS root store. Overlay mode requires ephemeral Docker Container runners. Existing configs remain disabled. New interactive Docker Container setup shows a yes/no prompt and defaults to enabling inheritance. EPAR treats the host's root set as a versioned generation. It installs that generation alongside Ubuntu's default roots and any certificates from @@ -139,11 +135,11 @@ additional configuration. ## Image Build -Docker-DinD images are Docker image tags, not Tart images or rootfs tar files: +Docker Container images are Docker image tags, not Tart images or rootfs tar files: ```bash ./bin/ephemeral-action-runner image build --replace -docker image ls epar-docker-dind-catthehacker-ubuntu +docker image ls epar-docker-container-catthehacker-ubuntu ``` The default build starts from `ghcr.io/catthehacker/ubuntu:full-latest`, installs the GitHub Actions runner and EPAR helper scripts, and reuses the base image's Docker Engine/CLI/Compose/Buildx. The generated image also includes `/opt/epar/container-entrypoint.sh`, which starts the private inner `dockerd` when the runner container starts. @@ -154,19 +150,19 @@ Run `image update-upstream` only when selected install scripts need EPAR's pinne EPAR maps provider operations to Docker commands: -- clone/create: `docker create --privileged --label epar.provider=docker-dind ...` +- clone/create: `docker create --privileged --label epar.provider=docker-container ...` - start: `docker start`, then wait for inner `docker info` - exec: `docker exec` - address: `docker inspect` - stop: `docker stop` - delete: `docker rm -f -v` -- list: `docker ps -a --filter label=epar.provider=docker-dind` +- list: `docker ps -a --filter label=epar.provider=docker-container` The provider does not mount `/var/run/docker.sock`, an OrbStack socket, or any host Docker socket into the runner container. It also does not publish host ports by default. If two jobs use the same Docker Compose project name or container ports, they are separated by their private inner Docker daemons. The inner daemon starts with `EPAR_DOCKERD_STORAGE_DRIVER=vfs` by default. `vfs` is slower than `overlay2`, but it is the most reliable default for nested Docker on Docker Desktop, OrbStack, and other privileged-container hosts where overlay mounts can fail inside the runner container. Advanced users can bake `EPAR_DOCKERD_STORAGE_DRIVER=overlay2` or `EPAR_DOCKERD_STORAGE_DRIVER=auto` into a derived image after validating that the exact host runtime supports it. -On Apple Silicon hosts using Docker Desktop or OrbStack, the inner daemon may be able to run `linux/amd64` containers through the host runtime's emulation support. Validate this on the exact host before routing amd64-only workflows to Docker-DinD: +On Apple Silicon hosts using Docker Desktop or OrbStack, the inner daemon may be able to run `linux/amd64` containers through the host runtime's emulation support. Validate this on the exact host before routing amd64-only workflows to Docker Container: ```bash docker exec docker run --rm --platform linux/amd64 alpine:3.20 uname -m @@ -207,11 +203,11 @@ Dry-run command construction: The dry run should show `docker create` with `--privileged` and no host socket mount. -For Docker Compose-heavy jobs that use fixed project names or ports, a useful isolation smoke test is to start two unregistered Docker-DinD instances, run the same compose stack in both with the same project name, and confirm the host Docker daemon only shows the two outer EPAR containers. The job-created containers should appear only when you run `docker exec docker ps` against each instance. +For Docker Compose-heavy jobs that use fixed project names or ports, a useful isolation smoke test is to start two unregistered Docker Container instances, run the same compose stack in both with the same project name, and confirm the host Docker daemon only shows the two outer EPAR containers. The job-created containers should appear only when you run `docker exec docker ps` against each instance. ## Caveats -- Docker-DinD requires privileged containers. Treat it as trusted-job infrastructure. +- Docker Container requires privileged containers. Treat it as trusted-job infrastructure. - It is not a security boundary for hostile code. - Inner Docker image cache is per runner instance and disappears on cleanup. - Optional registry mirrors can reduce repeated pull time, but they are external services that must be secured and monitored separately. diff --git a/docs/providers/wsl.md b/docs/providers/wsl.md index 1a62e9c..a133260 100644 --- a/docs/providers/wsl.md +++ b/docs/providers/wsl.md @@ -82,7 +82,7 @@ sudo -u runner -H chromium --headless --no-sandbox --dump-dom file:///tmp/epar-b The provider does not mount the Windows Docker Desktop socket. Docker-enabled jobs run against Docker Engine inside the WSL distro. -Runner startup sources `/opt/epar/source-image.env` before launching `/opt/actions-runner/run.sh`. This lets GitHub Actions jobs inherit source image variables such as `ImageOS`, `ImageVersion`, `RUNNER_TOOL_CACHE`, browser paths, and Java paths. WSL keeps its own systemd and host keepalive model; it does not reuse Docker-DinD's container entrypoint. +Runner startup sources `/opt/epar/source-image.env` before launching `/opt/actions-runner/run.sh`. This lets GitHub Actions jobs inherit source image variables such as `ImageOS`, `ImageVersion`, `RUNNER_TOOL_CACHE`, browser paths, and Java paths. WSL keeps its own systemd and host keepalive model; it does not reuse Docker Container entrypoint. WSL x64 is the preferred EPAR target for workflows that pull amd64-only Docker runtime images. diff --git a/docs/security.md b/docs/security.md index b914979..ae9232c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -14,7 +14,7 @@ If private reporting is unavailable, contact a repository maintainer privately t ## What EPAR Improves -Disposable instances reduce host pollution, stale runner state, and accidental cross-job interference. After a job completes, EPAR retires the instance and creates a replacement. For Docker-DinD, job-created containers, networks, volumes, and inner image cache live inside the runner container's private Docker daemon and are removed with that runner instance. +Disposable instances reduce host pollution, stale runner state, and accidental cross-job interference. After a job completes, EPAR retires the instance and creates a replacement. For Docker Container, job-created containers, networks, volumes, and inner image cache live inside the runner container's private Docker daemon and are removed with that runner instance. ## What EPAR Does Not Guarantee @@ -28,9 +28,9 @@ Use GitHub runner groups, repository restrictions, environment protections, and EPAR intentionally does not implement a Docker-socket provider. A runner that controls the host Docker socket can usually control the host. -Docker-DinD uses a privileged outer container with a private inner Docker daemon. That gives good cleanup and Docker resource separation for each job, but it is still trusted-job infrastructure because `--privileged` weakens container isolation. +Docker Container uses a privileged outer container with a private inner Docker daemon. That gives good cleanup and Docker resource separation for each job, but it is still trusted-job infrastructure because `--privileged` weakens container isolation. -Tart runs jobs inside VMs on Apple Silicon macOS. That is a stronger host boundary than Docker-DinD, but workflows still control the guest and any secrets exposed to the job. +Tart runs jobs inside VMs on Apple Silicon macOS. That is a stronger host boundary than Docker Container, but workflows still control the guest and any secrets exposed to the job. WSL2 has a weaker isolation story than one full VM per job. Treat the WSL provider as trusted-job infrastructure unless your environment has reviewed and accepted that model. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a51e8ac..dbf750e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -17,7 +17,7 @@ work/logs/epar-last-error.log Image build logs use provider-specific names, for example: ```text -work/logs/builds/epar-docker-dind-catthehacker-ubuntu.docker-build.log +work/logs/builds/epar-docker-container-catthehacker-ubuntu.docker-build.log work/logs/builds/epar-wsl-catthehacker-ubuntu.wsl-build.log ``` @@ -32,7 +32,7 @@ If you are running without local Go, use `./start --help`; the wrapper will run ### Docker-Backed Workflows -Use these on any host when the provider is Docker-DinD, when WSL image preparation starts from a Docker image, or when the no-Go wrapper is in use: +Use these on any host when the provider is Docker Container, when WSL image preparation starts from a Docker image, or when the no-Go wrapper is in use: ```bash docker version @@ -182,9 +182,9 @@ The expected output is `aarch64`. Add only the platforms the workflow needs; emu Provider notes: -- Docker-DinD: run the setup action inside the EPAR job before Docker Compose or other foreign-image commands. It configures the disposable runner's Docker execution environment; no EPAR configuration switch is required. +- Docker Container: run the setup action inside the EPAR job before Docker Compose or other foreign-image commands. It configures the disposable runner's Docker execution environment; no EPAR configuration switch is required. - WSL: run the setup action inside the WSL runner when its Linux Docker daemon must execute a foreign image. An x64 WSL runner does not gain ARM64 container support merely by pulling or loading an ARM64 image. -- Tart: Tart runs an ARM64 VM on Apple Silicon. Its optional Rosetta path is experimental and is not equivalent to QEMU/binfmt compatibility. Prefer Docker-DinD or a native matching architecture when a workload is not compatible. +- Tart: Tart runs an ARM64 VM on Apple Silicon. Its optional Rosetta path is experimental and is not equivalent to QEMU/binfmt compatibility. Prefer Docker Container or a native matching architecture when a workload is not compatible. - GitHub-hosted Windows and macOS: GitHub documents Docker container actions and service containers as Linux-runner features. A Windows or macOS hardware label alone is therefore not a substitute for a Linux Docker daemon with emulation configured. Official references: @@ -252,9 +252,9 @@ Antivirus, endpoint-security, firewall, or corporate-proxy software may be inspe ### How to Fix -Do not disable certificate verification. Use EPAR's host trust overlay so the disposable Docker-DinD runners automatically inherit the host's trusted root CAs while retaining Ubuntu's standard roots. +Do not disable certificate verification. Use EPAR's host trust overlay so the disposable Docker Container runners automatically inherit the host's trusted root CAs while retaining Ubuntu's standard roots. -New interactive Docker-DinD configurations enable the overlay by default. For an older Windows or macOS configuration, add: +New interactive Docker Container configurations enable the overlay by default. For an older Windows or macOS configuration, add: ```yaml image: @@ -292,7 +292,7 @@ Explicit certificates are validated and added to the same Ubuntu trust bundle; t ### Host Trust Overlay Is Missing, Stale, Or Mismatched -This section applies when a Docker-DinD config contains: +This section applies when a Docker Container config contains: ```yaml image: @@ -304,7 +304,7 @@ EPAR fails closed when host collection returns no roots, the official no-Go brid Check these boundaries: - Windows and macOS support `hostTrustScopes: [system, user]`; Linux supports `[system]` only. -- Overlay mode requires `provider.type: docker-dind` and `runner.ephemeral: true`. +- Overlay mode requires `provider.type: docker-container` and `runner.ephemeral: true`. - Use the official `./start`, `start.ps1`, or release launcher for the no-Go path. A bare Linux toolchain container cannot inspect Windows Certificate Stores or macOS Keychain and must not substitute its own CA bundle. - On an uncommon Linux distribution, set `EPAR_HOST_TRUST_BUNDLE` to the distribution-generated PEM CA bundle before launching EPAR. - Confirm host and guest clocks are correct; feed and lease expiry checks use timestamps and reject stale data. @@ -351,11 +351,11 @@ For more background, see: - - -## Docker-DinD Build Fails With `unknown flag: --progress` +## Docker Container Build Fails With `unknown flag: --progress` ### Symptom -The Docker-DinD image build fails with: +The Docker Container image build fails with: ```text unknown flag: --progress @@ -373,11 +373,11 @@ docker build --help docker buildx version ``` -## Docker-DinD Startup Fails +## Docker Container Startup Fails ### Privileged Containers -Docker-DinD requires the host Docker runtime to allow privileged Linux containers. Confirm the Docker host supports: +Docker Container requires the host Docker runtime to allow privileged Linux containers. Confirm the Docker host supports: ```bash docker run --rm --privileged alpine:3.20 true @@ -385,7 +385,7 @@ docker run --rm --privileged alpine:3.20 true ### Nested Docker Storage Driver -If Docker-DinD starts but nested Docker operations fail with overlay mount errors, keep the default inner daemon storage driver: +If Docker Container starts but nested Docker operations fail with overlay mount errors, keep the default inner daemon storage driver: ```text EPAR_DOCKERD_STORAGE_DRIVER=vfs diff --git a/docs/usage.md b/docs/usage.md index 1818678..909ee8a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,7 +2,7 @@ This page is the operational walkthrough. Start with the supported host you already have: -- Docker-DinD on a Docker-capable host +- Docker Container on a Docker-capable host - WSL2 on Windows - Tart on Apple Silicon macOS @@ -17,7 +17,7 @@ Install the host tools you need: | macOS provider | Tart | | Windows provider | WSL2 | | Windows WSL2 default image build | Docker Desktop, Docker Engine, or another working Docker daemon for the one-time Docker image export | -| Docker-DinD provider | Docker Engine, OrbStack, or Docker Desktop with privileged container support | +| Docker Container provider | Docker Engine, OrbStack, or Docker Desktop with privileged container support | | Optional Docker registry mirrors | A running mirror service on the host, LAN, intranet, or cloud registry cache | | Runner registration | GitHub App with organization self-hosted runner read/write permission | @@ -40,7 +40,7 @@ Don't want to install Go at all? See [Running EPAR Without Installing Go](advanc ## One-Command Start -For the default Docker-DinD setup, run EPAR from the source folder. On macOS, Linux, WSL, or Git Bash, use the `./start` wrapper; on native Windows PowerShell/cmd, use `.\start.ps1` or `start.cmd`. Either uses Go if installed, and otherwise runs EPAR from source with a containerized Go toolchain automatically, without creating a standalone EPAR executable (see [Running EPAR Without Installing Go](advanced/no-go-install.md)): +For the default Docker Container setup, run EPAR from the source folder. On macOS, Linux, WSL, or Git Bash, use the `./start` wrapper; on native Windows PowerShell/cmd, use `.\start.ps1` or `start.cmd`. Either uses Go if installed, and otherwise runs EPAR from source with a containerized Go toolchain automatically, without creating a standalone EPAR executable (see [Running EPAR Without Installing Go](advanced/no-go-install.md)): ```bash ./start @@ -52,7 +52,7 @@ Equivalent without the wrapper: go run ./cmd/ephemeral-action-runner ``` -If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then lists the organization's live runner groups. The group choice is explicit and has no Enter-for-default or offline fallback. Default and broad-access groups require warning confirmation; public-enabled groups are blocked by the generated safety policy. See [Runner Group Security](runner-groups.md). Docker-DinD is the default. For a new Docker-DinD config, the wizard asks whether to inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. On native Windows, when `wsl.exe --status` successfully confirms default version 2, the wizard also offers a WSL2 config. On macOS, when `tart --version` succeeds, it offers an experimental Tart config. Press Enter to retain Docker-DinD. The Docker preflight applies to Docker-DinD and the default WSL image, which uses Docker for its one-time rootfs export, but not to Tart. EPAR then checks runner-group policy and the configured image, builds or replaces the image when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. +If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then lists the organization's live runner groups. The group choice is explicit and has no Enter-for-default or offline fallback. Default and broad-access groups require warning confirmation; public-enabled groups are blocked by the generated safety policy. See [Runner Group Security](runner-groups.md). Docker Container is the default. For a new Docker Container config, the wizard asks whether to inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. On native Windows, when `wsl.exe --status` successfully confirms default version 2, the wizard also offers a WSL2 config. On macOS, when `tart --version` succeeds, it offers an experimental Tart config. Press Enter to retain Docker Container. The Docker preflight applies to Docker Container and the default WSL image, which uses Docker for its one-time rootfs export, but not to Tart. EPAR then checks runner-group policy and the configured image, builds or replaces the image when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. Pass flags through `./start` to choose a config or runner count: @@ -84,7 +84,7 @@ If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.ins ## Configure Only -Use `init` when you only want to create a config without building an image or starting runners. It creates Docker-DinD by default, with the same conditional native-Windows WSL2 and macOS Tart choices described above: +Use `init` when you only want to create a config without building an image or starting runners. It creates Docker Container by default, with the same conditional native-Windows WSL2 and macOS Tart choices described above: ```bash go run ./cmd/ephemeral-action-runner init @@ -105,9 +105,9 @@ For other WSL or Tart variants, or for custom labels, copy one example config in | Windows WSL2, default full Catthehacker runner image | `configs/wsl.example.yml` | | Windows WSL2, lean runner-only tar | `configs/wsl.lean.example.yml` | | Windows WSL2, lean web/E2E tar | `configs/wsl.web-e2e.example.yml` | -| Docker-DinD, default full Catthehacker runner image | `configs/docker-dind.example.yml` | -| Docker-DinD, Docker-focused Catthehacker Act image | `configs/docker-dind.act.example.yml` | -| Docker-DinD, smaller web/E2E custom image | `configs/docker-dind.web-e2e.example.yml` | +| Docker Container, default full Catthehacker runner image | `configs/docker-container.example.yml` | +| Docker Container, Docker-focused Catthehacker Act image | `configs/docker-container.act.example.yml` | +| Docker Container, smaller web/E2E custom image | `configs/docker-container.web-e2e.example.yml` | Tart is experimental. Its default image is a basic Ubuntu ARM64 OS image with the EPAR runner lifecycle, not the dependency-rich environment described by [`actions/runner-images`](https://github.com/actions/runner-images). If your workflows depend on that environment, adapt the upstream build scripts to create and maintain your own bootable Tart image and point `image.sourceImage` at it; EPAR does not automatically create one. @@ -125,11 +125,11 @@ New-Item -ItemType Directory -Force .local Copy-Item configs/wsl.example.yml .local/config.yml ``` -Default Docker-DinD manually: +Default Docker Container manually: ```bash mkdir -p .local -cp configs/docker-dind.example.yml .local/config.yml +cp configs/docker-container.example.yml .local/config.yml ``` EPAR looks for config in this order: @@ -157,7 +157,7 @@ EPAR only configures runner-side Docker daemons; it does not run or secure the m ## Prepare A WSL Source -Skip this section for Tart and Docker-DinD. +Skip this section for Tart and Docker Container. The default WSL config starts from `ghcr.io/catthehacker/ubuntu:full-latest`. During `image build`, EPAR runs Docker on the Windows host to pull that image, create a temporary container, export its filesystem into a rootfs tar, and then import that tar into WSL for EPAR's normal runner bootstrap. Docker is needed for this preparation step. Running WSL runner instances afterward does not require Docker Desktop unless your jobs need it. @@ -175,7 +175,7 @@ After that, EPAR imports disposable temporary distros for image builds and pool The `start` command builds or replaces the configured image automatically. Use this section when developing from source, debugging image builds, or intentionally separating image preparation from runner startup. -Default WSL and Docker-DinD builds and runner-only Tart builds do not need the upstream `actions/runner-images` checkout: +Default WSL and Docker Container builds and runner-only Tart builds do not need the upstream `actions/runner-images` checkout: ```bash go run ./cmd/ephemeral-action-runner image build --replace @@ -204,19 +204,19 @@ work/images/epar-wsl-catthehacker-ubuntu.tar When the WSL source is a Docker image, EPAR also writes an intermediate source rootfs tar and env cache next to the output image, for example `work/images/epar-wsl-catthehacker-ubuntu.source.rootfs.tar` and `.env`. Later builds reuse that source cache; delete those files when you intentionally want to reconvert the Docker image. -EPAR also writes image manifests so `start` can tell whether the local image still matches the config. Docker-DinD stores the manifest hash as a Docker image label and stores the manifest at `/opt/epar/image-manifest.json`. WSL stores `/opt/epar/image-manifest.json` inside the exported image and writes a sidecar next to the tar. +EPAR also writes image manifests so `start` can tell whether the local image still matches the config. Docker Container stores the manifest hash as a Docker image label and stores the manifest at `/opt/epar/image-manifest.json`. WSL stores `/opt/epar/image-manifest.json` inside the exported image and writes a sidecar next to the tar. -Docker-DinD output is a Docker image tag, such as `epar-docker-dind-catthehacker-ubuntu`. Confirm it with: +Docker Container output is a Docker image tag, such as `epar-docker-container-catthehacker-ubuntu`. Confirm it with: ```bash -docker image ls epar-docker-dind-catthehacker-ubuntu +docker image ls epar-docker-container-catthehacker-ubuntu ``` Build logs are written under `work/logs/builds` by default. Run `ephemeral-action-runner logs path` to resolve a customized logging root and see [Logging](logging.md) for rotation and retention. ## Customize The Image -WSL and Docker-DinD use the full Catthehacker runner image by default. For Docker-focused jobs, `configs/docker-dind.act.example.yml` uses the smaller Catthehacker Act image, which includes Node and the Docker Engine/CLI/Compose/Buildx stack EPAR needs. It does not guarantee browser dependencies; use `configs/docker-dind.web-e2e.example.yml` for Playwright or other browser tests. Tart and the WSL lean examples are runner-only. Use `image.customInstallScripts` when you want a different image shape, such as the smaller WSL or Docker-DinD web/E2E examples: +WSL and Docker Container use the full Catthehacker runner image by default. For Docker-focused jobs, `configs/docker-container.act.example.yml` uses the smaller Catthehacker Act image, which includes Node and the Docker Engine/CLI/Compose/Buildx stack EPAR needs. It does not guarantee browser dependencies; use `configs/docker-container.web-e2e.example.yml` for Playwright or other browser tests. Tart and the WSL lean examples are runner-only. Use `image.customInstallScripts` when you want a different image shape, such as the smaller WSL or Docker Container web/E2E examples: ```yaml image: @@ -253,13 +253,13 @@ Runtime validation always checks the base runner files and runner user. Images w - Docker/browser images validate Docker, Compose v2, Buildx, `hello-world`, and a headless browser. - Default WSL full images validate Docker, Compose v2, Buildx, and `hello-world`. -- Docker-DinD images validate the private inner Docker daemon inside each runner container. +- Docker Container images validate the private inner Docker daemon inside each runner container. - Tart Rosetta images validate `docker run --platform linux/amd64 alpine:3.20` and expect `uname -m` to return `x86_64`. - Web/E2E images also validate `node`, `npm`, `zip`, `unzip`, `tar`, `rsync`, and `mysql`. When `docker.registryMirrors` is configured, EPAR applies the mirror configuration before runtime validation. -If a Docker-DinD workflow depends on amd64-only images while the host is ARM64, validate host emulation inside a running EPAR instance: +If a Docker Container workflow depends on amd64-only images while the host is ARM64, validate host emulation inside a running EPAR instance: ```bash docker exec docker run --rm --platform linux/amd64 alpine:3.20 uname -m @@ -313,25 +313,25 @@ For the default WSL image, target the default WSL label: runs-on: [self-hosted, linux, X64, epar-wsl-catthehacker-ubuntu] ``` -For the default Docker-DinD image, target the default Docker-DinD label: +For the default Docker Container image, target the default Docker Container label: ```yaml -runs-on: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu] +runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu] ``` For the Docker-focused Act image, target its dedicated label: ```yaml -runs-on: [self-hosted, linux, epar-docker-dind-catthehacker-act] +runs-on: [self-hosted, linux, epar-docker-container-catthehacker-act] ``` -For Docker-DinD web/E2E images, target the custom web/E2E label: +For Docker Container web/E2E images, target the custom web/E2E label: ```yaml -runs-on: [self-hosted, linux, epar-docker-dind-catthehacker-ubuntu-web-e2e] +runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu-web-e2e] ``` -When that Docker-DinD runner is used for amd64-only runtime images, keep the workflow's Docker platform explicit, for example `DOCKER_PLATFORM=linux/amd64` or the equivalent variable used by your compose scripts, and verify the host runtime supports amd64 emulation as described above. +When that Docker Container runner is used for amd64-only runtime images, keep the workflow's Docker platform explicit, for example `DOCKER_PLATFORM=linux/amd64` or the equivalent variable used by your compose scripts, and verify the host runtime supports amd64 emulation as described above. Do not use `ubuntu-latest` for these self-hosted runners. diff --git a/internal/config/config.go b/internal/config/config.go index 7f9e748..8e1a435 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -670,7 +670,7 @@ func applyProviderDefaults(cfg *Config, explicit map[string]bool) { if !explicit["pool.namePrefix"] && !explicit["pool.vmPrefix"] { cfg.Pool.NamePrefix = "epar-wsl" } - case "docker-dind": + case "docker-container": if !explicit["image.sourceType"] { cfg.Image.SourceType = ImageSourceDockerImage } @@ -678,16 +678,16 @@ func applyProviderDefaults(cfg *Config, explicit map[string]bool) { cfg.Image.SourceImage = "ghcr.io/catthehacker/ubuntu:full-latest" } if !explicit["image.outputImage"] { - cfg.Image.OutputImage = "epar-docker-dind-catthehacker-ubuntu" + cfg.Image.OutputImage = "epar-docker-container-catthehacker-ubuntu" } if !explicit["provider.sourceImage"] { cfg.Provider.SourceImage = cfg.Image.OutputImage } if !explicit["runner.labels"] { - cfg.Runner.Labels = []string{"self-hosted", "linux", "epar-docker-dind-catthehacker-ubuntu"} + cfg.Runner.Labels = []string{"self-hosted", "linux", "epar-docker-container-catthehacker-ubuntu"} } if !explicit["pool.namePrefix"] && !explicit["pool.vmPrefix"] { - cfg.Pool.NamePrefix = "epar-dind" + cfg.Pool.NamePrefix = "epar-docker-container" } } } @@ -884,9 +884,9 @@ func Validate(cfg Config) error { return fmt.Errorf("provider.type is required") } switch cfg.Provider.Type { - case "tart", "wsl", "docker-dind": + case "tart", "wsl", "docker-container": case "docker-socket": - return fmt.Errorf("provider.type docker-socket is intentionally unsupported; use provider.type=docker-dind for a private Docker daemon") + return fmt.Errorf("provider.type docker-socket is intentionally unsupported; use provider.type=docker-container for a private Docker daemon") default: return fmt.Errorf("unsupported provider.type %q", cfg.Provider.Type) } @@ -902,8 +902,8 @@ func Validate(cfg Config) error { } } if cfg.Provider.Platform != "" { - if cfg.Provider.Type != "docker-dind" { - return fmt.Errorf("provider.platform is only supported with provider.type=docker-dind") + if cfg.Provider.Type != "docker-container" { + return fmt.Errorf("provider.platform is only supported with provider.type=docker-container") } if err := ValidateDockerPlatform(cfg.Provider.Platform); err != nil { return err @@ -1112,15 +1112,15 @@ func validateConsoleTextFormat(key, template, outputFormat string, allowed []str } // ValidateHostTrust keeps host trust inheritance deliberately limited to the -// ephemeral Docker-in-Docker image path. Other providers do not have a +// ephemeral private Docker daemon image path. Other providers do not have a // portable, unambiguous host trust boundary. func ValidateHostTrust(image ImageConfig, provider ProviderConfig, runner RunnerConfig) error { switch image.HostTrustMode { case "", HostTrustModeDisabled: return nil case HostTrustModeOverlay: - if provider.Type != "docker-dind" { - return fmt.Errorf("image.hostTrustMode %q is only supported with provider.type=docker-dind", HostTrustModeOverlay) + if provider.Type != "docker-container" { + return fmt.Errorf("image.hostTrustMode %q is only supported with provider.type=docker-container", HostTrustModeOverlay) } if !runner.Ephemeral { return fmt.Errorf("image.hostTrustMode %q requires runner.ephemeral=true", HostTrustModeOverlay) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1aba7f9..020913c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -425,7 +425,7 @@ image: hostTrustMode: overlay hostTrustScopes: [system, user] provider: - type: docker-dind + type: docker-container `), 0644); err != nil { t.Fatal(err) } @@ -452,12 +452,12 @@ func TestValidateRejectsInvalidHostTrustConfigurations(t *testing.T) { provider string ephemeral bool }{ - {name: "unknown mode", mode: "mirror", scopes: []string{HostTrustScopeSystem}, provider: "docker-dind", ephemeral: true}, + {name: "unknown mode", mode: "mirror", scopes: []string{HostTrustScopeSystem}, provider: "docker-container", ephemeral: true}, {name: "wrong provider", mode: HostTrustModeOverlay, scopes: []string{HostTrustScopeSystem}, provider: "wsl", ephemeral: true}, - {name: "non-ephemeral", mode: HostTrustModeOverlay, scopes: []string{HostTrustScopeSystem}, provider: "docker-dind"}, - {name: "empty scopes", mode: HostTrustModeOverlay, provider: "docker-dind", ephemeral: true}, - {name: "unknown scope", mode: HostTrustModeOverlay, scopes: []string{"global"}, provider: "docker-dind", ephemeral: true}, - {name: "duplicate scope", mode: HostTrustModeOverlay, scopes: []string{HostTrustScopeSystem, HostTrustScopeSystem}, provider: "docker-dind", ephemeral: true}, + {name: "non-ephemeral", mode: HostTrustModeOverlay, scopes: []string{HostTrustScopeSystem}, provider: "docker-container"}, + {name: "empty scopes", mode: HostTrustModeOverlay, provider: "docker-container", ephemeral: true}, + {name: "unknown scope", mode: HostTrustModeOverlay, scopes: []string{"global"}, provider: "docker-container", ephemeral: true}, + {name: "duplicate scope", mode: HostTrustModeOverlay, scopes: []string{HostTrustScopeSystem, HostTrustScopeSystem}, provider: "docker-container", ephemeral: true}, } { t.Run(test.name, func(t *testing.T) { cfg := Default() @@ -483,7 +483,7 @@ func TestRunnerHostLabelDefaultsToEnabled(t *testing.T) { path := filepath.Join(dir, "config.yml") if err := os.WriteFile(path, []byte(` provider: - type: docker-dind + type: docker-container `), 0644); err != nil { t.Fatal(err) } @@ -506,7 +506,7 @@ func TestRunnerHostLabelPrefersHostNameEnv(t *testing.T) { path := filepath.Join(dir, "config.yml") if err := os.WriteFile(path, []byte(` provider: - type: docker-dind + type: docker-container `), 0644); err != nil { t.Fatal(err) } @@ -531,7 +531,7 @@ func TestRunnerHostLabelCanBeDisabled(t *testing.T) { runner: includeHostLabel: false provider: - type: docker-dind + type: docker-container `), 0644); err != nil { t.Fatal(err) } @@ -558,7 +558,7 @@ func TestRunnerHostLabelDoesNotDuplicateExistingLabel(t *testing.T) { runner: labels: [self-hosted, linux, epar-host-build-box] provider: - type: docker-dind + type: docker-container `), 0644); err != nil { t.Fatal(err) } @@ -608,18 +608,18 @@ func TestSanitizeNamePart(t *testing.T) { } } -func TestLoadDockerDindPlatform(t *testing.T) { +func TestLoadDockerContainerPlatform(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yml") if err := os.WriteFile(path, []byte(` pool: instances: 1 - namePrefix: epar-dind + namePrefix: epar-docker-container runner: - labels: [self-hosted, linux, ARM64, epar-docker-dind] + labels: [self-hosted, linux, ARM64, epar-docker-container] provider: - type: docker-dind - sourceImage: epar-docker-dind-ubuntu-24 + type: docker-container + sourceImage: epar-docker-container-ubuntu-24 platform: linux/arm64 docker: registryMirrors: @@ -653,10 +653,10 @@ docker: t.Fatal(err) } if !DockerRegistryMirrorsNeedHostGateway(cfg.Docker.RegistryMirrors) { - t.Fatal("host.docker.internal mirror should request docker-dind host gateway") + t.Fatal("host.docker.internal mirror should request docker-container host gateway") } if !DockerConfigNeedsHostGateway(cfg.Docker) { - t.Fatal("host.docker.internal Docker config should request docker-dind host gateway") + t.Fatal("host.docker.internal Docker config should request docker-container host gateway") } } @@ -793,12 +793,12 @@ provider: } } -func TestProviderDefaultsForMinimalDockerDindConfig(t *testing.T) { +func TestProviderDefaultsForMinimalDockerContainerConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yml") if err := os.WriteFile(path, []byte(` provider: - type: docker-dind + type: docker-container `), 0644); err != nil { t.Fatal(err) } @@ -812,16 +812,16 @@ provider: if got, want := cfg.Image.SourceImage, "ghcr.io/catthehacker/ubuntu:full-latest"; got != want { t.Fatalf("image.sourceImage = %q, want %q", got, want) } - if got, want := cfg.Image.OutputImage, "epar-docker-dind-catthehacker-ubuntu"; got != want { + if got, want := cfg.Image.OutputImage, "epar-docker-container-catthehacker-ubuntu"; got != want { t.Fatalf("image.outputImage = %q, want %q", got, want) } if got, want := cfg.Provider.SourceImage, cfg.Image.OutputImage; got != want { t.Fatalf("provider.sourceImage = %q, want %q", got, want) } - if got, want := cfg.Pool.NamePrefix, "epar-dind"; got != want { + if got, want := cfg.Pool.NamePrefix, "epar-docker-container"; got != want { t.Fatalf("pool.namePrefix = %q, want %q", got, want) } - if got, want := cfg.Runner.Labels[2], "epar-docker-dind-catthehacker-ubuntu"; got != want { + if got, want := cfg.Runner.Labels[2], "epar-docker-container-catthehacker-ubuntu"; got != want { t.Fatalf("runner label = %q, want %q", got, want) } if err := Validate(cfg); err != nil { @@ -881,7 +881,7 @@ func TestValidateRosettaTag(t *testing.T) { func TestValidateDockerPlatform(t *testing.T) { cfg := Default() - cfg.Provider.Type = "docker-dind" + cfg.Provider.Type = "docker-container" cfg.Provider.SourceImage = "runner-image" cfg.Provider.Platform = "linux/amd64" if err := Validate(cfg); err != nil { @@ -890,7 +890,7 @@ func TestValidateDockerPlatform(t *testing.T) { for _, platform := range []string{"bad platform", "-linux/amd64", "linux/$bad"} { cfg := Default() - cfg.Provider.Type = "docker-dind" + cfg.Provider.Type = "docker-container" cfg.Provider.SourceImage = "runner-image" cfg.Provider.Platform = platform if err := Validate(cfg); err == nil { @@ -965,7 +965,7 @@ func TestValidateDockerDaemonProxy(t *testing.T) { func TestDockerConfigNeedsHostGatewayForProxy(t *testing.T) { if !DockerConfigNeedsHostGateway(DockerConfig{HTTPSProxy: "http://host.docker.internal:3128"}) { - t.Fatal("host.docker.internal proxy should request docker-dind host gateway") + t.Fatal("host.docker.internal proxy should request docker-container host gateway") } if DockerConfigNeedsHostGateway(DockerConfig{HTTPSProxy: "http://http.docker.internal:3128"}) { t.Fatal("http.docker.internal proxy should not request host.docker.internal mapping") @@ -980,7 +980,7 @@ func TestValidateRejectsDockerSocketProvider(t *testing.T) { if err == nil { t.Fatal("docker-socket provider accepted") } - if got := err.Error(); got != "provider.type docker-socket is intentionally unsupported; use provider.type=docker-dind for a private Docker daemon" { + if got := err.Error(); got != "provider.type docker-socket is intentionally unsupported; use provider.type=docker-container for a private Docker daemon" { t.Fatalf("error = %q", got) } } diff --git a/internal/image/README.md b/internal/image/README.md index 9cd73ad..f19021b 100644 --- a/internal/image/README.md +++ b/internal/image/README.md @@ -1,3 +1,3 @@ # Image Package -Provider-neutral image build abstractions can move here if the image build surface grows beyond the current manager-owned flow. Tart, WSL, and Docker-DinD have provider-specific build steps, while shared Ubuntu guest provisioning and image install scripts remain coordinated from the pool manager. +Provider-neutral image build abstractions can move here if the image build surface grows beyond the current manager-owned flow. Tart, WSL, and Docker Container have provider-specific build steps, while shared Ubuntu guest provisioning and image install scripts remain coordinated from the pool manager. diff --git a/internal/logging/paths.go b/internal/logging/paths.go index c2f1607..138fbe0 100644 --- a/internal/logging/paths.go +++ b/internal/logging/paths.go @@ -90,7 +90,7 @@ func validBuildComponent(component string) bool { func validInstanceComponent(component string) bool { switch component { - case "guest", "docker-dind", "wsl", "tart": + case "guest", "docker-container", "wsl", "tart": return true default: return false diff --git a/internal/logging/paths_test.go b/internal/logging/paths_test.go index 4221055..38bab1f 100644 --- a/internal/logging/paths_test.go +++ b/internal/logging/paths_test.go @@ -13,12 +13,12 @@ func TestPathHelpersAndRecognition(t *testing.T) { path string category Category }{ - {mustPath(InstancePath(root, "runner-1", "docker-dind")), CategoryInstances}, + {mustPath(InstancePath(root, "runner-1", "docker-container")), CategoryInstances}, {mustPath(InstancePath(root, "runner-1", "guest")), CategoryInstances}, {mustPath(BuildPath(root, "ubuntu-24.04", "docker-build")), CategoryBuilds}, {mustPath(BuildPath(root, "ubuntu-24.04", "guest")), CategoryBuilds}, {ErrorPath(root, timestamp), CategoryErrors}, - {mustPath(BenchmarkPath(root, timestamp, "docker-dind")), CategoryBenchmarks}, + {mustPath(BenchmarkPath(root, timestamp, "docker-container")), CategoryBenchmarks}, } for _, test := range tests { recognized, ok := recognizePath(root, test.path) @@ -56,11 +56,11 @@ func TestLegacyFlatRecognitionIsConstrained(t *testing.T) { category Category }{ {"epar-pool-20260715-010203-007.guest.log", CategoryInstances}, - {"epar-pool-20260715-010203-007.docker-dind.log", CategoryInstances}, + {"epar-pool-20260715-010203-007.docker-container.log", CategoryInstances}, {"ubuntu.docker-build.log", CategoryBuilds}, {"ubuntu.source.log", CategoryBuilds}, {"epar-20260715-010203-error.log", CategoryErrors}, - {"20260715T010203.000000123Z-docker-dind.jsonl", CategoryBenchmarks}, + {"20260715T010203.000000123Z-docker-container.jsonl", CategoryBenchmarks}, {"epar-2026-07-15T01-02-03.004.log.gz", CategoryManager}, } for _, test := range tests { diff --git a/internal/logging/recognition.go b/internal/logging/recognition.go index d1b5cab..1c5d95f 100644 --- a/internal/logging/recognition.go +++ b/internal/logging/recognition.go @@ -9,8 +9,8 @@ import ( var ( lumberjackSuffixPattern = regexp.MustCompile(`-\d{4}-\d{2}-\d{2}[Tt]\d{2}-\d{2}-\d{2}\.\d{3}(\.log|\.jsonl)$`) errorNamePattern = regexp.MustCompile(`^epar-\d{8}-\d{6}-error\.log$`) - instanceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(guest|docker-dind|wsl|tart)\.log$`) - legacyInstancePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*-\d{8}-\d{6}-\d{3}\.(guest|docker-dind|wsl|tart)\.log$`) + instanceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(guest|docker-container|wsl|tart)\.log$`) + legacyInstancePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*-\d{8}-\d{6}-\d{3}\.(guest|docker-container|wsl|tart)\.log$`) buildNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(docker-build|wsl-build|build|source|refresh|wsl-refresh|guest)\.log$`) legacyBuildNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(docker-build|wsl-build|build|source|refresh|wsl-refresh)\.log$`) benchmarkNamePattern = regexp.MustCompile(`^\d{8}[Tt]\d{6}\.\d{9}[Zz]-[A-Za-z0-9][A-Za-z0-9_-]*\.jsonl$`) diff --git a/internal/logging/runtime_test.go b/internal/logging/runtime_test.go index c6d22f6..cbe8b32 100644 --- a/internal/logging/runtime_test.go +++ b/internal/logging/runtime_test.go @@ -72,7 +72,7 @@ func TestDefaultManagerTextFormatIsHumanReadable(t *testing.T) { if err != nil { t.Fatal(err) } - runtime.Manager().Info("runner ready", "provider", "docker-dind", "attempt", 2) + runtime.Manager().Info("runner ready", "provider", "docker-container", "attempt", 2) if err := runtime.Close(); err != nil { t.Fatal(err) } @@ -222,11 +222,11 @@ func TestTranscriptCoalescesRawWritesAndFlushesContextualPartialLines(t *testing if err != nil { t.Fatalf("NewRuntime: %v", err) } - path, err := InstancePath(root, "runner-1", "docker-dind") + path, err := InstancePath(root, "runner-1", "docker-container") if err != nil { t.Fatalf("InstancePath: %v", err) } - transcript, err := runtime.OpenTranscript(TranscriptMetadata{SessionID: "session-1", Category: CategoryInstances, Instance: "runner-1", Component: "provider", Provider: "docker-dind"}, path) + transcript, err := runtime.OpenTranscript(TranscriptMetadata{SessionID: "session-1", Category: CategoryInstances, Instance: "runner-1", Component: "provider", Provider: "docker-container"}, path) if err != nil { t.Fatalf("OpenTranscript: %v", err) } diff --git a/internal/pool/controller_lock_test.go b/internal/pool/controller_lock_test.go index 93a8fa8..a7565e4 100644 --- a/internal/pool/controller_lock_test.go +++ b/internal/pool/controller_lock_test.go @@ -10,7 +10,7 @@ import ( func TestPoolControllerLockConflictsForSameProviderAndPrefix(t *testing.T) { t.Setenv("LOCALAPPDATA", t.TempDir()) - manager := Manager{ConfigPath: "config.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-same"}}} + manager := Manager{ConfigPath: "config.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-same"}}} first, err := manager.AcquirePoolControllerLock() if err != nil { t.Fatal(err) @@ -24,7 +24,7 @@ func TestPoolControllerLockConflictsForSameProviderAndPrefix(t *testing.T) { func TestVerifyRequiresPoolControllerLock(t *testing.T) { t.Setenv("LOCALAPPDATA", t.TempDir()) - manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "verify.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind", SourceImage: "image"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-verify"}}, Provider: &fakeProvider{}} + manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "verify.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container", SourceImage: "image"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-verify"}}, Provider: &fakeProvider{}} held, err := manager.AcquirePoolControllerLock() if err != nil { t.Fatal(err) @@ -38,7 +38,7 @@ func TestVerifyRequiresPoolControllerLock(t *testing.T) { func TestCleanupRequiresPoolControllerLock(t *testing.T) { t.Setenv("LOCALAPPDATA", t.TempDir()) - manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "cleanup.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-cleanup"}}, Provider: &fakeProvider{}} + manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "cleanup.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-cleanup"}}, Provider: &fakeProvider{}} held, err := manager.AcquirePoolControllerLock() if err != nil { t.Fatal(err) @@ -52,7 +52,7 @@ func TestCleanupRequiresPoolControllerLock(t *testing.T) { func TestProvisionPoolRequiresPoolControllerLock(t *testing.T) { t.Setenv("LOCALAPPDATA", t.TempDir()) - manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "provision.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind", SourceImage: "image"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-provision"}}, Provider: &fakeProvider{}} + manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "provision.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container", SourceImage: "image"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-provision"}}, Provider: &fakeProvider{}} held, err := manager.AcquirePoolControllerLock() if err != nil { t.Fatal(err) @@ -66,8 +66,8 @@ func TestProvisionPoolRequiresPoolControllerLock(t *testing.T) { func TestPoolControllerLockIsIndependentAcrossPoolIdentity(t *testing.T) { t.Setenv("LOCALAPPDATA", t.TempDir()) - firstManager := Manager{ConfigPath: "first.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-first"}}} - secondManager := Manager{ConfigPath: "second.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-second"}}} + firstManager := Manager{ConfigPath: "first.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-first"}}} + secondManager := Manager{ConfigPath: "second.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-second"}}} first, err := firstManager.AcquirePoolControllerLock() if err != nil { t.Fatal(err) @@ -82,8 +82,8 @@ func TestPoolControllerLockIsIndependentAcrossPoolIdentity(t *testing.T) { func TestPoolControllerLockIncludesCanonicalConfigIdentity(t *testing.T) { t.Setenv("LOCALAPPDATA", t.TempDir()) - firstManager := Manager{ConfigPath: "first.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-shared-prefix"}}} - secondManager := Manager{ConfigPath: "second.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-shared-prefix"}}} + firstManager := Manager{ConfigPath: "first.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-shared-prefix"}}} + secondManager := Manager{ConfigPath: "second.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-shared-prefix"}}} first, err := firstManager.AcquirePoolControllerLock() if err != nil { t.Fatal(err) diff --git a/internal/pool/docker_pull_test.go b/internal/pool/docker_pull_test.go index b81a254..6090c2e 100644 --- a/internal/pool/docker_pull_test.go +++ b/internal/pool/docker_pull_test.go @@ -16,11 +16,11 @@ func TestDockerPullProgressUsesManagerLoggerAndPreservesSourceTranscript(t *test root := t.TempDir() var console bytes.Buffer runtime, err := logging.NewRuntime(logging.Options{ - Directory: root, - ManagerSinks: logging.SinkConsole, - TranscriptSinks: logging.SinkFile, - Stdout: &console, - Stderr: &console, + Directory: root, + ManagerSinks: logging.SinkConsole, + TranscriptSinks: logging.SinkFile, + Stdout: &console, + Stderr: &console, }) if err != nil { t.Fatal(err) @@ -71,12 +71,12 @@ func TestDockerPullProgressHonorsManagerJSONConsoleFormat(t *testing.T) { root := t.TempDir() var console bytes.Buffer runtime, err := logging.NewRuntime(logging.Options{ - Directory: root, - ManagerSinks: logging.SinkConsole, - ManagerConsoleFormat: logging.FormatJSON, - TranscriptSinks: logging.SinkFile, - Stdout: &console, - Stderr: &console, + Directory: root, + ManagerSinks: logging.SinkConsole, + ManagerConsoleFormat: logging.FormatJSON, + TranscriptSinks: logging.SinkFile, + Stdout: &console, + Stderr: &console, }) if err != nil { t.Fatal(err) @@ -86,7 +86,7 @@ func TestDockerPullProgressHonorsManagerJSONConsoleFormat(t *testing.T) { cfg := config.Default() cfg.Logging.Directory = root cfg.Logging.ManagerConsoleFormat = "json" - cfg.Provider.Type = "docker-dind" + cfg.Provider.Type = "docker-container" manager := Manager{Config: cfg, Logging: runtime} previousTerminal := dockerPullProgressTerminal dockerPullProgressTerminal = func() bool { return true } @@ -98,7 +98,7 @@ func TestDockerPullProgressHonorsManagerJSONConsoleFormat(t *testing.T) { if err := json.Unmarshal(console.Bytes(), &record); err != nil { t.Fatalf("decode manager JSON console: %v: %q", err, console.String()) } - if record["msg"] != "Docker source pull: 0/1 layers complete; 1 B/2 B (50%)" || record["provider"] != "docker-dind" || record["operation"] != "docker-pull" || record["logPath"] != logPath { + if record["msg"] != "Docker source pull: 0/1 layers complete; 1 B/2 B (50%)" || record["provider"] != "docker-container" || record["operation"] != "docker-pull" || record["logPath"] != logPath { t.Fatalf("manager JSON console missing pull context: %#v", record) } } diff --git a/internal/pool/host_trust_test.go b/internal/pool/host_trust_test.go index 809b6da..b64b55d 100644 --- a/internal/pool/host_trust_test.go +++ b/internal/pool/host_trust_test.go @@ -16,7 +16,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" ) -func TestDockerDindBuildContextKeepsHostAndExplicitTrustSeparate(t *testing.T) { +func TestDockerContainerBuildContextKeepsHostAndExplicitTrustSeparate(t *testing.T) { root := t.TempDir() for _, dir := range []string{ filepath.Join(root, "scripts", "guest", "ubuntu"), @@ -40,7 +40,7 @@ func TestDockerDindBuildContextKeepsHostAndExplicitTrustSeparate(t *testing.T) { ProjectRoot: root, } buildContext := t.TempDir() - if err := manager.prepareDockerDindBuildContextWithHostTrust(buildContext, t.TempDir(), `{"hash":"test"}`+"\n", snapshot); err != nil { + if err := manager.prepareDockerContainerBuildContextWithHostTrust(buildContext, t.TempDir(), `{"hash":"test"}`+"\n", snapshot); err != nil { t.Fatal(err) } assertSingleCertificateFile(t, filepath.Join(buildContext, "trusted-ca-certificates")) @@ -319,7 +319,7 @@ func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing. HostTrustMode: config.HostTrustModeOverlay, HostTrustScopes: []string{"system", "user"}, }, - Provider: config.ProviderConfig{Type: "docker-dind"}, + Provider: config.ProviderConfig{Type: "docker-container"}, Runner: config.RunnerConfig{Ephemeral: true}, Logging: config.LoggingConfig{Directory: "work/logs"}, }, @@ -366,7 +366,7 @@ func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing. } return nil } - if err := manager.buildDockerDindImage(context.Background(), ImageBuildOptions{Replace: true}, t.TempDir()); err != nil { + if err := manager.buildDockerContainerImage(context.Background(), ImageBuildOptions{Replace: true}, t.TempDir()); err != nil { t.Fatal(err) } if builds != 2 { diff --git a/internal/pool/image.go b/internal/pool/image.go index 4c49f8e..8b07921 100644 --- a/internal/pool/image.go +++ b/internal/pool/image.go @@ -95,20 +95,20 @@ func (m *Manager) BuildImage(ctx context.Context, opts ImageBuildOptions) error return m.buildTartImage(ctx, opts, upstreamDir) case "wsl": return m.buildWSLImage(ctx, opts, upstreamDir) - case "docker-dind": - return m.buildDockerDindImage(ctx, opts, upstreamDir) + case "docker-container": + return m.buildDockerContainerImage(ctx, opts, upstreamDir) default: return fmt.Errorf("unsupported provider.type %q", m.Config.Provider.Type) } } -func (m *Manager) buildDockerDindImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { - return m.timeStartupStage("dind_image_build", func() error { - return m.buildDockerDindImageUntimed(ctx, opts, upstreamDir) +func (m *Manager) buildDockerContainerImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { + return m.timeStartupStage("docker_container_image_build", func() error { + return m.buildDockerContainerImageUntimed(ctx, opts, upstreamDir) }) } -func (m *Manager) buildDockerDindImageUntimed(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { +func (m *Manager) buildDockerContainerImageUntimed(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { buildLogPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".docker-build.log") defer m.releaseTranscript(buildLogPath) if err := resetLogs(buildLogPath); err != nil { @@ -140,7 +140,7 @@ func (m *Manager) buildDockerDindImageUntimed(ctx context.Context, opts ImageBui if m.hostTrustEnabled() { targetImage = temporaryDockerImageTag(targetImage, snapshot.Generation, attempt) } - if err := m.buildDockerDindImageAttempt(ctx, upstreamDir, buildLogPath, targetImage, *manifest, snapshot); err != nil { + if err := m.buildDockerContainerImageAttempt(ctx, upstreamDir, buildLogPath, targetImage, *manifest, snapshot); err != nil { return err } if m.DryRun || !m.hostTrustEnabled() { @@ -167,20 +167,20 @@ func (m *Manager) buildDockerDindImageUntimed(ctx context.Context, opts ImageBui return fmt.Errorf("host trust changed during all %d image build attempts; retry after the host trust store stabilizes", attempts) } -func (m *Manager) buildDockerDindImageAttempt(ctx context.Context, upstreamDir, buildLogPath, targetImage string, manifest ImageManifest, snapshot hosttrust.Snapshot) error { +func (m *Manager) buildDockerContainerImageAttempt(ctx context.Context, upstreamDir, buildLogPath, targetImage string, manifest ImageManifest, snapshot hosttrust.Snapshot) error { manifestContent, manifestHash, err := storedImageManifestContent(manifest) if err != nil { return err } - buildCtx, err := os.MkdirTemp("", "epar-docker-dind-build-*") + buildCtx, err := os.MkdirTemp("", "epar-docker-container-build-*") if err != nil { return err } defer os.RemoveAll(buildCtx) - if err := m.prepareDockerDindBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent, snapshot); err != nil { + if err := m.prepareDockerContainerBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent, snapshot); err != nil { return err } - m.infof("building Docker-DinD image %s from %s\n", targetImage, m.Config.Image.SourceImage) + m.infof("building Docker Container image %s from %s\n", targetImage, m.Config.Image.SourceImage) m.infof("log: %s\n", buildLogPath) args := []string{"build", "-t", targetImage} if m.Config.Provider.Platform != "" { @@ -708,8 +708,8 @@ func (m *Manager) RefreshScripts(ctx context.Context) error { return m.refreshTartScripts(ctx) case "wsl": return m.refreshWSLScripts(ctx) - case "docker-dind": - return m.buildDockerDindImage(ctx, ImageBuildOptions{Replace: true}, config.ProjectPath(m.ProjectRoot, m.Config.Image.UpstreamDir)) + case "docker-container": + return m.buildDockerContainerImage(ctx, ImageBuildOptions{Replace: true}, config.ProjectPath(m.ProjectRoot, m.Config.Image.UpstreamDir)) default: return fmt.Errorf("unsupported provider.type %q", m.Config.Provider.Type) } @@ -972,11 +972,11 @@ func (m *Manager) runnerImagesCopyMode() runnerImagesCopyMode { return runnerImagesCopyNone } -func (m *Manager) prepareDockerDindBuildContext(buildCtx, upstreamDir, manifestContent string) error { - return m.prepareDockerDindBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent, hosttrust.Snapshot{}) +func (m *Manager) prepareDockerContainerBuildContext(buildCtx, upstreamDir, manifestContent string) error { + return m.prepareDockerContainerBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent, hosttrust.Snapshot{}) } -func (m *Manager) prepareDockerDindBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent string, snapshot hosttrust.Snapshot) error { +func (m *Manager) prepareDockerContainerBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent string, snapshot hosttrust.Snapshot) error { if err := copyDir(filepath.Join(m.ProjectRoot, "scripts", "guest", "ubuntu"), filepath.Join(buildCtx, "scripts", "guest", "ubuntu")); err != nil { return err } @@ -989,7 +989,7 @@ func (m *Manager) prepareDockerDindBuildContextWithHostTrust(buildCtx, upstreamD } switch m.runnerImagesCopyMode() { case runnerImagesCopySubset: - m.infof("preparing Docker-DinD build context with runner-images script subset\n") + m.infof("preparing Docker Container build context with runner-images script subset\n") if err := copyRunnerImagesSubsetToDir(upstreamDir, upstreamDest, m.runnerImageBuildScripts()); err != nil { return err } @@ -997,7 +997,7 @@ func (m *Manager) prepareDockerDindBuildContextWithHostTrust(buildCtx, upstreamD return err } case runnerImagesCopyNone: - m.infof("preparing Docker-DinD build context without runner-images resources\n") + m.infof("preparing Docker Container build context without runner-images resources\n") } customDir := filepath.Join(buildCtx, "custom-install") if err := os.MkdirAll(customDir, 0755); err != nil { @@ -1030,7 +1030,7 @@ USER root ARG RUNNER_VERSION=latest ARG EPAR_IMAGE_MANIFEST_SHA256 ARG OCI_SOURCE=https://github.com/solutionforest/ephemeral-action-runner -ARG OCI_DESCRIPTION="EPAR Docker-DinD runner image" +ARG OCI_DESCRIPTION="EPAR Docker Container runner image" ARG OCI_LICENSES=MIT LABEL org.opencontainers.image.source="${OCI_SOURCE}" LABEL org.opencontainers.image.description="${OCI_DESCRIPTION}" diff --git a/internal/pool/image_manifest.go b/internal/pool/image_manifest.go index d37dc3f..c9d11cc 100644 --- a/internal/pool/image_manifest.go +++ b/internal/pool/image_manifest.go @@ -96,8 +96,8 @@ const ( func (m *Manager) currentImageState(ctx context.Context, wantHash string) (imageState, error) { switch m.Config.Provider.Type { - case "docker-dind": - got, exists, err := m.currentDockerDindManifestHash(ctx) + case "docker-container": + got, exists, err := m.currentDockerContainerManifestHash(ctx) if err != nil { return imageStateMissing, err } @@ -127,7 +127,7 @@ func (m *Manager) currentImageState(ctx context.Context, wantHash string) (image } } -func (m *Manager) currentDockerDindManifestHash(ctx context.Context) (string, bool, error) { +func (m *Manager) currentDockerContainerManifestHash(ctx context.Context) (string, bool, error) { output := strings.TrimSpace(m.Config.Image.OutputImage) if output == "" { return "", false, fmt.Errorf("image.outputImage is required") @@ -189,7 +189,7 @@ func (m *Manager) desiredImageManifestWithHostTrust(ctx context.Context, snapsho sourceType := m.Config.Image.SourceType if sourceType == "" { sourceType = config.ImageSourceRootFSTar - if m.Config.Provider.Type == "docker-dind" { + if m.Config.Provider.Type == "docker-container" { sourceType = config.ImageSourceDockerImage } } @@ -207,7 +207,7 @@ func (m *Manager) desiredImageManifestWithHostTrust(ctx context.Context, snapsho } switch sourceType { case config.ImageSourceDockerImage: - if m.Config.Provider.Type == "docker-dind" || m.Config.Provider.Type == "wsl" { + if m.Config.Provider.Type == "docker-container" || m.Config.Provider.Type == "wsl" { digest, err := m.refreshDockerSourceDigest(ctx) if err != nil { return manifest, err @@ -307,7 +307,7 @@ func (m *Manager) refreshDockerSourceDigestUntimed(ctx context.Context) (string, func (m *Manager) eparScriptDigests() ([]fileDigest, error) { var roots []string switch m.Config.Provider.Type { - case "docker-dind": + case "docker-container": roots = []string{ filepath.Join(m.ProjectRoot, "scripts", "guest", "ubuntu"), filepath.Join(m.ProjectRoot, "scripts", "container", "ubuntu"), diff --git a/internal/pool/image_manifest_test.go b/internal/pool/image_manifest_test.go index ddd03a6..6676f55 100644 --- a/internal/pool/image_manifest_test.go +++ b/internal/pool/image_manifest_test.go @@ -87,14 +87,14 @@ func TestImageManifestHashChangesWithImageInputs(t *testing.T) { } } -func TestDockerDindImageStateUsesManifestLabel(t *testing.T) { +func TestDockerContainerImageStateUsesManifestLabel(t *testing.T) { oldOutput := runHostOutputCommand t.Cleanup(func() { runHostOutputCommand = oldOutput }) manager := Manager{Config: config.Config{ Image: config.ImageConfig{OutputImage: "epar-test"}, - Provider: config.ProviderConfig{Type: "docker-dind"}, + Provider: config.ProviderConfig{Type: "docker-container"}, }} runHostOutputCommand = func(context.Context, string, ...string) (string, error) { diff --git a/internal/pool/image_test.go b/internal/pool/image_test.go index b5f057f..bf3ad0a 100644 --- a/internal/pool/image_test.go +++ b/internal/pool/image_test.go @@ -48,14 +48,14 @@ func TestNeedsRunnerImagesSubsetOnlyForBuiltInScripts(t *testing.T) { } } -func TestDockerDindBaseImageDoesNotRequireRunnerImages(t *testing.T) { - manager := Manager{Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}}} +func TestDockerContainerBaseImageDoesNotRequireRunnerImages(t *testing.T) { + manager := Manager{Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}}} if got := manager.runnerImagesCopyMode(); got != runnerImagesCopyNone { t.Fatalf("runnerImagesCopyMode() = %v, want none", got) } } -func TestDockerDindDockerfileRunsBuildStepsAsRoot(t *testing.T) { +func TestDockerContainerDockerfileRunsBuildStepsAsRoot(t *testing.T) { root := t.TempDir() for _, dir := range []string{ filepath.Join(root, "scripts", "guest", "ubuntu"), @@ -74,7 +74,7 @@ func TestDockerDindDockerfileRunsBuildStepsAsRoot(t *testing.T) { }, ProjectRoot: root, } - if err := manager.prepareDockerDindBuildContext(buildCtx, t.TempDir(), `{"hash":"test"}`+"\n"); err != nil { + if err := manager.prepareDockerContainerBuildContext(buildCtx, t.TempDir(), `{"hash":"test"}`+"\n"); err != nil { t.Fatal(err) } content, err := os.ReadFile(filepath.Join(buildCtx, "Dockerfile")) @@ -95,7 +95,7 @@ func TestDockerDindDockerfileRunsBuildStepsAsRoot(t *testing.T) { } } -func TestDockerDindBuildUsesLegacyBuilderCompatibleArgs(t *testing.T) { +func TestDockerContainerBuildUsesLegacyBuilderCompatibleArgs(t *testing.T) { root := t.TempDir() for _, dir := range []string{ filepath.Join(root, "scripts", "guest", "ubuntu"), @@ -110,26 +110,26 @@ func TestDockerDindBuildUsesLegacyBuilderCompatibleArgs(t *testing.T) { Config: config.Config{ Image: config.ImageConfig{ SourceImage: "ghcr.io/catthehacker/ubuntu:full-latest", - OutputImage: "epar-docker-dind-catthehacker-ubuntu", + OutputImage: "epar-docker-container-catthehacker-ubuntu", RunnerVersion: "latest", }, Logging: config.LoggingConfig{Directory: "logs"}, - Provider: config.ProviderConfig{Type: "docker-dind", Platform: "linux/amd64"}, + Provider: config.ProviderConfig{Type: "docker-container", Platform: "linux/amd64"}, }, ProjectRoot: root, DryRun: true, } manifest := ImageManifest{ SchemaVersion: imageManifestSchemaVersion, - ProviderType: "docker-dind", + ProviderType: "docker-container", SourceType: config.ImageSourceDockerImage, SourceImage: "ghcr.io/catthehacker/ubuntu:full-latest", - OutputImage: "epar-docker-dind-catthehacker-ubuntu", + OutputImage: "epar-docker-container-catthehacker-ubuntu", RunnerVersion: "latest", } out, err := capturePoolStdout(t, func() error { - return manager.buildDockerDindImage(context.Background(), ImageBuildOptions{Replace: true, Manifest: &manifest}, filepath.Join(root, "third_party", "runner-images")) + return manager.buildDockerContainerImage(context.Background(), ImageBuildOptions{Replace: true, Manifest: &manifest}, filepath.Join(root, "third_party", "runner-images")) }) if err != nil { t.Fatal(err) @@ -137,7 +137,7 @@ func TestDockerDindBuildUsesLegacyBuilderCompatibleArgs(t *testing.T) { if strings.Contains(out, "--progress") { t.Fatalf("docker build command should not require BuildKit progress support:\n%s", out) } - if !strings.Contains(out, "docker build -t epar-docker-dind-catthehacker-ubuntu --platform linux/amd64") { + if !strings.Contains(out, "docker build -t epar-docker-container-catthehacker-ubuntu --platform linux/amd64") { t.Fatalf("docker build command missing expected base args:\n%s", out) } } diff --git a/internal/pool/manager.go b/internal/pool/manager.go index ecaaf15..673c7df 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -1212,7 +1212,7 @@ func (m *Manager) PreflightRunnerGroup(ctx context.Context) error { } func (m *Manager) startupInstanceStartStage() string { - if m.Config.Provider.Type == "docker-dind" { + if m.Config.Provider.Type == "docker-container" { return "instance_start_and_inner_docker_ready" } return "instance_start_and_provider_ready" diff --git a/internal/pool/manager_test.go b/internal/pool/manager_test.go index 06cd884..1730ddc 100644 --- a/internal/pool/manager_test.go +++ b/internal/pool/manager_test.go @@ -111,14 +111,14 @@ func TestRetiredInstanceTranscriptsBecomeRetentionEligibleWhileLiveInstanceStays manager := Manager{ Config: config.Config{ Logging: config.LoggingConfig{Directory: root}, - Provider: config.ProviderConfig{Type: "docker-dind"}, + Provider: config.ProviderConfig{Type: "docker-container"}, }, ProjectRoot: root, Logging: runtime, } retired := ProvisionedInstance{ Name: "retired-runner", - LogPath: filepath.Join(root, "instances", "retired-runner.docker-dind.log"), + LogPath: filepath.Join(root, "instances", "retired-runner.docker-container.log"), GuestLogPath: filepath.Join(root, "instances", "retired-runner.guest.log"), } livePath := filepath.Join(root, "instances", "live-runner.guest.log") @@ -174,13 +174,13 @@ func TestRetirementSuccessIsNotReversedByTranscriptCloseFailure(t *testing.T) { manager := Manager{ Config: config.Config{ Logging: config.LoggingConfig{Directory: root}, - Provider: config.ProviderConfig{Type: "docker-dind"}, + Provider: config.ProviderConfig{Type: "docker-container"}, }, Provider: provider, ProjectRoot: root, Logging: runtime, } - vm := ProvisionedInstance{Name: "retired-runner", LogPath: filepath.Join(root, "instances", "retired-runner.docker-dind.log")} + vm := ProvisionedInstance{Name: "retired-runner", LogPath: filepath.Join(root, "instances", "retired-runner.docker-container.log")} transcript, err := manager.transcript(vm.LogPath, vm.Name, "provider") if err != nil { t.Fatal(err) @@ -384,7 +384,7 @@ func TestRunPoolAddsCurrentTrustCapacityWhileOldGenerationDrains(t *testing.T) { } manager := Manager{ Config: config.Config{ - Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-dind"}, + Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-container"}, Pool: config.PoolConfig{Instances: 1, NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}, Runner: config.RunnerConfig{Labels: []string{"self-hosted"}, Ephemeral: true}, @@ -453,7 +453,7 @@ func TestVerifyUsesIdleReadiness(t *testing.T) { } manager := Manager{ Config: config.Config{ - Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-dind"}, + Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-container"}, Pool: config.PoolConfig{Instances: 1, NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}, Runner: config.RunnerConfig{Labels: []string{"self-hosted"}, Ephemeral: true}, @@ -513,7 +513,7 @@ func TestProvisionOneRetriesTransientRuntimeValidationFailure(t *testing.T) { } manager := Manager{ Config: config.Config{ - Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-dind"}, + Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-container"}, Pool: config.PoolConfig{Instances: 1, NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}, Timeouts: config.TimeoutConfig{CommandSeconds: 5}, @@ -541,7 +541,7 @@ func TestVerifyCleanupUsesFreshContextAfterCancellation(t *testing.T) { } manager := Manager{ Config: config.Config{ - Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-dind"}, + Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-container"}, Pool: config.PoolConfig{Instances: 1, NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}, Timeouts: config.TimeoutConfig{CommandSeconds: 5}, @@ -572,7 +572,7 @@ func TestRunPoolCleanupUsesFreshContextAfterCancellation(t *testing.T) { } manager := Manager{ Config: config.Config{ - Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-dind"}, + Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-container"}, Pool: config.PoolConfig{Instances: 1, NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}, Timeouts: config.TimeoutConfig{CommandSeconds: 5}, @@ -607,7 +607,7 @@ func TestProvisionOnePassesRunnerRegistrationControlsWithoutPrivateKey(t *testin GitHub: config.GitHubConfig{PrivateKeyPath: "/secret/app.pem"}, Provider: config.ProviderConfig{ SourceImage: "image", - Type: "docker-dind", + Type: "docker-container", }, Pool: config.PoolConfig{ Instances: 1, @@ -1244,7 +1244,7 @@ func newRegisteredTestManager(t *testing.T, provider provider.Provider, github G t.Helper() return Manager{ Config: config.Config{ - Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-dind"}, + Provider: config.ProviderConfig{SourceImage: "image", Type: "docker-container"}, Pool: config.PoolConfig{Instances: 1, NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}, Runner: config.RunnerConfig{Labels: []string{"self-hosted"}, Ephemeral: true}, diff --git a/internal/pool/startup_timing.go b/internal/pool/startup_timing.go index 9f30a76..5013af7 100644 --- a/internal/pool/startup_timing.go +++ b/internal/pool/startup_timing.go @@ -40,7 +40,7 @@ type startupTiming struct { logger *slog.Logger } -// StartStartupTiming records the initial Docker-DinD or WSL start path. +// StartStartupTiming records the initial Docker Container or WSL start path. func (m *Manager) StartStartupTiming() (string, error) { provider := m.Config.Provider.Type if !supportsStartupTiming(provider) { @@ -189,13 +189,13 @@ func (t *startupTiming) eventLocked(stage, outcome string, elapsed time.Duration } func supportsStartupTiming(provider string) bool { - return provider == "docker-dind" || provider == "wsl" + return provider == "docker-container" || provider == "wsl" } func startupTimingLabel(provider string) string { switch provider { - case "docker-dind": - return "DinD" + case "docker-container": + return "Docker Container" case "wsl": return "WSL" default: diff --git a/internal/pool/startup_timing_test.go b/internal/pool/startup_timing_test.go index d27c9a8..4ecd2c3 100644 --- a/internal/pool/startup_timing_test.go +++ b/internal/pool/startup_timing_test.go @@ -29,18 +29,18 @@ func TestStartupTimingWritesOneReadableConsoleSummaryAndStructuredRecord(t *test logDirectory := filepath.Join(root, "logs") var console bytes.Buffer runtime, err := logging.NewRuntime(logging.Options{ - Directory: logDirectory, - ManagerSinks: logging.SinkBoth, - ManagerFileFormat: logging.FormatJSON, - TranscriptSinks: logging.SinkNone, - Stdout: &console, - Stderr: &console, + Directory: logDirectory, + ManagerSinks: logging.SinkBoth, + ManagerFileFormat: logging.FormatJSON, + TranscriptSinks: logging.SinkNone, + Stdout: &console, + Stderr: &console, }) if err != nil { t.Fatalf("create logging runtime: %v", err) } manager := Manager{ - Config: config.Config{Provider: config.ProviderConfig{Type: "docker-dind"}, Logging: config.LoggingConfig{Directory: "logs"}}, + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Logging: config.LoggingConfig{Directory: "logs"}}, ProjectRoot: root, Logging: runtime, } @@ -62,7 +62,7 @@ func TestStartupTimingWritesOneReadableConsoleSummaryAndStructuredRecord(t *test if count := strings.Count(output, "\n"); count != 1 { t.Fatalf("console emitted %d timing records, want 1: %q", count, output) } - for _, want := range []string{"DinD startup timing:", "source_image_pull=", "instance_container_create=", "total_startup=", "log: " + path} { + for _, want := range []string{"Docker Container startup timing:", "source_image_pull=", "instance_container_create=", "total_startup=", "log: " + path} { if !strings.Contains(output, want) { t.Fatalf("console summary missing %q: %q", want, output) } @@ -77,10 +77,10 @@ func TestStartupTimingWritesOneReadableConsoleSummaryAndStructuredRecord(t *test t.Fatalf("decode structured manager record: %v\n%s", err, content) } message, ok := record["msg"].(string) - if !ok || !strings.Contains(message, "DinD startup timing:") || !strings.Contains(message, "source_image_pull=") || !strings.Contains(message, "instance_container_create=") || !strings.Contains(message, "total_startup=") || !strings.Contains(message, "log: "+path) { + if !ok || !strings.Contains(message, "Docker Container startup timing:") || !strings.Contains(message, "source_image_pull=") || !strings.Contains(message, "instance_container_create=") || !strings.Contains(message, "total_startup=") || !strings.Contains(message, "log: "+path) { t.Fatalf("unexpected structured message: %#v", record["msg"]) } - if record["provider"] != "docker-dind" || record["operation"] != "startup" || record["logPath"] != path { + if record["provider"] != "docker-container" || record["operation"] != "startup" || record["logPath"] != path { t.Fatalf("structured record missing context: %#v", record) } stages, ok := record["stages"].(map[string]any) diff --git a/internal/pool/trusted_ca_test.go b/internal/pool/trusted_ca_test.go index c6e10dc..4aa95a7 100644 --- a/internal/pool/trusted_ca_test.go +++ b/internal/pool/trusted_ca_test.go @@ -18,7 +18,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" ) -func TestDockerDindBuildContextInstallsTrustedCABeforeNetworkSteps(t *testing.T) { +func TestDockerContainerBuildContextInstallsTrustedCABeforeNetworkSteps(t *testing.T) { root := t.TempDir() for _, dir := range []string{ filepath.Join(root, "scripts", "guest", "ubuntu"), @@ -38,7 +38,7 @@ func TestDockerDindBuildContextInstallsTrustedCABeforeNetworkSteps(t *testing.T) ProjectRoot: root, } buildContext := t.TempDir() - if err := manager.prepareDockerDindBuildContext(buildContext, t.TempDir(), `{"hash":"test"}`+"\n"); err != nil { + if err := manager.prepareDockerContainerBuildContext(buildContext, t.TempDir(), `{"hash":"test"}`+"\n"); err != nil { t.Fatal(err) } diff --git a/internal/provider/dockerdind/docker_dind.go b/internal/provider/dockercontainer/docker_container.go similarity index 96% rename from internal/provider/dockerdind/docker_dind.go rename to internal/provider/dockercontainer/docker_container.go index 0beb08b..71fcc03 100644 --- a/internal/provider/dockerdind/docker_dind.go +++ b/internal/provider/dockercontainer/docker_container.go @@ -1,4 +1,4 @@ -package dockerdind +package dockercontainer import ( "bytes" @@ -14,7 +14,7 @@ import ( const ( labelManaged = "epar.managed=true" - labelProvider = "epar.provider=docker-dind" + labelProvider = "epar.provider=docker-container" ) type Provider struct { @@ -47,7 +47,7 @@ func (p *Provider) Clone(ctx context.Context, source, name string) error { func (p *Provider) Start(ctx context.Context, name string, opts provider.StartOptions) (*provider.RunningProcess, error) { if opts.Network != "" && opts.Network != "default" { - return nil, fmt.Errorf("unsupported docker-dind network mode %q", opts.Network) + return nil, fmt.Errorf("unsupported docker-container network mode %q", opts.Network) } if _, err := p.run(ctx, nil, "start", name); err != nil { return nil, err @@ -98,7 +98,7 @@ func (p *Provider) IP(ctx context.Context, name string, waitSeconds int) (string if lastErr != nil { return "", lastErr } - return "", fmt.Errorf("docker-dind container %q did not report an IP within %d seconds", name, waitSeconds) + return "", fmt.Errorf("Docker Container %q did not report an IP within %d seconds", name, waitSeconds) } select { case <-ctx.Done(): diff --git a/internal/provider/dockerdind/docker_dind_test.go b/internal/provider/dockercontainer/docker_container_test.go similarity index 89% rename from internal/provider/dockerdind/docker_dind_test.go rename to internal/provider/dockercontainer/docker_container_test.go index 306bbf9..d3561fe 100644 --- a/internal/provider/dockerdind/docker_dind_test.go +++ b/internal/provider/dockercontainer/docker_container_test.go @@ -1,4 +1,4 @@ -package dockerdind +package dockercontainer import ( "bytes" @@ -42,18 +42,18 @@ func TestCreateArgsUsePrivilegedWithoutHostSocketOrPorts(t *testing.T) { "HTTPS_PROXY": "http://proxy.example.test:3128", "HTTP_PROXY": "http://proxy.example.test:3128", }, true) - args := p.createArgs("runner-image", "epar-dind-1") + args := p.createArgs("runner-image", "epar-docker-container-1") joined := strings.Join(args, " ") for _, want := range []string{ "create", "--platform linux/arm64", - "--name epar-dind-1", + "--name epar-docker-container-1", "--privileged", "--add-host host.docker.internal:host-gateway", "--env HTTP_PROXY=http://proxy.example.test:3128", "--env HTTPS_PROXY=http://proxy.example.test:3128", "--env NO_PROXY=localhost,127.0.0.1", - "--label epar.provider=docker-dind", + "--label epar.provider=docker-container", "runner-image", } { if !strings.Contains(joined, want) { @@ -79,14 +79,14 @@ func TestExecArgsPreserveEnvAndStdin(t *testing.T) { gotStdin = stdin != nil return provider.ExecResult{}, nil } - _, err := p.Exec(context.Background(), "epar-dind-1", []string{"bash", "-lc", "echo ok"}, provider.ExecOptions{ + _, err := p.Exec(context.Background(), "epar-docker-container-1", []string{"bash", "-lc", "echo ok"}, provider.ExecOptions{ Stdin: "input", Env: map[string]string{"A": "B"}, }) if err != nil { t.Fatal(err) } - want := []string{"exec", "-i", "-e", "A=B", "epar-dind-1", "bash", "-lc", "echo ok"} + want := []string{"exec", "-i", "-e", "A=B", "epar-docker-container-1", "bash", "-lc", "echo ok"} if !reflect.DeepEqual(gotArgs, want) { t.Fatalf("exec args = %#v, want %#v", gotArgs, want) } @@ -135,16 +135,16 @@ func TestExecRedactsSecretAssignmentsWithoutSensitiveValues(t *testing.T) { func TestListParsesDockerPSOutput(t *testing.T) { p := New("docker", "", false) p.runCommand = func(_ context.Context, _ io.Reader, _ string, _, _ io.Writer, args ...string) (provider.ExecResult, error) { - if strings.Join(args, " ") != "ps -a --filter label=epar.provider=docker-dind --format {{.Names}}\t{{.Image}}\t{{.Status}}" { + if strings.Join(args, " ") != "ps -a --filter label=epar.provider=docker-container --format {{.Names}}\t{{.Image}}\t{{.Status}}" { t.Fatalf("unexpected list args: %#v", args) } - return provider.ExecResult{Stdout: "epar-dind-1\trunner-image\tUp 2 minutes\n"}, nil + return provider.ExecResult{Stdout: "epar-docker-container-1\trunner-image\tUp 2 minutes\n"}, nil } instances, err := p.List(context.Background()) if err != nil { t.Fatal(err) } - want := []provider.Instance{{Name: "epar-dind-1", Source: "runner-image", State: "Up 2 minutes"}} + want := []provider.Instance{{Name: "epar-docker-container-1", Source: "runner-image", State: "Up 2 minutes"}} if !reflect.DeepEqual(instances, want) { t.Fatalf("instances = %#v, want %#v", instances, want) } @@ -152,7 +152,7 @@ func TestListParsesDockerPSOutput(t *testing.T) { func TestDryRunIPReturnsPlaceholder(t *testing.T) { p := New("docker", "", true) - ip, err := p.IP(context.Background(), "epar-dind-1", 1) + ip, err := p.IP(context.Background(), "epar-docker-container-1", 1) if err != nil { t.Fatal(err) } diff --git a/internal/provider/factory.go b/internal/provider/factory.go index 98b1465..0a9b9d4 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -3,7 +3,7 @@ package provider import "fmt" func SupportedTypes() []string { - return []string{"tart", "wsl", "docker-dind"} + return []string{"tart", "wsl", "docker-container"} } func UnsupportedTypeError(providerType string) error { diff --git a/scripts/ci/core-runner-controller.sh b/scripts/ci/core-runner-controller.sh index da7f9c1..7c8161c 100644 --- a/scripts/ci/core-runner-controller.sh +++ b/scripts/ci/core-runner-controller.sh @@ -298,7 +298,7 @@ security: requirePublicRepositoriesDisabled: false provider: - type: docker-dind + type: docker-container sourceImage: ${EPAR_OUTPUT_IMAGE} platform: linux/amd64 network: default diff --git a/scripts/ci/core-runner-controller_test.sh b/scripts/ci/core-runner-controller_test.sh index 000f496..cb2d807 100644 --- a/scripts/ci/core-runner-controller_test.sh +++ b/scripts/ci/core-runner-controller_test.sh @@ -175,9 +175,9 @@ if [[ " $* " == *" pool up "* ]]; then printf 'Authorization: token %s\n' "${MOCK_GUEST_AUTH_TOKEN}" } >"${log_dir}/mock.guest.log" { - echo 'Docker-DinD safe diagnostic' + echo 'Docker Container safe diagnostic' printf 'EPAR_APP_PRIVATE_KEY=%s\n' "${MOCK_PRIVATE_KEY_ENV}" - } >"${log_dir}/mock.docker-dind.log" + } >"${log_dir}/mock.docker-container.log" echo 'pool safe diagnostic' printf 'RUNNER_TOKEN=%s\n' "${MOCK_POOL_TOKEN}" printf -- '--token %s\n' "${MOCK_CLI_TOKEN}" @@ -333,10 +333,10 @@ fi grep -q 'EPAR pool supervisor exited unexpectedly with status 7' "${failure_output}" grep -q 'EPAR pool supervisor log (last 200 lines, sanitized)' "${failure_output}" grep -q 'EPAR runner log: mock.guest.log (last 200 lines, sanitized)' "${failure_output}" -grep -q 'EPAR runner log: mock.docker-dind.log (last 200 lines, sanitized)' "${failure_output}" +grep -q 'EPAR runner log: mock.docker-container.log (last 200 lines, sanitized)' "${failure_output}" grep -q '| pool safe diagnostic' "${failure_output}" grep -q '| guest safe diagnostic' "${failure_output}" -grep -q '| Docker-DinD safe diagnostic' "${failure_output}" +grep -q '| Docker Container safe diagnostic' "${failure_output}" grep -q '| RUNNER_TOKEN=\*\*\*' "${failure_output}" grep -q '| Authorization: Bearer \*\*\*' "${failure_output}" grep -q '| Authorization: Basic \*\*\*' "${failure_output}" diff --git a/scripts/container/ubuntu/entrypoint.sh b/scripts/container/ubuntu/entrypoint.sh index 1d01ac3..d77933a 100644 --- a/scripts/container/ubuntu/entrypoint.sh +++ b/scripts/container/ubuntu/entrypoint.sh @@ -8,9 +8,9 @@ dockerd_args=(--host=unix:///var/run/docker.sock) storage_driver="${EPAR_DOCKERD_STORAGE_DRIVER-vfs}" if [[ -n "${storage_driver}" && "${storage_driver}" != "auto" ]]; then dockerd_args+=(--storage-driver="${storage_driver}") - echo "EPAR Docker-DinD: starting inner Docker daemon with ${storage_driver} storage driver" + echo "EPAR Docker Container: starting inner Docker daemon with ${storage_driver} storage driver" else - echo "EPAR Docker-DinD: starting inner Docker daemon with Docker's default storage driver" + echo "EPAR Docker Container: starting inner Docker daemon with Docker's default storage driver" fi dockerd "${dockerd_args[@]}" >/var/log/epar-dockerd.log 2>&1 & diff --git a/scripts/run-with-docker.ps1 b/scripts/run-with-docker.ps1 index 1b04889..c8583e8 100644 --- a/scripts/run-with-docker.ps1 +++ b/scripts/run-with-docker.ps1 @@ -9,7 +9,7 @@ param( # instead of on the host. No binary is built or left on disk. # # Docker is still required (both for this wrapper and for EPAR's own -# Docker-DinD provider, reached here via the mounted host socket). +# Docker Container provider, reached here via the mounted host socket). # # Usage: scripts\run-with-docker.ps1 [epar-args...] diff --git a/scripts/run-with-docker.sh b/scripts/run-with-docker.sh index 5c25ea1..bed2c37 100755 --- a/scripts/run-with-docker.sh +++ b/scripts/run-with-docker.sh @@ -16,7 +16,7 @@ esac # instead of on the host. No binary is built or left on disk. # # Docker is still required (both for this wrapper and for EPAR's own -# Docker-DinD provider, reached here via the mounted host socket). +# Docker Container provider, reached here via the mounted host socket). # # Usage: scripts/run-with-docker.sh [epar-args...] diff --git a/scripts/sync-wiki.sh b/scripts/sync-wiki.sh index 43c9ed2..b49b5e6 100755 --- a/scripts/sync-wiki.sh +++ b/scripts/sync-wiki.sh @@ -64,9 +64,9 @@ rewrite_links() { "docs/providers/wsl.md" => "WSL-Provider", "providers/wsl.md" => "WSL-Provider", "wsl.md" => "WSL-Provider", - "docs/providers/docker-dind.md" => "Docker-DinD-Provider", - "providers/docker-dind.md" => "Docker-DinD-Provider", - "docker-dind.md" => "Docker-DinD-Provider", + "docs/providers/docker-container.md" => "Docker-Container-Provider", + "providers/docker-container.md" => "Docker-Container-Provider", + "docker-container.md" => "Docker-Container-Provider", "docs/providers/adding-provider.md" => "Adding-A-Provider", "providers/adding-provider.md" => "Adding-A-Provider", "adding-provider.md" => "Adding-A-Provider", @@ -111,7 +111,7 @@ copy_page "docs/security.md" "Security" copy_page "docs/background.md" "Background" copy_page "docs/providers/tart.md" "Tart-Provider" copy_page "docs/providers/wsl.md" "WSL-Provider" -copy_page "docs/providers/docker-dind.md" "Docker-DinD-Provider" +copy_page "docs/providers/docker-container.md" "Docker-Container-Provider" copy_page "docs/providers/adding-provider.md" "Adding-A-Provider" copy_page "docs/advanced/docker-registry-mirrors.md" "Docker-Registry-Mirrors" copy_page "docs/advanced/macos-startup.md" "macOS-Startup" @@ -130,7 +130,7 @@ cat > "$out_dir/_Sidebar.md" <<'SIDEBAR' ## Providers -- [Docker-DinD](Docker-DinD-Provider) +- [Docker Container](Docker-Container-Provider) - [Tart](Tart-Provider) - [WSL](WSL-Provider) - [Adding A Provider](Adding-A-Provider) diff --git a/scripts/test/host-trust-docker-e2e.sh b/scripts/test/host-trust-docker-e2e.sh index 604b51d..422c4fb 100644 --- a/scripts/test/host-trust-docker-e2e.sh +++ b/scripts/test/host-trust-docker-e2e.sh @@ -6,7 +6,7 @@ set -euo pipefail # MSYS leaves untouched; bind arguments still receive normal drive mapping. export MSYS2_ARG_CONV_EXCL='/CN=' -# Disposable Linux-container fixture for the Docker-DinD host-trust contract. +# Disposable Linux-container fixture for the Docker Container host-trust contract. # It creates test-only CAs under a temporary directory and never reads or # modifies the host's real trust store. diff --git a/scripts/test/no-go-first-run-smoke.ps1 b/scripts/test/no-go-first-run-smoke.ps1 index 262f244..bd347eb 100644 --- a/scripts/test/no-go-first-run-smoke.ps1 +++ b/scripts/test/no-go-first-run-smoke.ps1 @@ -42,7 +42,7 @@ if ($args -contains 'init') { if ($env:FAKE_INIT_FAIL -eq '1') { exit 23 } $local = Join-Path $env:FAKE_PROJECT '.local' New-Item -ItemType Directory -Force -Path $local | Out-Null - $config = "provider:`n type: docker-dind`nrunner:`n ephemeral: true`nimage:`n hostTrustMode: overlay`n hostTrustScopes: [system, user]`n" + $config = "provider:`n type: docker-container`nrunner:`n ephemeral: true`nimage:`n hostTrustMode: overlay`n hostTrustScopes: [system, user]`n" [System.IO.File]::WriteAllText((Join-Path $local 'config.yml'), $config, [System.Text.UTF8Encoding]::new($false)) } exit 0 diff --git a/scripts/test/no-go-first-run-smoke.sh b/scripts/test/no-go-first-run-smoke.sh index 95a1901..2e73a7f 100644 --- a/scripts/test/no-go-first-run-smoke.sh +++ b/scripts/test/no-go-first-run-smoke.sh @@ -24,7 +24,7 @@ if [[ " $* " == *" go run ./cmd/ephemeral-action-runner init "* ]]; then mkdir -p "$FAKE_PROJECT/.local" cat >"$FAKE_PROJECT/.local/config.yml" <<'YAML' provider: - type: docker-dind + type: docker-container runner: ephemeral: true image: From 6e96d71b0394aaf3b0384253f405ab5c3d02a00e Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 28 Jul 2026 13:07:03 +0800 Subject: [PATCH 04/22] feat: add Docker Sandboxes with shared lifecycle and storage - register Docker Sandboxes as the default provider with wizard, templates, policy, and validation - separate provider integration, common pool lifecycle, image management, durable state, and storage responsibilities - add capacity admission, owned-artifact inventory, status and prune previews, bounded retention, and exact cleanup - preserve source and no-Go startup entry points with human-readable storage remediation - align configuration, development guidance, provider documentation, wrappers, and contract tests --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/host-trust-verification.yml | 46 +- AGENTS.md | 7 + CONTRIBUTING.md | 8 +- README.md | 22 +- cmd/ephemeral-action-runner/init.go | 1043 +++++++++++++++- cmd/ephemeral-action-runner/init_test.go | 998 +++++++++++++++- cmd/ephemeral-action-runner/main.go | 148 ++- cmd/ephemeral-action-runner/provider_test.go | 35 +- cmd/ephemeral-action-runner/start.go | 10 +- cmd/ephemeral-action-runner/start_test.go | 73 +- cmd/ephemeral-action-runner/storage.go | 347 ++++++ .../storage_configured_test.go | 42 + .../storage_external.go | 441 +++++++ .../storage_external_test.go | 162 +++ .../storage_preflight_test.go | 30 + cmd/ephemeral-action-runner/version.go | 7 +- cmd/ephemeral-action-runner/version_test.go | 7 +- configs/docker-container.act.example.yml | 9 + configs/docker-container.core.example.yml | 9 + configs/docker-container.example.yml | 9 + configs/docker-container.web-e2e.example.yml | 9 + configs/docker-sandboxes.example.yml | 67 ++ configs/tart.example.yml | 9 + configs/tart.web-e2e.example.yml | 9 + configs/wsl.example.yml | 9 + configs/wsl.lean.example.yml | 9 + configs/wsl.web-e2e.example.yml | 9 + docs/advanced/macos-startup.md | 4 +- docs/advanced/no-go-install.md | 30 +- docs/configuration.md | 25 +- docs/design.md | 91 +- docs/development-principles.md | 15 + docs/image-build.md | 8 +- docs/providers/adding-provider.md | 15 +- docs/providers/docker-sandboxes.md | 217 ++++ docs/security.md | 2 + docs/storage.md | 30 + docs/usage.md | 15 +- internal/config/config.go | 532 ++++++++- internal/config/config_test.go | 383 +++++- internal/github/client.go | 5 + internal/github/client_test.go | 62 + internal/image/acquisition.go | 179 +++ .../artifact_lifecycle.go} | 171 +-- internal/{pool/image.go => image/build.go} | 126 +- internal/image/buildx.go | 170 +++ internal/image/buildx_test.go | 65 + internal/image/compat.go | 89 ++ internal/image/coordinator.go | 151 +++ internal/image/docker_pull.go | 316 +++++ internal/image/docker_pull_test.go | 81 ++ internal/image/manifest.go | 148 +++ internal/image/manifest_paths_test.go | 20 + internal/{pool => image}/trusted_ca.go | 21 +- internal/invocation/invocation.go | 58 + internal/invocation/invocation_test.go | 35 + internal/logging/paths.go | 2 +- internal/logging/paths_test.go | 1 + internal/logging/recognition.go | 8 +- internal/pool/architecture_test.go | 71 ++ internal/pool/docker_pull.go | 365 ------ internal/pool/host_trust.go | 77 +- internal/pool/host_trust_test.go | 50 +- ...pull_test.go => image_acquisition_test.go} | 13 +- internal/pool/image_service.go | 324 +++++ internal/pool/image_test.go | 6 +- internal/pool/lifecycle_cleanup.go | 258 ++++ internal/pool/lifecycle_cleanup_test.go | 256 ++++ internal/pool/lifecycle_state.go | 258 ++++ internal/pool/logging.go | 19 + internal/pool/logging_test.go | 80 ++ internal/pool/manager.go | 487 ++++++-- internal/pool/manager_test.go | 36 +- internal/pool/provider_lifecycle.go | 180 +++ internal/pool/runner_script_test.go | 52 +- internal/pool/state/doc.go | 20 + internal/pool/state/store.go | 615 ++++++++++ internal/pool/state/store_test.go | 281 +++++ internal/pool/state/types.go | 172 +++ internal/pool/storage_preflight.go | 102 ++ internal/pool/storage_preflight_test.go | 93 ++ .../dockercontainer/docker_container.go | 8 +- .../dockercontainer/docker_container_test.go | 6 +- .../dockersandboxes/capacity/capacity.go | 186 +++ .../dockersandboxes/capacity/capacity_test.go | 141 +++ .../capacity/storage_darwin.go | 22 + .../dockersandboxes/capacity/storage_linux.go | 26 + .../capacity/storage_linux_test.go | 27 + .../dockersandboxes/capacity/storage_other.go | 12 + .../capacity/storage_windows.go | 22 + internal/provider/dockersandboxes/doc.go | 7 + .../provider/dockersandboxes/json_parsing.go | 285 +++++ .../provider/dockersandboxes/live_test.go | 200 ++++ .../dockersandboxes/network_policy.go | 376 ++++++ .../provider/dockersandboxes/policy/policy.go | 262 ++++ .../dockersandboxes/policy/policy_test.go | 128 ++ .../provider/dockersandboxes/process_other.go | 7 + .../dockersandboxes/process_windows.go | 15 + .../dockersandboxes/promotion/preflight.go | 549 +++++++++ .../promotion/preflight_test.go | 341 ++++++ .../dockersandboxes/promotion/promotion.go | 188 +++ .../promotion/promotion_test.go | 94 ++ .../dockersandboxes/promotion/space_unix.go | 49 + .../promotion/space_windows.go | 33 + internal/provider/dockersandboxes/provider.go | 1025 ++++++++++++++++ .../provider/dockersandboxes/provider_test.go | 1050 +++++++++++++++++ .../dockersandboxes/staging/identity_other.go | 21 + .../staging/identity_windows.go | 26 + .../staging/permissions_other.go | 27 + .../staging/permissions_unix_test.go | 25 + .../staging/permissions_windows.go | 91 ++ .../staging/permissions_windows_test.go | 42 + .../dockersandboxes/staging/staging.go | 355 ++++++ .../dockersandboxes/staging/staging_test.go | 250 ++++ .../provider/dockersandboxes/validation.go | 218 ++++ internal/provider/factory.go | 24 +- internal/provider/layout_test.go | 73 ++ internal/provider/legacy_adapter.go | 161 +++ internal/provider/legacy_adapter_test.go | 109 ++ internal/provider/provider.go | 151 ++- .../provider/registry/contributions_test.go | 43 + internal/provider/registry/registry.go | 199 ++++ internal/provider/registry/registry_test.go | 218 ++++ internal/provider/storage.go | 141 +++ internal/provider/storage_test.go | 89 ++ internal/provider/tart/tart.go | 57 + internal/provider/tart/tart_test.go | 20 + internal/provider/wsl/process_other.go | 7 + internal/provider/wsl/process_windows.go | 15 + internal/provider/wsl/process_windows_test.go | 22 + internal/provider/wsl/wsl.go | 118 +- internal/provider/wsl/wsl_test.go | 18 + internal/storage/capacity.go | 88 ++ internal/storage/capacity_test.go | 68 ++ internal/storage/doc.go | 9 + internal/storage/executor.go | 117 ++ internal/storage/filesystem.go | 133 +++ internal/storage/filesystem_executor.go | 120 ++ internal/storage/filesystem_executor_test.go | 92 ++ internal/storage/filesystem_test.go | 85 ++ internal/storage/filesystem_unix.go | 38 + internal/storage/filesystem_windows.go | 52 + internal/storage/format.go | 64 + internal/storage/format_test.go | 59 + internal/storage/hash.go | 77 ++ internal/storage/hash_executor_test.go | 169 +++ internal/storage/inventory/collect.go | 161 +++ internal/storage/inventory/collect_test.go | 323 +++++ internal/storage/inventory/configured.go | 110 ++ internal/storage/inventory/doc.go | 7 + internal/storage/inventory/filesystem.go | 105 ++ internal/storage/inventory/filesystem_unix.go | 7 + .../storage/inventory/filesystem_windows.go | 16 + internal/storage/inventory/logs.go | 71 ++ internal/storage/inventory/logs_test.go | 40 + internal/storage/inventory/native.go | 297 +++++ internal/storage/inventory/native_test.go | 135 +++ internal/storage/inventory/template.go | 375 ++++++ internal/storage/inventory/template_test.go | 216 ++++ internal/storage/inventory/types.go | 111 ++ internal/storage/planner.go | 321 +++++ internal/storage/planner_test.go | 235 ++++ internal/storage/types.go | 252 ++++ scripts/build-native-controller.ps1 | 509 ++++++++ scripts/build-native-controller.sh | 321 +++++ scripts/docker-sandboxes/build-template.ps1 | 411 +++++++ scripts/docker-sandboxes/load-template.ps1 | 272 +++++ scripts/docker-sandboxes/validate-assets.ps1 | 296 +++++ .../ubuntu/check-host-trust-generation.sh | 20 +- scripts/run-with-docker.ps1 | 62 +- scripts/run-with-docker.sh | 33 +- scripts/sync-wiki.sh | 9 + scripts/test/docker-sandboxes-plan-smoke.ps1 | 152 +++ .../test/native-controller-cache-retention.sh | 141 +++ scripts/test/no-go-first-run-smoke.ps1 | 102 -- scripts/test/no-go-first-run-smoke.sh | 1 + scripts/test/start-command-forwarding.sh | 42 + .../windows-native-controller-contract.ps1 | 332 ++++++ start | 29 +- start.ps1 | 44 +- templates/docker-sandboxes/.dockerignore | 9 + templates/docker-sandboxes/Dockerfile | 85 ++ .../guest/check-host-trust-generation.sh | 77 ++ .../docker-sandboxes/guest/check-runner.sh | 30 + .../guest/collect-runner-diagnostics.sh | 17 + .../guest/collect-software-inventory.sh | 22 + .../guest/configure-runner.sh | 47 + .../guest/prepare-template.sh | 78 ++ .../docker-sandboxes/guest/run-runner.sh | 51 + .../guest/template-entrypoint.sh | 28 + .../docker-sandboxes/guest/verify-template.sh | 32 + templates/docker-sandboxes/helpers.sha256 | 9 + .../docker-sandboxes/hook-launcher/main.go | 54 + .../hook-launcher/main_test.go | 51 + .../act-22.04.amd64.compatibility.json | 28 + .../act-22.04.arm64.compatibility.json | 28 + .../profiles/full.amd64.compatibility.json | 28 + .../profiles/full.arm64.compatibility.json | 28 + templates/docker-sandboxes/sources.lock.json | 124 ++ 200 files changed, 24489 insertions(+), 1110 deletions(-) create mode 100644 AGENTS.md create mode 100644 cmd/ephemeral-action-runner/storage.go create mode 100644 cmd/ephemeral-action-runner/storage_configured_test.go create mode 100644 cmd/ephemeral-action-runner/storage_external.go create mode 100644 cmd/ephemeral-action-runner/storage_external_test.go create mode 100644 cmd/ephemeral-action-runner/storage_preflight_test.go create mode 100644 configs/docker-sandboxes.example.yml create mode 100644 docs/development-principles.md create mode 100644 docs/providers/docker-sandboxes.md create mode 100644 docs/storage.md create mode 100644 internal/image/acquisition.go rename internal/{pool/image_manifest.go => image/artifact_lifecycle.go} (65%) rename internal/{pool/image.go => image/build.go} (89%) create mode 100644 internal/image/buildx.go create mode 100644 internal/image/buildx_test.go create mode 100644 internal/image/compat.go create mode 100644 internal/image/coordinator.go create mode 100644 internal/image/docker_pull.go create mode 100644 internal/image/docker_pull_test.go create mode 100644 internal/image/manifest.go create mode 100644 internal/image/manifest_paths_test.go rename internal/{pool => image}/trusted_ca.go (86%) create mode 100644 internal/invocation/invocation.go create mode 100644 internal/invocation/invocation_test.go create mode 100644 internal/pool/architecture_test.go delete mode 100644 internal/pool/docker_pull.go rename internal/pool/{docker_pull_test.go => image_acquisition_test.go} (89%) create mode 100644 internal/pool/image_service.go create mode 100644 internal/pool/lifecycle_cleanup.go create mode 100644 internal/pool/lifecycle_cleanup_test.go create mode 100644 internal/pool/lifecycle_state.go create mode 100644 internal/pool/logging_test.go create mode 100644 internal/pool/provider_lifecycle.go create mode 100644 internal/pool/state/doc.go create mode 100644 internal/pool/state/store.go create mode 100644 internal/pool/state/store_test.go create mode 100644 internal/pool/state/types.go create mode 100644 internal/pool/storage_preflight.go create mode 100644 internal/pool/storage_preflight_test.go create mode 100644 internal/provider/dockersandboxes/capacity/capacity.go create mode 100644 internal/provider/dockersandboxes/capacity/capacity_test.go create mode 100644 internal/provider/dockersandboxes/capacity/storage_darwin.go create mode 100644 internal/provider/dockersandboxes/capacity/storage_linux.go create mode 100644 internal/provider/dockersandboxes/capacity/storage_linux_test.go create mode 100644 internal/provider/dockersandboxes/capacity/storage_other.go create mode 100644 internal/provider/dockersandboxes/capacity/storage_windows.go create mode 100644 internal/provider/dockersandboxes/doc.go create mode 100644 internal/provider/dockersandboxes/json_parsing.go create mode 100644 internal/provider/dockersandboxes/live_test.go create mode 100644 internal/provider/dockersandboxes/network_policy.go create mode 100644 internal/provider/dockersandboxes/policy/policy.go create mode 100644 internal/provider/dockersandboxes/policy/policy_test.go create mode 100644 internal/provider/dockersandboxes/process_other.go create mode 100644 internal/provider/dockersandboxes/process_windows.go create mode 100644 internal/provider/dockersandboxes/promotion/preflight.go create mode 100644 internal/provider/dockersandboxes/promotion/preflight_test.go create mode 100644 internal/provider/dockersandboxes/promotion/promotion.go create mode 100644 internal/provider/dockersandboxes/promotion/promotion_test.go create mode 100644 internal/provider/dockersandboxes/promotion/space_unix.go create mode 100644 internal/provider/dockersandboxes/promotion/space_windows.go create mode 100644 internal/provider/dockersandboxes/provider.go create mode 100644 internal/provider/dockersandboxes/provider_test.go create mode 100644 internal/provider/dockersandboxes/staging/identity_other.go create mode 100644 internal/provider/dockersandboxes/staging/identity_windows.go create mode 100644 internal/provider/dockersandboxes/staging/permissions_other.go create mode 100644 internal/provider/dockersandboxes/staging/permissions_unix_test.go create mode 100644 internal/provider/dockersandboxes/staging/permissions_windows.go create mode 100644 internal/provider/dockersandboxes/staging/permissions_windows_test.go create mode 100644 internal/provider/dockersandboxes/staging/staging.go create mode 100644 internal/provider/dockersandboxes/staging/staging_test.go create mode 100644 internal/provider/dockersandboxes/validation.go create mode 100644 internal/provider/layout_test.go create mode 100644 internal/provider/legacy_adapter.go create mode 100644 internal/provider/legacy_adapter_test.go create mode 100644 internal/provider/registry/contributions_test.go create mode 100644 internal/provider/registry/registry.go create mode 100644 internal/provider/registry/registry_test.go create mode 100644 internal/provider/storage.go create mode 100644 internal/provider/storage_test.go create mode 100644 internal/provider/wsl/process_other.go create mode 100644 internal/provider/wsl/process_windows.go create mode 100644 internal/provider/wsl/process_windows_test.go create mode 100644 internal/storage/capacity.go create mode 100644 internal/storage/capacity_test.go create mode 100644 internal/storage/doc.go create mode 100644 internal/storage/executor.go create mode 100644 internal/storage/filesystem.go create mode 100644 internal/storage/filesystem_executor.go create mode 100644 internal/storage/filesystem_executor_test.go create mode 100644 internal/storage/filesystem_test.go create mode 100644 internal/storage/filesystem_unix.go create mode 100644 internal/storage/filesystem_windows.go create mode 100644 internal/storage/format.go create mode 100644 internal/storage/format_test.go create mode 100644 internal/storage/hash.go create mode 100644 internal/storage/hash_executor_test.go create mode 100644 internal/storage/inventory/collect.go create mode 100644 internal/storage/inventory/collect_test.go create mode 100644 internal/storage/inventory/configured.go create mode 100644 internal/storage/inventory/doc.go create mode 100644 internal/storage/inventory/filesystem.go create mode 100644 internal/storage/inventory/filesystem_unix.go create mode 100644 internal/storage/inventory/filesystem_windows.go create mode 100644 internal/storage/inventory/logs.go create mode 100644 internal/storage/inventory/logs_test.go create mode 100644 internal/storage/inventory/native.go create mode 100644 internal/storage/inventory/native_test.go create mode 100644 internal/storage/inventory/template.go create mode 100644 internal/storage/inventory/template_test.go create mode 100644 internal/storage/inventory/types.go create mode 100644 internal/storage/planner.go create mode 100644 internal/storage/planner_test.go create mode 100644 internal/storage/types.go create mode 100644 scripts/build-native-controller.ps1 create mode 100644 scripts/build-native-controller.sh create mode 100644 scripts/docker-sandboxes/build-template.ps1 create mode 100644 scripts/docker-sandboxes/load-template.ps1 create mode 100644 scripts/docker-sandboxes/validate-assets.ps1 create mode 100644 scripts/test/docker-sandboxes-plan-smoke.ps1 create mode 100644 scripts/test/native-controller-cache-retention.sh delete mode 100644 scripts/test/no-go-first-run-smoke.ps1 create mode 100644 scripts/test/start-command-forwarding.sh create mode 100644 scripts/test/windows-native-controller-contract.ps1 create mode 100644 templates/docker-sandboxes/.dockerignore create mode 100644 templates/docker-sandboxes/Dockerfile create mode 100644 templates/docker-sandboxes/guest/check-host-trust-generation.sh create mode 100644 templates/docker-sandboxes/guest/check-runner.sh create mode 100644 templates/docker-sandboxes/guest/collect-runner-diagnostics.sh create mode 100644 templates/docker-sandboxes/guest/collect-software-inventory.sh create mode 100644 templates/docker-sandboxes/guest/configure-runner.sh create mode 100644 templates/docker-sandboxes/guest/prepare-template.sh create mode 100644 templates/docker-sandboxes/guest/run-runner.sh create mode 100644 templates/docker-sandboxes/guest/template-entrypoint.sh create mode 100644 templates/docker-sandboxes/guest/verify-template.sh create mode 100644 templates/docker-sandboxes/helpers.sha256 create mode 100644 templates/docker-sandboxes/hook-launcher/main.go create mode 100644 templates/docker-sandboxes/hook-launcher/main_test.go create mode 100644 templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json create mode 100644 templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json create mode 100644 templates/docker-sandboxes/profiles/full.amd64.compatibility.json create mode 100644 templates/docker-sandboxes/profiles/full.arm64.compatibility.json create mode 100644 templates/docker-sandboxes/sources.lock.json diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 65bc5c6..fdc0072 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,4 +11,5 @@ - [ ] I kept credentials, private keys, tokens, and machine-specific configuration out of this pull request. - [ ] I added or updated tests where behavior changed. - [ ] I updated relevant documentation. +- [ ] For provider or onboarding changes, I followed the Development and Extension Principles and documented and tested every intentional exception. - [ ] I read and followed the contributing guide and code of conduct. diff --git a/.github/workflows/host-trust-verification.yml b/.github/workflows/host-trust-verification.yml index 12adece..83f6809 100644 --- a/.github/workflows/host-trust-verification.yml +++ b/.github/workflows/host-trust-verification.yml @@ -19,6 +19,27 @@ concurrency: cancel-in-progress: true jobs: + go-quality: + name: Go race and vet + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + cache: true + + - name: Run race tests + run: go test -race ./... + + - name: Run vet + run: go vet ./... + native: name: Native read-only (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -203,10 +224,18 @@ jobs: if: runner.os == 'Windows' run: scripts/test/host-trust-wrapper-smoke.ps1 -ProjectRoot $env:GITHUB_WORKSPACE - - name: Exercise no-Go first-run start lifecycle + - name: Verify no-Go native-controller contract shell: pwsh if: runner.os == 'Windows' - run: scripts/test/no-go-first-run-smoke.ps1 -ProjectRoot $env:GITHUB_WORKSPACE + run: scripts/test/windows-native-controller-contract.ps1 -ProjectRoot $env:GITHUB_WORKSPACE + + - name: Verify Docker Sandboxes plan-only contracts + shell: pwsh + if: runner.os == 'Windows' + run: | + scripts/test/docker-sandboxes-plan-smoke.ps1 + scripts/docker-sandboxes/validate-assets.ps1 -Platform linux/amd64 + scripts/docker-sandboxes/validate-assets.ps1 -Platform linux/arm64 - name: Exercise official wrapper feed lifecycle and singleton lock shell: bash @@ -218,6 +247,11 @@ jobs: if: runner.os != 'Windows' run: bash scripts/test/no-go-first-run-smoke.sh + - name: Verify native-controller cache retention + shell: bash + if: runner.os != 'Windows' + run: bash scripts/test/native-controller-cache-retention.sh + - name: Parse Bash host-trust scripts if: runner.os != 'Windows' shell: bash @@ -229,6 +263,7 @@ jobs: scripts/host-trust/host-trust-feed.sh \ scripts/host-trust/wrapper-lib.sh \ scripts/test/host-trust-wrapper-smoke.sh \ + scripts/test/native-controller-cache-retention.sh \ scripts/test/no-go-first-run-smoke.sh \ scripts/guest/ubuntu/apply-trusted-ca-runtime.sh \ scripts/guest/ubuntu/check-host-trust-generation.sh @@ -241,10 +276,15 @@ jobs: $files = @( 'start.ps1', 'scripts/run-with-docker.ps1', + 'scripts/build-native-controller.ps1', + 'scripts/docker-sandboxes/build-template.ps1', + 'scripts/docker-sandboxes/load-template.ps1', + 'scripts/docker-sandboxes/validate-assets.ps1', 'scripts/host-trust/host-trust-feed.ps1', 'scripts/host-trust/wrapper-lib.ps1', + 'scripts/test/docker-sandboxes-plan-smoke.ps1', 'scripts/test/host-trust-wrapper-smoke.ps1', - 'scripts/test/no-go-first-run-smoke.ps1' + 'scripts/test/windows-native-controller-contract.ps1' ) foreach ($file in $files) { $tokens = $null diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e04ef15 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Repository Agent Guidance + +Before planning or changing provider, startup, configuration, pool, runner-image, or lifecycle behavior, read [Development and Extension Principles](docs/development-principles.md), [Contributing](CONTRIBUTING.md), [Design](docs/design.md), and [Adding a Provider](docs/providers/adding-provider.md). + +Treat the missing-config `./start` wizard, the no-Go native-controller path, Catthehacker defaults for providers that can consume Docker images, runner-artifact customization, the shared machine-derived pool-prefix generator, the shared `pool.RunnerName` format, runner routing, strict capacity, logging, host trust, registration, replacement, diagnostics, exact cleanup, and no-silent-fallback behavior as product-wide contracts. Compare an extension with the common manager path and at least one established provider instead of validating only its provider-local implementation. + +A provider or onboarding change is incomplete until its wizard and generated configuration, reusable artifact/update path, shared lifecycle behavior, documentation, normal and race tests, wrapper syntax, and relevant live platform evidence are addressed. Keep unvalidated platforms or capabilities explicitly preview-only. Document and test every intentional exception to the shared contracts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8945162..0456c74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,9 +11,10 @@ Thanks for taking the time to contribute. ## Development Workflow 1. Fork the repository and create a focused branch from `develop`. -2. Keep the change small and document any operational or security behavior that it changes. -3. Run the relevant tests locally. The baseline Go test suite is `go test ./...`. -4. Open a pull request targeting `develop` and complete the pull-request template. +2. Read and preserve the [Development and Extension Principles](docs/development-principles.md). +3. Keep the change small and document any operational or security behavior that it changes. +4. Run the relevant tests locally. The baseline Go test suite is `go test ./...`. +5. Open a pull request targeting `develop` and complete the pull-request template. Fork pull requests run the safe hosted verification workflow. The live EPAR canary is reserved for branches in this repository because it uses a protected environment and disposable privileged containers. @@ -23,5 +24,6 @@ Fork pull requests run the safe hosted verification workflow. The live EPAR cana - Add or update tests when behavior changes. - Keep credentials, private keys, tokens, and machine-specific configuration out of commits. - Update the relevant documentation when a user-visible or operational behavior changes. +- Document and test every intentional platform or security exception. By contributing, you agree to follow the [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md index 1fbd582..b3ccd45 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,13 @@ A normal long-lived self-hosted runner can leave dependencies, files, containers - **Warm pool:** keep ready self-hosted runners online after setup. - **Disposable jobs:** each runner is cleaned up after one job. -- **Great default image:** Docker Container and WSL use Catthehacker's full Ubuntu runner image by default. -- **Docker-friendly isolation:** Docker Container gives each runner its own private Docker daemon. +- **Great default image:** Docker Sandboxes, Docker Container, and WSL use or adapt Catthehacker's full Ubuntu runner image by default. +- **Strong Docker-friendly isolation:** Docker Sandboxes puts each runner and its private Docker daemon inside a dedicated microVM. - **Simple host use:** run Linux GitHub Actions jobs from a Windows, macOS, Linux, or Docker-capable host. ## Quick Start -The easiest path is the default **Docker Container** mode. It works well for most Linux GitHub Actions jobs, especially Docker and Docker Compose jobs. +The wizard recommends **Docker Sandboxes** when its prerequisites pass and offers other supported providers when they do not. ### 1. Install Docker @@ -41,6 +41,8 @@ The default quick start needs a Docker-compatible daemon: - macOS: [Docker Desktop](https://www.docker.com/products/docker-desktop/) or [OrbStack](https://orbstack.dev/) - Linux: [Docker Engine](https://docs.docker.com/engine/) +For Docker Sandboxes, also install and authenticate [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/). See the [provider guide](docs/providers/docker-sandboxes.md) for its prerequisites. + ### 2. Download EPAR Source Open the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases), select the release you want, and download GitHub's automatically generated **Source code (zip)** or **Source code (tar.gz)**. EPAR releases use these source archives only. @@ -75,7 +77,7 @@ That's it. #### What Happens -EPAR initializes `.local/config.yml` for you if it does not exist. The wizard reads your organization's runner groups, requires an explicit selection, and writes an enforced safety policy; see [Runner Group Security](docs/runner-groups.md). Docker Container is the default. The wizard asks whether new Docker Container runners should inherit the host's trusted TLS roots and defaults to yes. On native Windows, it also offers WSL2 when `wsl.exe --status` confirms default version 2. On macOS, it offers experimental Tart mode when `tart --version` succeeds. Press Enter to keep Docker Container. Existing configs do not enable host trust inheritance automatically. You can customize the config afterward; see [Configuration](docs/configuration.md). +If `.local/config.yml` does not exist, `./start` opens the setup wizard. It checks provider prerequisites, asks for a runner group, and writes the configuration. See [Runner Group Security](docs/runner-groups.md), [Configuration](docs/configuration.md), and the [provider guides](docs/providers/). Then EPAR checks the configured runner image, builds or replaces it when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. @@ -104,18 +106,19 @@ runs-on: [self-hosted] If you have multiple self-hosted runners and want this job to run on a specific kind of EPAR runner, add one of its extra labels to the list, e.g.: ```yaml -runs-on: [self-hosted, epar-docker-container-catthehacker-ubuntu] +runs-on: [self-hosted, linux, X64, epar-docker-sandboxes] ``` EPAR also adds an `epar-host-` label by default, so you can see which host registered each runner. You only need to include that label in `runs-on` when you intentionally want a job to target one machine. ## Other Modes -Docker Container is the default first choice. Other providers are available when they fit your host better: +The wizard chooses a default only from providers whose prerequisites pass. Docker Sandboxes is the recommended capability-driven default; the other providers remain available for compatibility and platform-specific needs: | Provider | Use when | | --- | --- | | Docker Container | You have a Docker-compatible daemon on Windows, macOS, or Linux, and want a private Docker daemon per runner. | +| Docker Sandboxes | Recommended when Docker and the exact supported `sbx` are ready and `sbx diagnose --output json` reports at least one pass and zero failures. It places each runner and private Docker daemon in a dedicated microVM. | | WSL2 | You are on Windows and want runners as disposable WSL distros. | | Tart (experimental) | You are on Apple Silicon macOS and want to experiment with native ARM64 Linux VMs. The default Tart image is a basic Ubuntu OS image and does not include the normal GitHub-hosted runner dependency set. | @@ -141,7 +144,7 @@ Yes. EPAR registers disposable ephemeral runners. After a job finishes, EPAR del ### Can jobs use Docker, Docker Compose, and Buildx? -Yes, with the default Docker Container mode. Each runner gets its own private Docker daemon, so job-created containers, networks, and volumes stay inside that disposable runner. +Yes. Docker Sandboxes and Docker Container each give every disposable runner its own private Docker daemon, so job-created containers, networks, and volumes stay inside that runner. ## Safety @@ -155,10 +158,13 @@ GitHub also warns against using self-hosted runners with public repositories tha - [Configuration](docs/configuration.md): config file sections and common edits. - [GitHub App Setup](docs/github-app.md): required GitHub App permissions and fields. - [Runner Group Security](docs/runner-groups.md): repository access, wizard choices, and registration preflight policy. -- [Docker Container Provider](docs/providers/docker-container.md): default Docker runner mode. +- [Docker Container Provider](docs/providers/docker-container.md): compatible container-based runner mode. +- [Docker Sandboxes Provider](docs/providers/docker-sandboxes.md): recommended Windows microVM runner mode, template build, security boundary, and platform status. - [WSL Provider](docs/providers/wsl.md): Windows WSL2 runners. - [Tart Provider (experimental)](docs/providers/tart.md): Apple Silicon ARM64 Linux VM runners and Rosetta compatibility limits. - [Image Build](docs/image-build.md): image internals and customization. +- [Storage](docs/storage.md): capacity checks, retention, and exact cleanup previews. +- [Development and Extension Principles](docs/development-principles.md): three rules for extending EPAR. - [Operations](docs/operations.md): logs, cleanup, and troubleshooting. - [Troubleshooting](docs/troubleshooting.md): symptom-first diagnostics by host and provider. - [Support](SUPPORT.md): where to start, what diagnostic information to collect, and where to ask for help. diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index baf5f8e..458230e 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -6,10 +6,12 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "flag" "fmt" "io" + "math" "os" "os/exec" "path/filepath" @@ -23,6 +25,13 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + "github.com/solutionforest/ephemeral-action-runner/internal/invocation" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes" + sandboxcapacity "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/capacity" + sandboxpolicy "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/policy" + sandboxpromotion "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/promotion" + providerregistry "github.com/solutionforest/ephemeral-action-runner/internal/provider/registry" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" ) var dockerAvailable = func(ctx context.Context) error { @@ -43,6 +52,86 @@ var initTartVersion = tartVersion var initResolveHostTrust = hosttrust.Resolve +var initSandboxPromotionPlatform = sandboxpromotion.CurrentPlatform + +var initSandboxPromotionLookup = sandboxpromotion.Lookup + +var initDockerSandboxesPreflight = func(ctx context.Context, record sandboxpromotion.Record, projectRoot string) sandboxpromotion.PreflightResult { + return sandboxpromotion.LocalPreflight(ctx, record, projectRoot, os.Getenv("EPAR_CONTROLLER_IN_DOCKER") != "1", sourceRevision) +} + +var initDockerSandboxesReadiness = func(ctx context.Context) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.New("sbx").VerifyHostReadiness(ctx) +} + +type initDockerSandboxesTemplate struct { + Reference string + Digest string + CacheID string + Platform string + Size int64 + Label string + SourceChannel string +} + +type dockerSandboxesSourceLock struct { + SchemaVersion int `json:"schemaVersion"` + Profiles map[string]dockerSandboxesLockProfile `json:"profiles"` +} + +type dockerSandboxesLockProfile struct { + ObservedTagReference string `json:"observedTagReference"` + Platforms map[string]dockerSandboxesLockProfilePlatform `json:"platforms"` +} + +type dockerSandboxesLockProfilePlatform struct { + TemplateTag string `json:"templateTag"` +} + +type dockerSandboxesActiveProfile struct { + Name string + ObservedTag string + TemplateReference string + DisplayLabel string +} + +type initDockerSandboxesDiscovery struct { + Templates []initDockerSandboxesTemplate + PolicyFingerprint string +} + +type initDockerSandboxesRootMeasurement struct { + PeakBytes int64 + Evidence string +} + +type initDockerSandboxesCapacityResult struct { + StorageRoot string + AvailableBytes uint64 + TotalBytes uint64 + Reservation uint64 + HostWatermark uint64 + RequiredBytes uint64 + DeficitBytes uint64 + CapacityStatus storage.CapacityStatus +} + +type initDockerSandboxesProfile struct { + HostPlatform sandboxpromotion.Platform + Template string + TemplateDigest string + PolicyFingerprint string + RootDisk string + DockerDisk string + MinHostFreeSpace string +} + +var initDiscoverDockerSandboxes = discoverDockerSandboxes + +var initDockerSandboxesRootMeasurementFor = dockerSandboxesRootMeasurement + +var initDockerSandboxesCapacityCheck = checkInitDockerSandboxesCapacity + type initRunnerGroupClient interface { ListRunnerGroups(context.Context) ([]gh.RunnerGroup, error) ListRunnerGroupRepositories(context.Context, int64) ([]gh.RunnerGroupRepository, error) @@ -154,25 +243,9 @@ func runInitWithOptions(opts initOptions) error { if err != nil { return err } - providerType := "docker-container" - if wsl2Available() { - providerType, err = promptProviderType(opts.Out, reader, "wsl") - if err != nil { - return err - } - } else if tartAvailable() { - providerType, err = promptProviderType(opts.Out, reader, "tart") - if err != nil { - return err - } - } - if !opts.SkipDockerCheck && providerType != "tart" { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - dockerErr := dockerAvailable(ctx) - cancel() - if dockerErr != nil { - return fmt.Errorf("Docker is required for the selected %s setup. Install Docker Desktop, Docker Engine, or a compatible Docker host, then rerun %s init", providerDisplayName(providerType), binaryName) - } + providerType, promotionRecord, selectedProfile, err := promptInitProvider(opts.Context, opts.ProjectRoot, opts.Out, reader, opts.SkipDockerCheck) + if err != nil { + return err } defaultPrefix, err := generatedPoolNamePrefix() if err != nil { @@ -188,7 +261,7 @@ func runInitWithOptions(opts initOptions) error { hostTrustMode := config.HostTrustModeDisabled hostTrustScopes := []string{config.HostTrustScopeSystem} - if providerType == "docker-container" { + if providerType == "docker-container" || providerType == "docker-sandboxes" { enabled, promptErr := promptYesNo(opts.Out, reader, "Inherit this host's trusted TLS roots into disposable runners?", true) if promptErr != nil { return promptErr @@ -218,6 +291,20 @@ func runInitWithOptions(opts initOptions) error { content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup) case "tart": content = defaultTartConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup) + case "docker-sandboxes": + profile := selectedProfile + if profile == nil { + var profileErr error + profile, profileErr = promotedDockerSandboxesProfile(promotionRecord) + if profileErr != nil { + return profileErr + } + } + guestPlatform, runnerArchitectureLabel, platformErr := dockerSandboxesPlatform(profile.HostPlatform) + if platformErr != nil { + return platformErr + } + content = defaultDockerSandboxesConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile, guestPlatform, runnerArchitectureLabel) } if err := os.MkdirAll(filepath.Dir(opts.ConfigPath), 0755); err != nil { return err @@ -598,45 +685,756 @@ func promptPoolNamePrefix(out io.Writer, reader *bufio.Reader, defaultValue stri } } -func promptProviderType(out io.Writer, reader *bufio.Reader, alternative string) (string, error) { - alternativeLabel := providerDisplayName(alternative) +type initProviderOption struct { + Number string + Type string + Label string + Available bool + Status string + Default bool + Aliases []string +} + +type initProviderPrerequisites struct { + DockerAvailable bool + DockerStatus string + DockerSandboxesAvailable bool + DockerSandboxesStatus string + WSLAvailable bool + WSLStatus string + TartAvailable bool + TartStatus string +} + +func promptInitProvider(ctx context.Context, projectRoot string, out io.Writer, reader *bufio.Reader, skipDockerCheck bool) (string, sandboxpromotion.Record, *initDockerSandboxesProfile, error) { + hostPlatform := initSandboxPromotionPlatform() + record, promoted := initSandboxPromotionLookup(hostPlatform) + prerequisites := detectInitProviderPrerequisites(ctx, hostPlatform, skipDockerCheck) + operationalDefault := !promoted && prerequisites.DockerSandboxesAvailable + promotionPassed := false + var promotionFailures []sandboxpromotion.Failure + if promoted { + var preflight sandboxpromotion.PreflightResult + if os.Getenv(sandboxpromotion.DisableEnvironment) == "1" { + preflight.Failures = append(preflight.Failures, sandboxpromotion.Failure{ + Gate: "operator kill switch", + Detail: sandboxpromotion.DisableEnvironment + "=1 disables Docker Sandboxes admission and automatic selection", + Resolution: "Unset the kill switch only after the Docker Sandboxes issue is resolved, or explicitly choose another provider.", + }) + } else if err := sandboxpromotion.Validate(record); err != nil { + preflight.Failures = append(preflight.Failures, sandboxpromotion.Failure{ + Gate: "promotion record", + Detail: err.Error(), + Resolution: "Explicitly choose Docker Container or another provider and report the invalid embedded promotion record.", + }) + } else if prerequisites.DockerSandboxesAvailable { + preflightContext, cancel := context.WithTimeout(ctx, 45*time.Second) + preflight = initDockerSandboxesPreflight(preflightContext, record, projectRoot) + cancel() + } + promotionPassed = preflight.Passed() && prerequisites.DockerSandboxesAvailable + promotionFailures = preflight.Failures + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Docker Sandboxes automatic-default preflight:") + if promotionPassed { + fmt.Fprintln(out, " PASS: the exact promoted platform, host, sbx, template, policy, and resource gates passed.") + } else { + for _, failure := range promotionFailures { + fmt.Fprintf(out, " FAIL [%s]: %s\n", failure.Gate, failure.Detail) + fmt.Fprintf(out, " Action: %s\n", failure.Resolution) + } + if len(promotionFailures) == 0 { + fmt.Fprintf(out, " FAIL [prerequisites]: %s\n", prerequisites.DockerSandboxesStatus) + } + prerequisites.DockerSandboxesAvailable = false + prerequisites.DockerSandboxesStatus = "UNAVAILABLE — promoted admission did not pass; review the failures above" + } + } + + defaultProvider := "docker-container" + if promotionPassed || operationalDefault { + defaultProvider = "docker-sandboxes" + } else if promoted || !prerequisites.DockerAvailable { + defaultProvider = "" + } + providerType, err := promptProviderOptions(out, reader, prerequisites, promoted, promotionPassed, operationalDefault, defaultProvider) + if err != nil { + return "", sandboxpromotion.Record{}, nil, err + } + if providerType != "docker-sandboxes" { + return providerType, sandboxpromotion.Record{}, nil, nil + } + if promotionPassed { + return providerType, record, nil, nil + } + if !promoted { + if _, _, err := dockerSandboxesPlatform(hostPlatform); err == nil { + profile, accepted, profileErr := promptDockerSandboxesProfile(ctx, projectRoot, hostPlatform, out, reader) + if profileErr != nil { + return "", sandboxpromotion.Record{}, nil, profileErr + } + if !accepted { + return "", sandboxpromotion.Record{}, nil, errors.New("Docker Sandboxes setup did not complete; no config was written") + } + return providerType, sandboxpromotion.Record{}, profile, nil + } + } + return "", sandboxpromotion.Record{}, nil, errors.New("Docker Sandboxes selection is unavailable") +} + +func promptDockerSandboxesProfile(ctx context.Context, projectRoot string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (*initDockerSandboxesProfile, bool, error) { fmt.Fprintln(out, "") - fmt.Fprintln(out, "Runner provider:") - fmt.Fprintln(out, " 1. Docker Container — private daemon (default)") - fmt.Fprintf(out, " 2. %s\n", alternativeLabel) + fmt.Fprintln(out, "Docker Sandboxes setup:") + fmt.Fprintln(out, " Docker Sandboxes is the recommended default because Docker and sbx diagnostics passed on this machine.") + fmt.Fprintln(out, " Docker Sandboxes provides EPAR's strongest current host boundary, but it is not a guarantee that arbitrary hostile workflows are safe.") + fmt.Fprintln(out, " Setup performs read-only checks for an exact locally built and loaded EPAR template and the current host-global Balanced policy.") + fmt.Fprintln(out, " New EPAR sandboxes default to open public HTTP/HTTPS egress with owned deny-wins host-alias guardrails; Docker Sandboxes' private-network isolation remains in force.") + if os.Getenv(sandboxpromotion.DisableEnvironment) == "1" { + fmt.Fprintf(out, " Unavailable: %s=1 disables Docker Sandboxes admission.\n", sandboxpromotion.DisableEnvironment) + return nil, false, fmt.Errorf("Docker Sandboxes setup is disabled by %s; no config was written", sandboxpromotion.DisableEnvironment) + } + + guestPlatform, _, err := dockerSandboxesPlatform(hostPlatform) + if err != nil { + fmt.Fprintf(out, " Docker Sandboxes is unavailable: %v\n", err) + return nil, false, fmt.Errorf("Docker Sandboxes setup is unavailable: %w", err) + } + var discovery initDockerSandboxesDiscovery for { - value, hitEOF, err := promptDefault(out, reader, "Runner provider", "1") + discoveryContext, cancel := context.WithTimeout(ctx, 45*time.Second) + discovery, err = initDiscoverDockerSandboxes(discoveryContext, projectRoot, guestPlatform) + cancel() + if err == nil && len(discovery.Templates) > 0 { + break + } + if err != nil { + fmt.Fprintf(out, " Docker Sandboxes setup preparation failed: %v\n", err) + fmt.Fprintln(out, " Action: repair the reported local admission check. For template or cache failures, build and load a Candidate A template using docs/providers/docker-sandboxes.md.") + } else { + fmt.Fprintln(out, " Docker Sandboxes setup found no exact locally built and loaded EPAR template for this platform.") + fmt.Fprintln(out, " Action: build and load a Candidate A template using docs/providers/docker-sandboxes.md.") + } + retry, promptErr := promptYesNo(out, reader, "Retry Docker Sandboxes setup checks?", false) + if promptErr != nil { + return nil, false, promptErr + } + if !retry { + return nil, false, errors.New("Docker Sandboxes setup checks did not pass; no config was written") + } + } + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Verified local Docker Sandboxes source profiles:") + for index, template := range discovery.Templates { + suffix := "" + if index == 0 { + if _, measured := initDockerSandboxesRootMeasurementFor(hostPlatform, template); measured { + suffix = " (recommended measured default)" + } else { + suffix = " (default selection; capacity unmeasured)" + } + } + fmt.Fprintf(out, " %d. %s — %s [%s, cache %s, %s on host]%s\n", index+1, template.Label, template.SourceChannel, template.Platform, template.CacheID, formatInitByteCount(template.Size), suffix) + } + fmt.Fprintln(out, " This wizard saves the exact verified EPAR template tag and full local digest in configuration.") + fmt.Fprintln(out, " Automatic Docker Sandboxes source refresh is not implemented yet; build and load a new locked EPAR template manually before running setup.") + var selected initDockerSandboxesTemplate + for { + value, hitEOF, promptErr := promptDefault(out, reader, "Docker Sandboxes source profile", "1") + if promptErr != nil { + return nil, false, promptErr + } + index, parseErr := strconv.Atoi(value) + if parseErr == nil && index >= 1 && index <= len(discovery.Templates) { + selected = discovery.Templates[index-1] + break + } + fmt.Fprintf(out, "Docker Sandboxes source profile must be a number from 1 to %d.\n", len(discovery.Templates)) + if hitEOF { + return nil, false, fmt.Errorf("invalid Docker Sandboxes source profile %q", value) + } + } + fmt.Fprintf(out, " Resolved exact EPAR template tag: %s\n", selected.Reference) + fmt.Fprintf(out, " Resolved full local template digest: %s\n", selected.Digest) + + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Selected template capacity:") + fmt.Fprintf(out, " Shared host template cache: %s (already present; do not add this byte count arithmetically to each sandbox root disk).\n", formatInitByteCount(selected.Size)) + fmt.Fprintln(out, " Source-image virtual or unpacked-size figures are not guest root-disk requirements.") + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Default resource reservations are recommended starting values and can be adjusted in the generated config.") + var rootDisk string + var dockerDisk string + if measurement, ok := initDockerSandboxesRootMeasurementFor(hostPlatform, selected); ok { + fmt.Fprintf(out, " Measured guest root peak: %s (%s).\n", formatInitByteCount(measurement.PeakBytes), measurement.Evidence) + fmt.Fprintln(out, " Root formula: measured peak + 25% safety margin + the recommended 20GiB writable headroom, rounded up to 10GiB.") + derivedRootDisk, deriveErr := sandboxcapacity.DeriveRootDisk(uint64(measurement.PeakBytes), sandboxcapacity.RootWritableHeadroom) + if deriveErr != nil { + return nil, false, fmt.Errorf("derive sandbox root filesystem capacity: %w", deriveErr) + } + if derivedRootDisk > math.MaxInt64 { + return nil, false, errors.New("derived sandbox root filesystem capacity exceeds the supported size") + } + rootDisk = formatInitByteCount(int64(derivedRootDisk)) + fmt.Fprintf(out, " Automatically selected sandbox root filesystem total capacity: %s.\n", rootDisk) + fmt.Fprintln(out, " Docker storage is the most workload-dependent reservation and is the only capacity question asked.") + dockerDisk, err = promptInitByteSize(out, reader, "Sandbox Docker disk", "100GiB", config.DockerSandboxesMinimumDockerDiskBytes) + if err != nil { + return nil, false, err + } + } else { + fmt.Fprintln(out, " Measured guest root peak: unavailable for this exact template digest.") + fmt.Fprintln(out, " Root capacity is the least certain reservation and is the only capacity question asked; the 30GiB default is provisional and is not derived from the template cache size.") + var promptErr error + rootDisk, promptErr = promptInitByteSize(out, reader, "Sandbox root filesystem total capacity", "30GiB", int64(sandboxcapacity.MinimumRootDisk)) + if promptErr != nil { + return nil, false, promptErr + } + dockerDisk = "100GiB" + fmt.Fprintln(out, " Automatically selected sandbox Docker disk: 100GiB.") + } + minHostFreeSpace := "50GiB" + fmt.Fprintln(out, " Automatically selected minimum host free space: 50GiB.") + fmt.Fprintln(out, " EPAR rechecks current Docker Sandboxes storage free space, existing and uncertain reservations, and raises the effective host watermark to at least 10% of the backing volume before every sandbox create.") + rootDiskBytes, err := config.ParseByteSize(rootDisk) + if err != nil { + return nil, false, fmt.Errorf("parse sandbox root filesystem capacity: %w", err) + } + dockerDiskBytes, err := config.ParseByteSize(dockerDisk) + if err != nil { + return nil, false, fmt.Errorf("parse sandbox Docker disk capacity: %w", err) + } + minHostFreeSpaceBytes, err := config.ParseByteSize(minHostFreeSpace) + if err != nil { + return nil, false, fmt.Errorf("parse minimum host free space: %w", err) + } + capacityResult, err := initDockerSandboxesCapacityCheck(uint64(rootDiskBytes), uint64(dockerDiskBytes), uint64(minHostFreeSpaceBytes)) + if err != nil { + return nil, false, fmt.Errorf("Docker Sandboxes setup cannot measure provider storage capacity: %w; no config was written", err) + } + if capacityResult.CapacityStatus != storage.CapacityReady { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Docker Sandboxes capacity admission failed:") + fmt.Fprintf(out, " Provider storage: %s\n", capacityResult.StorageRoot) + fmt.Fprintf(out, " Available: %s\n", formatInitUintByteCount(capacityResult.AvailableBytes)) + fmt.Fprintf(out, " Sandbox reservation: %s\n", formatInitUintByteCount(capacityResult.Reservation)) + fmt.Fprintf(out, " Required host reserve: %s\n", formatInitUintByteCount(capacityResult.HostWatermark)) + fmt.Fprintf(out, " Required before creation: %s\n", formatInitUintByteCount(capacityResult.RequiredBytes)) + fmt.Fprintf(out, " Shortfall: %s\n", formatInitUintByteCount(capacityResult.DeficitBytes)) + fmt.Fprintf(out, " Preview exact policy-selected cleanup with: %s\n", invocation.Command("storage", "prune", "--provider", "docker-sandboxes")) + return nil, false, errors.New("Docker Sandboxes storage is insufficient for the selected profile; no config was written") + } + fmt.Fprintf(out, " Verified Docker Sandboxes storage admission on %s: %s available; %s required before creation.\n", capacityResult.StorageRoot, formatInitUintByteCount(capacityResult.AvailableBytes), formatInitUintByteCount(capacityResult.RequiredBytes)) + profile := &initDockerSandboxesProfile{ + HostPlatform: hostPlatform, + Template: selected.Reference, + TemplateDigest: selected.Digest, + PolicyFingerprint: discovery.PolicyFingerprint, + RootDisk: rootDisk, + DockerDisk: dockerDisk, + MinHostFreeSpace: minHostFreeSpace, + } + if err := config.ValidateDockerSandboxes(config.DockerSandboxesConfig{ + Template: profile.Template, + TemplateDigest: profile.TemplateDigest, + PolicyGeneration: profile.PolicyFingerprint, + NetworkBaseline: config.DockerSandboxesNetworkBaselineOpen, + StagingRoot: ".local/docker-sandboxes-staging", + CPUs: 4, + Memory: "8GiB", + RootDisk: profile.RootDisk, + DockerDisk: profile.DockerDisk, + MaxConcurrentCreates: 2, + MinHostFreeSpace: profile.MinHostFreeSpace, + }); err != nil { + return nil, false, fmt.Errorf("validate discovered Docker Sandboxes profile: %w", err) + } + fmt.Fprintf(out, " Verified policy fingerprint: %s\n", discovery.PolicyFingerprint) + return profile, true, nil +} + +func checkInitDockerSandboxesCapacity(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { + storageRoot, err := sandboxcapacity.DockerSandboxesStorageRoot() + if err != nil { + return initDockerSandboxesCapacityResult{}, err + } + probePath := storageRoot + for { + if _, statErr := os.Lstat(probePath); statErr == nil { + break + } else if !os.IsNotExist(statErr) { + return initDockerSandboxesCapacityResult{}, fmt.Errorf("inspect %s: %w", probePath, statErr) + } + parent := filepath.Dir(probePath) + if parent == probePath { + return initDockerSandboxesCapacityResult{}, fmt.Errorf("find existing filesystem ancestor for %s", storageRoot) + } + probePath = parent + } + capacity, err := storage.ProbeFilesystemCapacity(probePath, time.Now()) + if err != nil { + return initDockerSandboxesCapacityResult{}, fmt.Errorf("probe %s for %s: %w", probePath, storageRoot, err) + } + if rootDisk > math.MaxUint64-dockerDisk { + return initDockerSandboxesCapacityResult{}, errors.New("sandbox root and Docker disk reservation overflows") + } + reservation := rootDisk + dockerDisk + hostWatermark, err := sandboxcapacity.HostWatermark(minHostFreeSpace, capacity.TotalBytes) + if err != nil { + return initDockerSandboxesCapacityResult{}, err + } + check, err := storage.EvaluateCapacity(storage.Surface{ + ID: "docker-sandboxes-backing", + Provider: "docker-sandboxes", + Kind: storage.SurfaceSandboxCache, + Location: storageRoot, + Capacity: capacity, + }, storage.Requirement{ + ID: "docker-sandboxes-instance-create", + Provider: "docker-sandboxes", + SurfaceID: "docker-sandboxes-backing", + PeakBytes: reservation, + MinimumFreeBytes: hostWatermark, + }) + if err != nil { + return initDockerSandboxesCapacityResult{}, err + } + return initDockerSandboxesCapacityResult{ + StorageRoot: storageRoot, + AvailableBytes: capacity.AvailableBytes, + TotalBytes: capacity.TotalBytes, + Reservation: reservation, + HostWatermark: hostWatermark, + RequiredBytes: check.RequiredAvailableBytes, + DeficitBytes: check.DeficitBytes, + CapacityStatus: check.Status, + }, nil +} + +func promptInitByteSize(out io.Writer, reader *bufio.Reader, label, defaultValue string, minimum int64) (string, error) { + for { + value, hitEOF, err := promptDefault(out, reader, label, defaultValue) if err != nil { return "", err } - switch strings.ToLower(value) { - case "1", "docker", "docker-container": - return "docker-container", nil - case "2", alternative: - return alternative, nil - case "wsl2": - if alternative == "wsl" { - return alternative, nil + parsed, parseErr := config.ParseByteSize(value) + if parseErr == nil && parsed >= minimum { + return value, nil + } + if parseErr != nil { + fmt.Fprintf(out, "%s is invalid: %v\n", label, parseErr) + } else { + fmt.Fprintf(out, "%s must be at least %s.\n", label, formatInitByteCount(minimum)) + } + if hitEOF { + return "", fmt.Errorf("invalid %s %q", strings.ToLower(label), value) + } + } +} + +func formatInitByteCount(value int64) string { + const ( + gib = int64(1 << 30) + mib = int64(1 << 20) + ) + if value >= gib { + if value%gib == 0 { + return strconv.FormatInt(value/gib, 10) + "GiB" + } + return strconv.FormatFloat(float64(value)/float64(gib), 'f', 2, 64) + "GiB" + } + if value >= mib { + if value%mib == 0 { + return strconv.FormatInt(value/mib, 10) + "MiB" + } + return strconv.FormatFloat(float64(value)/float64(mib), 'f', 2, 64) + "MiB" + } + return strconv.FormatInt(value, 10) + "B" +} + +func formatInitUintByteCount(value uint64) string { + if value <= math.MaxInt64 { + return formatInitByteCount(int64(value)) + } + return strconv.FormatFloat(float64(value)/float64(uint64(1)<<30), 'f', 2, 64) + "GiB" +} + +func dockerSandboxesRootMeasurement(hostPlatform sandboxpromotion.Platform, template initDockerSandboxesTemplate) (initDockerSandboxesRootMeasurement, bool) { + const ( + evidenceSBXVersion = "0.35.0" + fullTemplateDigest = "sha256:00303a3e249a1baf8b0585d20273af408c27182dcfc827a98aa25ffe66b1f67f" + ) + if dockersandboxes.SupportedVersion != evidenceSBXVersion || hostPlatform != sandboxpromotion.WindowsAMD64 || template.Digest != fullTemplateDigest || template.Platform != "linux/amd64" { + return initDockerSandboxesRootMeasurement{}, false + } + return initDockerSandboxesRootMeasurement{ + PeakBytes: 324_780_032, + Evidence: "exact full-template Buildx and Compose validation probe on Docker Sandboxes v0.35.0", + }, true +} + +func discoverDockerSandboxes(ctx context.Context, projectRoot, guestPlatform string) (initDockerSandboxesDiscovery, error) { + profiles, err := readDockerSandboxesActiveProfiles(projectRoot, guestPlatform) + if err != nil { + return initDockerSandboxesDiscovery{}, err + } + adapter := dockersandboxes.New("") + if err := adapter.VerifyAdmission(ctx); err != nil { + return initDockerSandboxesDiscovery{}, fmt.Errorf("Docker Sandboxes admission check failed: %w", err) + } + cached, err := adapter.CachedTemplates(ctx) + if err != nil { + return initDockerSandboxesDiscovery{}, fmt.Errorf("read Docker Sandboxes template inventory: %w", err) + } + cachedByReference := make(map[string]dockersandboxes.CachedTemplate, len(cached)) + for _, template := range cached { + reference, canonicalErr := canonicalDockerSandboxesTemplateReference(template.Reference) + if canonicalErr != nil { + continue + } + cachedByReference[reference] = template + } + var templates []initDockerSandboxesTemplate + for _, profile := range profiles { + template, found := cachedByReference[profile.TemplateReference] + if !found { + continue + } + identity, inspectErr := adapter.InspectLocalTemplate(ctx, template.Reference) + if inspectErr != nil || identity.Platform != guestPlatform { + continue + } + if !strings.HasPrefix(identity.Digest, "sha256:") || len(identity.Digest) != len("sha256:")+64 || template.CacheID != strings.TrimPrefix(identity.Digest, "sha256:")[:12] { + continue + } + templates = append(templates, initDockerSandboxesTemplate{ + Reference: template.Reference, + Digest: identity.Digest, + CacheID: template.CacheID, + Platform: identity.Platform, + Size: template.SizeBytes, + Label: profile.DisplayLabel, + SourceChannel: profile.ObservedTag, + }) + } + rules, err := adapter.ReadGlobalNetworkPolicy(ctx) + if err != nil { + return initDockerSandboxesDiscovery{}, fmt.Errorf("read Docker Sandboxes global policy: %w", err) + } + fingerprint, err := sandboxpolicy.Fingerprint(rules) + if err != nil { + return initDockerSandboxesDiscovery{}, fmt.Errorf("fingerprint Docker Sandboxes global policy: %w", err) + } + if err := sandboxpolicy.VerifyBaseline(fingerprint, "epar-preview-policy-probe", rules); err != nil { + return initDockerSandboxesDiscovery{}, fmt.Errorf("verify Docker Sandboxes global policy: %w", err) + } + return initDockerSandboxesDiscovery{Templates: templates, PolicyFingerprint: fingerprint}, nil +} + +func readDockerSandboxesActiveProfiles(projectRoot, guestPlatform string) ([]dockerSandboxesActiveProfile, error) { + if projectRoot == "" { + return nil, errors.New("read Docker Sandboxes source lock: project root is empty") + } + lockPath := filepath.Join(projectRoot, "templates", "docker-sandboxes", "sources.lock.json") + contents, err := os.ReadFile(lockPath) + if err != nil { + return nil, fmt.Errorf("read Docker Sandboxes source lock %q: %w", lockPath, err) + } + var lock dockerSandboxesSourceLock + if err := json.Unmarshal(contents, &lock); err != nil { + return nil, fmt.Errorf("parse Docker Sandboxes source lock %q: %w", lockPath, err) + } + if lock.SchemaVersion != 2 { + return nil, fmt.Errorf("read Docker Sandboxes source lock %q: unsupported schemaVersion %d", lockPath, lock.SchemaVersion) + } + if len(lock.Profiles) == 0 { + return nil, fmt.Errorf("read Docker Sandboxes source lock %q: no active profiles", lockPath) + } + profileOrder := []string{"full", "act-22.04"} + expectedSourceChannels := map[string]string{ + "full": "ghcr.io/catthehacker/ubuntu:full-latest", + "act-22.04": "ghcr.io/catthehacker/ubuntu:act-22.04", + } + profiles := make([]dockerSandboxesActiveProfile, 0, len(profileOrder)) + for _, name := range profileOrder { + profile, found := lock.Profiles[name] + if !found { + return nil, fmt.Errorf("read Docker Sandboxes source lock %q: active profile %q is missing", lockPath, name) + } + platform, found := profile.Platforms[guestPlatform] + if !found || platform.TemplateTag == "" { + return nil, fmt.Errorf("read Docker Sandboxes source lock %q: active profile %q has no templateTag for %s", lockPath, name, guestPlatform) + } + reference, err := canonicalDockerSandboxesTemplateReference(platform.TemplateTag) + if err != nil { + return nil, fmt.Errorf("read Docker Sandboxes source lock %q: active profile %q has invalid templateTag: %w", lockPath, name, err) + } + if profile.ObservedTagReference != expectedSourceChannels[name] { + return nil, fmt.Errorf("read Docker Sandboxes source lock %q: active profile %q has unexpected observedTagReference %q", lockPath, name, profile.ObservedTagReference) + } + label := "Catthehacker Ubuntu Act 22.04" + if name == "full" { + label = "Catthehacker Ubuntu Full" + } + profiles = append(profiles, dockerSandboxesActiveProfile{ + Name: name, + ObservedTag: profile.ObservedTagReference, + TemplateReference: reference, + DisplayLabel: label, + }) + } + if len(lock.Profiles) != len(profiles) { + return nil, fmt.Errorf("read Docker Sandboxes source lock %q: active profiles must be exactly full and act-22.04", lockPath) + } + profiles[0].DisplayLabel += " (recommended)" + profiles[1].DisplayLabel += " (current lean profile)" + return profiles, nil +} + +func canonicalDockerSandboxesTemplateReference(reference string) (string, error) { + if strings.HasPrefix(reference, "docker.io/library/") { + reference = strings.TrimPrefix(reference, "docker.io/library/") + } + if strings.ContainsAny(reference, "@/ \t\r\n") || !strings.HasPrefix(reference, "epar-docker-sandboxes-catthehacker-") || strings.Count(reference, ":") != 1 { + return "", fmt.Errorf("must be an EPAR repository:tag reference, got %q", reference) + } + parts := strings.SplitN(reference, ":", 2) + if parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("must include a repository and tag, got %q", reference) + } + return "docker.io/library/" + reference, nil +} + +func detectInitProviderPrerequisites(ctx context.Context, hostPlatform sandboxpromotion.Platform, skipDockerCheck bool) initProviderPrerequisites { + result := initProviderPrerequisites{} + if skipDockerCheck { + result.DockerAvailable = true + result.DockerStatus = "AVAILABLE — Docker check skipped by --skip-docker-check" + } else { + dockerContext, cancel := context.WithTimeout(ctx, 10*time.Second) + err := dockerAvailable(dockerContext) + cancel() + if err == nil { + result.DockerAvailable = true + result.DockerStatus = "READY — Docker CLI and daemon are available" + } else { + result.DockerStatus = fmt.Sprintf("UNAVAILABLE — Docker CLI or daemon check failed: %v", err) + } + } + + if _, _, err := dockerSandboxesPlatform(hostPlatform); err != nil { + result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — %v", err) + } else if os.Getenv(sandboxpromotion.DisableEnvironment) == "1" { + result.DockerSandboxesStatus = "UNAVAILABLE — " + sandboxpromotion.DisableEnvironment + "=1 disables admission" + } else if !result.DockerAvailable { + result.DockerSandboxesStatus = "UNAVAILABLE — Docker CLI and daemon are required" + } else { + readinessContext, cancel := context.WithTimeout(ctx, 30*time.Second) + readiness, err := initDockerSandboxesReadiness(readinessContext) + cancel() + if err == nil { + capacityResult, capacityErr := initDockerSandboxesCapacityCheck(sandboxcapacity.MinimumRootDisk, sandboxcapacity.MinimumDockerDisk, sandboxcapacity.MinimumHostFreeSpace) + switch { + case capacityErr != nil: + result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — provider storage capacity cannot be measured: %v", capacityErr) + case capacityResult.CapacityStatus != storage.CapacityReady: + result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — provider storage %s has %s available; the minimum valid sandbox and host reserve require %s (shortfall %s)", capacityResult.StorageRoot, formatInitUintByteCount(capacityResult.AvailableBytes), formatInitUintByteCount(capacityResult.RequiredBytes), formatInitUintByteCount(capacityResult.DeficitBytes)) + default: + result.DockerSandboxesAvailable = true + result.DockerSandboxesStatus = fmt.Sprintf("READY — Docker and sbx v%s diagnostics passed (%d pass, %d warn, %d fail, %d skip); minimum storage admission passed", dockersandboxes.SupportedVersion, readiness.ChecksPassed, readiness.ChecksWarned, readiness.ChecksFailed, readiness.ChecksSkipped) + } + } else { + result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — sbx v%s readiness failed: %v", dockersandboxes.SupportedVersion, err) + } + } + + switch { + case initGOOS != "windows": + result.WSLStatus = "UNAVAILABLE — native Windows, WSL2, and Docker are required" + case !result.DockerAvailable: + result.WSLStatus = "UNAVAILABLE — Docker CLI and daemon are required" + case !wsl2Available(): + result.WSLStatus = "UNAVAILABLE — wsl.exe must report Default Version: 2" + default: + result.WSLAvailable = true + result.WSLStatus = "READY — Docker and WSL2 are available" + } + + switch { + case initGOOS != "darwin": + result.TartStatus = "UNAVAILABLE — native macOS and tart are required" + case !tartAvailable(): + result.TartStatus = "UNAVAILABLE — tart --version failed" + default: + result.TartAvailable = true + result.TartStatus = "READY — tart is available" + } + return result +} + +func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites initProviderPrerequisites, promoted, promotionPassed, operationalDefault bool, defaultProvider string) (string, error) { + options := make([]initProviderOption, 0, len(providerregistry.Descriptors())) + for _, descriptor := range providerregistry.Descriptors() { + option := initProviderOption{ + Number: descriptor.WizardNumber, + Type: descriptor.Type, + Label: descriptor.WizardLabel, + Aliases: append([]string(nil), descriptor.WizardAliases...), + } + switch descriptor.Type { + case "docker-container": + option.Available = prerequisites.DockerAvailable + option.Status = prerequisites.DockerStatus + case "docker-sandboxes": + option.Available = prerequisites.DockerSandboxesAvailable && (!promoted || promotionPassed) + option.Status = prerequisites.DockerSandboxesStatus + if operationalDefault { + option.Label = "Docker Sandboxes — recommended" + } else if promoted { + option.Label = "Docker Sandboxes (independently certified for this exact platform)" } + case "wsl": + option.Available = prerequisites.WSLAvailable + option.Status = prerequisites.WSLStatus + case "tart": + option.Available = prerequisites.TartAvailable + option.Status = prerequisites.TartStatus default: - // Continue below so aliases that belong to an unavailable provider are rejected. + return "", fmt.Errorf("registered provider %q has no prerequisite contribution", descriptor.Type) + } + options = append(options, option) + } + if err := validateWizardProviderOptions(options); err != nil { + return "", err + } + defaultNumber := prioritizeDefaultProviderOption(options, defaultProvider) + + fmt.Fprintln(out, "") + if defaultNumber == "" { + fmt.Fprintln(out, "Runner provider (explicit choice required):") + } else { + fmt.Fprintln(out, "Runner provider:") + } + for _, option := range options { + defaultLabel := "" + if option.Default { + defaultLabel = " (default)" + } + fmt.Fprintf(out, " %s. %s%s\n", option.Number, option.Label, defaultLabel) + fmt.Fprintf(out, " Prerequisites: %s\n", option.Status) + } + for { + var value string + var hitEOF bool + var err error + if defaultNumber == "" { + fmt.Fprint(out, "Runner provider: ") + value, err = reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + hitEOF = errors.Is(err, io.EOF) + if hitEOF { + err = nil + } + value = strings.TrimSpace(value) + } else { + value, hitEOF, err = promptDefault(out, reader, "Runner provider", defaultNumber) + } + if err != nil { + return "", err + } + normalized := strings.ToLower(value) + var selected *initProviderOption + for index := range options { + option := &options[index] + if normalized == option.Number || normalized == option.Type { + selected = option + break + } + for _, alias := range option.Aliases { + if normalized == alias { + selected = option + break + } + } + if selected != nil { + break + } + } + if selected != nil && selected.Available { + return selected.Type, nil + } + if selected != nil { + fmt.Fprintf(out, "%s is unavailable: %s\n", selected.Label, selected.Status) + } else { + fmt.Fprintln(out, "Choose an available provider number or name shown above.") } - fmt.Fprintf(out, "Runner provider must be 1 (Docker Container — private daemon) or 2 (%s).\n", alternativeLabel) if hitEOF { + if selected != nil { + return "", fmt.Errorf("runner provider %q is unavailable: %s", value, selected.Status) + } return "", fmt.Errorf("invalid runner provider %q", value) } } } +func prioritizeDefaultProviderOption(options []initProviderOption, defaultProvider string) string { + defaultIndex := -1 + for index := range options { + options[index].Default = false + if options[index].Type == defaultProvider && options[index].Available { + defaultIndex = index + } + } + if defaultIndex > 0 { + selected := options[defaultIndex] + copy(options[1:defaultIndex+1], options[:defaultIndex]) + options[0] = selected + defaultIndex = 0 + } + for index := range options { + options[index].Number = strconv.Itoa(index + 1) + } + if defaultIndex < 0 { + return "" + } + options[defaultIndex].Default = true + return options[defaultIndex].Number +} + +func validateWizardProviderOptions(options []initProviderOption) error { + registered := make(map[string]struct{}, len(options)) + for _, option := range options { + descriptor, found := providerregistry.DescriptorFor(option.Type) + if !found { + return fmt.Errorf("wizard provider %q has no registry entry", option.Type) + } + if !descriptor.WizardSupported { + return fmt.Errorf("wizard provider %q is not registered for onboarding", option.Type) + } + if option.Number != descriptor.WizardNumber || option.Label == "" || len(option.Aliases) == 0 { + return fmt.Errorf("wizard provider %q does not use its complete registry contribution", option.Type) + } + if _, duplicate := registered[option.Type]; duplicate { + return fmt.Errorf("wizard provider %q is duplicated", option.Type) + } + registered[option.Type] = struct{}{} + } + for _, descriptor := range providerregistry.Descriptors() { + if descriptor.WizardSupported { + if _, found := registered[descriptor.Type]; !found { + return fmt.Errorf("registered provider %q has no ./start wizard option", descriptor.Type) + } + } + } + return nil +} + func providerDisplayName(providerType string) string { - switch providerType { - case "wsl": - return "WSL2" - case "tart": - return "Tart (experimental)" - default: - return "Docker Container — private daemon" + if descriptor, found := providerregistry.DescriptorFor(providerType); found { + return descriptor.DisplayName } + return providerType } func promptDefault(out io.Writer, reader *bufio.Reader, label string, defaultValue string) (string, bool, error) { @@ -858,6 +1656,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] @@ -908,6 +1715,138 @@ timeouts: `, appID, organization, privateKeyPath, hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } +func promotedDockerSandboxesPlatform(record sandboxpromotion.Record) (string, string, error) { + return dockerSandboxesPlatform(record.Platform) +} + +func dockerSandboxesPlatform(platform sandboxpromotion.Platform) (string, string, error) { + hostOS, hostArch, found := strings.Cut(string(platform), "/") + if !found || hostOS == "" || hostArch == "" || strings.Contains(hostArch, "/") { + return "", "", fmt.Errorf("unsupported Docker Sandboxes controller platform %q", platform) + } + guestPlatform, err := config.DockerSandboxesGuestPlatform(hostOS, hostArch) + if err != nil { + return "", "", err + } + switch guestPlatform { + case "linux/amd64": + return guestPlatform, "X64", nil + case "linux/arm64": + return guestPlatform, "ARM64", nil + default: + return "", "", fmt.Errorf("Docker Sandboxes guest platform %q has no GitHub runner architecture label", guestPlatform) + } +} + +func promotedDockerSandboxesProfile(record sandboxpromotion.Record) (*initDockerSandboxesProfile, error) { + if _, _, err := promotedDockerSandboxesPlatform(record); err != nil { + return nil, err + } + return &initDockerSandboxesProfile{ + HostPlatform: record.Platform, + Template: record.Template, + TemplateDigest: record.TemplateDigest, + PolicyFingerprint: record.PolicyFingerprint, + RootDisk: formatPromotionBytes(record.RootDiskBytes), + DockerDisk: formatPromotionBytes(record.DockerDiskBytes), + MinHostFreeSpace: formatPromotionBytes(record.MinHostFreeSpaceBytes), + }, nil +} + +func defaultDockerSandboxesConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile, guestPlatform, runnerArchitectureLabel string) string { + return fmt.Sprintf(`github: + appId: %d + organization: %s + privateKeyPath: %s + apiBaseUrl: https://api.github.com + webBaseUrl: https://github.com + +image: + hostTrustMode: %s + hostTrustScopes: [%s] + +pool: + instances: 1 + namePrefix: %s + replacementRetryInitialSeconds: 15 + replacementRetryMaxSeconds: 1800 + replacementRetryMultiplier: 2 + replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + +logging: + directory: work/logs + managerSinks: [console] + managerConsoleFormat: text + managerConsoleTextFormat: "{time} [{level}] {message}" + managerFileFormat: json + transcriptSinks: [file] + transcriptConsoleFormat: text + maxFileSizeMiB: 100 + maxBackups: 3 + compressBackups: true + retentionEnabled: true + retentionMaxTotalMiB: 1024 + managerMaxAgeDays: 14 + instanceMaxAgeDays: 14 + buildMaxAgeDays: 14 + errorMaxAgeDays: 30 + benchmarkMaxAgeDays: 90 + retentionIntervalMinutes: 60 + +runner: + group: %s + labels: [self-hosted, linux, %s, epar-docker-sandboxes] + includeHostLabel: true + ephemeral: true + +security: + runnerGroup: + enforcement: %s + requireExplicitGroup: %t + requireNonDefaultGroup: %t + requiredRepositoryAccess: %s + requirePublicRepositoriesDisabled: %t + +provider: + type: docker-sandboxes + platform: %s + +dockerSandboxes: + template: %s + templateDigest: %s + policyGeneration: %s + networkBaseline: open + stagingRoot: .local/docker-sandboxes-staging + cpus: 4 + memory: 8GiB + rootDisk: %s + dockerDisk: %s + maxConcurrentCreates: 2 + minHostFreeSpace: %s + +timeouts: + bootSeconds: 180 + githubOnlineSeconds: 180 + commandSeconds: 900 +`, appID, organization, privateKeyPath, hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerArchitectureLabel, runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled, guestPlatform, profile.Template, profile.TemplateDigest, profile.PolicyFingerprint, profile.RootDisk, profile.DockerDisk, profile.MinHostFreeSpace) +} + +func formatPromotionBytes(value uint64) string { + const gib = uint64(1 << 30) + if value != 0 && value%gib == 0 { + return strconv.FormatUint(value/gib, 10) + "GiB" + } + return strconv.FormatUint(value, 10) + "B" +} + func defaultWSLConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection) string { return fmt.Sprintf(`github: appId: %d @@ -935,6 +1874,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] @@ -1013,6 +1961,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index 5556fff..f0af647 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "errors" @@ -10,16 +11,77 @@ import ( "slices" "strings" "testing" + "time" "unicode/utf16" "github.com/solutionforest/ephemeral-action-runner/internal/config" gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes" + sandboxpromotion "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/promotion" + providerregistry "github.com/solutionforest/ephemeral-action-runner/internal/provider/registry" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" ) +func TestWizardCoversEveryRegisteredProvider(t *testing.T) { + options := make([]initProviderOption, 0, len(providerregistry.Descriptors())) + for _, descriptor := range providerregistry.Descriptors() { + options = append(options, initProviderOption{ + Number: descriptor.WizardNumber, + Type: descriptor.Type, + Label: descriptor.WizardLabel, + Aliases: descriptor.WizardAliases, + }) + } + if err := validateWizardProviderOptions(options); err != nil { + t.Fatal(err) + } +} + +func TestProviderWizardPutsAvailableDefaultFirst(t *testing.T) { + options := []initProviderOption{ + {Number: "1", Type: "docker-container", Available: true}, + {Number: "2", Type: "docker-sandboxes", Available: true}, + {Number: "3", Type: "wsl", Available: true}, + } + + if got := prioritizeDefaultProviderOption(options, "docker-sandboxes"); got != "1" { + t.Fatalf("default number = %q, want 1", got) + } + if options[0].Type != "docker-sandboxes" || !options[0].Default { + t.Fatalf("first option = %+v, want Docker Sandboxes default", options[0]) + } + if options[1].Type != "docker-container" || options[1].Number != "2" || options[1].Default { + t.Fatalf("second option = %+v, want non-default Docker Container", options[1]) + } + if options[2].Type != "wsl" || options[2].Number != "3" { + t.Fatalf("third option = %+v, want WSL", options[2]) + } +} + +func TestProviderWizardDoesNotPromoteUnavailableDefault(t *testing.T) { + options := []initProviderOption{ + {Number: "1", Type: "docker-container", Available: true}, + {Number: "2", Type: "docker-sandboxes", Available: false}, + } + + if got := prioritizeDefaultProviderOption(options, "docker-sandboxes"); got != "" { + t.Fatalf("default number = %q, want none", got) + } + if options[0].Type != "docker-container" || options[0].Number != "1" || options[0].Default { + t.Fatalf("first option = %+v, want unchanged non-default Docker Container", options[0]) + } +} + func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) stubNoWSL2(t) + oldPreflight := initDockerSandboxesPreflight + initDockerSandboxesPreflight = func(context.Context, sandboxpromotion.Record, string) sandboxpromotion.PreflightResult { + t.Fatal("empty promotion table must not run Docker Sandboxes preflight") + return sandboxpromotion.PreflightResult{} + } + t.Cleanup(func() { initDockerSandboxesPreflight = oldPreflight }) dir := t.TempDir() path := filepath.Join(dir, ".local", "config.yml") @@ -97,6 +159,9 @@ func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { if !strings.Contains(string(configText), "replacementRetryInitialSeconds: 15\n replacementRetryMaxSeconds: 1800\n replacementRetryMultiplier: 2\n replacementRetryJitterPercent: 20\n") { t.Fatalf("generated config did not include replacement retry settings:\n%s", configText) } + if !strings.Contains(string(configText), "storage:\n minimumFree: 20GiB\n gracePeriod: 168h\n keepPrevious: 0\n automaticHousekeeping: conservative\n buildCacheLimit: 64GiB\n goCacheLimit: 10GiB\n") { + t.Fatalf("generated config did not include bounded storage settings:\n%s", configText) + } if got := strings.Join(cfg.Runner.Labels, ","); !strings.Contains(got, "epar-docker-container-catthehacker-ubuntu") { t.Fatalf("runner labels = %q", got) } @@ -106,6 +171,11 @@ func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { if !strings.Contains(out.String(), "Pool name prefix (press Enter to use build-box-01-a4f9c2):") { t.Fatalf("init output did not explain default prefix acceptance:\n%s", out.String()) } + for _, want := range []string{"1. Docker Container", "private daemon (default)", "2. Docker Sandboxes — recommended when ready"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("init output did not preserve explicit Docker Container default and capability-driven Docker Sandboxes labeling %q:\n%s", want, out.String()) + } + } for _, want := range []string{"Repository access meanings:", "Selected repositories: Only repositories explicitly added", "Assessment: RECOMMENDED"} { if !strings.Contains(out.String(), want) { t.Fatalf("init output did not explain runner-group policy term %q:\n%s", want, out.String()) @@ -401,7 +471,7 @@ func TestInitCanDisableHostTrustOverlay(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n"), Out: &bytes.Buffer{}, }); err != nil { t.Fatal(err) @@ -451,7 +521,7 @@ func TestInitAcceptsCustomPoolNamePrefix(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\ncustom-prefix\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\ncustom-prefix\n\n"), Out: &bytes.Buffer{}, }); err != nil { t.Fatal(err) @@ -477,7 +547,7 @@ func TestInitRepromptsInvalidPoolNamePrefix(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n-bad\nfixed-prefix\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n-bad\nfixed-prefix\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -562,6 +632,20 @@ func TestGeneratedPoolNamePrefixFallsBackWhenHostnameSanitizesEmpty(t *testing.T } func TestInitRefusesExistingConfig(t *testing.T) { + oldPlatform := initSandboxPromotionPlatform + oldLookup := initSandboxPromotionLookup + initSandboxPromotionPlatform = func() sandboxpromotion.Platform { + t.Fatal("existing config must be rejected before promotion lookup") + return "" + } + initSandboxPromotionLookup = func(sandboxpromotion.Platform) (sandboxpromotion.Record, bool) { + t.Fatal("existing config must be rejected before promotion lookup") + return sandboxpromotion.Record{}, false + } + t.Cleanup(func() { + initSandboxPromotionPlatform = oldPlatform + initSandboxPromotionLookup = oldLookup + }) dir := t.TempDir() path := filepath.Join(dir, ".local", "config.yml") if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { @@ -594,14 +678,18 @@ func TestInitChecksDockerByDefault(t *testing.T) { return errors.New("docker unavailable") } + var out bytes.Buffer err := runInitWithOptions(initOptions{ ProjectRoot: t.TempDir(), ConfigPath: filepath.Join(t.TempDir(), ".local", "config.yml"), - In: strings.NewReader("123\norg\nkey.pem\n1\n"), - Out: &bytes.Buffer{}, + In: strings.NewReader("123\norg\nkey.pem\n1\n1"), + Out: &out, }) - if err == nil || !strings.Contains(err.Error(), "Docker is required") { - t.Fatalf("error = %v, want Docker requirement", err) + if err == nil || !strings.Contains(err.Error(), `runner provider "1" is unavailable`) { + t.Fatalf("error = %v, want unavailable Docker Container selection", err) + } + if !strings.Contains(out.String(), "Docker CLI or daemon check failed: docker unavailable") { + t.Fatalf("output did not report Docker prerequisite status:\n%s", out.String()) } } @@ -617,7 +705,7 @@ func TestInitOffersWSL2ConfigWhenAvailable(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n2\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n3\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -642,7 +730,7 @@ func TestInitOffersWSL2ConfigWhenAvailable(t *testing.T) { if string(got) != wantText { t.Fatalf("WSL config did not match configs/wsl.example.yml:\nwant:\n%s\ngot:\n%s", wantText, got) } - if !strings.Contains(out.String(), "2. WSL2") { + if !strings.Contains(out.String(), "2. Docker Sandboxes — recommended when ready") || !strings.Contains(out.String(), "3. WSL2") { t.Fatalf("init output did not offer WSL2:\n%s", out.String()) } providerPrompt := strings.Index(out.String(), "Runner provider:") @@ -676,18 +764,779 @@ func TestInitWSL2ChoiceDefaultsToDockerContainerAndRepromptsInvalidValues(t *tes if !strings.Contains(string(configBytes), "type: docker-container") { t.Fatalf("config did not use the default Docker Container provider:\n%s", configBytes) } - if !strings.Contains(out.String(), "Runner provider must be 1 (Docker Container — private daemon) or 2 (WSL2).") { + if !strings.Contains(out.String(), "Choose an available provider number or name shown above.") { t.Fatalf("init output did not explain invalid provider input:\n%s", out.String()) } } +func TestInitDockerSandboxesGeneratesConfigFromDiscoveredTemplate(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + policyFingerprint := "sha256:" + strings.Repeat("b", 64) + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + Templates: []initDockerSandboxesTemplate{ + {Reference: "docker.io/library/epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64", Digest: "sha256:" + strings.Repeat("a", 64), CacheID: strings.Repeat("a", 12), Platform: "linux/amd64", Size: 8 << 30, Label: "Catthehacker Ubuntu Full (recommended)", SourceChannel: "ghcr.io/catthehacker/ubuntu:full-latest"}, + {Reference: "docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64", Digest: "sha256:" + strings.Repeat("c", 64), CacheID: strings.Repeat("c", 12), Platform: "linux/amd64", Size: 4 << 30, Label: "Catthehacker Ubuntu Act 22.04 (current lean profile)", SourceChannel: "ghcr.io/catthehacker/ubuntu:act-22.04"}, + }, + PolicyFingerprint: policyFingerprint, + }, nil) + + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n2\nnot-a-size\n30GiB\n\nn\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Provider.Type, "docker-sandboxes"; got != want { + t.Fatalf("provider.type = %q, want %q", got, want) + } + if got, want := cfg.Provider.Platform, "linux/amd64"; got != want { + t.Fatalf("provider.platform = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.Template, "docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64"; got != want { + t.Fatalf("dockerSandboxes.template = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.TemplateDigest, "sha256:"+strings.Repeat("c", 64); got != want { + t.Fatalf("dockerSandboxes.templateDigest = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.PolicyGeneration, policyFingerprint; got != want { + t.Fatalf("dockerSandboxes.policyGeneration = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.NetworkBaseline, config.DockerSandboxesNetworkBaselineOpen; got != want { + t.Fatalf("dockerSandboxes.networkBaseline = %q, want %q", got, want) + } + for key, values := range map[string]struct{ got, want string }{ + "rootDisk": {cfg.DockerSandboxes.RootDisk, "30GiB"}, + "dockerDisk": {cfg.DockerSandboxes.DockerDisk, "100GiB"}, + "minHostFreeSpace": {cfg.DockerSandboxes.MinHostFreeSpace, "50GiB"}, + } { + if values.got != values.want { + t.Fatalf("dockerSandboxes.%s = %q, want %q", key, values.got, values.want) + } + } + for _, want := range []string{"Docker Sandboxes — recommended", "provides EPAR's strongest current host boundary", "current host-global Balanced policy", "default to open public HTTP/HTTPS egress with owned deny-wins host-alias guardrails", "Verified local Docker Sandboxes source profiles:", "Catthehacker Ubuntu Full (recommended) — ghcr.io/catthehacker/ubuntu:full-latest", "Catthehacker Ubuntu Act 22.04 (current lean profile) — ghcr.io/catthehacker/ubuntu:act-22.04", "Automatic Docker Sandboxes source refresh is not implemented yet", "Resolved exact EPAR template tag: docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64", "Resolved full local template digest: sha256:" + strings.Repeat("c", 64), "8GiB on host", "default selection; capacity unmeasured", "Shared host template cache: 4GiB (already present; do not add this byte count arithmetically to each sandbox root disk).", "Measured guest root peak: unavailable", "Sandbox root filesystem total capacity is invalid", "Automatically selected sandbox Docker disk: 100GiB.", "Automatically selected minimum host free space: 50GiB.", "Verified policy fingerprint: " + policyFingerprint} { + if !strings.Contains(out.String(), want) { + t.Fatalf("init output omitted %q:\n%s", want, out.String()) + } + } +} + +func TestDockerSandboxesPrerequisitesRejectInsufficientMinimumCapacity(t *testing.T) { + stubNoWSL2(t) + oldReadiness := initDockerSandboxesReadiness + oldCapacityCheck := initDockerSandboxesCapacityCheck + initDockerSandboxesReadiness = func(context.Context) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.HostReadiness{ChecksPassed: 8, ChecksWarned: 1}, nil + } + initDockerSandboxesCapacityCheck = func(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { + if rootDisk != 20<<30 || dockerDisk != 100<<30 || minHostFreeSpace != 50<<30 { + t.Fatalf("minimum capacity check = root %d, Docker %d, reserve %d", rootDisk, dockerDisk, minHostFreeSpace) + } + return initDockerSandboxesCapacityResult{ + StorageRoot: `C:\Users\runner\AppData\Local\DockerSandboxes`, + AvailableBytes: 140 << 30, + TotalBytes: 1 << 40, + Reservation: 120 << 30, + HostWatermark: 50 << 30, + RequiredBytes: 170 << 30, + DeficitBytes: 30 << 30, + CapacityStatus: storage.CapacityInsufficient, + }, nil + } + t.Cleanup(func() { + initDockerSandboxesReadiness = oldReadiness + initDockerSandboxesCapacityCheck = oldCapacityCheck + }) + + got := detectInitProviderPrerequisites(context.Background(), sandboxpromotion.WindowsAMD64, true) + if got.DockerSandboxesAvailable { + t.Fatal("Docker Sandboxes was available despite insufficient minimum capacity") + } + for _, want := range []string{`C:\Users\runner\AppData\Local\DockerSandboxes`, "140GiB available", "require 170GiB", "shortfall 30GiB"} { + if !strings.Contains(got.DockerSandboxesStatus, want) { + t.Fatalf("Docker Sandboxes status omitted %q: %s", want, got.DockerSandboxesStatus) + } + } +} + +func TestDockerSandboxesProfileRejectsSelectedCapacityWithoutWritingConfig(t *testing.T) { + policyFingerprint := "sha256:" + strings.Repeat("b", 64) + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + Templates: []initDockerSandboxesTemplate{{ + Reference: "docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04:test-amd64", + Digest: "sha256:" + strings.Repeat("c", 64), + CacheID: strings.Repeat("c", 12), + Platform: "linux/amd64", + Size: 4 << 30, + Label: "Catthehacker Ubuntu Act 22.04", + SourceChannel: "ghcr.io/catthehacker/ubuntu:act-22.04", + }}, + PolicyFingerprint: policyFingerprint, + }, nil) + oldCapacityCheck := initDockerSandboxesCapacityCheck + initDockerSandboxesCapacityCheck = func(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { + if rootDisk != 30<<30 || dockerDisk != 100<<30 || minHostFreeSpace != 50<<30 { + t.Fatalf("selected capacity check = root %d, Docker %d, reserve %d", rootDisk, dockerDisk, minHostFreeSpace) + } + return initDockerSandboxesCapacityResult{ + StorageRoot: `C:\Users\runner\AppData\Local\DockerSandboxes`, + AvailableBytes: 140 << 30, + TotalBytes: 1 << 40, + Reservation: 130 << 30, + HostWatermark: 50 << 30, + RequiredBytes: 180 << 30, + DeficitBytes: 40 << 30, + CapacityStatus: storage.CapacityInsufficient, + }, nil + } + t.Cleanup(func() { initDockerSandboxesCapacityCheck = oldCapacityCheck }) + + var out bytes.Buffer + profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, &out, bufio.NewReader(strings.NewReader("1\n30GiB\n"))) + if err == nil || !strings.Contains(err.Error(), "no config was written") { + t.Fatalf("capacity rejection error = %v", err) + } + if profile != nil || accepted { + t.Fatalf("capacity rejection returned profile=%+v accepted=%t", profile, accepted) + } + for _, want := range []string{"Docker Sandboxes capacity admission failed:", "Available: 140GiB", "Required before creation: 180GiB", "Shortfall: 40GiB", "storage prune --provider docker-sandboxes"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("capacity rejection output omitted %q:\n%s", want, out.String()) + } + } +} + +func TestInitCapabilityReadyDockerSandboxesIsDefaultWithoutPreviewAcknowledgement(t *testing.T) { + for _, test := range []struct { + name string + hostPlatform sandboxpromotion.Platform + guestPlatform string + }{ + {name: "windows amd64", hostPlatform: sandboxpromotion.WindowsAMD64, guestPlatform: "linux/amd64"}, + {name: "linux amd64", hostPlatform: sandboxpromotion.LinuxAMD64, guestPlatform: "linux/amd64"}, + {name: "darwin arm64", hostPlatform: sandboxpromotion.DarwinARM64, guestPlatform: "linux/arm64"}, + {name: "future os amd64", hostPlatform: sandboxpromotion.Platform("futureos/amd64"), guestPlatform: "linux/amd64"}, + } { + t.Run(test.name, func(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + policyFingerprint := "sha256:" + strings.Repeat("b", 64) + stubInitDockerSandboxesSetup(t, test.hostPlatform, initDockerSandboxesDiscovery{ + Templates: []initDockerSandboxesTemplate{{ + Reference: "docker.io/library/epar-docker-sandboxes-catthehacker-full:capability-default", + Digest: "sha256:" + strings.Repeat("a", 64), + CacheID: strings.Repeat("a", 12), + Platform: test.guestPlatform, + Size: 18_730_706_190, + Label: "Catthehacker Ubuntu Full (recommended)", + SourceChannel: "ghcr.io/catthehacker/ubuntu:full-latest", + }}, + PolicyFingerprint: policyFingerprint, + }, nil) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n\n\nn\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Provider.Type, "docker-sandboxes"; got != want { + t.Fatalf("provider.type = %q, want %q", got, want) + } + if got, want := cfg.Provider.Platform, test.guestPlatform; got != want { + t.Fatalf("provider.platform = %q, want %q", got, want) + } + for _, want := range []string{ + "1. Docker Sandboxes — recommended (default)", + "2. Docker Container — private daemon", + "Docker Sandboxes — recommended (default)", + "Runner provider (press Enter to use 1):", + "Docker Sandboxes setup:", + "recommended default because Docker and sbx diagnostics passed on this machine", + "Default resource reservations are recommended starting values", + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("capability-default output omitted %q:\n%s", want, out.String()) + } + } + for _, forbidden := range []string{"Continue with explicit preview setup?", "Docker Sandboxes preview:"} { + if strings.Contains(out.String(), forbidden) { + t.Fatalf("capability-default output retained preview interaction %q:\n%s", forbidden, out.String()) + } + } + }) + } +} + +func TestReadDockerSandboxesActiveProfilesUsesOnlyCurrentLockedTemplates(t *testing.T) { + projectRoot := t.TempDir() + lockDirectory := filepath.Join(projectRoot, "templates", "docker-sandboxes") + if err := os.MkdirAll(lockDirectory, 0755); err != nil { + t.Fatal(err) + } + const lock = `{ + "schemaVersion": 2, + "profiles": { + "full": { + "observedTagReference": "ghcr.io/catthehacker/ubuntu:full-latest", + "platforms": {"linux/amd64": {"templateTag": "epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64"}} + }, + "act-22.04": { + "observedTagReference": "ghcr.io/catthehacker/ubuntu:act-22.04", + "platforms": {"linux/amd64": {"templateTag": "epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64"}} + } + }, + "supersededRecords": { + "linux/amd64": { + "full": {"templateTag": "epar-docker-sandboxes-catthehacker-full:20260723-r1-amd64"}, + "act-22.04": {"templateTag": "epar-docker-sandboxes-catthehacker-act-22.04:20260723-r3-amd64"} + } + } +}` + if err := os.WriteFile(filepath.Join(lockDirectory, "sources.lock.json"), []byte(lock), 0644); err != nil { + t.Fatal(err) + } + + profiles, err := readDockerSandboxesActiveProfiles(projectRoot, "linux/amd64") + if err != nil { + t.Fatal(err) + } + if got, want := len(profiles), 2; got != want { + t.Fatalf("active profile count = %d, want %d", got, want) + } + for index, want := range []struct { + name, channel, reference, label string + }{ + {"full", "ghcr.io/catthehacker/ubuntu:full-latest", "docker.io/library/epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64", "Catthehacker Ubuntu Full (recommended)"}, + {"act-22.04", "ghcr.io/catthehacker/ubuntu:act-22.04", "docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64", "Catthehacker Ubuntu Act 22.04 (current lean profile)"}, + } { + got := profiles[index] + if got.Name != want.name || got.ObservedTag != want.channel || got.TemplateReference != want.reference || got.DisplayLabel != want.label { + t.Fatalf("active profile %d = %#v, want name=%q channel=%q reference=%q label=%q", index, got, want.name, want.channel, want.reference, want.label) + } + if strings.Contains(got.TemplateReference, "-r1-") || strings.Contains(got.TemplateReference, "-r3-") { + t.Fatalf("historical template leaked into active profiles: %#v", got) + } + } +} + +func TestReadDockerSandboxesActiveProfilesRejectsUnexpectedSourceChannel(t *testing.T) { + projectRoot := t.TempDir() + lockDirectory := filepath.Join(projectRoot, "templates", "docker-sandboxes") + if err := os.MkdirAll(lockDirectory, 0755); err != nil { + t.Fatal(err) + } + const lock = `{"schemaVersion":2,"profiles":{"full":{"observedTagReference":"ghcr.io/catthehacker/ubuntu:full-latest\nnot-a-channel","platforms":{"linux/amd64":{"templateTag":"epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64"}}},"act-22.04":{"observedTagReference":"ghcr.io/catthehacker/ubuntu:act-22.04","platforms":{"linux/amd64":{"templateTag":"epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64"}}}}}` + if err := os.WriteFile(filepath.Join(lockDirectory, "sources.lock.json"), []byte(lock), 0644); err != nil { + t.Fatal(err) + } + + if _, err := readDockerSandboxesActiveProfiles(projectRoot, "linux/amd64"); err == nil || !strings.Contains(err.Error(), "unexpected observedTagReference") { + t.Fatalf("unexpected source channel was accepted: %v", err) + } +} + +func TestInitDockerSandboxesDerivesRootDiskFromExactMeasurement(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + policyFingerprint := "sha256:" + strings.Repeat("b", 64) + template := initDockerSandboxesTemplate{ + Reference: "docker.io/library/epar-docker-sandboxes-catthehacker-full:measured", + Digest: "sha256:" + strings.Repeat("a", 64), + CacheID: strings.Repeat("a", 12), + Platform: "linux/amd64", + Size: 18_730_706_190, + } + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + Templates: []initDockerSandboxesTemplate{ + {Reference: "docker.io/library/epar-docker-sandboxes-catthehacker-full:unmeasured-newer", Digest: "sha256:" + strings.Repeat("f", 64), CacheID: strings.Repeat("f", 12), Platform: "linux/amd64", Size: 19 << 30}, + template, + }, + PolicyFingerprint: policyFingerprint, + }, nil) + oldMeasurement := initDockerSandboxesRootMeasurementFor + initDockerSandboxesRootMeasurementFor = func(host sandboxpromotion.Platform, actual initDockerSandboxesTemplate) (initDockerSandboxesRootMeasurement, bool) { + if host != sandboxpromotion.WindowsAMD64 { + t.Fatalf("measurement host = %q, want %q", host, sandboxpromotion.WindowsAMD64) + } + if actual.Digest != template.Digest { + return initDockerSandboxesRootMeasurement{}, false + } + return initDockerSandboxesRootMeasurement{ + PeakBytes: 324_780_032, + Evidence: "test workload", + }, true + } + t.Cleanup(func() { + initDockerSandboxesRootMeasurementFor = oldMeasurement + }) + + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n2\n99GiB\n100GiB\n\nn\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.DockerSandboxes.RootDisk, "30GiB"; got != want { + t.Fatalf("dockerSandboxes.rootDisk = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.Template, template.Reference; got != want { + t.Fatalf("dockerSandboxes.template = %q, want measured default %q", got, want) + } + for _, want := range []string{ + "17.44GiB on host", + "default selection; capacity unmeasured", + "Shared host template cache: 17.44GiB (already present; do not add this byte count arithmetically to each sandbox root disk).", + "Measured guest root peak: 309.73MiB (test workload).", + "Automatically selected sandbox root filesystem total capacity: 30GiB.", + "Sandbox Docker disk must be at least 100GiB.", + "Automatically selected minimum host free space: 50GiB.", + "EPAR rechecks current Docker Sandboxes storage free space, existing and uncertain reservations", + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("init output omitted %q:\n%s", want, out.String()) + } + } +} + +func TestFormatInitByteCountUsesReadableBinaryUnits(t *testing.T) { + for _, test := range []struct { + value int64 + want string + }{ + {value: 18_730_706_190, want: "17.44GiB"}, + {value: 324_780_032, want: "309.73MiB"}, + {value: 20 << 30, want: "20GiB"}, + {value: 512, want: "512B"}, + } { + if got := formatInitByteCount(test.value); got != test.want { + t.Fatalf("formatInitByteCount(%d) = %q, want %q", test.value, got, test.want) + } + } +} + +func TestDockerSandboxesRootMeasurementIsBoundToExactTemplateIdentity(t *testing.T) { + const digest = "sha256:00303a3e249a1baf8b0585d20273af408c27182dcfc827a98aa25ffe66b1f67f" + measurement, ok := dockerSandboxesRootMeasurement(sandboxpromotion.WindowsAMD64, initDockerSandboxesTemplate{Digest: digest, Platform: "linux/amd64"}) + if !ok { + t.Fatal("exact measured template identity did not return capacity evidence") + } + if got, want := measurement.PeakBytes, int64(324_780_032); got != want { + t.Fatalf("measurement peak = %d, want %d", got, want) + } + for _, test := range []struct { + host sandboxpromotion.Platform + template initDockerSandboxesTemplate + }{ + {host: sandboxpromotion.WindowsAMD64, template: initDockerSandboxesTemplate{Digest: "sha256:" + strings.Repeat("f", 64), Platform: "linux/amd64"}}, + {host: sandboxpromotion.WindowsAMD64, template: initDockerSandboxesTemplate{Digest: digest, Platform: "linux/arm64"}}, + {host: sandboxpromotion.LinuxAMD64, template: initDockerSandboxesTemplate{Digest: digest, Platform: "linux/amd64"}}, + } { + if _, ok := dockerSandboxesRootMeasurement(test.host, test.template); ok { + t.Fatalf("unexpected measurement for host %q digest %q platform %q", test.host, test.template.Digest, test.template.Platform) + } + } +} + +func TestInitDockerSandboxesDiscoveryRetryKeepsProviderSelectionAndWritesVerifiedConfig(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + policyFingerprint := "sha256:" + strings.Repeat("b", 64) + discovery := initDockerSandboxesDiscovery{ + Templates: []initDockerSandboxesTemplate{{ + Reference: "docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64", + Digest: "sha256:" + strings.Repeat("c", 64), + CacheID: strings.Repeat("c", 12), + Platform: "linux/amd64", + Size: 4 << 30, + Label: "Catthehacker Ubuntu Act 22.04 (current lean profile)", + SourceChannel: "ghcr.io/catthehacker/ubuntu:act-22.04", + }}, + PolicyFingerprint: policyFingerprint, + } + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, discovery, nil) + originalDiscovery := initDiscoverDockerSandboxes + discoveryCalls := 0 + initDiscoverDockerSandboxes = func(ctx context.Context, projectRoot, guestPlatform string) (initDockerSandboxesDiscovery, error) { + discoveryCalls++ + if discoveryCalls == 1 { + return initDockerSandboxesDiscovery{}, errors.New("template cache unavailable") + } + return originalDiscovery(ctx, projectRoot, guestPlatform) + } + + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\ny\n\n\n\nn\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Provider.Type, "docker-sandboxes"; got != want { + t.Fatalf("provider.type = %q, want %q after retained-selection retry", got, want) + } + if got := strings.Count(out.String(), "Runner provider:"); got != 1 { + t.Fatalf("Runner provider prompts = %d, want 1 after Docker Sandboxes discovery retry:\n%s", got, out.String()) + } + if got := strings.Count(out.String(), "Continue with explicit preview setup?"); got != 0 { + t.Fatalf("preview acknowledgements = %d, want 0 after capability-driven default:\n%s", got, out.String()) + } + if got := strings.Count(out.String(), "Retry Docker Sandboxes setup checks?"); got != 1 { + t.Fatalf("setup retry prompts = %d, want 1:\n%s", got, out.String()) + } + if discoveryCalls != 2 { + t.Fatalf("Docker Sandboxes discovery calls = %d, want 2", discoveryCalls) + } + if !strings.Contains(out.String(), "Docker Sandboxes setup preparation failed: template cache unavailable") || !strings.Contains(out.String(), "build and load a Candidate A template") { + t.Fatalf("init output did not explain Docker Sandboxes discovery recovery:\n%s", out.String()) + } +} + +func TestInitDockerSandboxesDiscoveryRetryDeclinedExitsWithoutRepeatingProviderOrWritingConfig(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{}, errors.New("template cache unavailable")) + + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\nn\n"), + Out: &out, + }) + if err == nil || !strings.Contains(err.Error(), "no config was written") { + t.Fatalf("retry-declined error = %v", err) + } + if got := strings.Count(out.String(), "Runner provider:"); got != 1 { + t.Fatalf("Runner provider prompts = %d, want 1:\n%s", got, out.String()) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("retry-declined setup wrote a config: %v", statErr) + } +} + +func TestInitDockerSandboxesKillSwitchDoesNotInvokeDiscovery(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + oldPlatform := initSandboxPromotionPlatform + oldLookup := initSandboxPromotionLookup + oldDiscovery := initDiscoverDockerSandboxes + initSandboxPromotionPlatform = func() sandboxpromotion.Platform { return sandboxpromotion.WindowsAMD64 } + initSandboxPromotionLookup = func(sandboxpromotion.Platform) (sandboxpromotion.Record, bool) { + return sandboxpromotion.Record{}, false + } + initDiscoverDockerSandboxes = func(context.Context, string, string) (initDockerSandboxesDiscovery, error) { + t.Fatal("kill switch must prevent Docker Sandboxes discovery") + return initDockerSandboxesDiscovery{}, nil + } + t.Cleanup(func() { + initSandboxPromotionPlatform = oldPlatform + initSandboxPromotionLookup = oldLookup + initDiscoverDockerSandboxes = oldDiscovery + }) + t.Setenv(sandboxpromotion.DisableEnvironment, "1") + + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n2"), + Out: &out, + }) + if err == nil || !strings.Contains(err.Error(), "EPAR_DISABLE_DOCKER_SANDBOXES") { + t.Fatalf("kill-switch setup error = %v", err) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("kill-switch setup wrote a config: %v", statErr) + } + if !strings.Contains(out.String(), "EPAR_DISABLE_DOCKER_SANDBOXES=1 disables admission") { + t.Fatalf("init output did not explain preview kill switch:\n%s", out.String()) + } +} + +func TestInitPromotedDockerSandboxesDefaultsOnlyAfterPassingPreflight(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + record := validInitPromotionRecord() + stubInitSandboxPromotion(t, record, sandboxpromotion.PreflightResult{}) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Provider.Type, "docker-sandboxes"; got != want { + t.Fatalf("provider.type = %q, want %q", got, want) + } + if got, want := cfg.Provider.Platform, "linux/amd64"; got != want { + t.Fatalf("provider.platform = %q, want %q", got, want) + } + if !slices.Contains(cfg.Runner.Labels, "X64") { + t.Fatalf("runner.labels = %q, want the mapped X64 guest architecture", cfg.Runner.Labels) + } + if got, want := cfg.DockerSandboxes.Template, record.Template; got != want { + t.Fatalf("dockerSandboxes.template = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.TemplateDigest, record.TemplateDigest; got != want { + t.Fatalf("dockerSandboxes.templateDigest = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.PolicyGeneration, record.PolicyFingerprint; got != want { + t.Fatalf("dockerSandboxes.policyGeneration = %q, want %q", got, want) + } + for key, values := range map[string]struct { + got string + want string + }{ + "rootDisk": {cfg.DockerSandboxes.RootDisk, "120GiB"}, + "dockerDisk": {cfg.DockerSandboxes.DockerDisk, "100GiB"}, + "minHostFreeSpace": {cfg.DockerSandboxes.MinHostFreeSpace, "50GiB"}, + } { + if values.got != values.want { + t.Fatalf("dockerSandboxes.%s = %q, want %q", key, values.got, values.want) + } + } + configText, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"type: docker-sandboxes", "dockerSandboxes:", "epar-docker-sandboxes", "policyGeneration: " + record.PolicyFingerprint} { + if !strings.Contains(string(configText), required) { + t.Fatalf("generated Docker Sandboxes config omitted %q:\n%s", required, configText) + } + } + for _, forbidden := range []string{"dockerSandbox:", "\n type: docker-sandbox\n", "epar-docker-sandbox]"} { + if strings.Contains(string(configText), forbidden) { + t.Fatalf("generated config used singular Docker Sandbox key %q:\n%s", forbidden, configText) + } + } + if !strings.Contains(out.String(), "PASS: the exact promoted platform") || !strings.Contains(out.String(), "Docker Sandboxes (independently certified for this exact platform) (default)") { + t.Fatalf("init output did not explain the promoted default:\n%s", out.String()) + } +} + +func TestPromotedDockerSandboxesPlatformUsesSharedHostGuestMapping(t *testing.T) { + tests := []struct { + host sandboxpromotion.Platform + wantGuest string + wantRunnerLabel string + wantUnsupported bool + }{ + {host: sandboxpromotion.WindowsAMD64, wantGuest: "linux/amd64", wantRunnerLabel: "X64"}, + {host: sandboxpromotion.LinuxAMD64, wantGuest: "linux/amd64", wantRunnerLabel: "X64"}, + {host: sandboxpromotion.DarwinARM64, wantGuest: "linux/arm64", wantRunnerLabel: "ARM64"}, + {host: sandboxpromotion.Platform("linux/arm64"), wantGuest: "linux/arm64", wantRunnerLabel: "ARM64"}, + {host: sandboxpromotion.Platform("windows/arm64"), wantGuest: "linux/arm64", wantRunnerLabel: "ARM64"}, + {host: sandboxpromotion.Platform("darwin/amd64"), wantGuest: "linux/amd64", wantRunnerLabel: "X64"}, + {host: sandboxpromotion.Platform("futureos/amd64"), wantGuest: "linux/amd64", wantRunnerLabel: "X64"}, + {host: sandboxpromotion.Platform("futureos/386"), wantUnsupported: true}, + } + for _, test := range tests { + t.Run(string(test.host), func(t *testing.T) { + guest, runnerLabel, err := promotedDockerSandboxesPlatform(sandboxpromotion.Record{Platform: test.host}) + if test.wantUnsupported { + if err == nil { + t.Fatalf("promotedDockerSandboxesPlatform(%q) unexpectedly succeeded", test.host) + } + return + } + if err != nil { + t.Fatal(err) + } + if guest != test.wantGuest || runnerLabel != test.wantRunnerLabel { + t.Fatalf("promotedDockerSandboxesPlatform(%q) = (%q, %q), want (%q, %q)", test.host, guest, runnerLabel, test.wantGuest, test.wantRunnerLabel) + } + }) + } +} + +func TestInitPromotionGateFailuresRequireExplicitProviderAndExplainAction(t *testing.T) { + record := validInitPromotionRecord() + tests := []struct { + name string + gate string + detail string + disable bool + }{ + {name: "kill switch", gate: "operator kill switch", detail: sandboxpromotion.DisableEnvironment, disable: true}, + {name: "native controller", gate: "native controller", detail: "native controller is unavailable"}, + {name: "authentication", gate: "authentication", detail: "authentication is not valid"}, + {name: "daemon", gate: "daemon health", detail: "daemon is not running"}, + {name: "version", gate: "sbx version", detail: "version is not exactly v0.35.0"}, + {name: "virtualization", gate: "virtualization", detail: "virtualization is unavailable"}, + {name: "template", gate: "promoted template", detail: "template identity differs"}, + {name: "policy", gate: "promoted policy", detail: "policy fingerprint differs"}, + {name: "resource", gate: "resource availability", detail: "insufficient free space"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + preflight := sandboxpromotion.PreflightResult{Failures: []sandboxpromotion.Failure{{ + Gate: test.gate, + Detail: test.detail, + Resolution: "take the exact corrective action and rerun setup", + }}} + stubInitSandboxPromotion(t, record, preflight) + if test.disable { + t.Setenv(sandboxpromotion.DisableEnvironment, "1") + initDockerSandboxesPreflight = func(context.Context, sandboxpromotion.Record, string) sandboxpromotion.PreflightResult { + t.Fatal("kill switch must stop automatic-default preflight before admission checks") + return sandboxpromotion.PreflightResult{} + } + } else { + t.Setenv(sandboxpromotion.DisableEnvironment, "") + } + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\ndocker-sandboxes\n1\n\nn\n"), + Out: &out, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Provider.Type != "docker-container" { + t.Fatalf("provider.type = %q, want explicit Docker Container selection", cfg.Provider.Type) + } + if !strings.Contains(out.String(), "FAIL ["+test.gate+"]") || !strings.Contains(out.String(), test.detail) || !strings.Contains(out.String(), "Action:") { + t.Fatalf("init output omitted actionable %s failure:\n%s", test.gate, out.String()) + } + if !strings.Contains(out.String(), "explicit choice required") || !strings.Contains(out.String(), "Docker Sandboxes (independently certified for this exact platform) is unavailable") { + t.Fatalf("init output did not reject explicit unavailable Docker Sandboxes selection:\n%s", out.String()) + } + }) + } +} + +func TestInitFailedPromotionAllowsExplicitWSLSelection(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubWSL2Available(t) + record := validInitPromotionRecord() + stubInitSandboxPromotion(t, record, sandboxpromotion.PreflightResult{Failures: []sandboxpromotion.Failure{{ + Gate: "authentication", Detail: "authentication is not valid", Resolution: "run sbx login", + }}}) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + if err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n3\n\n"), + Out: &bytes.Buffer{}, + }); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Provider.Type != "wsl" { + t.Fatalf("provider.type = %q, want explicit WSL selection", cfg.Provider.Type) + } +} + +func TestInitFailedPromotionDoesNotSilentlyFallBackOnEOF(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + record := validInitPromotionRecord() + stubInitSandboxPromotion(t, record, sandboxpromotion.PreflightResult{Failures: []sandboxpromotion.Failure{{ + Gate: "authentication", Detail: "authentication is not valid", Resolution: "run sbx login", + }}}) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n"), + Out: &bytes.Buffer{}, + }) + if err == nil || !strings.Contains(err.Error(), "invalid runner provider") { + t.Fatalf("error = %v, want explicit provider requirement", err) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("config was written after failed explicit selection: %v", statErr) + } +} + func TestInitOffersTartConfigWhenAvailable(t *testing.T) { stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) stubTartAvailable(t) oldDockerAvailable := dockerAvailable dockerAvailable = func(context.Context) error { - t.Fatal("Docker availability should not be checked for Tart") - return nil + return errors.New("Docker is unavailable on this Mac") } t.Cleanup(func() { dockerAvailable = oldDockerAvailable }) @@ -698,7 +1547,7 @@ func TestInitOffersTartConfigWhenAvailable(t *testing.T) { ProjectRoot: dir, ConfigPath: path, SkipHostTrustCheck: true, - In: strings.NewReader("654321\nexample\n.local/github-app.pem\n1\n2\n\n"), + In: strings.NewReader("654321\nexample\n.local/github-app.pem\n1\n4\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -723,7 +1572,7 @@ func TestInitOffersTartConfigWhenAvailable(t *testing.T) { if string(got) != wantText { t.Fatalf("Tart config did not match configs/tart.example.yml:\nwant:\n%s\ngot:\n%s", wantText, got) } - if !strings.Contains(out.String(), "2. Tart (experimental)") { + if !strings.Contains(out.String(), "2. Docker Sandboxes — recommended when ready") || !strings.Contains(out.String(), "3. WSL2") || !strings.Contains(out.String(), "4. Tart (experimental)") || !strings.Contains(out.String(), "Docker CLI or daemon check failed: Docker is unavailable on this Mac") { t.Fatalf("init output did not offer Tart:\n%s", out.String()) } } @@ -929,3 +1778,124 @@ func stubTartAvailable(t *testing.T) { initTartVersion = oldTartVersion }) } + +func stubInitDockerSandboxesSetup(t *testing.T, platform sandboxpromotion.Platform, discovery initDockerSandboxesDiscovery, discoveryErr error) { + t.Helper() + oldPlatform := initSandboxPromotionPlatform + oldLookup := initSandboxPromotionLookup + oldDiscovery := initDiscoverDockerSandboxes + oldReadiness := initDockerSandboxesReadiness + oldCapacityCheck := initDockerSandboxesCapacityCheck + initSandboxPromotionPlatform = func() sandboxpromotion.Platform { return platform } + initSandboxPromotionLookup = func(actual sandboxpromotion.Platform) (sandboxpromotion.Record, bool) { + if actual != platform { + t.Fatalf("promotion lookup platform = %q, want %q", actual, platform) + } + return sandboxpromotion.Record{}, false + } + initDiscoverDockerSandboxes = func(_ context.Context, projectRoot, guestPlatform string) (initDockerSandboxesDiscovery, error) { + if projectRoot == "" { + t.Fatal("Docker Sandboxes discovery project root is empty") + } + expectedGuestPlatform, _, err := dockerSandboxesPlatform(platform) + if err != nil { + t.Fatalf("derive Docker Sandboxes discovery guest platform: %v", err) + } + if guestPlatform != expectedGuestPlatform { + t.Fatalf("Docker Sandboxes discovery guest platform = %q, want %q", guestPlatform, expectedGuestPlatform) + } + return discovery, discoveryErr + } + initDockerSandboxesReadiness = func(context.Context) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.HostReadiness{ChecksPassed: 8, ChecksWarned: 1}, nil + } + initDockerSandboxesCapacityCheck = func(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { + return initDockerSandboxesCapacityResult{ + StorageRoot: `C:\stub\DockerSandboxes`, + AvailableBytes: 1 << 40, + TotalBytes: 2 << 40, + Reservation: rootDisk + dockerDisk, + HostWatermark: minHostFreeSpace, + RequiredBytes: rootDisk + dockerDisk + minHostFreeSpace, + CapacityStatus: storage.CapacityReady, + }, nil + } + t.Cleanup(func() { + initSandboxPromotionPlatform = oldPlatform + initSandboxPromotionLookup = oldLookup + initDiscoverDockerSandboxes = oldDiscovery + initDockerSandboxesReadiness = oldReadiness + initDockerSandboxesCapacityCheck = oldCapacityCheck + }) +} + +func stubInitSandboxPromotion(t *testing.T, record sandboxpromotion.Record, result sandboxpromotion.PreflightResult) { + t.Helper() + oldPlatform := initSandboxPromotionPlatform + oldLookup := initSandboxPromotionLookup + oldPreflight := initDockerSandboxesPreflight + oldReadiness := initDockerSandboxesReadiness + oldCapacityCheck := initDockerSandboxesCapacityCheck + initSandboxPromotionPlatform = func() sandboxpromotion.Platform { return record.Platform } + initSandboxPromotionLookup = func(platform sandboxpromotion.Platform) (sandboxpromotion.Record, bool) { + if platform != record.Platform { + t.Fatalf("promotion lookup platform = %q, want %q", platform, record.Platform) + } + return record, true + } + initDockerSandboxesPreflight = func(context.Context, sandboxpromotion.Record, string) sandboxpromotion.PreflightResult { + return result + } + initDockerSandboxesReadiness = func(context.Context) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.HostReadiness{ChecksPassed: 8}, nil + } + initDockerSandboxesCapacityCheck = func(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { + return initDockerSandboxesCapacityResult{ + StorageRoot: `C:\stub\DockerSandboxes`, + AvailableBytes: 1 << 40, + TotalBytes: 2 << 40, + Reservation: rootDisk + dockerDisk, + HostWatermark: minHostFreeSpace, + RequiredBytes: rootDisk + dockerDisk + minHostFreeSpace, + CapacityStatus: storage.CapacityReady, + }, nil + } + t.Cleanup(func() { + initSandboxPromotionPlatform = oldPlatform + initSandboxPromotionLookup = oldLookup + initDockerSandboxesPreflight = oldPreflight + initDockerSandboxesReadiness = oldReadiness + initDockerSandboxesCapacityCheck = oldCapacityCheck + }) +} + +func validInitPromotionRecord() sandboxpromotion.Record { + digest := func(character string) string { return "sha256:" + strings.Repeat(character, 64) } + return sandboxpromotion.Record{ + Platform: sandboxpromotion.WindowsAMD64, + EPARRevision: digest("1"), + SBXVersion: "0.35.0", + Template: "epar-docker-sandboxes-catthehacker-full:promoted", + TemplateDigest: digest("a"), + TemplateCacheID: strings.Repeat("a", 12), + TemplateMetadataDigest: digest("b"), + TemplateArchiveDigest: digest("b"), + PolicyFingerprint: digest("b"), + EvidenceDigest: digest("c"), + SBOMDigest: digest("d"), + ProvenanceDigest: digest("e"), + SoftwareInventoryDigest: digest("f"), + VerifiedAt: time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC), + Verifier: "independent-test-verifier", + Gates: sandboxpromotion.GateResults{Local: true, Functional: true, Recovery: true, Security: true, Policy: true, Cleanup: true, SecretScanning: true, ConcurrentProvisioning: true, IndependentSecurityReview: true}, + RootDiskBytes: 120 << 30, + DockerDiskBytes: 100 << 30, + MinHostFreeSpaceBytes: 50 << 30, + ReliabilityJobs: 25, + ReliabilityDuration: 2 * time.Hour, + CachedCreateP95: 30 * time.Second, + QueueToOnlineP95: 90 * time.Second, + ForceRemoveP95: 60 * time.Second, + BuildxComposeSlowdownPct: 10, + } +} diff --git a/cmd/ephemeral-action-runner/main.go b/cmd/ephemeral-action-runner/main.go index 4f76710..1fc3c2c 100644 --- a/cmd/ephemeral-action-runner/main.go +++ b/cmd/ephemeral-action-runner/main.go @@ -13,12 +13,13 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" gh "github.com/solutionforest/ephemeral-action-runner/internal/github" + "github.com/solutionforest/ephemeral-action-runner/internal/invocation" "github.com/solutionforest/ephemeral-action-runner/internal/logging" "github.com/solutionforest/ephemeral-action-runner/internal/pool" + poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" "github.com/solutionforest/ephemeral-action-runner/internal/provider" - dockercontainerprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockercontainer" - tartprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/tart" - wslprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/wsl" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/registry" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" ) const binaryName = "ephemeral-action-runner" @@ -113,6 +114,8 @@ func run(args []string) error { return runStatus(args[1:]) case "logs": return runLogs(args[1:]) + case "storage": + return runStorage(args[1:]) case "version": printVersion(os.Stdout) return nil @@ -232,6 +235,9 @@ func runImage(args []string) error { return err } defer m.Close() + if err := rejectDockerSandboxesImageCommand(m, "image update-upstream"); err != nil { + return err + } controllerLock, err := m.AcquirePoolControllerLock() if err != nil { return err @@ -252,6 +258,9 @@ func runImage(args []string) error { return err } defer m.Close() + if err := rejectDockerSandboxesImageCommand(m, "image build"); err != nil { + return err + } ctx := interruptContext() poolControllerLock, err := m.AcquirePoolControllerLock() if err != nil { @@ -282,6 +291,9 @@ func runImage(args []string) error { return err } defer m.Close() + if err := rejectDockerSandboxesImageCommand(m, "image refresh-scripts"); err != nil { + return err + } controllerLock, err := m.AcquirePoolControllerLock() if err != nil { return err @@ -303,7 +315,7 @@ func runPool(args []string) error { common := addCommonFlags(fs) instances := fs.Int("instances", 0, "number of concurrent instances to verify; overrides pool.instances") registerOnly := fs.Bool("register-only", false, "register runners and verify online/idle without dispatching a job") - cleanup := fs.Bool("cleanup", false, "clean up prefixed instances and GitHub runners after verification") + cleanup := fs.Bool("cleanup", false, "clean up verification resources; legacy providers use the configured pool prefix, while Docker Sandboxes uses exact owned records") if err := fs.Parse(args[1:]); err != nil { return err } @@ -352,6 +364,7 @@ func runCleanup(args []string) error { fs := flag.NewFlagSet("cleanup", flag.ExitOnError) common := addCommonFlags(fs) noGitHub := fs.Bool("no-github", false, "skip GitHub runner deletion") + acknowledgeFailedDiagnostics := fs.Bool("acknowledge-failed-diagnostics", false, "allow exact cleanup of retained sandboxes after failed diagnostics evidence has been reviewed") if err := fs.Parse(args); err != nil { return err } @@ -360,6 +373,7 @@ func runCleanup(args []string) error { return err } defer m.Close() + m.AcknowledgeFailedDiagnostics = *acknowledgeFailedDiagnostics return m.Cleanup(context.Background()) } @@ -428,6 +442,16 @@ func newManager(configPath, projectRoot string, dryRun bool, githubEnabled bool) if err := config.Validate(cfg); err != nil { return nil, err } + providerRuntime, err := registry.New(cfg, projectRoot, dryRun) + if err != nil { + return nil, err + } + if providerRuntime.Lifecycle == nil || providerRuntime.Storage == nil { + return nil, fmt.Errorf("provider %q registry entry is missing required lifecycle or storage behavior", cfg.Provider.Type) + } + if err := preflightControllerStorage(projectRoot, cfg, providerRuntime.Storage); err != nil { + return nil, err + } var client pool.GitHubClient if githubEnabled && !dryRun { if err := config.ValidateGitHub(cfg); err != nil { @@ -435,9 +459,12 @@ func newManager(configPath, projectRoot string, dryRun bool, githubEnabled bool) } client = gh.New(cfg.GitHub) } - provider, err := newProvider(cfg, projectRoot, dryRun) - if err != nil { - return nil, err + var lifecycleState *poolstate.Store + if !dryRun { + lifecycleState, err = pool.OpenLifecycleState(projectRoot, resolvedConfigPath) + if err != nil { + return nil, err + } } runtime, err := logging.NewRuntime(logging.Options{ Directory: config.ProjectPath(projectRoot, cfg.Logging.Directory), @@ -458,13 +485,17 @@ func newManager(configPath, projectRoot string, dryRun bool, githubEnabled bool) return nil, err } manager := &pool.Manager{ - Config: cfg, - Provider: provider, - GitHub: client, - ProjectRoot: projectRoot, - ConfigPath: resolvedConfigPath, - DryRun: dryRun, - Logging: runtime, + Config: cfg, + Provider: providerRuntime.Legacy, + Lifecycle: providerRuntime.Lifecycle, + PolicyManager: providerRuntime.PolicyManager, + Storage: providerRuntime.Storage, + LifecycleState: lifecycleState, + GitHub: client, + ProjectRoot: projectRoot, + ConfigPath: resolvedConfigPath, + DryRun: dryRun, + Logging: runtime, } if cfg.Logging.RetentionEnabled { report, pruneErr := manager.PruneLogs(false) @@ -479,6 +510,74 @@ func newManager(configPath, projectRoot string, dryRun bool, githubEnabled bool) return manager, nil } +func preflightControllerStorage(projectRoot string, cfg config.Config, contributions ...provider.StorageContribution) error { + minimumFree, err := config.EffectiveMinimumFreeBytes(cfg) + if err != nil { + return err + } + if len(contributions) != 0 && contributions[0] != nil { + snapshot, err := contributions[0].StorageSnapshot(context.Background(), provider.StorageRequest{ + Operation: "controller-bootstrap", + Now: time.Now(), + MinimumFreeBytes: minimumFree, + }) + if err != nil { + return fmt.Errorf("provider storage surface cannot be measured before controller bootstrap: %w\n\nInspect storage with:\n %s", err, invocation.Command("storage", "status", "--provider", cfg.Provider.Type)) + } + surfaces := make(map[string]storage.Surface, len(snapshot.Surfaces)) + for _, surface := range snapshot.Surfaces { + surfaces[surface.ID] = surface + } + for _, requirement := range snapshot.Requirements { + surface, found := surfaces[requirement.SurfaceID] + if !found { + return fmt.Errorf("controller storage requirement %q references unknown surface %q", requirement.ID, requirement.SurfaceID) + } + check, err := storage.EvaluateCapacity(surface, requirement) + if err != nil { + return err + } + if check.Status != storage.CapacityReady { + return storage.CapacityAdmissionError("initialize the EPAR controller", surface, requirement, check, invocation.Command("storage", "prune", "--provider", cfg.Provider.Type)) + } + } + return nil + } + capacity, err := storage.ProbeFilesystemCapacity(projectRoot, time.Now()) + if err != nil { + return fmt.Errorf("controller storage surface %q cannot be measured before bootstrap: %w\n\nInspect storage with:\n %s", projectRoot, err, invocation.Command("storage", "status", "--provider", cfg.Provider.Type)) + } + check, err := storage.EvaluateCapacity(storage.Surface{ + ID: "project", + Provider: cfg.Provider.Type, + Kind: storage.SurfaceHostFilesystem, + Location: projectRoot, + Capacity: capacity, + }, storage.Requirement{ + ID: "controller-bootstrap", + Provider: cfg.Provider.Type, + SurfaceID: "project", + MinimumFreeBytes: minimumFree, + }) + if err != nil { + return fmt.Errorf("evaluate controller storage capacity: %w", err) + } + if check.Status != storage.CapacityReady { + return storage.CapacityAdmissionError("initialize the EPAR controller", storage.Surface{ + Location: projectRoot, + Capacity: check.Capacity, + }, check.Requirement, check, invocation.Command("storage", "prune", "--provider", cfg.Provider.Type)) + } + return nil +} + +func rejectDockerSandboxesImageCommand(manager *pool.Manager, command string) error { + if manager.Config.Provider.Type != "docker-sandboxes" { + return nil + } + return fmt.Errorf("%s is not supported by docker-sandboxes; build and load the pinned template with scripts/docker-sandboxes before admission", command) +} + func loggingSinks(values []string) logging.Sinks { var sinks logging.Sinks for _, value := range values { @@ -522,25 +621,6 @@ func resolveConfigPath(projectRoot, explicit string) (string, error) { return "", nil } -func newProvider(cfg config.Config, projectRoot string, dryRun bool) (provider.Provider, error) { - switch cfg.Provider.Type { - case "tart": - return tartprovider.New("", dryRun), nil - case "wsl": - return wslprovider.New("", config.ProjectPath(projectRoot, cfg.Provider.InstallRoot), projectRoot, dryRun), nil - case "docker-container": - hostGateway := config.DockerConfigNeedsHostGateway(cfg.Docker) - environment := map[string]string{ - "HTTP_PROXY": cfg.Docker.HTTPProxy, - "HTTPS_PROXY": cfg.Docker.HTTPSProxy, - "NO_PROXY": cfg.Docker.NoProxy, - } - return dockercontainerprovider.NewWithOptions("", cfg.Provider.Platform, hostGateway, environment, dryRun), nil - default: - return nil, provider.UnsupportedTypeError(cfg.Provider.Type) - } -} - func interruptContext() context.Context { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) go func() { @@ -568,6 +648,8 @@ Commands: ephemeral-action-runner logs path ephemeral-action-runner logs list ephemeral-action-runner logs prune [--dry-run] + ephemeral-action-runner storage status [--provider NAME] [--json] + ephemeral-action-runner storage prune [--provider NAME] [--json] [--execute] ephemeral-action-runner version `) } diff --git a/cmd/ephemeral-action-runner/provider_test.go b/cmd/ephemeral-action-runner/provider_test.go index 9df9505..5aee9de 100644 --- a/cmd/ephemeral-action-runner/provider_test.go +++ b/cmd/ephemeral-action-runner/provider_test.go @@ -1,38 +1,17 @@ package main import ( + "strings" "testing" "github.com/solutionforest/ephemeral-action-runner/internal/config" - dockercontainerprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockercontainer" + "github.com/solutionforest/ephemeral-action-runner/internal/pool" ) -func TestNewProviderWiresDockerDaemonProxy(t *testing.T) { - cfg := config.Default() - cfg.Provider.Type = "docker-container" - cfg.Provider.Platform = "linux/amd64" - cfg.Docker.HTTPProxy = "http://host.docker.internal:3128" - cfg.Docker.HTTPSProxy = "http://host.docker.internal:3128" - cfg.Docker.NoProxy = "localhost,127.0.0.1" - - created, err := newProvider(cfg, t.TempDir(), false) - if err != nil { - t.Fatal(err) - } - dockerContainer, ok := created.(*dockercontainerprovider.Provider) - if !ok { - t.Fatalf("newProvider() type = %T, want Docker Container provider", created) - } - if !dockerContainer.HostGateway { - t.Fatal("host.docker.internal proxy did not enable host gateway") - } - for key, want := range map[string]string{ - "HTTP_PROXY": cfg.Docker.HTTPProxy, - "HTTPS_PROXY": cfg.Docker.HTTPSProxy, - "NO_PROXY": cfg.Docker.NoProxy, - } { - if got := dockerContainer.Environment[key]; got != want { - t.Errorf("provider environment %s = %q, want %q", key, got, want) - } +func TestDockerSandboxesImageCommandsAreRejectedClearly(t *testing.T) { + manager := &pool.Manager{Config: config.Config{Provider: config.ProviderConfig{Type: "docker-sandboxes"}}} + err := rejectDockerSandboxesImageCommand(manager, "image build") + if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "scripts/docker-sandboxes") { + t.Fatalf("image command rejection = %v", err) } } diff --git a/cmd/ephemeral-action-runner/start.go b/cmd/ephemeral-action-runner/start.go index df91ee3..4c090fa 100644 --- a/cmd/ephemeral-action-runner/start.go +++ b/cmd/ephemeral-action-runner/start.go @@ -160,14 +160,18 @@ func runStartWithOptions(opts startOptions) (err error) { hostTrustLockHeld = true } } - fmt.Fprintf(opts.Out, "Ensuring runner image is current for %s\n", configPath) + fmt.Fprintf(opts.Out, "Ensuring the runner image or sandbox template is current for %s\n", configPath) if err = manager.EnsureImage(opts.Context); err != nil { return err } + stopGuidance := "Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window." + if opts.KeepOnExit { + stopGuidance = "Press Ctrl-C once to stop; --keep-on-exit will leave owned runner resources running." + } if opts.Instances > 0 { - fmt.Fprintf(opts.Out, "Starting EPAR pool with %d instance(s). Press Ctrl-C to stop; cleanup is enabled by default.\n", opts.Instances) + fmt.Fprintf(opts.Out, "Starting EPAR pool with %d instance(s). %s\n", opts.Instances, stopGuidance) } else { - fmt.Fprintf(opts.Out, "Starting EPAR pool using pool.instances from config. Press Ctrl-C to stop; cleanup is enabled by default.\n") + fmt.Fprintf(opts.Out, "Starting EPAR pool using pool.instances from config. %s\n", stopGuidance) } err = manager.RunPool(opts.Context, pool.RunOptions{ Instances: opts.Instances, diff --git a/cmd/ephemeral-action-runner/start_test.go b/cmd/ephemeral-action-runner/start_test.go index 1efd3a3..c9e2546 100644 --- a/cmd/ephemeral-action-runner/start_test.go +++ b/cmd/ephemeral-action-runner/start_test.go @@ -12,6 +12,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" "github.com/solutionforest/ephemeral-action-runner/internal/pool" + sandboxpromotion "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/promotion" ) func TestNoArgsRoutesToStart(t *testing.T) { @@ -56,12 +57,13 @@ func TestStartPropagatesConfigAndInstances(t *testing.T) { } fake := &fakeStarterManager{} var gotPath string + var out bytes.Buffer err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, ConfigPath: "custom.yml", Instances: 3, - Out: &bytes.Buffer{}, + Out: &out, ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { gotPath = path return fake, nil @@ -76,6 +78,9 @@ func TestStartPropagatesConfigAndInstances(t *testing.T) { if fake.runOptions.Instances != 3 { t.Fatalf("instances = %d, want 3", fake.runOptions.Instances) } + if !strings.Contains(out.String(), "Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.") { + t.Fatalf("start guidance = %q", out.String()) + } } func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { @@ -142,7 +147,7 @@ func TestStartInteractiveMissingConfigCanSelectWSL2(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n2\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n3\n\n"), Out: &out, ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { if path != filepath.Join(dir, ".local", "config.yml") { @@ -169,6 +174,65 @@ func TestStartInteractiveMissingConfigCanSelectWSL2(t *testing.T) { } } +func TestStartInteractiveMissingConfigCanSelectDockerSandboxes(t *testing.T) { + dir := t.TempDir() + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + policyFingerprint := "sha256:" + strings.Repeat("b", 64) + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + Templates: []initDockerSandboxesTemplate{{ + Reference: "docker.io/library/epar-docker-sandboxes-catthehacker-full:preview", + Digest: "sha256:" + strings.Repeat("a", 64), + CacheID: strings.Repeat("a", 12), + Platform: "linux/amd64", + Size: 8 << 30, + }}, + PolicyFingerprint: policyFingerprint, + }, nil) + oldInteractive := stdinIsInteractive + oldDocker := dockerAvailable + t.Cleanup(func() { + stdinIsInteractive = oldInteractive + dockerAvailable = oldDocker + }) + stdinIsInteractive = func() bool { return true } + dockerAvailable = func(context.Context) error { return nil } + + fake := &fakeStarterManager{} + var out bytes.Buffer + err := runStartWithOptions(startOptions{ + Context: context.Background(), + ProjectRoot: dir, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\n\n\n\n\nn\n"), + Out: &out, + ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { + if path != filepath.Join(dir, ".local", "config.yml") { + t.Fatalf("config path = %q", path) + } + return fake, nil + }, + }) + if err != nil { + t.Fatal(err) + } + cfg, err := config.Load(filepath.Join(dir, ".local", "config.yml")) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Provider.Type, "docker-sandboxes"; got != want { + t.Fatalf("provider.type = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.PolicyGeneration, policyFingerprint; got != want { + t.Fatalf("dockerSandboxes.policyGeneration = %q, want %q", got, want) + } + if !strings.Contains(out.String(), "Docker Sandboxes — recommended") || !strings.Contains(out.String(), "Continuing with") { + t.Fatalf("output did not include capability-ready Docker Sandboxes selection and start continuation:\n%s", out.String()) + } + if fake.ensureCalls != 1 || fake.runCalls != 1 { + t.Fatalf("ensure/run calls = %d/%d, want 1/1", fake.ensureCalls, fake.runCalls) + } +} + func TestStartInteractiveMissingConfigCanSelectTartWithoutDocker(t *testing.T) { dir := t.TempDir() stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) @@ -181,8 +245,7 @@ func TestStartInteractiveMissingConfigCanSelectTartWithoutDocker(t *testing.T) { }) stdinIsInteractive = func() bool { return true } dockerAvailable = func(context.Context) error { - t.Fatal("Docker availability should not be checked for Tart") - return nil + return errors.New("Docker is unavailable on this Mac") } fake := &fakeStarterManager{} @@ -190,7 +253,7 @@ func TestStartInteractiveMissingConfigCanSelectTartWithoutDocker(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n2\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n4\n\n"), Out: &out, ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { if path != filepath.Join(dir, ".local", "config.yml") { diff --git a/cmd/ephemeral-action-runner/storage.go b/cmd/ephemeral-action-runner/storage.go new file mode 100644 index 0000000..4a0044a --- /dev/null +++ b/cmd/ephemeral-action-runner/storage.go @@ -0,0 +1,347 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + artifactimage "github.com/solutionforest/ephemeral-action-runner/internal/image" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + providerregistry "github.com/solutionforest/ephemeral-action-runner/internal/provider/registry" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" + "github.com/solutionforest/ephemeral-action-runner/internal/storage/inventory" +) + +type storageCommandReport struct { + Inventory storageInventorySummary `json:"inventory"` + Plan storage.Plan `json:"plan"` + Execution *storage.ExecutionReport `json:"execution,omitempty"` +} + +type storageInventorySummary struct { + ProjectRoot string `json:"projectRoot"` + Provider string `json:"provider,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +func runStorage(args []string) error { + if len(args) == 0 { + return fmt.Errorf("storage requires subcommand: status or prune") + } + subcommand := args[0] + if subcommand == "effective-go-cache-limit" { + return runEffectiveGoCacheLimit(args[1:]) + } + if subcommand != "status" && subcommand != "prune" { + return fmt.Errorf("unknown storage subcommand %q", subcommand) + } + fs := flag.NewFlagSet("storage "+subcommand, flag.ContinueOnError) + cwd, _ := os.Getwd() + projectRootFlag := fs.String("project-root", cwd, "project root containing EPAR state and artifacts") + configPathFlag := fs.String("config", "", "config file path; defaults to EPAR_CONFIG or .local/config.yml when present") + providerFlag := fs.String("provider", "", "limit provider-specific inventory") + jsonOutput := fs.Bool("json", false, "write the complete storage report as JSON") + execute := fs.Bool("execute", false, "execute the exact policy-selected prune plan") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("storage %s does not accept positional arguments", subcommand) + } + if subcommand == "status" && *execute { + return fmt.Errorf("storage status does not support --execute") + } + if *providerFlag != "" { + if _, found := providerregistry.DescriptorFor(*providerFlag); !found { + return provider.UnsupportedTypeError(*providerFlag) + } + } + + projectRoot, cfg, configPath, configTime, err := loadStorageConfig(*projectRootFlag, *configPathFlag) + if err != nil { + return err + } + now := time.Now().UTC() + var selections []inventory.TemplateSelection + if cfg.DockerSandboxes.Template != "" && cfg.DockerSandboxes.TemplateDigest != "" { + selections = append(selections, inventory.TemplateSelection{ + Platform: cfg.Provider.Platform, + Tag: cfg.DockerSandboxes.Template, + TemplateDigest: cfg.DockerSandboxes.TemplateDigest, + ActivatedAt: configTime, + }) + } + currentExecutable, _ := os.Executable() + configuredFiles := configuredStorageFiles(cfg, projectRoot, configTime) + snapshot, err := inventory.Collect(inventory.Options{ + ProjectRoot: projectRoot, + Provider: *providerFlag, + Now: now, + LogsRoot: config.ProjectPath(projectRoot, cfg.Logging.Directory), + NativeRoot: filepath.Join(projectRoot, ".local", "bin"), + TemplateRoot: filepath.Join(projectRoot, "work", "template-builds", "docker-sandboxes"), + CurrentExecutable: currentExecutable, + ConfiguredTemplates: selections, + ConfiguredFiles: configuredFiles, + }) + if err != nil { + return err + } + collectExternalStorage(&snapshot, *providerFlag) + if cfg.Storage.AutomaticHousekeeping == config.StorageHousekeepingDisabled { + for index := range snapshot.Artifacts { + snapshot.Artifacts[index].Protections = append(snapshot.Artifacts[index].Protections, storage.Protection{ + Kind: storage.ProtectionOperator, + Detail: "storage.automaticHousekeeping is disabled", + }) + } + } + policy, minimumFree, err := storagePolicyFromConfig(cfg.Storage) + if err != nil { + return err + } + storageProvider := cfg.Provider.Type + if *providerFlag != "" { + storageProvider = *providerFlag + } + storageConfig := cfg + storageConfig.Provider.Type = storageProvider + effectiveMinimumFree, err := config.EffectiveMinimumFreeBytes(storageConfig) + if err != nil { + return err + } + if effectiveMinimumFree > minimumFree { + minimumFree = effectiveMinimumFree + } + requirements := []storage.Requirement{{ + ID: "controller-bootstrap", + Provider: *providerFlag, + SurfaceID: inventory.ProjectSurfaceID, + MinimumFreeBytes: minimumFree, + }} + providerRuntime, runtimeErr := providerregistry.New(storageConfig, projectRoot, true) + if runtimeErr != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Provider storage surfaces are unavailable: %v", runtimeErr)) + } else { + providerSnapshot, snapshotErr := providerRuntime.Storage.StorageSnapshot(context.Background(), provider.StorageRequest{ + Operation: "storage-status", + Now: now, + MinimumFreeBytes: minimumFree, + }) + if snapshotErr != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Provider storage surfaces are unavailable: %v", snapshotErr)) + } else { + snapshot.Surfaces = append(snapshot.Surfaces, providerSnapshot.Surfaces...) + snapshot.Artifacts = append(snapshot.Artifacts, providerSnapshot.Artifacts...) + requirements = append(requirements, providerSnapshot.Requirements...) + } + } + plan, err := storage.Preview(snapshot.PreviewRequest(policy, requirements)) + if err != nil { + return err + } + report := storageCommandReport{ + Inventory: storageInventorySummary{ + ProjectRoot: projectRoot, + Provider: *providerFlag, + Warnings: snapshot.Warnings, + }, + Plan: plan, + } + + if subcommand == "prune" && *execute { + for _, decision := range plan.Decisions { + if decision.Action == storage.ActionRemove { + fmt.Fprintf(os.Stderr, "storage prune exact identity=%s kind=%s target=%s bytes=%d\n", decision.Artifact.Target.Identity, decision.Artifact.Target.Kind, decision.Artifact.Target.Locator, decision.Artifact.SizeBytes) + } + } + if plan.RemovalCount > 0 { + executor, err := storage.NewFilesystemExecutor( + filepath.Join(projectRoot, ".local", "bin"), + filepath.Join(projectRoot, "work", "template-builds", "docker-sandboxes"), + ) + if err != nil { + return err + } + execution, err := storage.Execute(context.Background(), plan, plan.Hash, executor) + report.Execution = &execution + if err != nil { + if *jsonOutput { + _ = writeStorageJSON(report) + } + return err + } + } else { + execution := storage.ExecutionReport{PlanHash: plan.Hash} + report.Execution = &execution + } + } + if *jsonOutput { + return writeStorageJSON(report) + } + printStorageReport(subcommand, report) + if configPath == "" { + fmt.Fprintln(os.Stdout, "Configuration: defaults (no config file found)") + } else { + fmt.Fprintln(os.Stdout, "Configuration:", configPath) + } + return nil +} + +func runEffectiveGoCacheLimit(args []string) error { + fs := flag.NewFlagSet("storage effective-go-cache-limit", flag.ContinueOnError) + cwd, _ := os.Getwd() + projectRootFlag := fs.String("project-root", cwd, "project root containing EPAR configuration") + configPathFlag := fs.String("config", "", "config file path") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("storage effective-go-cache-limit does not accept positional arguments") + } + _, cfg, _, _, err := loadStorageConfig(*projectRootFlag, *configPathFlag) + if err != nil { + return err + } + if err := config.ValidateStorage(cfg.Storage); err != nil { + return err + } + limit, err := config.ParseByteSize(cfg.Storage.GoCacheLimit) + if err != nil { + return err + } + fmt.Fprintln(os.Stdout, uint64(limit)) + return nil +} + +func configuredStorageFiles(cfg config.Config, projectRoot string, configuredAt time.Time) []inventory.ConfiguredFile { + if cfg.Provider.Type != "wsl" || strings.TrimSpace(cfg.Image.OutputImage) == "" { + return nil + } + output := config.ProjectPath(projectRoot, cfg.Image.OutputImage) + files := []inventory.ConfiguredFile{ + {Provider: "wsl", Role: "reusable-image", Path: output, Kind: storage.ArtifactProviderImage, Current: true, ConfiguredAt: configuredAt, ProtectionKind: storage.ProtectionConfiguration, ProtectionDetail: "current reusable WSL image"}, + {Provider: "wsl", Role: "image-manifest", Path: artifactimage.WSLImageManifestPath(output), Kind: storage.ArtifactOther, Current: true, ConfiguredAt: configuredAt, ProtectionKind: storage.ProtectionCertification, ProtectionDetail: "current WSL image manifest"}, + } + if cfg.Image.SourceType == "docker-image" { + rootfs := artifactimage.WSLSourceRootfsPath(output) + files = append(files, + inventory.ConfiguredFile{Provider: "wsl", Role: "source-rootfs-cache", Path: rootfs, Kind: storage.ArtifactProviderCache, Current: true, ConfiguredAt: configuredAt, ProtectionKind: storage.ProtectionLock, ProtectionDetail: "current reusable WSL source rootfs cache"}, + inventory.ConfiguredFile{Provider: "wsl", Role: "source-cache-manifest", Path: artifactimage.SourceCacheManifestPath(rootfs), Kind: storage.ArtifactOther, Current: true, ConfiguredAt: configuredAt, ProtectionKind: storage.ProtectionCertification, ProtectionDetail: "current WSL source cache manifest"}, + ) + } + return files +} + +func loadStorageConfig(projectRoot, explicitConfig string) (string, config.Config, string, time.Time, error) { + absoluteRoot, err := filepath.Abs(projectRoot) + if err != nil { + return "", config.Config{}, "", time.Time{}, err + } + cfg := config.Default() + configPath, err := resolveConfigPath(absoluteRoot, explicitConfig) + if err != nil { + return "", config.Config{}, "", time.Time{}, err + } + var configTime time.Time + if configPath != "" { + cfg, err = config.Load(configPath) + if err != nil { + return "", config.Config{}, "", time.Time{}, err + } + if err := config.ValidateStorage(cfg.Storage); err != nil { + return "", config.Config{}, "", time.Time{}, err + } + if info, statErr := os.Stat(configPath); statErr == nil { + configTime = info.ModTime().UTC() + } + } + return absoluteRoot, cfg, configPath, configTime, nil +} + +func storagePolicyFromConfig(cfg config.StorageConfig) (storage.Policy, uint64, error) { + grace, err := time.ParseDuration(cfg.GracePeriod) + if err != nil { + return storage.Policy{}, 0, err + } + minimumFree, err := config.ParseByteSize(cfg.MinimumFree) + if err != nil { + return storage.Policy{}, 0, err + } + buildLimit, err := config.ParseByteSize(cfg.BuildCacheLimit) + if err != nil { + return storage.Policy{}, 0, err + } + goLimit, err := config.ParseByteSize(cfg.GoCacheLimit) + if err != nil { + return storage.Policy{}, 0, err + } + return storage.Policy{ + GracePeriod: grace, + KeepPrevious: cfg.KeepPrevious, + Budgets: []storage.Budget{ + {Kind: storage.ArtifactBuildKitCache, MaxBytes: uint64(buildLimit)}, + {Kind: storage.ArtifactGoCache, MaxBytes: uint64(goLimit)}, + }, + }, uint64(minimumFree), nil +} + +func writeStorageJSON(report storageCommandReport) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(report) +} + +func printStorageReport(subcommand string, report storageCommandReport) { + fmt.Fprintf(os.Stdout, "Storage %s plan: %s\n", subcommand, report.Plan.Hash) + for _, surface := range report.Plan.Surfaces { + available := "unknown" + if surface.Capacity.Known { + available = formatStorageBytes(surface.Capacity.AvailableBytes) + } + fmt.Fprintf(os.Stdout, "Surface %s\tprovider=%s\tkind=%s\tavailable=%s\tlocation=%s\n", surface.ID, valueOrDash(surface.Provider), surface.Kind, available, surface.Location) + } + for _, check := range report.Plan.CapacityChecks { + fmt.Fprintf(os.Stdout, "Capacity %s\tstatus=%s\tavailable=%s\testimated=%s\treserve=%s\trequired=%s\n", check.Requirement.ID, check.Status, formatStorageBytes(check.Capacity.AvailableBytes), formatStorageBytes(check.Requirement.PeakBytes), formatStorageBytes(check.Requirement.MinimumFreeBytes), formatStorageBytes(check.RequiredAvailableBytes)) + } + for _, decision := range report.Plan.Decisions { + fmt.Fprintf(os.Stdout, "Artifact %s\taction=%s\tprovider=%s\tkind=%s\tbytes=%s\tidentity=%s\ttarget=%s\treason=%s\n", decision.Artifact.ID, decision.Action, valueOrDash(decision.Artifact.Provider), decision.Artifact.Kind, formatStorageBytes(decision.Artifact.SizeBytes), valueOrDash(decision.Artifact.Target.Identity), decision.Artifact.Target.Locator, strings.Join(decision.Reasons, ",")) + } + for _, warning := range append(append([]string(nil), report.Inventory.Warnings...), report.Plan.Warnings...) { + fmt.Fprintln(os.Stdout, "Warning:", warning) + } + fmt.Fprintf(os.Stdout, "Summary: removals=%d reclaimable=%s", report.Plan.RemovalCount, formatStorageBytes(report.Plan.ReclaimableBytes)) + if report.Execution != nil { + fmt.Fprintf(os.Stdout, " removed=%d reclaimed=%s", report.Execution.RemovedCount, formatStorageBytes(report.Execution.ReclaimedBytes)) + } + fmt.Fprintln(os.Stdout) + if subcommand == "prune" && report.Execution == nil { + fmt.Fprintln(os.Stdout, "Preview only. Re-run with --execute to apply only the exact identities marked remove.") + } +} + +func formatStorageBytes(value uint64) string { + const gib = uint64(1 << 30) + const mib = uint64(1 << 20) + switch { + case value >= gib: + return fmt.Sprintf("%.2fGiB", float64(value)/float64(gib)) + case value >= mib: + return fmt.Sprintf("%.2fMiB", float64(value)/float64(mib)) + default: + return fmt.Sprintf("%dB", value) + } +} + +func valueOrDash(value string) string { + if value == "" { + return "-" + } + return value +} diff --git a/cmd/ephemeral-action-runner/storage_configured_test.go b/cmd/ephemeral-action-runner/storage_configured_test.go new file mode 100644 index 0000000..83b7f69 --- /dev/null +++ b/cmd/ephemeral-action-runner/storage_configured_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "path/filepath" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func TestConfiguredStorageFilesIncludesCurrentWSLArtifacts(t *testing.T) { + project := t.TempDir() + configuredAt := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + cfg := config.Default() + cfg.Provider.Type = "wsl" + cfg.Image.SourceType = "docker-image" + cfg.Image.OutputImage = filepath.Join("work", "images", "runner.tar") + + files := configuredStorageFiles(cfg, project, configuredAt) + if len(files) != 4 { + t.Fatalf("configuredStorageFiles() returned %d files, want 4: %+v", len(files), files) + } + byRole := make(map[string]storage.ArtifactKind, len(files)) + for _, file := range files { + if file.Provider != "wsl" || !file.Current || file.ConfiguredAt != configuredAt { + t.Fatalf("configured file = %+v", file) + } + byRole[file.Role] = file.Kind + } + if byRole["reusable-image"] != storage.ArtifactProviderImage || byRole["source-rootfs-cache"] != storage.ArtifactProviderCache || byRole["image-manifest"] != storage.ArtifactOther || byRole["source-cache-manifest"] != storage.ArtifactOther { + t.Fatalf("configured roles = %+v", byRole) + } +} + +func TestConfiguredStorageFilesSkipsNonWSLProvider(t *testing.T) { + cfg := config.Default() + cfg.Provider.Type = "docker-container" + if files := configuredStorageFiles(cfg, t.TempDir(), time.Now()); len(files) != 0 { + t.Fatalf("configuredStorageFiles() = %+v, want none", files) + } +} diff --git a/cmd/ephemeral-action-runner/storage_external.go b/cmd/ephemeral-action-runner/storage_external.go new file mode 100644 index 0000000..8b156c1 --- /dev/null +++ b/cmd/ephemeral-action-runner/storage_external.go @@ -0,0 +1,441 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + artifactimage "github.com/solutionforest/ephemeral-action-runner/internal/image" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" + "github.com/solutionforest/ephemeral-action-runner/internal/storage/inventory" +) + +func collectExternalStorage(snapshot *inventory.Snapshot, providerFilter string) { + if providerFilter == "" || providerFilter == "docker-container" || providerFilter == "docker-sandboxes" || providerFilter == "wsl" { + collectDockerStorage(snapshot, providerFilter) + } + if providerFilter == "" || providerFilter == "docker-sandboxes" { + collectDockerSandboxesStorage(snapshot) + } + if runtime.GOOS == "windows" && (providerFilter == "" || providerFilter == "wsl") { + collectWSLStorage(snapshot) + } + if runtime.GOOS == "darwin" && (providerFilter == "" || providerFilter == "tart") { + collectTartStorage(snapshot) + } +} + +func collectDockerStorage(snapshot *inventory.Snapshot, providerFilter string) { + const surfaceID = "docker-engine" + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ + ID: surfaceID, + Kind: storage.SurfaceDockerEngine, + Location: "docker-engine", + Capacity: storage.Capacity{ObservedAt: snapshot.CollectedAt}, + }) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "docker", "image", "ls", "--all", "--no-trunc", "--format", "{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}").Output() + if err != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Docker image inventory is unavailable and remains report-only: %v", err)) + } else { + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + parts := strings.Split(strings.TrimSpace(line), "\t") + if len(parts) != 4 || !strings.HasPrefix(parts[0], "epar-") { + continue + } + reference := parts[0] + ":" + parts[1] + artifactProvider := dockerImageProvider(parts[0]) + if !storageProviderMatches(providerFilter, artifactProvider) { + continue + } + snapshot.Artifacts = append(snapshot.Artifacts, storage.Artifact{ + ID: externalStorageID("docker-image", reference, parts[2]), + Provider: artifactProvider, + SurfaceID: surfaceID, + Kind: storage.ArtifactDockerImage, + Target: storage.Target{ + Kind: storage.TargetDockerImageTag, + Locator: reference, + Identity: parts[2], + Match: storage.MatchExact, + }, + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown, Evidence: "EPAR prefix is not exact ownership"}, + SizeBytes: parseDockerSize(parts[3]), + Protections: []storage.Protection{{Kind: storage.ProtectionUncertain, Detail: "image lacks persisted EPAR owner metadata; explicit prune only"}}, + }) + } + } + output, err = exec.CommandContext(ctx, "docker", "system", "df", "-v", "--format", "json").Output() + if err != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Docker volume size inventory is unavailable and remains report-only: %v", err)) + collectDockerVolumeNames(snapshot, surfaceID) + } else { + volumes, parseErr := parseDockerDiskUsageVolumes(output) + if parseErr != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Docker volume size inventory is unreadable and remains report-only: %v", parseErr)) + collectDockerVolumeNames(snapshot, surfaceID) + } else { + collectDockerVolumeRecords(snapshot, surfaceID, volumes) + } + } + collectDedicatedBuildxStorage(snapshot, surfaceID) +} + +type dockerDiskUsageVolume struct { + Name string `json:"Name"` + Size string `json:"Size"` + Labels string `json:"Labels"` +} + +func parseDockerDiskUsageVolumes(output []byte) ([]dockerDiskUsageVolume, error) { + var usage struct { + Volumes []dockerDiskUsageVolume `json:"Volumes"` + } + if err := json.Unmarshal(output, &usage); err != nil { + return nil, err + } + return usage.Volumes, nil +} + +func collectDockerVolumeNames(snapshot *inventory.Snapshot, surfaceID string) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "docker", "volume", "ls", "--format", "{{.Name}}").Output() + if err != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Docker volume inventory is unavailable and remains report-only: %v", err)) + return + } + volumes := make([]dockerDiskUsageVolume, 0) + for _, name := range strings.Fields(string(output)) { + volumes = append(volumes, dockerDiskUsageVolume{Name: name}) + } + collectDockerVolumeRecords(snapshot, surfaceID, volumes) +} + +func collectDockerVolumeRecords(snapshot *inventory.Snapshot, surfaceID string, volumes []dockerDiskUsageVolume) { + for _, volume := range volumes { + if !strings.HasPrefix(volume.Name, "epar-") { + continue + } + labels := parseDockerLabels(volume.Labels) + role := labels["io.solutionforest.epar.cache"] + if labels["io.solutionforest.epar.project"] == storageProjectID(snapshot.ProjectRoot) && + labels["io.solutionforest.epar.schema"] == "1" && + sameStorageProjectRoot(labels["io.solutionforest.epar.root"], snapshot.ProjectRoot) && + (role == "gomod" || role == "gobuild") { + snapshot.Artifacts = append(snapshot.Artifacts, storage.Artifact{ + ID: externalStorageID("go-cache-volume", volume.Name, role, labels["io.solutionforest.epar.root"]), + SurfaceID: surfaceID, + Kind: storage.ArtifactGoCache, + Target: storage.Target{Kind: storage.TargetDockerVolume, Locator: volume.Name, Identity: volume.Name, Fingerprint: labels["io.solutionforest.epar.project"] + "\x00" + role + "\x001", Match: storage.MatchExact}, + Ownership: storage.Ownership{Kind: storage.OwnershipExact, OwnerID: labels["io.solutionforest.epar.project"], Evidence: "exact Docker volume labels"}, + SizeBytes: parseDockerSize(volume.Size), + LastUsedAt: snapshot.CollectedAt, + Protections: []storage.Protection{{ + Kind: storage.ProtectionLock, + Detail: "project-scoped Go cache is bounded by the native-controller wrapper", + }}, + }) + continue + } + snapshot.Artifacts = append(snapshot.Artifacts, storage.Artifact{ + ID: externalStorageID("docker-volume", volume.Name), + SurfaceID: surfaceID, + Kind: storage.ArtifactDockerVolume, + Target: storage.Target{Kind: storage.TargetDockerVolume, Locator: volume.Name, Identity: volume.Name, Match: storage.MatchExact}, + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown, Evidence: "name prefix or incomplete labels are not exact ownership"}, + SizeBytes: parseDockerSize(volume.Size), + Protections: []storage.Protection{{Kind: storage.ProtectionUncertain, Detail: "volume lacks exact persisted EPAR ownership labels; explicit operator review only"}}, + }) + } +} + +func parseDockerLabels(value string) map[string]string { + labels := make(map[string]string) + for _, field := range strings.Split(value, ",") { + key, item, found := strings.Cut(strings.TrimSpace(field), "=") + if found && key != "" { + labels[key] = item + } + } + return labels +} + +func storageProjectID(projectRoot string) string { + canonical := filepath.Clean(projectRoot) + if runtime.GOOS == "windows" { + canonical = strings.ToLower(canonical) + } + sum := sha256.Sum256([]byte(canonical)) + return hex.EncodeToString(sum[:6]) +} + +func sameStorageProjectRoot(labelRoot, projectRoot string) bool { + if labelRoot == "" { + return false + } + left, right := filepath.Clean(labelRoot), filepath.Clean(projectRoot) + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func collectDedicatedBuildxStorage(snapshot *inventory.Snapshot, surfaceID string) { + metadata, err := artifactimage.LoadBuildxMetadata(snapshot.ProjectRoot) + if err != nil { + if !os.IsNotExist(err) { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR Buildx ownership metadata is invalid; no builder cache is trusted: %v", err)) + } + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "docker", "buildx", "du", "--builder", metadata.Builder, "--format", "json").Output() + if err != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR Buildx cache inventory for %q is unavailable: %v", metadata.Builder, err)) + return + } + total, err := parseBuildxDiskUsage(output) + if err != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR Buildx cache inventory for %q is unreadable: %v", metadata.Builder, err)) + return + } + cacheLimit, limitErr := config.ParseByteSize(metadata.CacheLimit) + if limitErr != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR Buildx cache limit for %q is invalid: %v", metadata.Builder, limitErr)) + } + artifact := storage.Artifact{ + ID: externalStorageID("buildx-cache", metadata.Builder, metadata.ProjectRoot), + SurfaceID: surfaceID, + Kind: storage.ArtifactBuildKitCache, + Target: storage.Target{Kind: storage.TargetBuildKitRecord, Locator: metadata.Builder, Identity: metadata.Builder, Fingerprint: metadata.ProjectRoot + "\x00" + metadata.CacheLimit, Match: storage.MatchExact}, + Ownership: storage.Ownership{Kind: storage.OwnershipExact, OwnerID: metadata.ProjectRoot, Evidence: artifactimage.BuildxMetadataPath(snapshot.ProjectRoot)}, + SizeBytes: total, + LastUsedAt: snapshot.CollectedAt, + Protections: []storage.Protection{{Kind: storage.ProtectionLock, Detail: "dedicated BuildKit enforces its configured garbage-collection ceiling"}}, + } + snapshot.Artifacts = append(snapshot.Artifacts, artifact) + if limitErr == nil && total > uint64(cacheLimit) { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR Buildx cache %q currently uses %d bytes above its %d-byte configured ceiling; BuildKit garbage collection is authoritative", metadata.Builder, total, uint64(cacheLimit))) + } +} + +func parseBuildxDiskUsage(output []byte) (uint64, error) { + type record struct { + ID string `json:"ID"` + Size json.RawMessage `json:"Size"` + Total json.RawMessage `json:"Total"` + } + parseBytes := func(raw json.RawMessage) (uint64, bool) { + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return 0, false + } + var numeric uint64 + if err := json.Unmarshal(raw, &numeric); err == nil { + return numeric, true + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + value := parseDockerSize(text) + return value, value > 0 || strings.TrimSpace(text) == "0B" + } + return 0, false + } + trimmed := bytes.TrimSpace(output) + if len(trimmed) == 0 { + return 0, nil + } + var records []record + if trimmed[0] == '[' { + if err := json.Unmarshal(trimmed, &records); err != nil { + return 0, err + } + } else { + scanner := bufio.NewScanner(bytes.NewReader(trimmed)) + for scanner.Scan() { + var value record + if err := json.Unmarshal(scanner.Bytes(), &value); err != nil { + return 0, err + } + records = append(records, value) + } + if err := scanner.Err(); err != nil { + return 0, err + } + } + var sum uint64 + for _, value := range records { + if total, found := parseBytes(value.Total); found { + return total, nil + } + if value.ID == "" { + continue + } + size, found := parseBytes(value.Size) + if found { + sum += size + } + } + return sum, nil +} + +func parseDockerSize(value string) uint64 { + value = strings.TrimSpace(value) + index := 0 + for index < len(value) && (value[index] == '.' || value[index] >= '0' && value[index] <= '9') { + index++ + } + if index == 0 || index == len(value) { + return 0 + } + number, err := strconv.ParseFloat(value[:index], 64) + if err != nil || number < 0 { + return 0 + } + multiplier := float64(0) + switch strings.ToUpper(strings.TrimSpace(value[index:])) { + case "B": + multiplier = 1 + case "KB": + multiplier = 1e3 + case "MB": + multiplier = 1e6 + case "GB": + multiplier = 1e9 + case "TB": + multiplier = 1e12 + case "KIB": + multiplier = 1 << 10 + case "MIB": + multiplier = 1 << 20 + case "GIB": + multiplier = 1 << 30 + case "TIB": + multiplier = 1 << 40 + default: + return 0 + } + bytes := number * multiplier + if math.IsInf(bytes, 0) || bytes > math.MaxUint64 { + return 0 + } + return uint64(bytes) +} + +func dockerImageProvider(repository string) string { + switch { + case strings.HasPrefix(repository, "epar-docker-sandboxes-"): + return "docker-sandboxes" + case strings.HasPrefix(repository, "epar-docker-container-"): + return "docker-container" + default: + // Development images, retired naming schemes, and cache volumes can be + // shared by more than one provider. Keep them provider-neutral. + return "" + } +} + +func storageProviderMatches(filter, artifactProvider string) bool { + return filter == "" || artifactProvider == "" || filter == artifactProvider +} + +func collectDockerSandboxesStorage(snapshot *inventory.Snapshot) { + const surfaceID = "docker-sandboxes-template-cache" + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ + ID: surfaceID, + Provider: "docker-sandboxes", + Kind: storage.SurfaceSandboxCache, + Location: "docker-sandboxes-containerd", + Capacity: storage.Capacity{ObservedAt: snapshot.CollectedAt}, + }) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + templates, err := dockersandboxes.New("").CachedTemplates(ctx) + if err != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Docker Sandboxes template inventory is unavailable and remains report-only: %v", err)) + return + } + for _, template := range templates { + snapshot.Artifacts = append(snapshot.Artifacts, storage.Artifact{ + ID: externalStorageID("sandbox-template", template.Reference, template.CacheID), + Provider: "docker-sandboxes", + SurfaceID: surfaceID, + Kind: storage.ArtifactSandboxTemplate, + Target: storage.Target{Kind: storage.TargetSandboxTemplate, Locator: template.Reference, Identity: template.CacheID, Match: storage.MatchExact}, + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown, Evidence: "imported template has no persisted EPAR owner receipt"}, + SizeBytes: uint64(maxInt64(template.SizeBytes, 0)), + Protections: []storage.Protection{{Kind: storage.ProtectionUncertain, Detail: "imported templates require explicit prune execution"}}, + }) + } +} + +func collectWSLStorage(snapshot *inventory.Snapshot) { + const surfaceID = "wsl-distributions" + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ID: surfaceID, Provider: "wsl", Kind: storage.SurfaceExternal, Location: "wsl", Capacity: storage.Capacity{ObservedAt: snapshot.CollectedAt}}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "wsl.exe", "--list", "--quiet").Output() + if err != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("WSL distribution inventory is unavailable and remains report-only: %v", err)) + return + } + for _, name := range strings.Fields(strings.ReplaceAll(string(output), "\x00", "")) { + if !strings.HasPrefix(strings.ToLower(name), "epar-") { + continue + } + snapshot.Artifacts = append(snapshot.Artifacts, externalReportOnlyArtifact("wsl-distribution", "wsl", surfaceID, name)) + } +} + +func collectTartStorage(snapshot *inventory.Snapshot) { + const surfaceID = "tart-images" + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ID: surfaceID, Provider: "tart", Kind: storage.SurfaceExternal, Location: "tart", Capacity: storage.Capacity{ObservedAt: snapshot.CollectedAt}}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "tart", "list", "--format", "json").Output() + if err != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Tart image inventory is unavailable and remains report-only: %v", err)) + return + } + snapshot.Artifacts = append(snapshot.Artifacts, externalReportOnlyArtifact("tart-inventory", "tart", surfaceID, strings.TrimSpace(string(output)))) +} + +func externalReportOnlyArtifact(kind, providerName, surfaceID, identity string) storage.Artifact { + return storage.Artifact{ + ID: externalStorageID(kind, identity), + Provider: providerName, + SurfaceID: surfaceID, + Kind: storage.ArtifactOther, + Target: storage.Target{Kind: storage.TargetExternal, Locator: kind + ":" + identity, Identity: identity, Match: storage.MatchExact}, + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown}, + Protections: []storage.Protection{{Kind: storage.ProtectionUncertain, Detail: "external provider resource requires explicit operator-reviewed prune"}}, + } +} + +func externalStorageID(parts ...string) string { + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return "external-" + hex.EncodeToString(sum[:12]) +} + +func maxInt64(value, minimum int64) int64 { + if value < minimum { + return minimum + } + return value +} diff --git a/cmd/ephemeral-action-runner/storage_external_test.go b/cmd/ephemeral-action-runner/storage_external_test.go new file mode 100644 index 0000000..c513712 --- /dev/null +++ b/cmd/ephemeral-action-runner/storage_external_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" + "github.com/solutionforest/ephemeral-action-runner/internal/storage/inventory" +) + +func TestDockerImageProvider(t *testing.T) { + tests := map[string]string{ + "epar-docker-sandboxes-catthehacker-full": "docker-sandboxes", + "epar-docker-container-catthehacker-full": "docker-container", + "epar-docker-dind-act": "", + "epar-dev-toolchain": "", + } + for repository, want := range tests { + if got := dockerImageProvider(repository); got != want { + t.Fatalf("dockerImageProvider(%q) = %q, want %q", repository, got, want) + } + } +} + +func TestStorageProviderMatches(t *testing.T) { + tests := []struct { + filter string + provider string + want bool + }{ + {filter: "", provider: "docker-container", want: true}, + {filter: "docker-sandboxes", provider: "", want: true}, + {filter: "docker-sandboxes", provider: "docker-sandboxes", want: true}, + {filter: "docker-sandboxes", provider: "docker-container", want: false}, + } + for _, test := range tests { + if got := storageProviderMatches(test.filter, test.provider); got != test.want { + t.Fatalf("storageProviderMatches(%q, %q) = %t, want %t", test.filter, test.provider, got, test.want) + } + } +} + +func TestParseDockerSize(t *testing.T) { + tests := map[string]uint64{ + "0B": 0, + "972.1MB": 972_100_000, + "17.44GB": 17_440_000_000, + "1.5GiB": 1_610_612_736, + "unknown": 0, + } + for input, want := range tests { + if got := parseDockerSize(input); got != want { + t.Fatalf("parseDockerSize(%q) = %d, want %d", input, got, want) + } + } +} + +func TestParseBuildxDiskUsageArray(t *testing.T) { + got, err := parseBuildxDiskUsage([]byte(`[{"ID":"a","Size":1024},{"ID":"b","Size":"1KiB"}]`)) + if err != nil { + t.Fatal(err) + } + if got != 2048 { + t.Fatalf("parseBuildxDiskUsage() = %d, want 2048", got) + } +} + +func TestParseBuildxDiskUsageNDJSONSummary(t *testing.T) { + got, err := parseBuildxDiskUsage([]byte("{\"ID\":\"a\",\"Size\":1024}\n{\"Total\":4096}\n")) + if err != nil { + t.Fatal(err) + } + if got != 4096 { + t.Fatalf("parseBuildxDiskUsage() = %d, want 4096", got) + } +} + +func TestParseBuildxDiskUsageRejectsInvalidJSON(t *testing.T) { + if _, err := parseBuildxDiskUsage([]byte("{")); err == nil { + t.Fatal("parseBuildxDiskUsage() error = nil, want invalid JSON error") + } +} + +func TestParseDockerDiskUsageVolumes(t *testing.T) { + volumes, err := parseDockerDiskUsageVolumes([]byte(`{"Volumes":[{"Name":"epar-project-gocache","Size":"1.5GiB","Labels":"io.solutionforest.epar.cache=gobuild"}]}`)) + if err != nil { + t.Fatal(err) + } + if len(volumes) != 1 || volumes[0].Name != "epar-project-gocache" || volumes[0].Size != "1.5GiB" { + t.Fatalf("unexpected Docker volume records: %#v", volumes) + } +} + +func TestParseDockerLabels(t *testing.T) { + labels := parseDockerLabels("io.solutionforest.epar.project=abc123,io.solutionforest.epar.cache=gomod,missing") + if labels["io.solutionforest.epar.project"] != "abc123" || labels["io.solutionforest.epar.cache"] != "gomod" { + t.Fatalf("unexpected labels: %#v", labels) + } + if _, found := labels["missing"]; found { + t.Fatalf("malformed label was accepted: %#v", labels) + } +} + +func TestCollectDockerVolumeRecordsTrustsOnlyExactProjectLabels(t *testing.T) { + root := t.TempDir() + projectID := storageProjectID(root) + snapshot := inventory.Snapshot{ProjectRoot: root, CollectedAt: time.Now().UTC()} + collectDockerVolumeRecords(&snapshot, "docker-engine", []dockerDiskUsageVolume{ + { + Name: "epar-" + projectID + "-gocache", + Size: "1GiB", + Labels: "io.solutionforest.epar.project=" + projectID + ",io.solutionforest.epar.cache=gobuild,io.solutionforest.epar.schema=1,io.solutionforest.epar.root=" + root, + }, + {Name: "epar-gocache", Size: "4GiB"}, + }) + if len(snapshot.Artifacts) != 2 { + t.Fatalf("artifact count = %d, want 2", len(snapshot.Artifacts)) + } + if snapshot.Artifacts[0].Kind != storage.ArtifactGoCache || snapshot.Artifacts[0].Ownership.Kind != storage.OwnershipExact { + t.Fatalf("exact labelled cache was not trusted: %#v", snapshot.Artifacts[0]) + } + if snapshot.Artifacts[1].Kind != storage.ArtifactDockerVolume || snapshot.Artifacts[1].Ownership.Kind != storage.OwnershipUnknown { + t.Fatalf("unlabelled cache was trusted: %#v", snapshot.Artifacts[1]) + } +} + +func TestRunEffectiveGoCacheLimitUsesDefaults(t *testing.T) { + root := t.TempDir() + output, err := captureStdout(t, func() error { + return runStorage([]string{"effective-go-cache-limit", "--project-root", root}) + }) + if err != nil { + t.Fatal(err) + } + if output != "10737418240\n" { + t.Fatalf("effective Go cache limit = %q, want 10GiB in bytes", output) + } +} + +func TestNativeWrappersUseExactBoundedGoCaches(t *testing.T) { + for _, name := range []string{"build-native-controller.sh", "build-native-controller.ps1"} { + content, err := os.ReadFile(filepath.Join("..", "..", "scripts", name)) + if err != nil { + t.Fatal(err) + } + text := string(content) + for _, required := range []string{ + "io.solutionforest.epar.project", + "io.solutionforest.epar.cache", + "io.solutionforest.epar.root", + "effective-go-cache-limit", + "EPAR_GO_CACHE_LIMIT_BYTES", + } { + if !strings.Contains(text, required) { + t.Fatalf("%s is missing exact bounded Go cache contract %q", name, required) + } + } + } +} diff --git a/cmd/ephemeral-action-runner/storage_preflight_test.go b/cmd/ephemeral-action-runner/storage_preflight_test.go new file mode 100644 index 0000000..1e063b8 --- /dev/null +++ b/cmd/ephemeral-action-runner/storage_preflight_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "strings" + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" +) + +func TestPreflightControllerStorageRejectsInsufficientSurface(t *testing.T) { + t.Setenv("EPAR_INVOCATION", "start") + cfg := config.Default() + cfg.Provider.Type = "docker-container" + cfg.Storage.MinimumFree = "9223372036854775807B" + + err := preflightControllerStorage(t.TempDir(), cfg) + if err == nil { + t.Fatal("preflightControllerStorage() error = nil, want insufficient capacity") + } + for _, want := range []string{ + "not enough disk space to initialize the EPAR controller", + "Estimated operation growth: 0 bytes", + "Free-space reserve: 8.00 EiB", + "./start storage prune --provider docker-container", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("preflightControllerStorage() error = %q, want %q", err, want) + } + } +} diff --git a/cmd/ephemeral-action-runner/version.go b/cmd/ephemeral-action-runner/version.go index 28909c3..46ff31f 100644 --- a/cmd/ephemeral-action-runner/version.go +++ b/cmd/ephemeral-action-runner/version.go @@ -10,15 +10,20 @@ var ( version = "dev" commit = "unknown" buildDate = "unknown" + // sourceRevision is populated only by the native-controller build scripts. + // Promotion requires its exact clean-source sha256 identity; go run, + // release builds without equivalent plumbing, and dirty builds fail closed. + sourceRevision = "unknown" ) func versionString() string { return fmt.Sprintf(`%s %s commit: %s buildDate: %s +sourceRevision: %s go: %s platform: %s/%s -`, binaryName, version, commit, buildDate, runtime.Version(), runtime.GOOS, runtime.GOARCH) +`, binaryName, version, commit, buildDate, sourceRevision, runtime.Version(), runtime.GOOS, runtime.GOARCH) } func printVersion(w io.Writer) { diff --git a/cmd/ephemeral-action-runner/version_test.go b/cmd/ephemeral-action-runner/version_test.go index 380bbe5..eb765cd 100644 --- a/cmd/ephemeral-action-runner/version_test.go +++ b/cmd/ephemeral-action-runner/version_test.go @@ -14,6 +14,7 @@ func TestVersionStringDefaults(t *testing.T) { "ephemeral-action-runner dev", "commit: unknown", "buildDate: unknown", + "sourceRevision: unknown", "go: " + runtime.Version(), "platform: " + runtime.GOOS + "/" + runtime.GOARCH, } { @@ -24,20 +25,22 @@ func TestVersionStringDefaults(t *testing.T) { } func TestVersionStringInjectedMetadata(t *testing.T) { - oldVersion, oldCommit, oldBuildDate := version, commit, buildDate + oldVersion, oldCommit, oldBuildDate, oldSourceRevision := version, commit, buildDate, sourceRevision t.Cleanup(func() { - version, commit, buildDate = oldVersion, oldCommit, oldBuildDate + version, commit, buildDate, sourceRevision = oldVersion, oldCommit, oldBuildDate, oldSourceRevision }) version = "v1.2.3-beta.1" commit = "abc1234" buildDate = "2026-07-07T00:00:00Z" + sourceRevision = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" got := versionString() for _, want := range []string{ "ephemeral-action-runner v1.2.3-beta.1", "commit: abc1234", "buildDate: 2026-07-07T00:00:00Z", + "sourceRevision: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } { if !strings.Contains(got, want) { t.Fatalf("versionString() missing %q in:\n%s", want, got) diff --git a/configs/docker-container.act.example.yml b/configs/docker-container.act.example.yml index 7a2682d..3d5eaee 100644 --- a/configs/docker-container.act.example.yml +++ b/configs/docker-container.act.example.yml @@ -26,6 +26,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/configs/docker-container.core.example.yml b/configs/docker-container.core.example.yml index 9b7e60b..3680882 100644 --- a/configs/docker-container.core.example.yml +++ b/configs/docker-container.core.example.yml @@ -26,6 +26,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/configs/docker-container.example.yml b/configs/docker-container.example.yml index 8b97f92..443993f 100644 --- a/configs/docker-container.example.yml +++ b/configs/docker-container.example.yml @@ -26,6 +26,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/configs/docker-container.web-e2e.example.yml b/configs/docker-container.web-e2e.example.yml index 0ed8c97..a24ec3d 100644 --- a/configs/docker-container.web-e2e.example.yml +++ b/configs/docker-container.web-e2e.example.yml @@ -27,6 +27,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/configs/docker-sandboxes.example.yml b/configs/docker-sandboxes.example.yml new file mode 100644 index 0000000..3fb7c76 --- /dev/null +++ b/configs/docker-sandboxes.example.yml @@ -0,0 +1,67 @@ +github: + appId: 123456 + organization: your-org + privateKeyPath: ~/.config/ephemeral-action-runner/github-app.pem + apiBaseUrl: https://api.github.com + webBaseUrl: https://github.com + +image: + hostTrustMode: overlay + hostTrustScopes: [system] + +pool: + instances: 1 + namePrefix: change-me-docker-sandboxes + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + +runner: + group: your-runner-group + # On Apple Silicon, replace X64 with ARM64 when provider.platform is linux/arm64. + labels: [self-hosted, linux, X64, epar-docker-sandboxes] + includeHostLabel: true + ephemeral: true + +security: + runnerGroup: + enforcement: enforce + requireExplicitGroup: true + requireNonDefaultGroup: true + requiredRepositoryAccess: selected + requirePublicRepositoriesDisabled: true + +provider: + type: docker-sandboxes + # Exact host mapping: Windows/Linux amd64 -> linux/amd64; macOS arm64 -> linux/arm64. + platform: linux/amd64 + +# This sample tag is the repository's current linux/amd64 full-template lock. Replace template, templateDigest, and policyGeneration with the exact platform-specific identities printed by the wizard or approved for your environment. templateDigest is the full verified local template ID printed by build-template.ps1. +dockerSandboxes: + template: epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64 + templateDigest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + policyGeneration: sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 + networkBaseline: open + additionalAllow: + - api.github.com + - '*.githubusercontent.com:443' + additionalDeny: + - telemetry.example.invalid + stagingRoot: .local/docker-sandboxes-staging + cpus: 4 + memory: 8GiB + # Current full-template recommendation: 309.73MiB measured guest root peak + 25% + 20GiB headroom, rounded up to 10GiB. + rootDisk: 30GiB + dockerDisk: 100GiB + maxConcurrentCreates: 2 + minHostFreeSpace: 50GiB + +timeouts: + bootSeconds: 180 + githubOnlineSeconds: 180 + commandSeconds: 900 diff --git a/configs/tart.example.yml b/configs/tart.example.yml index 58e910a..1e305c5 100644 --- a/configs/tart.example.yml +++ b/configs/tart.example.yml @@ -24,6 +24,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/configs/tart.web-e2e.example.yml b/configs/tart.web-e2e.example.yml index 8a49681..ffd9ae8 100644 --- a/configs/tart.web-e2e.example.yml +++ b/configs/tart.web-e2e.example.yml @@ -22,6 +22,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/configs/wsl.example.yml b/configs/wsl.example.yml index 2d734bb..cd60777 100644 --- a/configs/wsl.example.yml +++ b/configs/wsl.example.yml @@ -24,6 +24,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/configs/wsl.lean.example.yml b/configs/wsl.lean.example.yml index 910bfa0..29dbfb2 100644 --- a/configs/wsl.lean.example.yml +++ b/configs/wsl.lean.example.yml @@ -23,6 +23,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/configs/wsl.web-e2e.example.yml b/configs/wsl.web-e2e.example.yml index 2ae9c84..11532bd 100644 --- a/configs/wsl.web-e2e.example.yml +++ b/configs/wsl.web-e2e.example.yml @@ -23,6 +23,15 @@ pool: replacementRetryMaxSeconds: 1800 replacementRetryMultiplier: 2 replacementRetryJitterPercent: 20 + +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB + logging: directory: work/logs managerSinks: [console] diff --git a/docs/advanced/macos-startup.md b/docs/advanced/macos-startup.md index 953d75c..68eb07d 100644 --- a/docs/advanced/macos-startup.md +++ b/docs/advanced/macos-startup.md @@ -24,7 +24,7 @@ Double-click `.local/start-epar.command` in Finder or run it from Terminal: The script: - finds the EPAR source folder, such as when the script lives at `.local/start-epar.command`; -- delegates to `./start`, which runs EPAR with `go run ./cmd/ephemeral-action-runner` if Go is installed and working, or a containerized `go run` if not (see [No Go Install](#no-go-install)); +- delegates to `./start`, which runs EPAR with `go run ./cmd/ephemeral-action-runner` if Go is installed and working, or uses a containerized toolchain to build and cache a native controller if not (see [No Go Install](#no-go-install)); - uses `.local/config.yml` by default; - waits for Docker to become ready before starting EPAR; - starts an existing `epar-dockerhub-cache` mirror container if one exists; @@ -56,7 +56,7 @@ If you use the optional Docker registry mirror, create the mirror container sepa ## No Go Install -`start-epar.command` delegates to `./start` at the repo root. If `go` isn't on `PATH`, or the `go` found there doesn't actually run (stale/wrong-architecture installs happen — see below), `./start` runs EPAR straight from source with a containerized Go toolchain (`scripts/run-with-docker.sh`) instead of failing. No binary is built or left on disk either way. Docker is required in both cases. +`start-epar.command` delegates to `./start` at the repo root. If `go` isn't on `PATH`, or the `go` found there doesn't actually run (stale/wrong-architecture installs happen — see below), `./start` uses a containerized Go toolchain through `scripts/run-with-docker.sh` to cross-compile a CGO-disabled native controller, cache it under `.local/bin`, and run it on the host. Docker is required for this fallback build path. To force this path even when Go is installed (for example, to avoid rebuilding via `go run` on every start), set in your local copy: diff --git a/docs/advanced/no-go-install.md b/docs/advanced/no-go-install.md index 7c439a6..3b9f7ef 100644 --- a/docs/advanced/no-go-install.md +++ b/docs/advanced/no-go-install.md @@ -1,10 +1,10 @@ # Running EPAR Without Installing Go -The standard path is to download GitHub's automatic **Source code (zip)** or **Source code (tar.gz)** from the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases) and run `go run ./cmd/ephemeral-action-runner` from the extracted source folder. If you do not want Go installed on the host, use EPAR's Docker-based source runner instead. The default Docker Container provider still needs Docker. +The standard path is to download GitHub's automatic **Source code (zip)** or **Source code (tar.gz)** from the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases) and run `go run ./cmd/ephemeral-action-runner` from the extracted source folder. If you do not want Go installed on the host, use EPAR's Docker-based native-controller builder instead. Docker remains required for that build toolchain. ## Run With Docker -Run `./start` at the source folder root on macOS, Linux, WSL, or Git Bash, or `./start.ps1` / `start.cmd` in native Windows PowerShell or cmd. The wrapper uses local Go when it is installed and runnable; otherwise it runs EPAR from the checked-out source with a containerized Go toolchain: +Run `./start` at the source folder root on macOS, Linux, WSL, or Git Bash, or `./start.ps1` / `start.cmd` in native Windows PowerShell or cmd. The wrapper uses local Go when it is installed and runnable; otherwise a containerized Go toolchain cross-compiles a CGO-disabled binary for the native host, caches it by source, platform, and immutable toolchain-image hash under `.local/bin`, and executes it on the host: ```bash ./start --config .local/config.yml --instances 2 @@ -14,25 +14,19 @@ Run `./start` at the source folder root on macOS, Linux, WSL, or Git Bash, or `. .\start.ps1 --config .local\config.yml --instances 2 ``` -Under the hood, the wrapper calls `scripts/run-with-docker.sh` (or `scripts/run-with-docker.ps1` on Windows), builds a small local image with the Go toolchain and Docker CLI from `scripts/docker/dev.Dockerfile`, and runs `go run ./cmd/ephemeral-action-runner ...`. This stays source-based; it does not download or use a separately packaged EPAR executable. The Docker CLI in the toolchain image lets EPAR's own Docker operations reach the host daemon through the mounted socket. +Under the hood, the wrapper calls `scripts/run-with-docker.sh` or `scripts/run-with-docker.ps1`, builds the small toolchain image from `scripts/docker/dev.Dockerfile`, compiles EPAR with `CGO_ENABLED=0`, and runs the cached host-native binary. This stays source-based and does not download a separately packaged EPAR executable. Native execution is mandatory for Docker Sandboxes because its management endpoint and host security state are not exposed to the build container. -### Host trust bridge +### Host trust -When the selected config enables `image.hostTrustMode: overlay`, the controller must inherit trust from the real Windows, macOS, or Linux host, not from the temporary Linux Go-toolchain container. The official wrappers handle this boundary automatically: +When the selected config enables `image.hostTrustMode: overlay`, the native controller reads trust from the real Windows, macOS, or Linux host. The temporary Go toolchain container only compiles the binary and does not supply runtime trust roots. -1. A native host helper reads the configured host trust scopes. -2. It rejects an empty collection and publishes a fresh, content-addressed snapshot in the host user's cache. -3. A watcher refreshes the snapshot every 10 seconds. -4. The wrapper bind-mounts only that feed read-only at `/run/epar-host-trust` and identifies the real controller host OS. -5. The containerized controller validates certificate hashes, scopes, host OS, and the 30-second feed expiry before using it. +The explicit legacy path `EPAR_LEGACY_CONTROLLER_IN_DOCKER=1` remains available only for compatible providers. That path uses the existing short-lived host-trust bridge and rejects `provider.type: docker-sandboxes`; it is not an automatic fallback. -On a first `start` with no config, the wrapper performs this as two phases: it runs interactive initialization with the real host OS identity, validates and publishes the selected host roots, then starts the controller again with the new feed mounted. A failed collection leaves the generated config disabled and does not start a controller with the toolchain container's trust store. +On a first `start` with no config, the native controller performs interactive initialization with the real host identity. A failed host-root collection stops initialization rather than using roots from the toolchain container. On Windows the helper reads local-machine and current-user root stores and excludes Windows-disallowed certificates. On macOS it evaluates the system, administrator, and selected user's native trust settings for TLS server use, with explicit deny taking precedence. On Linux it reads the distribution-generated system CA bundle; set `EPAR_HOST_TRUST_BUNDLE` to a readable generated PEM bundle when the host uses an unsupported layout. -The watcher and controller are tied to the wrapper lifecycle. If the host helper stops, its feed expires and EPAR stops authorizing stale idle runners. The feed is never mounted into the disposable runner containers. - -Do not replace the official wrapper with a bare `docker run` when overlay mode is enabled. A containerized controller without `EPAR_CONTROLLER_HOST_OS`, a fresh `EPAR_HOST_TRUST_FEED`, and the corresponding read-only mount fails closed; it does not fall back to the toolchain container's CA store. +Do not replace the official wrapper with a bare `docker run` for Docker Sandboxes. EPAR rejects the legacy controller-in-Docker path for that provider. You can run the Docker wrapper directly instead of through `./start`: @@ -41,15 +35,17 @@ scripts/run-with-docker.sh version scripts/run-with-docker.sh start --config .local/config.yml ``` -Set `EPAR_USE_DOCKER_RUN=1` to force `./start` down this path even when Go is installed, or `=0` to force local `go run` and error instead of falling back. A Docker volume caches Go modules and build output across runs, so repeat starts are fast. +Set `EPAR_USE_DOCKER_RUN=1` to force `./start` to use the containerized compiler even when Go is installed, or `=0` to force local `go run` and error instead of falling back. Docker volumes cache Go modules and build output, while `.local/bin` caches the resulting native binary, so unchanged repeat starts are fast. Native-controller retention never removes active revisions, gives completed revisions a seven-day grace period, then keeps at most five previous completed revisions while the selected cache plus the current revision fits within 256 MiB. Active and grace-protected revisions may temporarily exceed those bounds. Automatic retention requires the ownership manifest written by the current wrapper; older unmanifested revisions remain for explicit review. + +Before the first containerized compilation, the wrapper requires 20 GiB free on the source-folder filesystem so bootstrap does not begin when the host is already critically constrained. `EPAR_BOOTSTRAP_MIN_FREE_BYTES` may raise this reserve for managed installations. -The Docker wrapper passes the real host name into the toolchain container as `EPAR_HOST_NAME` so first-run defaults and generated host labels describe the machine running EPAR, not the temporary Go container. Set `EPAR_HOST_NAME` before launching EPAR to override that identity. +The wrapper passes the real host name to the native controller as `EPAR_HOST_NAME` so first-run defaults and generated host labels describe the machine running EPAR. Set `EPAR_HOST_NAME` before launching EPAR to override that identity. The wrapper also defaults `DOCKER_CLI_HINTS=false` for its Docker calls. This suppresses Docker Desktop hint text that can otherwise appear after a normal Ctrl-C shutdown. Set `DOCKER_CLI_HINTS=true` before launching EPAR if you want Docker CLI hints during wrapper runs. ### Linux file ownership -The container runs as root because the Go toolchain image requires it to write module and build-cache directories. On Docker Desktop for macOS, bind-mounted files remain owned by your normal user. On native Linux hosts, `scripts/run-with-docker.sh` returns ownership of `.local/` and `work/` to the invoking user after each run; do not rely on the same behavior for other repository directories. +The compiler container mounts the source read-only and writes only the temporary native binary output plus named Go caches. The controller itself runs as the invoking host user, so its `.local/` and `work/` state is not created by a root controller process. ### Windows: WSL versus native PowerShell diff --git a/docs/configuration.md b/docs/configuration.md index d0112a5..b71db37 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -EPAR stores local settings in `.local/config.yml` by default. The first run creates that file for the default Docker Container setup when it does not exist. +EPAR stores local settings in `.local/config.yml` by default. When the file does not exist, the first-run wizard always displays Docker Container, Docker Sandboxes, WSL2, and experimental Tart with a prerequisite result, while refusing unavailable selections. Docker Sandboxes is the recommended Enter default without an operating-system allowlist when Docker is ready, the exact supported `sbx` version is installed, the host architecture maps to an available Linux guest template, and `sbx diagnose --output json` reports at least one pass and zero failures. Diagnostic warnings remain visible but do not disable the choice. The wizard performs read-only local discovery of an active lock-selected EPAR template, its full local image identity and platform, and the current host-global Balanced-policy fingerprint before writing configuration. New Docker Sandboxes configs set `networkBaseline: open`, which adds an EPAR-owned sandbox-scoped `allow **` rule for public HTTP/HTTPS compatibility plus deny-wins host-alias guardrails without changing the host-global policy. The rolling Catthehacker source-channel labels do not refresh automatically; the source lock is an approved snapshot. ## Pre-release provider rename @@ -32,13 +32,15 @@ EPAR looks for config in this order: | Section | Purpose | | --- | --- | | `github` | GitHub App ID, organization, private key path, and optional GitHub API/web URLs. | -| `provider` | How EPAR creates disposable runners: `docker-container`, `wsl`, or `tart`. | +| `provider` | How EPAR creates disposable runners: `docker-container`, `docker-sandboxes`, `wsl`, or `tart`. | | `image` | Source image/rootfs, output image, runner version, and optional install scripts. | | `pool` | Runner count, instance name prefix, and replacement retry policy. | +| `storage` | Provider-neutral free-space reserve, grace period, retained generations, and cache limits. | | `logging` | Manager and transcript sinks, formats, rotation, retention, and log directory. | | `runner` | GitHub Actions labels, runner group, default-label policy, and whether to add the host-machine label. | | `security` | Runner-group policy requirements checked before runner registration. | | `docker` | Optional Docker registry mirrors and private Docker daemon proxy settings. | +| `dockerSandboxes` | Pinned template, policy, staging, capacity, and resource settings for Docker Sandboxes. | | `timeouts` | Boot, GitHub online, and command timeout values in seconds. | ## Common Edits @@ -57,7 +59,7 @@ pool: namePrefix: buildbox01-a4f9c2 ``` -`pool.namePrefix` is both the prefix for generated GitHub runner names and the cleanup boundary for GitHub runner records. It must be 2-40 characters and should leave room for EPAR's generated `-YYYYMMDD-HHMMSS-###` suffix. Do not reuse the same prefix on different machines or for separate EPAR supervisors in the same organization. If two machines share a prefix, one machine's cleanup can delete the other machine's GitHub runner record, causing the other supervisor to report that the runner record is gone and replace a healthy runner. +The first-run wizard derives `pool.namePrefix` from the sanitized machine name and a six-character cryptographically random hexadecimal suffix, making it recognizable while reducing collision risk when multiple EPAR configurations are created on the same host. `pool.namePrefix` is the literal beginning of every generated local instance and GitHub runner name. It must be 2-40 characters; the shared name generator appends `-YYYYMMDD-HHMMSS-###`, so a generated name is at most 60 characters and remains within GitHub's runner-name limit and the Docker Sandboxes 63-character limit. Do not reuse the same prefix on different machines or for separate EPAR supervisors in the same organization. Legacy-provider cleanup uses this boundary, while Docker Sandboxes binds and removes exact identities through its ledger. A shared prefix can therefore let one legacy supervisor delete another machine's GitHub runner record and makes every provider's ownership ambiguous. Configure replacement retry behavior after a transient GitHub or network outage: @@ -71,6 +73,20 @@ pool: These values default to `15`, `1800`, `2`, and `20`, so existing configurations remain valid without changes. `replacementRetryInitialSeconds` must be positive, `replacementRetryMaxSeconds` must be at least the initial delay, `replacementRetryMultiplier` must be at least `1`, and `replacementRetryJitterPercent` must be from `0` through `100`. +Configure capacity and bounded retention: + +```yaml +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB +``` + +Existing configurations use these defaults without requiring a new section. See [Storage](storage.md) for reporting and exact cleanup behavior. + The supervisor backs off only replacement allocation after transient network errors and GitHub HTTP `429` or `5xx` responses. The nominal delay doubles from 15 seconds to a 30-minute cap with the configured jitter; a longer GitHub `Retry-After` response takes precedence. Authentication and deterministic configuration failures remain fail-fast after safe rollback. Initial `pool up` startup also remains fail-fast rather than entering an unattended retry loop. `pool.instances` is an absolute local physical-instance cap, not only an online-runner target. Provisioning, ready, draining, quarantined, and cleanup-pending instances all count toward it. Host-trust generation rotation does not receive surge capacity: an old busy runner keeps its slot until it exits or is safely removed. @@ -197,6 +213,8 @@ addresses in ignored `.local/config.yml`, not tracked example files. For `provider.type: docker-container`, EPAR defaults to Catthehacker's full Ubuntu runner image and creates a Docker Container image named `epar-docker-container-catthehacker-ubuntu`. +For `provider.type: docker-sandboxes`, EPAR rejects `provider.sourceImage` and requires an explicitly built and loaded Candidate A template plus its full OCI configuration digest, verified policy fingerprint, staging root, and resource limits. The missing-config wizard offers only the active lock-selected source channels for the host platform: Catthehacker `full-latest` (recommended) and `act-22.04` (current lean profile). Each source channel maps to one exact versioned EPAR template tag; raw Catthehacker images cannot be used directly. The wizard filters historical cached EPAR tags, writes the selected template tag and full local digest to configuration, and does not refresh the approved source-lock snapshot automatically. It reports the shared host template-cache size separately from per-sandbox storage and asks one capacity question. With matching capacity evidence, it derives the total `rootDisk` from measured guest `/` use, a 25% margin, and the recommended 20 GiB writable headroom, then asks for Docker-disk capacity. Without exact root evidence, it asks for provisional total root capacity and writes the Docker-disk default. It writes the 50 GiB host-free floor in both cases; runtime admission strengthens this to at least 10% of the backing volume. Configuration validation enforces a 20 GiB minimum root capacity independently from the 100 GiB Docker-disk minimum. A configured Docker Sandboxes pool never falls back to Docker Container. See the [Docker Sandboxes provider](providers/docker-sandboxes.md) and the tracked example. + For `provider.type: wsl`, EPAR defaults to Catthehacker's full Ubuntu runner image, converts it into a WSL rootfs, and stores the output under `work/images/`. For the experimental `provider.type: tart`, EPAR defaults to `ghcr.io/cirruslabs/ubuntu:latest`, a basic Ubuntu ARM64 VM image. EPAR installs its runner lifecycle but does not add the broad tool and dependency set found in GitHub's hosted runner images. If you require a GitHub-runner-like environment, build and maintain a bootable Tart image yourself by adapting the scripts in [actions/runner-images](https://github.com/actions/runner-images), then set `image.sourceImage` to that Tart image. Rosetta-based amd64 execution also has compatibility limits and must be validated against the exact workflow. @@ -204,5 +222,6 @@ For the experimental `provider.type: tart`, EPAR defaults to `ghcr.io/cirruslabs See the provider docs for details: - [Docker Container Provider](providers/docker-container.md) +- [Docker Sandboxes Provider](providers/docker-sandboxes.md) - [WSL Provider](providers/wsl.md) - [Tart Provider](providers/tart.md) diff --git a/docs/design.md b/docs/design.md index 791ba64..b8f0d57 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,82 +1,43 @@ # Design -EPAR has three main layers: - -- `cmd/ephemeral-action-runner`: CLI for image builds, pool lifecycle, verification, cleanup, and status. -- `internal/provider`: a local instance provider interface. Tart, WSL, and Docker Container are implemented providers. -- `internal/github`: GitHub App authentication and self-hosted runner API calls. +EPAR has one provider-neutral control flow: ```mermaid flowchart LR - CLI["CLI commands"] --> Manager["Pool manager"] - Manager --> Provider["Provider interface"] - Manager --> GitHub["GitHub App client"] - Provider --> Tart["Tart"] - Provider --> WSL["WSL2"] - Provider --> DockerContainer["Docker Container"] - GitHub --> API["GitHub Actions runner APIs"] + CLI["CLI and ./start wizard"] --> Pool["Common pool lifecycle"] + Pool --> Provider["Provider contracts"] + Pool --> GitHub["GitHub runner API"] + Pool --> Storage["Capacity and retention"] + Provider --> Implementations["Tart, WSL, Docker Container, Docker Sandboxes"] ``` -## Lifecycle - -For each runner instance: - -1. Clone or create an instance from `provider.sourceImage`. -2. Start the instance headless when the provider supports that distinction. -3. Wait for provider-level reachability. -4. Apply optional Docker daemon registry mirror settings. -5. Run `/opt/epar/validate-runtime.sh`. -6. Fetch a short-lived GitHub registration token on the host. -7. Run `config.sh --ephemeral --unattended` inside the instance. -8. Start the runner process. VM and WSL images use systemd; Docker Container falls back to a PID-file managed background process. -9. Poll GitHub until the runner is ready. Verification-only flows require online/idle; supervised replacement pools also accept an online runner that is already busy with a queued job. -10. Monitor the runner service and GitHub runner record. -11. Delete the instance after the ephemeral runner exits, then create a replacement to maintain pool size. - -```mermaid -sequenceDiagram - participant M as Manager - participant P as Provider - participant I as Instance - participant G as GitHub - M->>P: Clone provider.sourceImage - M->>P: Start instance - M->>I: Apply optional Docker registry mirrors - M->>I: Validate runtime - M->>G: Request registration token - M->>I: Configure ephemeral runner - M->>I: Start runner process - I-->>G: Runner online - G-->>I: Assign one job - I-->>G: Runner exits after job - M->>P: Stop and delete instance - M->>G: Delete stale runner record if needed -``` - -## Multi-Instance Behavior - -`pool verify --instances 2 --register-only --cleanup` creates two instances concurrently, registers two ephemeral runners, verifies both are online/idle, and removes them. A supervised `pool up --replace-completed` accepts an online runner that is already busy, because requiring an idle observation would race immediate job assignment; the supervisor observes its completion and creates the replacement. - -`pool up --instances 2` keeps two runners available in the foreground. Replacement names use `pool.namePrefix` plus a timestamp and sequence suffix, for example `epar-20260703-010530-003`. +## Responsibilities -The supervisor tracks every prefix-owned local resource as provisioning, ready, draining, quarantined, or cleanup-pending. All five states count against `pool.instances`, which is an absolute physical-instance cap. A cleanup failure, unknown remote state, or busy runner therefore blocks allocation rather than allowing a capacity surge; host-trust rotation follows the same rule. +- `cmd/ephemeral-action-runner` owns command routing and the missing-config wizard. +- `internal/pool` owns naming, capacity admission, GitHub registration, readiness, strict instance limits, replacement, reconciliation, diagnostics, status, and exact instance cleanup. +- `internal/provider` defines required contracts; `internal/provider/` owns provider commands and host integration. +- `internal/image` owns reusable runner artifact acquisition, manifests, builds, and updates. +- `internal/storage` owns storage measurements, artifact ownership, retention plans, and exact cleanup execution. -Reconciliation runs at startup and before each replacement allocation. It adopts healthy exact-name local/GitHub pairs, deletes proven stopped or unregistered local resources, and deletes exact stale GitHub records when GitHub is reachable. A pre-listener provisioning failure is locally rolled back immediately and queued for later exact-name GitHub reconciliation because registration may have partially succeeded. After the listener starts, uncertain GitHub readiness becomes quarantine rather than deletion until the remote state can be resolved. +Provider code must not implement a second pool lifecycle. A capability that every provider needs belongs in a common contract; genuinely optional behavior uses an explicit capability interface. -Supervised replacement treats network errors and GitHub HTTP `429` or `5xx` responses as transient: allocation pauses behind the configured exponential retry policy while monitoring and cleanup continue. A fully online replacement or successful adoption resets the retry delay. Initial pool startup remains fail-fast after safe rollback, and authentication or deterministic configuration failures are terminal rather than retryable. +## Instance Lifecycle -## Liveness Model +For every provider, the common controller: -The foreground supervisor checks each instance every 15 seconds by default. A runner is considered healthy when: +1. Verifies configuration, runner-group policy, reusable artifacts, and every required storage surface. +2. Allocates one exact instance using the shared pool prefix and `pool.RunnerName`. +3. Verifies runtime isolation, trust, diagnostics, and provider admission rules. +4. Requests a short-lived GitHub token and configures one ephemeral runner. +5. Tracks the exact provider and GitHub identities in durable state. +6. Removes the completed instance, verifies absence, and creates a replacement without exceeding `pool.instances`. -- The matching GitHub runner record exists and reports `online`. -- A GitHub runner with `busy=true` is kept alive even if the local service check is temporarily inconclusive. -- When the runner is idle, `/opt/epar/check-runner.sh` reports the runner process is active. The script checks `actions-runner.service` on systemd instances and `/var/run/actions-runner.pid` on non-systemd instances such as Docker runner containers. +Unknown ownership, unavailable dependencies, failed cleanup, and uncertain remote state consume capacity and block new allocation. EPAR does not silently fall back to another provider or broaden cleanup from an exact identity to a prefix, wildcard, prune, or reset. -The instance is retired when an idle runner process exits, the runner record disappears, or the runner reports a non-online status. Runner process exit is expected after an ephemeral runner finishes one job. +## Storage Lifecycle -## Provider Boundary +Each provider reports the storage surfaces and temporary expansion required by bootstrap, artifact builds, instance creation, and replacement. The common preflight requires enough space for the operation plus the configured free-space reserve. -The controller depends on provider operations for clone/create, start, exec, address discovery, stop, delete, and list. Provider implementations own host-specific details such as Tart VM names, WSL distro names, Docker runner container names, or future Hyper-V VM names. +Artifacts are classified as active, current reusable, superseded EPAR-owned, incomplete temporary, or shared/unknown. Conservative housekeeping may remove only expired, unleased, exactly owned local artifacts and bounded dedicated caches. Removing Docker images, volumes, imported templates, WSL distributions, or Tart images requires an explicit previewed storage-prune operation. -Docker Container is intentionally modeled as an instance provider, not a host Docker socket shortcut. Each instance is a privileged outer container that starts its own Docker daemon. Workflow `docker compose` resources are created inside that private daemon and disappear when EPAR removes the runner container with its volumes. +See [Adding a Provider](providers/adding-provider.md) for the extension checklist. diff --git a/docs/development-principles.md b/docs/development-principles.md new file mode 100644 index 0000000..727cb25 --- /dev/null +++ b/docs/development-principles.md @@ -0,0 +1,15 @@ +# Development and Extension Principles + +EPAR extensions preserve the existing user flow and controller design. + +## First Run + +`./start` is the Quick Start and general source entry point. With no command, or with start flags only, it runs the `start` command and opens the missing-configuration wizard when needed; with an explicit command, it must forward that command and all arguments exactly as the binary and `go run ./cmd/ephemeral-action-runner` do. The local-Go and no-Go native-controller paths must behave the same, and user-facing remediation commands must use the entry point the user actually invoked. A selectable provider must appear in the wizard with its prerequisite status. Reject an unavailable selection clearly and never silently switch a configured provider. + +## Runner Names + +The wizard defaults `pool.namePrefix` to `-`, capped at 40 characters. The machine name shows where a runner belongs, the random suffix reduces collisions, and the cap leaves room for the GitHub runner suffix. Every provider must use the shared `internal/pool.RunnerName` function and keep the configured prefix literal. + +## Components And Flow + +Keep the flow `CLI -> pool manager -> provider -> guest/GitHub`. Provider-specific host operations belong in `internal/provider/`; the pool manager owns shared registration, readiness, replacement, status, and cleanup flow. Extend the shared interfaces instead of creating a second control path. diff --git a/docs/image-build.md b/docs/image-build.md index 209c560..728a866 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -71,7 +71,7 @@ flowchart LR Optional --> Yours["your scripts"] ``` -The public WSL and Docker Container default examples start from `ghcr.io/catthehacker/ubuntu:full-latest`, so they inherit Docker plus the broader Catthehacker runner tool stack. Tart and the WSL lean examples leave `image.customInstallScripts` empty, producing a runner-only Ubuntu image. Docker Container always needs Docker Engine because the provider depends on a private inner Docker daemon. +The public WSL and Docker Container default examples start from `ghcr.io/catthehacker/ubuntu:full-latest`, so they inherit Docker plus the broader Catthehacker runner tool stack. The recommended Docker Sandboxes template uses its separate pinned Candidate A adaptation of the same full Catthehacker source; it is built and loaded with `scripts/docker-sandboxes/build-template.ps1` and `scripts/docker-sandboxes/load-template.ps1`, not the normal `image build` command. Tart and the WSL lean examples leave `image.customInstallScripts` empty, producing a runner-only Ubuntu image. Docker Container always needs Docker Engine because the provider depends on a private inner Docker daemon. EPAR ships reusable install scripts for common cases: @@ -202,7 +202,7 @@ Docker registry mirrors are runtime configuration, not image content. Keep them ## Upstream Runner Images -Runner-only Tart images and the default WSL and Docker Container images do not require EPAR's pinned `actions/runner-images` checkout. The default WSL and Docker Container images start from `ghcr.io/catthehacker/ubuntu:full-latest`, which already includes Docker Engine, Compose, Buildx, Node/npm, and the broader Catthehacker runner tool stack. +Runner-only Tart images, the default WSL and Docker Container images, and the Docker Sandboxes Catthehacker templates do not require EPAR's pinned `actions/runner-images` checkout. The default WSL and Docker Container images start from `ghcr.io/catthehacker/ubuntu:full-latest`; Docker Sandboxes adapts an approved digest-pinned snapshot of that full source. It already includes Docker Engine, Compose, Buildx, Node/npm, and the broader Catthehacker runner tool stack. The built-in Docker/browser and web/E2E scripts require a pinned checkout of `actions/runner-images`: @@ -273,7 +273,7 @@ Use `configs/wsl.lean.example.yml` when you want the old smaller rootfs-tar path ## Installed Runtime -The default WSL and Docker Container builds use `ghcr.io/catthehacker/ubuntu:full-latest` as the source image. It is larger than the medium Catthehacker act image, but it is the recommended default for public users because common tools such as Node/npm are already present. The WSL lean and web/E2E examples keep demonstrating smaller custom paths that layer only selected dependencies. +The default WSL and Docker Container builds use `ghcr.io/catthehacker/ubuntu:full-latest` as the source image, and the recommended Docker Sandboxes template adapts a digest-pinned snapshot from that full channel. It is larger than the medium Catthehacker act image, but it is the recommended default for public users because common tools such as Node/npm are already present. The WSL lean and web/E2E examples keep demonstrating smaller custom paths that layer only selected dependencies. Catthehacker's `full-latest` and `act-latest` tags are rolling references. EPAR records the resolved source digest in its image manifest so a built image remains auditable, but a later `image build --replace` can consume a newer upstream digest. Pin `image.sourceImage` to a tested digest when rebuild reproducibility is required. @@ -289,7 +289,7 @@ The optional `install-docker-browser.sh` layer installs: - upstream Google Chrome on x64 - Playwright-managed Chromium on ARM64, exposed as `epar-browser`, `chromium`, and `chromium-browser` -The WSL default and Docker Container provider validate Docker Engine/CLI/Compose/Buildx through `scripts/guest/ubuntu/install-docker-engine.sh`. Docker Container then starts the daemon at container runtime from `/opt/epar/container-entrypoint.sh`; WSL starts Docker through systemd inside the distro. Set `EPAR_FORCE_UPSTREAM_DOCKER_INSTALL=true` inside non-WSL-default image builds only if you intentionally want to replace the base image's Docker packages with the pinned upstream `actions/runner-images` Docker install harness. +The WSL default and Docker Container provider validate Docker Engine/CLI/Compose/Buildx through `scripts/guest/ubuntu/install-docker-engine.sh`. Docker Container then starts the daemon at container runtime from `/opt/epar/container-entrypoint.sh`; WSL starts Docker through systemd inside the distro. Docker Sandboxes instead validates the private daemon and toolchain as part of its Candidate A template admission and runtime verification. Set `EPAR_FORCE_UPSTREAM_DOCKER_INSTALL=true` inside non-WSL-default image builds only if you intentionally want to replace the base image's Docker packages with the pinned upstream `actions/runner-images` Docker install harness. The ARM64 Docker harness prefers upstream `toolset-2404-arm64.json`. If an older upstream checkout does not contain that file, EPAR falls back to a minimal ARM-aware Docker toolset. diff --git a/docs/providers/adding-provider.md b/docs/providers/adding-provider.md index 5bd235d..5084b4c 100644 --- a/docs/providers/adding-provider.md +++ b/docs/providers/adding-provider.md @@ -1,7 +1,16 @@ # Adding A Provider -Providers implement the shared interface in `internal/provider`. The controller expects a provider to clone or create an instance, start it, execute guest commands, return an address when available, stop/delete it, and list existing instances for prefix-safe cleanup. Tart, WSL, and Docker Container are the current examples of that boundary. +Read [Development and Extension Principles](../development-principles.md) and [Design](../design.md). -Keep provider behavior idempotent where possible. Cleanup must only remove instances whose names match `pool.namePrefix`. +Put provider commands and host integration in `internal/provider/`. Shared onboarding, naming, image, pool lifecycle, GitHub, state, capacity, and retention behavior stays in its common package. -Use provider-specific docs for host setup, image format, and isolation caveats. New providers should also document whether runner liveness uses systemd or the PID-file fallback in `/opt/epar/check-runner.sh`. +A provider is complete only when it: + +- Registers its constructor, configuration rules, wizard contribution, reusable-artifact capabilities, and platform status in the provider registry. +- Implements every required lifecycle, exact inventory, artifact-requirement, and storage-surface contract without silent fallback. +- Uses the wizard’s complete machine-derived prefix and the shared `pool.RunnerName` format. +- Preserves strict `pool.instances`, durable exact identities, quarantine on uncertainty, resumable cleanup, diagnostics, and replacement. +- Reuses Catthehacker defaults when it consumes Docker runner images, unless an intentional exception is documented and tested. +- Adds provider contract tests, configuration and wizard tests, race tests, wrapper checks, and relevant live-platform evidence. + +Provider cleanup must target exact identities. Never replace an unavailable exact operation with a prefix deletion, wildcard, broad prune, reset, or deletion of an unknown/shared resource. diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md new file mode 100644 index 0000000..ff4552f --- /dev/null +++ b/docs/providers/docker-sandboxes.md @@ -0,0 +1,217 @@ +# Docker Sandboxes Provider + +The `docker-sandboxes` provider runs each GitHub Actions listener directly inside its own Docker Sandboxes microVM. Each microVM has a private filesystem, network boundary, and Docker daemon. This is a materially stronger host boundary than Docker Container, but EPAR does not describe it as universally safe for arbitrary hostile workflows. + +Docker Sandboxes is EPAR's capability-driven recommended default when its local prerequisites pass, without an operating-system allowlist. It provides the strongest current host boundary among EPAR providers by placing the listener, filesystem, network boundary, and private Docker daemon in a dedicated microVM. EPAR never falls back from a configured Docker Sandboxes pool to Docker Container. Set `EPAR_DISABLE_DOCKER_SANDBOXES=1` to stop admission during an incident or compatibility problem; the switch causes Docker Sandboxes startup to fail closed. + +## Architecture + +```mermaid +flowchart TB + subgraph Host["Native controller host"] + EPAR["EPAR native controller"] + GitHubKey["GitHub App private key"] + Ledger["Transactional ownership ledger"] + EmptyStage["Fresh empty staging directory"] + SBX["Docker Sandboxes daemon and CLI"] + TemplateCache["Host-level immutable template cache"] + end + subgraph VM["One disposable Docker Sandboxes microVM"] + Listener["Ephemeral Actions listener"] + Work["Guest-only Actions _work"] + GuestFS["Private guest filesystem"] + Dockerd["Private Docker daemon and block volume"] + JobContainers["Workflow, service, and job containers"] + Policy["Sandbox-scoped network rules"] + end + GitHubKey -->|"installation and registration token exchange stays native"| EPAR + EPAR -->|"exact constrained sbx operations"| SBX + EPAR --> Ledger + TemplateCache --> SBX + SBX --> VM + EmptyStage -->|"only approved host path; not Actions _work"| VM + EPAR -->|"registration token through sbx exec stdin"| Listener + Listener --> Work + Listener --> Dockerd + Dockerd --> JobContainers + Policy --> Listener + Policy --> JobContainers + GuestFS --> Listener +``` + +For comparison, Docker Container creates a privileged outer container directly on the host Docker daemon. The outer container starts a private inner Docker daemon for workflow Docker operations: + +```mermaid +flowchart TB + subgraph Host["Docker host"] + EPAR["EPAR controller"] + HostDocker["Host Docker daemon"] + subgraph Outer["Privileged EPAR runner container"] + Listener["Ephemeral Actions listener"] + InnerDocker["Private inner Docker daemon"] + JobContainers["Workflow, service, and job containers"] + end + end + EPAR --> HostDocker + HostDocker --> Outer + Listener --> InnerDocker + InnerDocker --> JobContainers +``` + +Docker Container is therefore a container-backed runner with an inner private daemon. Docker Sandboxes adds the microVM boundary around the listener, filesystem, network, and private daemon. + +## Candidate A template + +EPAR uses Candidate A only. The template extends a platform-specific pinned Catthehacker manifest directly, installs the matching pinned Actions runner and Tini artifacts, and lets Docker Sandboxes attach and start the sandbox-private Docker daemon. It does not run the 70+ GiB image as a second nested container, preload `/var/lib/docker`, or use Candidate B. + +The first-run wizard recommends the Catthehacker `full-latest` rolling source channel and can also use the current lean `act-22.04` source channel. Each choice still requires its corresponding locally built and loaded, versioned EPAR template from the approved source-lock snapshot; raw Catthehacker images cannot be used directly as Docker Sandboxes templates. Build the smaller pinned `act-22.04` profile first when you want the lean path. The default platform remains `linux/amd64`: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/build-template.ps1 -Profile act-22.04 -Platform linux/amd64 -Execute +``` + +On Apple Silicon, use PowerShell 7 and select the native ARM64 guest: + +```powershell +pwsh -NoProfile -File scripts/docker-sandboxes/build-template.ps1 -Profile act-22.04 -Platform linux/arm64 -Execute +``` + +Executed builds require the native Docker server platform to match `-Platform`. The pinned build stages intentionally do not use emulation, so an x86_64 Docker server cannot execute the ARM64 build and an ARM64 Docker server cannot execute the x86_64 build. Plan-only mode remains non-mutating and can inspect either platform. + +The build emits the OCI image digest, the full local Docker image identity, and a SHA-256 digest for `template-metadata.json`. Record the metadata digest outside the artifact directory before review, and set `dockerSandboxes.templateDigest` to the full local Docker image identity printed by the script. Docker Sandboxes v0.35.0 exposes only a 12-hex cache ID in `sbx template ls --json`; that value is not a full digest. EPAR independently requires the exact full identity from the local Docker image store and compares the inventory only to the expected 12-hex cache ID. + +Load the resulting archive exactly once after reviewing its SBOM, provenance, software inventory, compatibility metadata, and checksums: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/load-template.ps1 -ArtifactDirectory work/template-builds/docker-sandboxes/act-22.04 -ExpectedMetadataSha256 sha256: -Execute +``` + +The default ARM64 output directory is `work/template-builds/docker-sandboxes/act-22.04-arm64`; use that directory with `pwsh -NoProfile -File scripts/docker-sandboxes/load-template.ps1` on Apple Silicon. + +The loader first verifies the operator-supplied metadata digest, anchors that metadata to the repository source lock and complete template build context, hashes every evidence artifact and the archive, validates the Buildx metadata, max-mode provenance, SPDX JSON SBOM, software inventory, helper hashes, compatibility record, and exact full local Docker image identity, then requires exactly `sbx` v0.35.0. Only `-Execute` may invoke `sbx template load`, and it does so at most once before reading the exact tag and 12-hex cache ID back from local inventory. It never invokes `sbx reset`. + +The loader retains the archive because re-import and independent certification can require its exact bytes. Delete only a reviewed obsolete archive, and preserve its metadata, SBOM, provenance, inventory, hashes, and any certification record. + +The pinned full profile is intentionally attempted only after the smaller profile passes. Its approved source-lock snapshot uses index `sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c`; see `templates/docker-sandboxes/sources.lock.json` for the pinned `linux/amd64` and `linux/arm64` manifests and other build inputs. The rolling `full-latest` channel may move upstream after that snapshot is approved. EPAR does not refresh it automatically: rebuild, validate, and load a new versioned EPAR template before it becomes selectable. Source lock schema 2 keeps active inputs under platform-scoped records. Older amd64 tags remain only as non-authoritative `supersededRecords` and are never selected by the build script or treated as current proof. + +The ARM64 path is implementation-complete but lacks equivalent real-host evidence: its OCI manifests and helper downloads are pinned, the build and loader select `linux/arm64`, and any native `arm64` controller host can admit that guest after the capability and exact-template checks pass. Cross-compilation of the native controller and non-mutating asset validation passed, but no ARM64 image has been built, loaded, or run on a real ARM64 host for the evidence recorded in this repository, and no ARM64 independent-certification record exists. + +## Capacity and cold-start handling + +The large Catthehacker filesystem is part of the outer sandbox template, not an image pulled into every sandbox-private Docker daemon. The expensive cold acquisition and first lazy materialization happen during an explicit prewarm operation outside the job path. Docker Sandboxes retains loaded templates in its host-level template cache, so later runner creation reuses the cached template. Each sandbox still receives a separate Docker block volume for workflow-created images and layers. A template archive or cache size is a host-cache measurement; it is not a per-sandbox root-disk baseline and must not be added to `rootDisk`. + +This design addresses capacity and cold start explicitly: + +- Load each approved template once, verify its tag and configuration ID, then prime it with one EPAR-managed unregistered create/verify/exact-delete cycle before admitting jobs. +- Do not pull the 70+ GiB Catthehacker image into each private daemon. +- Size the root disk from the measured guest `/` peak for the exact template and workload, plus 25%, plus at least 20 GiB writable headroom, rounded up to the next 10 GiB. `rootDisk` is the resulting total guest root-filesystem capacity, not the headroom value by itself. +- Size the Docker disk to at least 100 GiB and at least the measured representative peak plus 25% and 20 GiB deletion headroom. +- Keep at least 50 GiB or 10% of the backing volume free, whichever is larger. +- Count provisioning and uncertain-cleanup reservations against capacity; do not allocate past unresolved state. +- Do not create standby microVMs initially. Cached templates provide the first optimization without retaining live, stateful guests. + +After loading the template and completing the configuration, prime its lazy state on Windows with: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-native-controller.ps1 pool verify --config .local/docker-sandboxes.yml --project-root . --instances 1 --cleanup +``` + +Do not add `--register-only` to this prewarm command. EPAR verifies the template, trust, private daemon, policy, filesystem boundary, ports, ownership ledger, and exact cleanup without requesting a GitHub registration token or creating a GitHub runner. The first post-load create may still take minutes and must show visible progress outside the job path; only the measured subsequent creates count toward the cached-create performance gate. + +The values in the example are recommended starting reservations. Runtime admission rechecks exact storage and capacity before every create, and operators can adjust the generated configuration for their workload. + +## Storage Retention + +Docker Sandboxes caches templates after individual sandboxes are deleted. Before removing an old EPAR template, verify that no active configuration or live sandbox uses its exact tag and cache ID, run `sbx template rm `, and verify that the same identity is absent. Do not use `sbx reset` as EPAR maintenance because it removes the entire Docker Sandboxes cache. + +Docker images, BuildKit records, and named Go-cache volumes live in Docker's shared store and may be used by other projects or EPAR checkouts. EPAR does not run broad Docker prune commands automatically. Inspect them separately, remove only exact confirmed objects, and treat shared BuildKit sizes as overlapping image storage rather than guaranteed additional recovery. For constrained build hosts, configure Docker or BuildKit garbage-collection limits deliberately; see [Docker build garbage collection](https://docs.docker.com/build/cache/garbage-collection/). + +On Windows, deleting Docker objects makes internal blocks reusable but does not automatically shrink Docker Desktop's dynamically expanding VHDX. Host-file compaction is separate offline maintenance and requires the virtual disk to be detached or attached read-only; see [Microsoft's `compact vdisk` requirements](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/compact-vdisk). + +For a missing `.local/config.yml`, `./start` and `init` always show Docker Container, Docker Sandboxes, WSL2, and experimental Tart with prerequisite status instead of hiding unavailable providers. The available Enter default is displayed first. Docker Sandboxes becomes that default when Docker is ready, the host architecture maps to an available Linux guest template, the exact supported `sbx` version is installed, `sbx diagnose --output json` reports `summary.pass > 0` and `summary.fail == 0`, and the provider backing volume can admit at least the minimum valid sandbox reservation plus its host-free watermark. Warnings and skipped checks are displayed but do not disable an otherwise healthy installation. This is a capability test rather than an OS allowlist; Docker's current troubleshooting guidance identifies the same command as its machine-readable diagnostic path. The wizard runs read-only admission and inventory checks, then offers only the active lock-selected source channels for that platform: Catthehacker `full-latest` (recommended) and `act-22.04` (current lean profile). A profile appears only when its exact versioned EPAR template tag is locally loaded, the Docker Sandboxes cache entry and full local Docker image identity agree, and the platform matches. Historical and superseded cached EPAR tags that are not the active profile tags are deliberately not choices. The wizard shows the friendly source-channel label before selection, then separately prints and saves the selected exact EPAR template tag and full local digest. Automatic source refresh is not implemented, so the lock remains an approved snapshot even if the rolling upstream channel moves. It fingerprints and validates the active host-global Balanced-policy baseline, writes a sandbox-scoped Open public-egress default, and displays the shared host template-cache size separately from per-sandbox capacity. The wizard asks one capacity question. When EPAR contains a measurement record for the selected exact template digest, it automatically derives `rootDisk` with the recommended 20 GiB writable headroom, asks for the workload-dependent Docker disk, and writes the 50 GiB host-free floor. Without exact root evidence, it asks for the provisional total root capacity and writes the 100 GiB Docker-disk and 50 GiB host-free defaults. Before writing configuration, setup evaluates the exact selected root and Docker reservations against the provider backing volume and reports the available space, required reserve, and shortfall if admission fails. Runtime admission repeats the measurement immediately before every create and remains authoritative because free space and active reservations can change. If a read-only check fails, the selected provider is retained while the wizard offers to retry those checks; declining exits without writing a config, and it never silently falls back or repeats the provider menu. The wizard does not build, load, prewarm, create, start, stop, or remove a sandbox during this discovery step. See [Docker's diagnostics guidance](https://docs.docker.com/ai/sandboxes/troubleshooting/). + +For the current exact Windows amd64 full-template proof, the host cache entry is approximately 17.44 GiB while the measured guest `/` peak after the representative Buildx and Compose probe is approximately 309.73 MiB. With the required 25% safety margin and 20 GiB writable headroom, rounding to the next 10 GiB produces a provisional `rootDisk` of 30 GiB. Earlier 70+ GB Docker Desktop storage observations and source-image virtual-size figures describe different accounting domains and are not inputs to this calculation. + +Docker documents `DOCKER_SANDBOXES_ROOT_SIZE` and `DOCKER_SANDBOXES_DOCKER_SIZE` as independent root and `/var/lib/docker` capacities. Docker also documents the Docker-data volume as sparse and the template cache as reusable across sandbox deletion. EPAR locates Docker Sandboxes' documented platform storage root: `%LOCALAPPDATA%\DockerSandboxes` on Windows, `~/Library/Application Support/com.docker.sandboxes` on macOS, or `${XDG_STATE_HOME:-~/.local/state}/sandboxes` on Linux. If the provider root does not exist yet, setup measures its nearest existing parent on the same filesystem; after creation, runtime measures the exact provider root. EPAR fails closed if the storage volume cannot be measured, reserves the configured root and Docker capacities conservatively, keeps the stronger of the configured free-space floor, 50 GiB, and 10% of the provider-storage volume, and repeats admission immediately before each create. A setup-time result can become stale as cache state, concurrent reservations, and free space change, so runtime admission remains authoritative. See [Docker's disk-space troubleshooting](https://docs.docker.com/ai/sandboxes/troubleshooting/#sandbox-runs-out-of-disk-space), [template base-image storage](https://docs.docker.com/ai/sandboxes/customize/templates/#base-images), and [template caching](https://docs.docker.com/ai/sandboxes/customize/templates/#template-caching). + +## Configuration + +Start from `configs/docker-sandboxes.example.yml`. `provider.sourceImage` is invalid for this provider; template identity belongs under `dockerSandboxes`: + +```yaml +# Any supported native host with an amd64 guest template +provider: + type: docker-sandboxes + platform: linux/amd64 + +dockerSandboxes: + template: epar-docker-sandboxes-catthehacker-full: + templateDigest: sha256: + policyGeneration: sha256: + networkBaseline: open + additionalAllow: [] + additionalDeny: [] + stagingRoot: .local/docker-sandboxes-staging + cpus: 4 + memory: 8GiB + rootDisk: + dockerDisk: + maxConcurrentCreates: 2 + minHostFreeSpace: +``` + +`networkBaseline: open` does not rewrite the host-global Docker Sandboxes preset. EPAR keeps verifying the configured global Balanced fingerprint and adds an exact sandbox-scoped `allow **` rule plus deny-wins rules for `host.docker.internal`, `gateway.docker.internal`, `kubernetes.docker.internal`, and `host.containers.internal`. EPAR owns, reads back, fingerprints, and removes all of these rules during exact cleanup. The host-alias denies are required because Docker Sandboxes v0.35.0 can otherwise proxy an allowed `host.docker.internal` request to a native-host loopback service. This avoids reactive public-domain allowlisting for general CI while preserving the host-service boundary and Docker Sandboxes' separate private-address, link-local, and inter-sandbox isolation. Public egress is still an exfiltration path for any source or secret available to a workflow, so EPAR's trusted-workload runner-group contract remains mandatory. + +Set `networkBaseline: balanced` to omit the wildcard rule and use default-deny public egress with `additionalAllow`. In either mode, `additionalDeny` supplies sandbox-scoped hostname denies, and deny rules take precedence. The current EPAR configuration grammar deliberately does not expose arbitrary raw policy arguments or global scope. Private-range CIDR rules are not generated automatically. Provider-boundary behavior, including `host.docker.internal`, remains part of live platform validation and runtime admission. + +For Apple Silicon, change the architecture-bearing values together: + +```yaml +runner: + labels: [self-hosted, linux, ARM64, epar-docker-sandboxes] + +provider: + type: docker-sandboxes + platform: linux/arm64 + +dockerSandboxes: + template: epar-docker-sandboxes-catthehacker-full: + templateDigest: sha256: +``` + +Platform admission is architecture-exact and has no wizard OS allowlist: + +| Native controller architecture | Required guest platform | Architecture label | Default-selection behavior | +| --- | --- | --- | --- | +| `amd64` | `linux/amd64` | `X64` | Default when Docker, the supported `sbx` diagnostics, and exact template admission pass | +| `arm64` | `linux/arm64` | `ARM64` | Default when Docker, the supported `sbx` diagnostics, and exact template admission pass | + +Other architectures fail before admission can reserve or create a new sandbox because EPAR has no matching runner/template architecture. Cleanup and status access remain available for already-owned state. EPAR does not use emulation to admit an architecture-mismatched template. + +Docker Sandboxes also requires `security.runnerGroup.enforcement: enforce`; a warning-only runner-group policy is rejected during configuration validation before any sandbox is reserved. + +Allowed network entries are exact hostnames or `*.domain[:port]`. The unrestricted `**` pattern is rejected by default configuration. Docker Sandboxes v0.35.0 adds one non-editable shell-kit allow rule for `openrouter.ai` to each shell sandbox. EPAR accepts only that exact built-in rule shape: `kit:`, exact `sandbox:` scope and target, network `allow`, one `openrouter.ai` resource, `scoped` origin, active state, and non-editable status. Provider-generated rule and policy IDs may vary, so the stable configured `policyGeneration` fingerprints the complete global baseline while EPAR also fingerprints and reports the complete effective readback, including the dynamic built-in and configured sandbox-scoped rules, before registration. Missing optional built-ins are allowed; duplicates, changed fields, inactive rules, unrelated scoped rules, or unattributed rules fail closed. EPAR-created sandbox-scoped rules are bound in the ownership ledger and removed by their exact IDs during cleanup; the built-in rule remains provider-owned and disappears with the exact sandbox. + +The staging root is not a shared checkout. EPAR creates one fresh, canonical, empty, owner-restricted directory per sandbox and exposes only that directory as Docker Sandboxes' required read-write workspace. EPAR does not use `sbx create --clone`: clone mode starts a Git daemon and publishes a host-loopback port, which conflicts with the no-published-port admission boundary. EPAR rejects symlinks, junctions, reparse points, alternate data streams, path escapes, weak permissions, overlapping roots, and pre-existing contents, verifies that the staging directory is still empty before admission, and never executes or parses guest-created staging content. Actions `_work` remains under `/opt/actions-runner` on the private guest filesystem. Cleanup purges the exact owned staging object only after the exact sandbox is absent. + +## Lifecycle and secrets + +EPAR records intent in `.local/state/` before each external side effect. The ledger binds the exact sandbox name and stable provider ID, staging path, template and policy identities, resource reservation, registration state, GitHub runner name and ID, and cleanup outcome. Reconciliation never selects or deletes sandboxes by prefix and never uses a global reset. + +Docker Sandboxes uses the same runner-name contract as the established providers: `-YYYYMMDD-HHMMSS-NNN`. EPAR seeds each allocation range from the durable ownership ledger's next sequence, so a controller restart does not reset the suffix to `001`; the timestamp and sequence together distinguish allocations while the user-selected prefix remains visible without truncation or hashing. The complete configured prefix plus EPAR's suffix fits within the provider's 63-character name limit. + +The GitHub App key and installation-token exchange stay on the native host. EPAR sends the short-lived registration token through `sbx exec` standard input; it is permitted only in the guest `config.sh --token` process during registration and is excluded from host arguments, `sbx` arguments, templates, staging, the ledger, diagnostics, and logs. The guest must be unregistered and pass template, trust, Docker daemon, and policy verification before EPAR requests that token. + +Host trust roots are streamed into a fresh unregistered guest before its private Docker daemon makes any registry request. EPAR updates the guest certificate bundle, records the sole verified `dockerd` identity, pulls and removes an immutable multi-platform Alpine image through that daemon, and confirms that the same daemon remains ready before it persists the trust generation or requests a registration token. Candidate A does not attempt to stop and relaunch Docker Sandboxes' `dockerd`: v0.35.0 exposes no supported per-sandbox daemon restart lifecycle, and the negative PoC showed that a raw process restart can leave the guest without its private daemon. A trust-generation change therefore requires disposal and creation of a fresh sandbox. This follows the [Docker daemon certificate guidance](https://docs.docker.com/reference/cli/dockerd/) and Moby's [registry TLS implementation documentation](https://pkg.go.dev/github.com/moby/moby/registry), which constructs an endpoint TLS configuration from the system certificate pool. + +A stopped sandbox is diagnostic state, not proof of disposal. Final cleanup uses exact `sbx rm --force`, then verifies that the sandbox identity, sandbox-scoped policy rules, GitHub runner record, and staging directory are absent. Uncertain state retains its capacity reservation and blocks replacement allocation. + +If diagnostics fail, automatic cleanup stops and retains the exact sandbox. After reviewing and preserving the failed-diagnostics evidence reported by `status`, an operator may run `ephemeral-action-runner cleanup --acknowledge-failed-diagnostics`. This explicit acknowledgement permits exact disposal while preserving the durable failed evidence in the ownership ledger; normal startup and automatic cleanup never apply the override. + +## Capability default and independent certification + +Docker's current documentation provides installation and runtime support paths for Windows, macOS, and Linux. EPAR does not duplicate that platform list in the first-run default decision: a native host is eligible when Docker works, the exact EPAR-supported `sbx` version passes machine-readable diagnostics with at least one pass and zero failures, the architecture is `amd64` or `arm64`, and the exact template and admission checks pass. + +Windows 11 x86_64 has the accepted real-host lifecycle, replacement, cleanup, and comparative CI runtime evidence recorded during this implementation. macOS and Linux do not yet have equivalent EPAR real-host evidence in this repository, but that evidence gap no longer disables the capability-ready wizard default. The separate embedded independent-certification table remains empty. A future independently certified record must bind reviewed evidence to the exact full template image identity, exact 12-hex cache ID, operator-anchored metadata and archive digests, and an exact clean native-controller source/build identity. This stronger certification record is separate from default selection and is not a claim that arbitrary hostile workflows are universally safe. + +See Docker's documentation for the underlying [security defaults](https://docs.docker.com/ai/sandboxes/security/defaults/), [architecture](https://docs.docker.com/ai/sandboxes/architecture/), and [custom template behavior](https://docs.docker.com/ai/sandboxes/customize/templates/). diff --git a/docs/security.md b/docs/security.md index ae9232c..08fd3eb 100644 --- a/docs/security.md +++ b/docs/security.md @@ -30,6 +30,8 @@ EPAR intentionally does not implement a Docker-socket provider. A runner that co Docker Container uses a privileged outer container with a private inner Docker daemon. That gives good cleanup and Docker resource separation for each job, but it is still trusted-job infrastructure because `--privileged` weakens container isolation. +Docker Sandboxes places the listener, guest filesystem, network boundary, and private Docker daemon inside a dedicated microVM. It provides EPAR's strongest current host boundary and materially strengthens host isolation relative to Docker Container. The first-run wizard makes it the capability-driven default on any OS when Docker and the supported `sbx` diagnostics are healthy; this selection rule is separate from independent platform certification and does not claim that every host combination has received the same real-host validation. EPAR does not market any provider as universally safe for arbitrary hostile workflows. + Tart runs jobs inside VMs on Apple Silicon macOS. That is a stronger host boundary than Docker Container, but workflows still control the guest and any secrets exposed to the job. WSL2 has a weaker isolation story than one full VM per job. Treat the WSL provider as trusted-job infrastructure unless your environment has reviewed and accepted that model. diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..724d437 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,30 @@ +# Storage + +EPAR checks the storage surfaces required by the selected provider before bootstrap, reusable-artifact work, instance creation, and replacement. An operation starts only when its estimated temporary expansion leaves at least `storage.minimumFree` available. + +```yaml +storage: + minimumFree: 20GiB + gracePeriod: 168h + keepPrevious: 0 + automaticHousekeeping: conservative + buildCacheLimit: 64GiB + goCacheLimit: 10GiB +``` + +`storage status` reports capacity and known artifacts. `storage prune` is always a preview unless `--execute` is supplied. + +```text +ephemeral-action-runner storage status +ephemeral-action-runner storage status --json +ephemeral-action-runner storage prune +ephemeral-action-runner storage prune --execute +``` + +Conservative housekeeping is limited to expired, unleased, exactly owned local staging artifacts and dedicated recomputable caches. Docker images, named volumes, imported Docker Sandboxes templates, WSL distributions, and Tart images require explicit prune execution. + +EPAR image builds use a project-scoped Buildx builder with persisted ownership metadata and BuildKit garbage collection capped by `storage.buildCacheLimit`. EPAR does not select, modify, or prune Docker's shared/default builder. The no-Go native-controller path similarly creates project-scoped, ownership-labelled Go module and build-cache volumes, measures their combined use, and clears only those exact recomputable caches when they exceed `storage.goCacheLimit`; active cache volumes are not touched. Existing unlabelled or explicitly overridden Go cache volumes remain shared or ownership-unknown and report-only. The opt-in legacy controller-in-Docker path uses ephemeral container caches unless the operator explicitly supplies cache volume names. + +Unknown, shared, prefix-only, custom-path, or identity-drifted resources are report-only. EPAR never turns storage cleanup into a broad Docker prune, Docker Sandboxes reset, Docker Desktop reset, WSL reset, or VHDX compaction. + +Deleting Docker data does not necessarily reduce Docker Desktop's VHDX file. VHDX compaction is separate offline host maintenance and is never performed by EPAR. diff --git a/docs/usage.md b/docs/usage.md index 909ee8a..a8fc02b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -40,7 +40,7 @@ Don't want to install Go at all? See [Running EPAR Without Installing Go](advanc ## One-Command Start -For the default Docker Container setup, run EPAR from the source folder. On macOS, Linux, WSL, or Git Bash, use the `./start` wrapper; on native Windows PowerShell/cmd, use `.\start.ps1` or `start.cmd`. Either uses Go if installed, and otherwise runs EPAR from source with a containerized Go toolchain automatically, without creating a standalone EPAR executable (see [Running EPAR Without Installing Go](advanced/no-go-install.md)): +Run EPAR from the source folder. On macOS, Linux, WSL, or Git Bash, use the `./start` wrapper; on native Windows PowerShell/cmd, use `.\start.ps1` or `start.cmd`. Either uses Go if installed, or uses a containerized Go toolchain to build and cache a CGO-disabled native controller under `.local/bin` automatically (see [Running EPAR Without Installing Go](advanced/no-go-install.md)): ```bash ./start @@ -52,7 +52,7 @@ Equivalent without the wrapper: go run ./cmd/ephemeral-action-runner ``` -If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then lists the organization's live runner groups. The group choice is explicit and has no Enter-for-default or offline fallback. Default and broad-access groups require warning confirmation; public-enabled groups are blocked by the generated safety policy. See [Runner Group Security](runner-groups.md). Docker Container is the default. For a new Docker Container config, the wizard asks whether to inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. On native Windows, when `wsl.exe --status` successfully confirms default version 2, the wizard also offers a WSL2 config. On macOS, when `tart --version` succeeds, it offers an experimental Tart config. Press Enter to retain Docker Container. The Docker preflight applies to Docker Container and the default WSL image, which uses Docker for its one-time rootfs export, but not to Tart. EPAR then checks runner-group policy and the configured image, builds or replaces the image when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. +If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then lists the organization's live runner groups. The group choice is explicit and has no Enter-for-default or offline fallback. Default and broad-access groups require warning confirmation; public-enabled groups are blocked by the generated safety policy. See [Runner Group Security](runner-groups.md). The provider menu always displays the same four choices and prints a prerequisite status beneath each one: option 1 Docker Container requires Docker; option 2 Docker Sandboxes requires Docker, a supported host architecture, the exact supported `sbx` version, and healthy `sbx diagnose --output json` results; option 3 WSL2 requires native Windows, Docker, and `wsl.exe --status` reporting default version 2; option 4 experimental Tart requires native macOS and a successful `tart --version`. Unavailable choices remain visible but cannot be selected. Docker Sandboxes is the Enter default on any OS when those capability checks pass: diagnostics must contain at least one pass and zero failures, while warnings such as an available update are accepted. Setup performs read-only checks for compatible `sbx` admission, an exact locally built and loaded EPAR template, the matching full local Docker image identity and platform, and the current host-global Balanced-policy fingerprint. If discovery fails, the wizard retains the Docker Sandboxes selection and offers to retry the checks without repeating the provider menu; declining exits without writing a config or falling back. The wizard asks only one capacity question, chosen from the available measurement evidence, and writes the other recommended values for later editing. New Docker Sandboxes configs default to sandbox-scoped open public HTTP/HTTPS egress with deny-wins host-alias guardrails so ordinary CI dependency endpoints do not require reactive allowlisting; this does not change the machine's global Docker Sandboxes policy. The wizard asks whether a new Docker Container or Docker Sandboxes config should inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. EPAR then checks provider admission and runner-group policy, prepares the configured environment, and starts the configured number of runners. The default config uses `pool.instances: 1`. Pass flags through `./start` to choose a config or runner count: @@ -84,7 +84,7 @@ If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.ins ## Configure Only -Use `init` when you only want to create a config without building an image or starting runners. It creates Docker Container by default, with the same conditional native-Windows WSL2 and macOS Tart choices described above: +Use `init` when you only want to create a config without building an image or starting runners. It uses the same prerequisite-aware provider menu as missing-config `./start`; Docker Sandboxes is the recommended default on any OS when Docker and the supported `sbx` diagnostics are ready, and Docker Container, WSL2, and experimental Tart remain visible with their current availability status: ```bash go run ./cmd/ephemeral-action-runner init @@ -100,6 +100,7 @@ For other WSL or Tart variants, or for custom labels, copy one example config in | Host and image | Example config | | --- | --- | +| Windows Docker Sandboxes, recommended full Catthehacker template | `configs/docker-sandboxes.example.yml` | | macOS Tart, experimental basic Ubuntu ARM64 image | `configs/tart.example.yml` | | macOS Tart, web/E2E with Rosetta amd64 Docker support | `configs/tart.web-e2e.example.yml` | | Windows WSL2, default full Catthehacker runner image | `configs/wsl.example.yml` | @@ -216,7 +217,7 @@ Build logs are written under `work/logs/builds` by default. Run `ephemeral-actio ## Customize The Image -WSL and Docker Container use the full Catthehacker runner image by default. For Docker-focused jobs, `configs/docker-container.act.example.yml` uses the smaller Catthehacker Act image, which includes Node and the Docker Engine/CLI/Compose/Buildx stack EPAR needs. It does not guarantee browser dependencies; use `configs/docker-container.web-e2e.example.yml` for Playwright or other browser tests. Tart and the WSL lean examples are runner-only. Use `image.customInstallScripts` when you want a different image shape, such as the smaller WSL or Docker Container web/E2E examples: +Docker Sandboxes adapts a pinned full Catthehacker source into its recommended template; WSL and Docker Container use the full Catthehacker runner image by default. For Docker-focused jobs, `configs/docker-container.act.example.yml` uses the smaller Catthehacker Act image, which includes Node and the Docker Engine/CLI/Compose/Buildx stack EPAR needs. It does not guarantee browser dependencies; use `configs/docker-container.web-e2e.example.yml` for Playwright or other browser tests. Tart and the WSL lean examples are runner-only. Use `image.customInstallScripts` when you want a different WSL or Docker Container image shape, such as the smaller web/E2E examples. Docker Sandboxes customization uses its separate template build pipeline: ```yaml image: @@ -319,6 +320,12 @@ For the default Docker Container image, target the default Docker Container labe runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu] ``` +For the recommended Docker Sandboxes full template, target the provider label: + +```yaml +runs-on: [self-hosted, linux, X64, epar-docker-sandboxes] +``` + For the Docker-focused Act image, target its dedicated label: ```yaml diff --git a/internal/config/config.go b/internal/config/config.go index 8e1a435..b521f63 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,19 +10,22 @@ import ( "path/filepath" "strconv" "strings" + "time" ) type Config struct { - GitHub GitHubConfig - Image ImageConfig - Pool PoolConfig - Logging LoggingConfig - Runner RunnerConfig - Security SecurityConfig - Provider ProviderConfig - Docker DockerConfig - Timeouts TimeoutConfig - warnings []string + GitHub GitHubConfig + Image ImageConfig + Pool PoolConfig + Storage StorageConfig + Logging LoggingConfig + Runner RunnerConfig + Security SecurityConfig + Provider ProviderConfig + Docker DockerConfig + DockerSandboxes DockerSandboxesConfig + Timeouts TimeoutConfig + warnings []string } // Warnings returns non-fatal configuration migration notices discovered while loading. @@ -69,6 +72,23 @@ type PoolConfig struct { ReplacementRetryJitterPercent int } +// StorageConfig controls provider-neutral capacity admission and conservative +// retention. String byte sizes keep the YAML readable and are validated before +// any provider or cleanup operation is constructed. +type StorageConfig struct { + MinimumFree string + GracePeriod string + KeepPrevious int + AutomaticHousekeeping string + BuildCacheLimit string + GoCacheLimit string +} + +const ( + StorageHousekeepingConservative = "conservative" + StorageHousekeepingDisabled = "disabled" +) + type LoggingConfig struct { Directory string ManagerSinks []string @@ -136,6 +156,51 @@ type DockerConfig struct { NoProxy string } +// DockerSandboxesConfig configures the docker-sandboxes provider. Template, +// TemplateDigest, and PolicyGeneration deliberately have no defaults: a sandbox must +// be created from an explicitly pinned template and policy generation. +type DockerSandboxesConfig struct { + Template string + TemplateDigest string + PolicyGeneration string + NetworkBaseline string + AdditionalAllow []string + AdditionalDeny []string + StagingRoot string + CPUs int + Memory string + RootDisk string + DockerDisk string + MaxConcurrentCreates int + MinHostFreeSpace string +} + +const ( + DockerSandboxesNetworkBaselineOpen = "open" + DockerSandboxesNetworkBaselineBalanced = "balanced" +) + +var dockerSandboxesOpenDefaultDenyResources = []string{ + "host.docker.internal", + "gateway.docker.internal", + "kubernetes.docker.internal", + "host.containers.internal", +} + +// DockerSandboxesOpenDefaultDenyResources returns host aliases that EPAR denies +// in every sandbox-scoped Open policy. Docker Sandboxes v0.35.0 can proxy an +// allowed host.docker.internal request to a native-host loopback service, so +// public egress must not implicitly enable these host-service aliases. +func DockerSandboxesOpenDefaultDenyResources() []string { + return append([]string(nil), dockerSandboxesOpenDefaultDenyResources...) +} + +const ( + DockerSandboxesMinimumRootDiskBytes int64 = 20 << 30 + DockerSandboxesMinimumDockerDiskBytes int64 = 100 << 30 + DockerSandboxesMinimumHostFreeSpaceBytes int64 = 50 << 30 +) + type TimeoutConfig struct { BootSeconds int GitHubOnlineSeconds int @@ -174,6 +239,14 @@ func Default() Config { ReplacementRetryMultiplier: 2, ReplacementRetryJitterPercent: 20, }, + Storage: StorageConfig{ + MinimumFree: "20GiB", + GracePeriod: "168h", + KeepPrevious: 0, + AutomaticHousekeeping: StorageHousekeepingConservative, + BuildCacheLimit: "64GiB", + GoCacheLimit: "10GiB", + }, Logging: LoggingConfig{ Directory: "work/logs", ManagerSinks: []string{"console"}, @@ -213,6 +286,13 @@ func Default() Config { Network: "default", InstallRoot: "work/wsl", }, + DockerSandboxes: DockerSandboxesConfig{ + NetworkBaseline: DockerSandboxesNetworkBaselineOpen, + StagingRoot: ".local/docker-sandboxes-staging", + CPUs: 4, + Memory: "8GiB", + MaxConcurrentCreates: 2, + }, Timeouts: TimeoutConfig{ BootSeconds: 180, GitHubOnlineSeconds: 180, @@ -511,6 +591,27 @@ func apply(cfg *Config, section, key, value string) error { default: return unknownKey(section, key) } + case "storage": + switch key { + case "minimumFree": + cfg.Storage.MinimumFree = value + case "gracePeriod": + cfg.Storage.GracePeriod = value + case "keepPrevious": + v, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid storage.keepPrevious: %w", err) + } + cfg.Storage.KeepPrevious = v + case "automaticHousekeeping": + cfg.Storage.AutomaticHousekeeping = strings.ToLower(value) + case "buildCacheLimit": + cfg.Storage.BuildCacheLimit = value + case "goCacheLimit": + cfg.Storage.GoCacheLimit = value + default: + return unknownKey(section, key) + } case "runner": switch key { case "labels": @@ -595,6 +696,46 @@ func apply(cfg *Config, section, key, value string) error { default: return unknownKey(section, key) } + case "dockerSandboxes": + switch key { + case "template": + cfg.DockerSandboxes.Template = value + case "templateDigest": + cfg.DockerSandboxes.TemplateDigest = value + case "policyGeneration": + cfg.DockerSandboxes.PolicyGeneration = value + case "networkBaseline": + cfg.DockerSandboxes.NetworkBaseline = strings.ToLower(value) + case "additionalAllow", "additionalDeny": + return setListValue(cfg, section, key, parseList(value)) + case "stagingRoot": + cfg.DockerSandboxes.StagingRoot = value + case "cpus": + v, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid dockerSandboxes.cpus: %w", err) + } + cfg.DockerSandboxes.CPUs = v + case "memory", "rootDisk", "dockerDisk", "minHostFreeSpace": + switch key { + case "memory": + cfg.DockerSandboxes.Memory = value + case "rootDisk": + cfg.DockerSandboxes.RootDisk = value + case "dockerDisk": + cfg.DockerSandboxes.DockerDisk = value + case "minHostFreeSpace": + cfg.DockerSandboxes.MinHostFreeSpace = value + } + case "maxConcurrentCreates": + v, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid dockerSandboxes.maxConcurrentCreates: %w", err) + } + cfg.DockerSandboxes.MaxConcurrentCreates = v + default: + return unknownKey(section, key) + } case "timeouts": v, err := strconv.Atoi(value) if err != nil { @@ -622,7 +763,7 @@ func unknownKey(section, key string) error { func isKnownSection(section string) bool { switch section { - case "github", "image", "pool", "logging", "runner", "security", "provider", "docker", "timeouts": + case "github", "image", "pool", "storage", "logging", "runner", "security", "provider", "docker", "dockerSandboxes", "timeouts": return true default: return false @@ -689,6 +830,25 @@ func applyProviderDefaults(cfg *Config, explicit map[string]bool) { if !explicit["pool.namePrefix"] && !explicit["pool.vmPrefix"] { cfg.Pool.NamePrefix = "epar-docker-container" } + case "docker-sandboxes": + // Provider.SourceImage belongs to image-building providers. Do not carry the + // Tart default into the sandbox provider, where the template is explicit. + if !explicit["provider.sourceImage"] { + cfg.Provider.SourceImage = "" + } + if !explicit["provider.platform"] { + cfg.Provider.Platform = "linux/amd64" + } + if !explicit["runner.labels"] { + architecture := "X64" + if cfg.Provider.Platform == "linux/arm64" { + architecture = "ARM64" + } + cfg.Runner.Labels = []string{"self-hosted", "linux", architecture, "epar-docker-sandboxes"} + } + if !explicit["pool.namePrefix"] && !explicit["pool.vmPrefix"] { + cfg.Pool.NamePrefix = "epar-docker-sandboxes" + } } } @@ -788,6 +948,8 @@ func isListKey(section, key string) bool { return key == "labels" case "docker": return key == "registryMirrors" + case "dockerSandboxes": + return key == "additionalAllow" || key == "additionalDeny" case "logging": return key == "managerSinks" || key == "transcriptSinks" default: @@ -819,6 +981,15 @@ func setListValue(cfg *Config, section, key string, values []string) error { cfg.Docker.RegistryMirrors = values return nil } + case "dockerSandboxes": + switch key { + case "additionalAllow": + cfg.DockerSandboxes.AdditionalAllow = values + return nil + case "additionalDeny": + cfg.DockerSandboxes.AdditionalDeny = values + return nil + } case "logging": switch key { case "managerSinks": @@ -860,6 +1031,15 @@ func appendListValue(cfg *Config, section, key, value string) error { cfg.Docker.RegistryMirrors = append(cfg.Docker.RegistryMirrors, item) return nil } + case "dockerSandboxes": + switch key { + case "additionalAllow": + cfg.DockerSandboxes.AdditionalAllow = append(cfg.DockerSandboxes.AdditionalAllow, item) + return nil + case "additionalDeny": + cfg.DockerSandboxes.AdditionalDeny = append(cfg.DockerSandboxes.AdditionalDeny, item) + return nil + } case "logging": switch key { case "managerSinks": @@ -880,19 +1060,39 @@ func Validate(cfg Config) error { if err := ValidateLogging(cfg.Logging); err != nil { return err } + if err := ValidateStorage(cfg.Storage); err != nil { + return err + } if cfg.Provider.Type == "" { return fmt.Errorf("provider.type is required") } switch cfg.Provider.Type { - case "tart", "wsl", "docker-container": + case "tart", "wsl", "docker-container", "docker-sandboxes": case "docker-socket": return fmt.Errorf("provider.type docker-socket is intentionally unsupported; use provider.type=docker-container for a private Docker daemon") default: return fmt.Errorf("unsupported provider.type %q", cfg.Provider.Type) } - if cfg.Provider.SourceImage == "" { + if cfg.Provider.Type != "docker-sandboxes" && cfg.Provider.SourceImage == "" { return fmt.Errorf("provider.sourceImage is required") } + if cfg.Provider.Type == "docker-sandboxes" { + if cfg.Provider.SourceImage != "" { + return fmt.Errorf("provider.sourceImage is not supported with provider.type=docker-sandboxes; use dockerSandboxes.template and dockerSandboxes.templateDigest") + } + if cfg.Provider.Platform != "linux/amd64" && cfg.Provider.Platform != "linux/arm64" { + return fmt.Errorf("provider.platform must be linux/amd64 or linux/arm64 with provider.type=docker-sandboxes") + } + if !cfg.Runner.Ephemeral { + return fmt.Errorf("runner.ephemeral must be true with provider.type=docker-sandboxes") + } + if cfg.Security.RunnerGroup.Enforcement != RunnerGroupEnforcementEnforce { + return fmt.Errorf("security.runnerGroup.enforcement must be enforce with provider.type=docker-sandboxes") + } + if err := ValidateDockerSandboxes(cfg.DockerSandboxes); err != nil { + return err + } + } if cfg.Provider.RosettaTag != "" { if cfg.Provider.Type != "tart" { return fmt.Errorf("provider.rosettaTag is only supported with provider.type=tart") @@ -902,8 +1102,8 @@ func Validate(cfg Config) error { } } if cfg.Provider.Platform != "" { - if cfg.Provider.Type != "docker-container" { - return fmt.Errorf("provider.platform is only supported with provider.type=docker-container") + if cfg.Provider.Type != "docker-container" && cfg.Provider.Type != "docker-sandboxes" { + return fmt.Errorf("provider.platform is only supported with provider.type=docker-container or docker-sandboxes") } if err := ValidateDockerPlatform(cfg.Provider.Platform); err != nil { return err @@ -953,6 +1153,13 @@ func Validate(cfg Config) error { if err := ValidatePrefix(cfg.Pool.NamePrefix); err != nil { return err } + if cfg.Provider.Type == "docker-sandboxes" { + for _, r := range cfg.Pool.NamePrefix { + if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.') { + return fmt.Errorf("pool.namePrefix for docker-sandboxes must contain only lowercase letters, digits, hyphens, and periods") + } + } + } if len(cfg.Runner.Labels) == 0 { return fmt.Errorf("runner.labels must not be empty") } @@ -978,6 +1185,276 @@ func Validate(cfg Config) error { return nil } +// ValidateStorage rejects policies that would silently disable capacity or +// leave a supposedly bounded cache without a usable limit. +func ValidateStorage(storage StorageConfig) error { + for key, value := range map[string]string{ + "minimumFree": storage.MinimumFree, + "buildCacheLimit": storage.BuildCacheLimit, + "goCacheLimit": storage.GoCacheLimit, + } { + if _, err := ParseByteSize(value); err != nil { + return fmt.Errorf("invalid storage.%s: %w", key, err) + } + } + grace, err := time.ParseDuration(storage.GracePeriod) + if err != nil { + return fmt.Errorf("invalid storage.gracePeriod: %w", err) + } + if grace <= 0 { + return fmt.Errorf("storage.gracePeriod must be greater than zero") + } + if storage.KeepPrevious < 0 { + return fmt.Errorf("storage.keepPrevious must be zero or greater") + } + switch storage.AutomaticHousekeeping { + case StorageHousekeepingConservative, StorageHousekeepingDisabled: + default: + return fmt.Errorf("unsupported storage.automaticHousekeeping %q; supported values are conservative and disabled", storage.AutomaticHousekeeping) + } + return nil +} + +// ValidateDockerSandboxes validates provider settings independently so +// callers constructing Config values programmatically receive the same checks as +// YAML-loaded configuration. +func ValidateDockerSandboxes(sandboxes DockerSandboxesConfig) error { + if err := validateDockerSandboxTemplate(sandboxes.Template); err != nil { + return err + } + if err := validateSHA256Fingerprint("dockerSandboxes.templateDigest", sandboxes.TemplateDigest); err != nil { + return err + } + if err := validateSHA256Fingerprint("dockerSandboxes.policyGeneration", sandboxes.PolicyGeneration); err != nil { + return err + } + if sandboxes.NetworkBaseline != DockerSandboxesNetworkBaselineOpen && sandboxes.NetworkBaseline != DockerSandboxesNetworkBaselineBalanced { + return fmt.Errorf("unsupported dockerSandboxes.networkBaseline %q; supported values are open and balanced", sandboxes.NetworkBaseline) + } + if err := validateDockerSandboxHostnameList("additionalAllow", sandboxes.AdditionalAllow); err != nil { + return err + } + if err := validateDockerSandboxHostnameList("additionalDeny", sandboxes.AdditionalDeny); err != nil { + return err + } + allowSet := make(map[string]struct{}, len(sandboxes.AdditionalAllow)) + for _, resource := range sandboxes.AdditionalAllow { + allowSet[resource] = struct{}{} + } + if sandboxes.NetworkBaseline == DockerSandboxesNetworkBaselineOpen { + for _, resource := range DockerSandboxesOpenDefaultDenyResources() { + if _, exists := allowSet[resource]; exists { + return fmt.Errorf("dockerSandboxes.additionalAllow must not override the Open host-boundary deny for %q", resource) + } + } + } + for _, resource := range sandboxes.AdditionalDeny { + if _, exists := allowSet[resource]; exists { + return fmt.Errorf("dockerSandboxes.additionalAllow and dockerSandboxes.additionalDeny must not both contain %q", resource) + } + } + if err := validateDockerSandboxesStagingRoot(sandboxes.StagingRoot); err != nil { + return err + } + if sandboxes.CPUs <= 0 { + return fmt.Errorf("dockerSandboxes.cpus must be greater than zero") + } + parsedSizes := make(map[string]int64, 4) + for key, value := range map[string]string{ + "memory": sandboxes.Memory, + "rootDisk": sandboxes.RootDisk, + "dockerDisk": sandboxes.DockerDisk, + "minHostFreeSpace": sandboxes.MinHostFreeSpace, + } { + parsed, err := ParseByteSize(value) + if err != nil { + return fmt.Errorf("invalid dockerSandboxes.%s: %w", key, err) + } + parsedSizes[key] = parsed + } + if parsedSizes["rootDisk"] < DockerSandboxesMinimumRootDiskBytes { + return fmt.Errorf("dockerSandboxes.rootDisk must be at least 20GiB") + } + if parsedSizes["dockerDisk"] < DockerSandboxesMinimumDockerDiskBytes { + return fmt.Errorf("dockerSandboxes.dockerDisk must be at least 100GiB") + } + if parsedSizes["minHostFreeSpace"] < DockerSandboxesMinimumHostFreeSpaceBytes { + return fmt.Errorf("dockerSandboxes.minHostFreeSpace must be at least 50GiB") + } + if sandboxes.MaxConcurrentCreates <= 0 { + return fmt.Errorf("dockerSandboxes.maxConcurrentCreates must be greater than zero") + } + return nil +} + +func validateDockerSandboxesStagingRoot(value string) error { + if value == "" || value != strings.TrimSpace(value) || strings.ContainsAny(value, "\x00\r\n:") { + return fmt.Errorf("dockerSandboxes.stagingRoot must be a canonical project-relative path under .local") + } + normalizedInput := strings.ReplaceAll(value, `\`, "/") + native := filepath.FromSlash(normalizedInput) + if filepath.IsAbs(native) || filepath.VolumeName(native) != "" { + return fmt.Errorf("dockerSandboxes.stagingRoot must not select an absolute host path") + } + clean := filepath.ToSlash(filepath.Clean(native)) + if clean != normalizedInput || clean == ".local" || !strings.HasPrefix(clean, ".local/") { + return fmt.Errorf("dockerSandboxes.stagingRoot must be a canonical project-relative path under .local") + } + for _, reserved := range []string{".local/bin", ".local/state"} { + if clean == reserved || strings.HasPrefix(clean, reserved+"/") { + return fmt.Errorf("dockerSandboxes.stagingRoot must not overlap reserved EPAR path %s", reserved) + } + } + return nil +} + +func validateDockerSandboxTemplate(template string) error { + if template == "" || template != strings.TrimSpace(template) { + return fmt.Errorf("dockerSandboxes.template is required") + } + if strings.ContainsAny(template, "@\\") || strings.ContainsAny(template, "\t\r\n") || strings.Contains(template, "..") || strings.Contains(template, "//") { + return fmt.Errorf("dockerSandboxes.template must be a non-empty immutable template identity") + } + for i, r := range template { + if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' || r == '/' || r == ':') { + return fmt.Errorf("dockerSandboxes.template contains invalid character at position %d", i) + } + } + if strings.HasPrefix(template, ".") || strings.HasPrefix(template, "-") || strings.HasPrefix(template, "/") || strings.HasSuffix(template, ".") || strings.HasSuffix(template, ":") || strings.HasSuffix(template, "/") { + return fmt.Errorf("dockerSandboxes.template must be a non-empty immutable template identity") + } + return nil +} + +func validateSHA256Fingerprint(key, value string) error { + const prefix = "sha256:" + if len(value) != len(prefix)+64 || !strings.HasPrefix(value, prefix) { + return fmt.Errorf("%s must be a lowercase sha256:<64-hex> fingerprint", key) + } + for _, r := range value[len(prefix):] { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return fmt.Errorf("%s must be a lowercase sha256:<64-hex> fingerprint", key) + } + } + return nil +} + +func validateDockerSandboxHostnameList(key string, resources []string) error { + seen := make(map[string]struct{}, len(resources)) + for _, resource := range resources { + if err := ValidateDockerSandboxHostname(resource); err != nil { + return fmt.Errorf("invalid dockerSandboxes.%s value %q: %w", key, resource, err) + } + if _, exists := seen[resource]; exists { + return fmt.Errorf("dockerSandboxes.%s must not contain duplicate value %q", key, resource) + } + seen[resource] = struct{}{} + } + return nil +} + +// ValidateDockerSandboxHostname accepts an exact DNS hostname or a wildcard +// constrained to the left-most label (for example, *.githubusercontent.com), +// with an optional numeric port. +func ValidateDockerSandboxHostname(resource string) error { + if resource == "" || resource != strings.TrimSpace(resource) || strings.ContainsAny(resource, "\\/@?#[]") || strings.ContainsAny(resource, "\t\r\n") { + return fmt.Errorf("must be an exact hostname or *.domain with an optional port") + } + host := resource + if strings.Contains(resource, ":") { + parsedHost, port, err := net.SplitHostPort(resource) + if err != nil || parsedHost == "" || port == "" { + return fmt.Errorf("must be an exact hostname or *.domain with an optional port") + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + host = parsedHost + } + if strings.HasPrefix(host, "*.") { + host = strings.TrimPrefix(host, "*.") + if !strings.Contains(host, ".") { + return fmt.Errorf("wildcards must be followed by a domain") + } + } else if strings.Contains(host, "*") { + return fmt.Errorf("wildcards are only supported as the left-most label") + } + if net.ParseIP(host) != nil || len(host) > 253 || host == "" { + return fmt.Errorf("must be an exact hostname or *.domain with an optional port") + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return fmt.Errorf("must be an exact hostname or *.domain with an optional port") + } + for _, r := range label { + if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') { + return fmt.Errorf("must be an exact hostname or *.domain with an optional port") + } + } + } + return nil +} + +// ParseByteSize parses a positive byte count with an explicit binary unit. The +// original configuration string is retained because the sandbox command surface +// consumes these values verbatim. +func ParseByteSize(value string) (int64, error) { + if value == "" || value != strings.TrimSpace(value) { + return 0, fmt.Errorf("must be a positive byte size such as 4GiB") + } + units := []struct { + suffix string + multiplier int64 + }{ + {suffix: "TiB", multiplier: 1 << 40}, + {suffix: "GiB", multiplier: 1 << 30}, + {suffix: "MiB", multiplier: 1 << 20}, + {suffix: "KiB", multiplier: 1 << 10}, + {suffix: "B", multiplier: 1}, + } + for _, unit := range units { + if !strings.HasSuffix(value, unit.suffix) { + continue + } + number := strings.TrimSuffix(value, unit.suffix) + if number == "" { + break + } + parsed, err := strconv.ParseInt(number, 10, 64) + if err != nil || parsed <= 0 || parsed > math.MaxInt64/unit.multiplier { + return 0, fmt.Errorf("must be a positive byte size such as 4GiB") + } + return parsed * unit.multiplier, nil + } + return 0, fmt.Errorf("must be a positive byte size such as 4GiB") +} + +// EffectiveMinimumFreeBytes returns the provider-neutral reserve, raised to a +// provider's stricter configured reserve when one exists. Docker Sandboxes +// keeps its established 50 GiB admission watermark while all providers share +// the storage.minimumFree contract. +func EffectiveMinimumFreeBytes(cfg Config) (uint64, error) { + value := cfg.Storage.MinimumFree + if value == "" { + value = Default().Storage.MinimumFree + } + minimum, err := ParseByteSize(value) + if err != nil { + return 0, fmt.Errorf("parse storage minimum free reserve: %w", err) + } + if cfg.Provider.Type == "docker-sandboxes" && cfg.DockerSandboxes.MinHostFreeSpace != "" { + providerMinimum, err := ParseByteSize(cfg.DockerSandboxes.MinHostFreeSpace) + if err != nil { + return 0, fmt.Errorf("parse dockerSandboxes.minHostFreeSpace: %w", err) + } + if providerMinimum > minimum { + minimum = providerMinimum + } + } + return uint64(minimum), nil +} + func ValidateRunnerGroupSecurity(policy RunnerGroupSecurityConfig) error { switch policy.Enforcement { case RunnerGroupEnforcementEnforce, RunnerGroupEnforcementWarn: @@ -1111,17 +1588,14 @@ func validateConsoleTextFormat(key, template, outputFormat string, allowed []str return nil } -// ValidateHostTrust keeps host trust inheritance deliberately limited to the -// ephemeral private Docker daemon image path. Other providers do not have a -// portable, unambiguous host trust boundary. -func ValidateHostTrust(image ImageConfig, provider ProviderConfig, runner RunnerConfig) error { +// ValidateHostTrust applies the provider-neutral ephemeral-runner trust +// contract. The common pool installs and validates the resolved trust snapshot +// in an unregistered guest before requesting a registration token. +func ValidateHostTrust(image ImageConfig, _ ProviderConfig, runner RunnerConfig) error { switch image.HostTrustMode { case "", HostTrustModeDisabled: return nil case HostTrustModeOverlay: - if provider.Type != "docker-container" { - return fmt.Errorf("image.hostTrustMode %q is only supported with provider.type=docker-container", HostTrustModeOverlay) - } if !runner.Ephemeral { return fmt.Errorf("image.hostTrustMode %q requires runner.ephemeral=true", HostTrustModeOverlay) } @@ -1268,6 +1742,20 @@ func ValidateDockerNoProxy(value string) error { return nil } +func DockerSandboxesGuestPlatform(hostOS, hostArch string) (string, error) { + if strings.TrimSpace(hostOS) == "" { + return "", fmt.Errorf("docker-sandboxes controller host operating system is empty") + } + switch hostArch { + case "amd64": + return "linux/amd64", nil + case "arm64": + return "linux/arm64", nil + default: + return "", fmt.Errorf("docker-sandboxes has no EPAR template for controller architecture %s on %s", hostArch, hostOS) + } +} + func ValidateDockerPlatform(platform string) error { if strings.TrimSpace(platform) != platform || platform == "" { return fmt.Errorf("provider.platform must be a non-empty Docker platform") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 020913c..e34838f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -112,6 +112,64 @@ func TestRunnerRegistrationControlsDefaultToDisabled(t *testing.T) { } } +func TestStorageDefaultsAreBoundedAndConservative(t *testing.T) { + storage := Default().Storage + if storage.MinimumFree != "20GiB" || + storage.GracePeriod != "168h" || + storage.KeepPrevious != 0 || + storage.AutomaticHousekeeping != StorageHousekeepingConservative || + storage.BuildCacheLimit != "64GiB" || + storage.GoCacheLimit != "10GiB" { + t.Fatalf("unexpected storage defaults: %+v", storage) + } + if err := ValidateStorage(storage); err != nil { + t.Fatal(err) + } +} + +func TestLoadStoragePolicy(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + content := "storage:\n minimumFree: 30GiB\n gracePeriod: 72h\n keepPrevious: 1\n automaticHousekeeping: disabled\n buildCacheLimit: 80GiB\n goCacheLimit: 12GiB\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Storage.MinimumFree, "30GiB"; got != want { + t.Fatalf("storage.minimumFree = %q, want %q", got, want) + } + if got, want := cfg.Storage.GracePeriod, "72h"; got != want { + t.Fatalf("storage.gracePeriod = %q, want %q", got, want) + } + if cfg.Storage.KeepPrevious != 1 || cfg.Storage.AutomaticHousekeeping != StorageHousekeepingDisabled { + t.Fatalf("unexpected loaded storage policy: %+v", cfg.Storage) + } + if err := ValidateStorage(cfg.Storage); err != nil { + t.Fatal(err) + } +} + +func TestValidateStorageRejectsUnboundedOrUnknownPolicy(t *testing.T) { + tests := []func(*StorageConfig){ + func(storage *StorageConfig) { storage.MinimumFree = "0GiB" }, + func(storage *StorageConfig) { storage.GracePeriod = "0h" }, + func(storage *StorageConfig) { storage.KeepPrevious = -1 }, + func(storage *StorageConfig) { storage.AutomaticHousekeeping = "aggressive" }, + func(storage *StorageConfig) { storage.BuildCacheLimit = "64GB" }, + func(storage *StorageConfig) { storage.GoCacheLimit = "" }, + } + for _, mutate := range tests { + storage := Default().Storage + mutate(&storage) + if err := ValidateStorage(storage); err == nil { + t.Fatalf("ValidateStorage accepted invalid policy: %+v", storage) + } + } +} + func TestRunnerGroupSecurityDefaultsToStrictWarnings(t *testing.T) { policy := Default().Security.RunnerGroup if policy.Enforcement != RunnerGroupEnforcementWarn || @@ -344,6 +402,7 @@ func TestLoadRejectsUnknownSectionsAndKeys(t *testing.T) { "github:\n unknown: value\n", "image:\n unknown: value\n", "pool:\n unknown: value\n", + "storage:\n unknown: value\n", "logging:\n unknown: value\n", "runner:\n unknown: value\n", "security:\n runnerGroup:\n unknown: value\n", @@ -453,7 +512,6 @@ func TestValidateRejectsInvalidHostTrustConfigurations(t *testing.T) { ephemeral bool }{ {name: "unknown mode", mode: "mirror", scopes: []string{HostTrustScopeSystem}, provider: "docker-container", ephemeral: true}, - {name: "wrong provider", mode: HostTrustModeOverlay, scopes: []string{HostTrustScopeSystem}, provider: "wsl", ephemeral: true}, {name: "non-ephemeral", mode: HostTrustModeOverlay, scopes: []string{HostTrustScopeSystem}, provider: "docker-container"}, {name: "empty scopes", mode: HostTrustModeOverlay, provider: "docker-container", ephemeral: true}, {name: "unknown scope", mode: HostTrustModeOverlay, scopes: []string{"global"}, provider: "docker-container", ephemeral: true}, @@ -473,6 +531,28 @@ func TestValidateRejectsInvalidHostTrustConfigurations(t *testing.T) { } } +func TestValidateHostTrustAllowsDockerSandboxesOverlay(t *testing.T) { + cfg := validDockerSandboxesConfig() + cfg.Image.HostTrustMode = HostTrustModeOverlay + cfg.Image.HostTrustScopes = []string{HostTrustScopeSystem} + if err := Validate(cfg); err != nil { + t.Fatalf("Docker Sandboxes host trust overlay rejected: %v", err) + } +} + +func TestValidateHostTrustAllowsWSLOverlay(t *testing.T) { + cfg := Default() + cfg.Provider.Type = "wsl" + cfg.Provider.SourceImage = "runner-image.tar" + cfg.Provider.InstallRoot = "work/wsl" + cfg.Runner.Ephemeral = true + cfg.Image.HostTrustMode = HostTrustModeOverlay + cfg.Image.HostTrustScopes = []string{HostTrustScopeSystem} + if err := Validate(cfg); err != nil { + t.Fatalf("WSL host trust overlay rejected: %v", err) + } +} + func TestRunnerHostLabelDefaultsToEnabled(t *testing.T) { oldHostname := osHostname osHostname = func() (string, error) { return "Build Box_01.example", nil } @@ -1138,3 +1218,304 @@ image: t.Fatal("Load accepted image.profile") } } + +func TestDockerSandboxesRejectsNamePrefixOutsideProviderGrammar(t *testing.T) { + cfg := Default() + cfg.Provider.Type = "docker-sandboxes" + cfg.Provider.Platform = "linux/amd64" + cfg.Provider.SourceImage = "" + cfg.Pool.NamePrefix = "EPAR_sandbox" + cfg.Runner.Ephemeral = true + cfg.Security.RunnerGroup.Enforcement = RunnerGroupEnforcementEnforce + cfg.DockerSandboxes.Template = "epar-template:v1" + cfg.DockerSandboxes.TemplateDigest = "sha256:" + strings.Repeat("a", 64) + cfg.DockerSandboxes.PolicyGeneration = "sha256:" + strings.Repeat("b", 64) + cfg.DockerSandboxes.RootDisk = "120GiB" + cfg.DockerSandboxes.DockerDisk = "100GiB" + cfg.DockerSandboxes.MinHostFreeSpace = "50GiB" + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "lowercase") { + t.Fatalf("Validate() error = %v, want Docker Sandboxes prefix grammar rejection", err) + } +} + +func TestLoadDockerSandboxesConfig(t *testing.T) { + t.Setenv(HostNameEnv, "sandbox-preview-host") + dir := t.TempDir() + path := filepath.Join(dir, "docker-sandboxes.yml") + if err := os.WriteFile(path, []byte(` +provider: + type: docker-sandboxes +runner: + ephemeral: true +security: + runnerGroup: + enforcement: enforce +dockerSandboxes: + template: registry.example.invalid/epar/runner-template:preview + templateDigest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + policyGeneration: sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 + networkBaseline: balanced + additionalAllow: [api.github.com, '*.githubusercontent.com:443'] + additionalDeny: + - telemetry.example.invalid + stagingRoot: .local/docker-sandboxes + cpus: 2 + memory: 4GiB + rootDisk: 20GiB + dockerDisk: 100GiB + maxConcurrentCreates: 1 + minHostFreeSpace: 50GiB +`), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Provider.SourceImage, ""; got != want { + t.Fatalf("provider.sourceImage = %q, want empty for docker-sandboxes", got) + } + if got, want := cfg.Provider.Platform, "linux/amd64"; got != want { + t.Fatalf("provider.platform = %q, want %q", got, want) + } + if got, want := cfg.Pool.NamePrefix, "epar-docker-sandboxes"; got != want { + t.Fatalf("pool.namePrefix = %q, want %q", got, want) + } + if got, want := cfg.Runner.Labels, []string{"self-hosted", "linux", "X64", "epar-docker-sandboxes", "epar-host-sandbox-preview-host"}; !slices.Equal(got, want) { + t.Fatalf("runner.labels = %#v, want %#v", got, want) + } + if got, want := cfg.DockerSandboxes.Memory, "4GiB"; got != want { + t.Fatalf("dockerSandboxes.memory = %q, want %q", got, want) + } + if got, want := cfg.DockerSandboxes.AdditionalAllow, []string{"api.github.com", "*.githubusercontent.com:443"}; !slices.Equal(got, want) { + t.Fatalf("dockerSandboxes.additionalAllow = %#v, want %#v", got, want) + } + if err := Validate(cfg); err != nil { + t.Fatal(err) + } +} + +func TestValidateDockerSandboxesRejectsInvalidPreviewConfiguration(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + }{ + { + name: "source image is unsupported", + mutate: func(cfg *Config) { cfg.Provider.SourceImage = "runner-image" }, + }, + { + name: "platform is not a supported sandbox guest", + mutate: func(cfg *Config) { cfg.Provider.Platform = "linux/s390x" }, + }, + { + name: "runner is persistent", + mutate: func(cfg *Config) { cfg.Runner.Ephemeral = false }, + }, + { + name: "runner group enforcement is not fail closed", + mutate: func(cfg *Config) { cfg.Security.RunnerGroup.Enforcement = RunnerGroupEnforcementWarn }, + }, + { + name: "template is empty", + mutate: func(cfg *Config) { cfg.DockerSandboxes.Template = "" }, + }, + { + name: "template digest is not pinned", + mutate: func(cfg *Config) { cfg.DockerSandboxes.TemplateDigest = "sha256:ABCDEF" }, + }, + { + name: "policy generation is not content addressed", + mutate: func(cfg *Config) { cfg.DockerSandboxes.PolicyGeneration = "verified-balanced-policy-fingerprint" }, + }, + { + name: "network baseline is unsupported", + mutate: func(cfg *Config) { cfg.DockerSandboxes.NetworkBaseline = "locked-down" }, + }, + { + name: "allowlist wildcard is unsafe", + mutate: func(cfg *Config) { cfg.DockerSandboxes.AdditionalAllow = []string{"**.example.test"} }, + }, + { + name: "allowlist contains a URL", + mutate: func(cfg *Config) { cfg.DockerSandboxes.AdditionalAllow = []string{"https://example.test"} }, + }, + { + name: "allowlist overlaps denylist", + mutate: func(cfg *Config) { cfg.DockerSandboxes.AdditionalDeny = []string{"api.github.com"} }, + }, + { + name: "open allowlist overrides host boundary", + mutate: func(cfg *Config) { cfg.DockerSandboxes.AdditionalAllow = []string{"host.docker.internal"} }, + }, + { + name: "cpus are not positive", + mutate: func(cfg *Config) { cfg.DockerSandboxes.CPUs = 0 }, + }, + { + name: "memory is not a positive byte size", + mutate: func(cfg *Config) { cfg.DockerSandboxes.Memory = "0GiB" }, + }, + { + name: "root disk is below the hard minimum", + mutate: func(cfg *Config) { cfg.DockerSandboxes.RootDisk = "19GiB" }, + }, + { + name: "docker disk is below the hard minimum", + mutate: func(cfg *Config) { cfg.DockerSandboxes.DockerDisk = "99GiB" }, + }, + { + name: "host free space is below the hard minimum", + mutate: func(cfg *Config) { cfg.DockerSandboxes.MinHostFreeSpace = "49GiB" }, + }, + { + name: "concurrency is not positive", + mutate: func(cfg *Config) { cfg.DockerSandboxes.MaxConcurrentCreates = 0 }, + }, + { + name: "staging root is an absolute host path", + mutate: func(cfg *Config) { cfg.DockerSandboxes.StagingRoot = `C:\epar-staging` }, + }, + { + name: "staging root is an absolute Unix host path", + mutate: func(cfg *Config) { cfg.DockerSandboxes.StagingRoot = "/tmp/epar-staging" }, + }, + { + name: "staging root escapes project local state", + mutate: func(cfg *Config) { cfg.DockerSandboxes.StagingRoot = "../epar-staging" }, + }, + { + name: "staging root overlaps ledger", + mutate: func(cfg *Config) { cfg.DockerSandboxes.StagingRoot = ".local/state/docker-sandboxes" }, + }, + { + name: "staging root overlaps native binary cache", + mutate: func(cfg *Config) { cfg.DockerSandboxes.StagingRoot = ".local/bin/staging" }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := validDockerSandboxesConfig() + test.mutate(&cfg) + if err := Validate(cfg); err == nil { + t.Fatal("Validate accepted invalid docker-sandboxes configuration") + } + }) + } +} + +func TestValidateDockerSandboxesAcceptsARM64Configuration(t *testing.T) { + cfg := validDockerSandboxesConfig() + cfg.Provider.Platform = "linux/arm64" + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() rejected linux/arm64 Docker Sandboxes configuration: %v", err) + } +} + +func TestDockerSandboxesGuestPlatform(t *testing.T) { + tests := []struct { + hostOS string + hostArch string + want string + wantErr bool + }{ + {hostOS: "windows", hostArch: "amd64", want: "linux/amd64"}, + {hostOS: "linux", hostArch: "amd64", want: "linux/amd64"}, + {hostOS: "darwin", hostArch: "arm64", want: "linux/arm64"}, + {hostOS: "windows", hostArch: "arm64", want: "linux/arm64"}, + {hostOS: "linux", hostArch: "arm64", want: "linux/arm64"}, + {hostOS: "darwin", hostArch: "amd64", want: "linux/amd64"}, + {hostOS: "futureos", hostArch: "amd64", want: "linux/amd64"}, + {hostOS: "futureos", hostArch: "arm64", want: "linux/arm64"}, + {hostOS: "futureos", hostArch: "386", wantErr: true}, + {hostOS: "", hostArch: "amd64", wantErr: true}, + } + for _, test := range tests { + t.Run(test.hostOS+"_"+test.hostArch, func(t *testing.T) { + got, err := DockerSandboxesGuestPlatform(test.hostOS, test.hostArch) + if test.wantErr { + if err == nil { + t.Fatalf("DockerSandboxesGuestPlatform(%q, %q) = %q, nil; want error", test.hostOS, test.hostArch, got) + } + return + } + if err != nil { + t.Fatalf("DockerSandboxesGuestPlatform(%q, %q) error = %v", test.hostOS, test.hostArch, err) + } + if got != test.want { + t.Fatalf("DockerSandboxesGuestPlatform(%q, %q) = %q, want %q", test.hostOS, test.hostArch, got, test.want) + } + }) + } +} + +func TestValidateDockerSandboxHostname(t *testing.T) { + for _, hostname := range []string{"api.github.com", "api.github.com:443", "*.githubusercontent.com", "*.githubusercontent.com:443"} { + if err := ValidateDockerSandboxHostname(hostname); err != nil { + t.Fatalf("hostname %q rejected: %v", hostname, err) + } + } + for _, hostname := range []string{"**.githubusercontent.com", "api.*.github.com", "https://api.github.com", "api.github.com/path", "api.github.com:0", "api.github.com:65536", "127.0.0.1", "[::1]:443"} { + if err := ValidateDockerSandboxHostname(hostname); err == nil { + t.Fatalf("hostname %q accepted", hostname) + } + } +} + +func TestParseByteSize(t *testing.T) { + if got, err := ParseByteSize("4GiB"); err != nil || got != 4*(1<<30) { + t.Fatalf("ParseByteSize(4GiB) = %d, %v", got, err) + } + for _, value := range []string{"", " 4GiB", "4GB", "0GiB", "-1GiB", "999999999999999999999TiB"} { + if _, err := ParseByteSize(value); err == nil { + t.Fatalf("ParseByteSize(%q) accepted invalid value", value) + } + } +} + +func TestEffectiveMinimumFreeBytesUsesProviderReserve(t *testing.T) { + cfg := Default() + cfg.Provider.Type = "docker-sandboxes" + cfg.Storage.MinimumFree = "20GiB" + cfg.DockerSandboxes.MinHostFreeSpace = "50GiB" + + got, err := EffectiveMinimumFreeBytes(cfg) + if err != nil { + t.Fatal(err) + } + if want := uint64(50 << 30); got != want { + t.Fatalf("EffectiveMinimumFreeBytes() = %d, want %d", got, want) + } +} + +func TestEffectiveMinimumFreeBytesKeepsStricterCommonReserve(t *testing.T) { + cfg := Default() + cfg.Provider.Type = "docker-sandboxes" + cfg.Storage.MinimumFree = "60GiB" + cfg.DockerSandboxes.MinHostFreeSpace = "50GiB" + + got, err := EffectiveMinimumFreeBytes(cfg) + if err != nil { + t.Fatal(err) + } + if want := uint64(60 << 30); got != want { + t.Fatalf("EffectiveMinimumFreeBytes() = %d, want %d", got, want) + } +} + +func validDockerSandboxesConfig() Config { + cfg := Default() + cfg.Provider.Type = "docker-sandboxes" + cfg.Provider.SourceImage = "" + cfg.Provider.Platform = "linux/amd64" + cfg.Runner.Ephemeral = true + cfg.Security.RunnerGroup.Enforcement = RunnerGroupEnforcementEnforce + cfg.DockerSandboxes.Template = "registry.example.invalid/epar/runner-template:preview" + cfg.DockerSandboxes.TemplateDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cfg.DockerSandboxes.PolicyGeneration = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + cfg.DockerSandboxes.AdditionalAllow = []string{"api.github.com"} + cfg.DockerSandboxes.RootDisk = "120GiB" + cfg.DockerSandboxes.DockerDisk = "100GiB" + cfg.DockerSandboxes.MinHostFreeSpace = "50GiB" + return cfg +} diff --git a/internal/github/client.go b/internal/github/client.go index 6ccc2dd..d2f3b06 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "github.com/solutionforest/ephemeral-action-runner/internal/config" @@ -18,6 +19,7 @@ import ( type Client struct { cfg config.GitHubConfig httpClient *http.Client + tokenMu sync.Mutex token string tokenExpires time.Time } @@ -175,6 +177,9 @@ func (c *Client) installationRequest(ctx context.Context, method, path string, b } func (c *Client) installationToken(ctx context.Context) (string, error) { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + if c.token != "" && time.Now().Before(c.tokenExpires.Add(-2*time.Minute)) { return c.token, nil } diff --git a/internal/github/client_test.go b/internal/github/client_test.go index b0c87d6..c40045e 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -6,10 +6,13 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "fmt" "io" "net/http" "os" "strings" + "sync" + "sync/atomic" "testing" "time" @@ -88,6 +91,65 @@ func TestListRunnersUsesInstallationToken(t *testing.T) { } } +func TestInstallationTokenCacheIsConcurrentAndSingleFlight(t *testing.T) { + keyPath := writeKey(t) + client := New(config.GitHubConfig{ + AppID: 123, + Organization: "example", + PrivateKeyPath: keyPath, + APIBaseURL: "https://api.github.test", + WebBaseURL: "https://github.test", + }) + var installationRequests atomic.Int32 + var tokenRequests atomic.Int32 + client.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + var body string + switch r.URL.Path { + case "/orgs/example/installation": + installationRequests.Add(1) + body = `{"id":42}` + case "/app/installations/42/access_tokens": + tokenRequests.Add(1) + body = `{"token":"installation-token","expires_at":"2099-01-01T00:00:00Z"}` + default: + return nil, fmt.Errorf("unexpected path %s", r.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + })} + + const callers = 32 + errorsCh := make(chan error, callers) + var wait sync.WaitGroup + wait.Add(callers) + for index := 0; index < callers; index++ { + go func() { + defer wait.Done() + token, err := client.installationToken(context.Background()) + if err == nil && token != "installation-token" { + err = fmt.Errorf("installationToken() = %q", token) + } + errorsCh <- err + }() + } + wait.Wait() + close(errorsCh) + for err := range errorsCh { + if err != nil { + t.Fatal(err) + } + } + if got := installationRequests.Load(); got != 1 { + t.Fatalf("installation lookup requests = %d, want 1", got) + } + if got := tokenRequests.Load(); got != 1 { + t.Fatalf("access-token requests = %d, want 1", got) + } +} + func TestWaitRunnerOnlineAcceptsBusyRunner(t *testing.T) { keyPath := writeKey(t) client := New(config.GitHubConfig{ diff --git a/internal/image/acquisition.go b/internal/image/acquisition.go new file mode 100644 index 0000000..9aabc17 --- /dev/null +++ b/internal/image/acquisition.go @@ -0,0 +1,179 @@ +package image + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/moby/moby/client" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +const dockerPullProgressInterval = 250 * time.Millisecond + +type DockerSourcePullOptions struct { + Image string + Platform string + LogPath string + AnnounceRemoteSize bool +} + +func (m *Coordinator) PullDockerSource(ctx context.Context, opts DockerSourcePullOptions) error { + result, err := PullDockerImage(ctx, DockerPullOptions{ + Image: opts.Image, + Platform: opts.Platform, + FallbackPlatform: m.Config.Provider.Platform, + QueryRemoteSize: opts.AnnounceRemoteSize, + }) + var enginePullErr *DockerEnginePullError + if err != nil && !errors.As(err, &enginePullErr) { + return m.pullDockerSourceWithCLI(ctx, opts, err) + } + if opts.AnnounceRemoteSize { + if result.RemoteCompressedError != nil { + m.WriteDockerPullNotice(opts.LogPath, "warning: could not determine remote compressed layer size: "+sanitizeImageError(result.RemoteCompressedError)) + } else { + m.WriteDockerPullNotice(opts.LogPath, fmt.Sprintf("Remote compressed layers: %s; actual transfer may be lower when Docker reuses layers.", FormatDockerPullBytes(result.RemoteCompressedSize))) + } + } + if result.RegistryAuthError != nil { + m.WriteDockerPullNotice(opts.LogPath, "warning: could not load Docker registry credentials; continuing without explicit credentials: "+sanitizeImageError(result.RegistryAuthError)) + } + if err != nil { + return err + } + if err := m.renderDockerPullProgress(ctx, result.Response, opts.LogPath); err != nil { + return fmt.Errorf("Docker Engine pull %s: %w", opts.Image, err) + } + m.WriteDockerPullNotice(opts.LogPath, "Docker source pull complete: "+opts.Image) + return nil +} + +func (m *Coordinator) pullDockerSourceWithCLI(ctx context.Context, opts DockerSourcePullOptions, apiErr error) error { + m.WriteDockerPullNotice(opts.LogPath, "warning: "+sanitizeImageError(apiErr)+"; falling back to docker pull CLI") + args := []string{"pull"} + if opts.Platform != "" { + args = append(args, "--platform", opts.Platform) + } + args = append(args, opts.Image) + return m.runHostLogged(ctx, opts.LogPath, "docker", args...) +} + +func (m *Coordinator) renderDockerPullProgress(ctx context.Context, response client.ImagePullResponse, logPath string) error { + transcript, err := m.transcript(logPath, "", "docker-pull") + if err != nil { + return err + } + layers := map[string]DockerPullProgress{} + lastRender := time.Time{} + rendered := false + err = ConsumeDockerPullProgress(ctx, response, func(message DockerPullEvent) error { + writeDockerPullEvent(transcript.Stdout, message) + if message.Error != nil { + return nil + } + if message.ID != "" { + layer := layers[message.ID] + if message.Progress != nil { + layer.Current = message.Progress.Current + if message.Progress.Total > 0 { + layer.Total = message.Progress.Total + } + } + if IsDockerPullLayerComplete(message.Status) { + layer.Completed = true + if layer.Total > 0 { + layer.Current = layer.Total + } + } + layers[message.ID] = layer + } + if time.Since(lastRender) >= dockerPullProgressInterval { + m.writeDockerPullProgress(logPath, layers) + lastRender = time.Now() + rendered = true + } + return nil + }) + if err != nil { + return err + } + if rendered { + m.writeDockerPullProgress(logPath, layers) + if m.dockerPullProgressIsInteractive() { + fmt.Fprintln(m.environment.DockerPullProgressConsole()) + } + } + return nil +} + +func (m *Coordinator) WriteDockerPullNotice(logPath, message string) { + attributes := []any{"provider", m.Config.Provider.Type, "operation", "docker-pull", "logPath", logPath} + if strings.HasPrefix(message, "warning:") { + m.environment.LogWarn(strings.TrimSpace(strings.TrimPrefix(message, "warning:")), attributes...) + } else { + m.environment.LogInfo(message, attributes...) + } + transcript, err := m.transcript(logPath, "", "docker-pull") + if err != nil { + m.environment.LogWarn("docker pull transcript unavailable", "operation", "docker-pull", "logPath", logPath, "error", err) + return + } + _, _ = fmt.Fprintf(transcript.Stdout, "%s\n", message) +} + +func writeDockerPullEvent(logFile io.Writer, event DockerPullEvent) { + if logFile == nil { + return + } + parts := make([]string, 0, 4) + if event.ID != "" { + parts = append(parts, event.ID) + } + if event.Status != "" { + parts = append(parts, event.Status) + } + if event.Progress != nil { + parts = append(parts, fmt.Sprintf("progress=%d/%d", event.Progress.Current, event.Progress.Total)) + } + if event.Stream != "" { + parts = append(parts, strings.TrimSpace(event.Stream)) + } + if event.Error != nil { + parts = append(parts, "error="+sanitizeImageError(event.Error)) + } + fmt.Fprintf(logFile, "%s %s\n", time.Now().UTC().Format(time.RFC3339Nano), strings.Join(parts, " ")) +} + +func (m *Coordinator) writeDockerPullProgress(logPath string, layers map[string]DockerPullProgress) { + line := DockerPullProgressSummary(layers) + if m.dockerPullProgressIsInteractive() { + _, _ = fmt.Fprintf(m.environment.DockerPullProgressConsole(), "\r\033[2K%s", line) + return + } + m.environment.LogInfo(line, "provider", m.Config.Provider.Type, "operation", "docker-pull", "logPath", logPath) +} + +func (m *Coordinator) dockerPullProgressIsInteractive() bool { + return m.environment.DockerPullProgressTerminal() && containsString(m.Config.Logging.ManagerSinks, "console") && m.Config.Logging.ManagerConsoleFormat == "text" +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func sanitizeImageError(err error) string { + text := provider.RedactText(strings.Join(strings.Fields(err.Error()), " ")) + if len(text) > 500 { + return text[:500] + "..." + } + return text +} diff --git a/internal/pool/image_manifest.go b/internal/image/artifact_lifecycle.go similarity index 65% rename from internal/pool/image_manifest.go rename to internal/image/artifact_lifecycle.go index c9d11cc..17c6bb8 100644 --- a/internal/pool/image_manifest.go +++ b/internal/image/artifact_lifecycle.go @@ -1,4 +1,4 @@ -package pool +package image import ( "context" @@ -18,46 +18,38 @@ import ( ) const ( - imageManifestSchemaVersion = 1 - imageManifestGuestPath = "/opt/epar/image-manifest.json" - imageManifestLabel = "org.solutionforest.epar.manifest-sha256" + imageManifestSchemaVersion = ManifestSchemaVersion + imageManifestGuestPath = ManifestGuestPath + imageManifestLabel = ManifestLabel ) -type ImageManifest struct { - SchemaVersion int `json:"schemaVersion"` - ProviderType string `json:"providerType"` - ProviderPlatform string `json:"providerPlatform,omitempty"` - ProviderRosettaTag string `json:"providerRosettaTag,omitempty"` - SourceType string `json:"sourceType,omitempty"` - SourceImage string `json:"sourceImage"` - SourcePlatform string `json:"sourcePlatform,omitempty"` - SourceDigest string `json:"sourceDigest,omitempty"` - OutputImage string `json:"outputImage"` - RunnerVersion string `json:"runnerVersion"` - UpstreamCommit string `json:"upstreamCommit,omitempty"` - EPARScripts []fileDigest `json:"eparScripts,omitempty"` - CustomInstallScripts []fileDigest `json:"customInstallScripts,omitempty"` - TrustedCACertificates []fileDigest `json:"trustedCaCertificates,omitempty"` - HostTrust *hostTrustImageMetadata `json:"hostTrust,omitempty"` -} - -type fileDigest struct { - Path string `json:"path"` - SHA256 string `json:"sha256"` -} - -type storedImageManifest struct { - Hash string `json:"hash"` - Manifest ImageManifest `json:"manifest"` -} +type ImageManifest = Manifest +type fileDigest = FileDigest +type sourceCacheManifest = SourceCacheManifest -type sourceCacheManifest struct { - SourceImage string `json:"sourceImage"` - SourcePlatform string `json:"sourcePlatform,omitempty"` - SourceDigest string `json:"sourceDigest,omitempty"` +func hostTrustMetadata(snapshot hosttrust.Snapshot) *HostTrustMetadata { + if snapshot.Generation == "" { + return nil + } + return &HostTrustMetadata{ + Mode: hosttrust.ModeOverlay, + HostOS: snapshot.HostOS, + Scopes: append([]string(nil), snapshot.Scopes...), + Generation: snapshot.Generation, + CertificateCount: len(snapshot.Certificates), + } } -func (m *Manager) EnsureImage(ctx context.Context) error { +func (m *Coordinator) EnsureImage(ctx context.Context) error { + if err := m.preflightStorage("image-pull", imagePullExpansionBytes); err != nil { + return err + } + if artifactManager, ok := m.Lifecycle.(provider.ArtifactManager); ok { + handled, err := artifactManager.EnsureArtifacts(ctx, m.DryRun) + if handled || err != nil { + return err + } + } manifest, err := m.desiredImageManifest(ctx) if err != nil { return err @@ -94,7 +86,7 @@ const ( imageStateCurrent ) -func (m *Manager) currentImageState(ctx context.Context, wantHash string) (imageState, error) { +func (m *Coordinator) currentImageState(ctx context.Context, wantHash string) (imageState, error) { switch m.Config.Provider.Type { case "docker-container": got, exists, err := m.currentDockerContainerManifestHash(ctx) @@ -127,12 +119,12 @@ func (m *Manager) currentImageState(ctx context.Context, wantHash string) (image } } -func (m *Manager) currentDockerContainerManifestHash(ctx context.Context) (string, bool, error) { +func (m *Coordinator) currentDockerContainerManifestHash(ctx context.Context) (string, bool, error) { output := strings.TrimSpace(m.Config.Image.OutputImage) if output == "" { return "", false, fmt.Errorf("image.outputImage is required") } - out, err := runHostOutputCommand(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Labels}}", output) + out, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Labels}}", output) if err != nil { if dockerInspectMeansMissing(err) { return "", false, nil @@ -146,7 +138,7 @@ func (m *Manager) currentDockerContainerManifestHash(ctx context.Context) (strin return labels[imageManifestLabel], true, nil } -func (m *Manager) currentWSLManifestHash() (string, bool, error) { +func (m *Coordinator) currentWSLManifestHash() (string, bool, error) { outputPath := config.ProjectPath(m.ProjectRoot, m.Config.Image.OutputImage) if _, err := os.Stat(outputPath); err != nil { if errors.Is(err, os.ErrNotExist) { @@ -164,7 +156,7 @@ func (m *Manager) currentWSLManifestHash() (string, bool, error) { return stored.Hash, true, nil } -func (m *Manager) currentTartImageState(ctx context.Context) (imageState, error) { +func (m *Coordinator) currentTartImageState(ctx context.Context) (imageState, error) { instances, err := m.Provider.List(ctx) if err != nil { return imageStateMissing, err @@ -177,7 +169,7 @@ func (m *Manager) currentTartImageState(ctx context.Context) (imageState, error) return imageStateMissing, nil } -func (m *Manager) desiredImageManifest(ctx context.Context) (ImageManifest, error) { +func (m *Coordinator) desiredImageManifest(ctx context.Context) (ImageManifest, error) { snapshot, err := m.resolveHostTrust(ctx) if err != nil { return ImageManifest{}, err @@ -185,7 +177,7 @@ func (m *Manager) desiredImageManifest(ctx context.Context) (ImageManifest, erro return m.desiredImageManifestWithHostTrust(ctx, snapshot) } -func (m *Manager) desiredImageManifestWithHostTrust(ctx context.Context, snapshot hosttrust.Snapshot) (ImageManifest, error) { +func (m *Coordinator) desiredImageManifestWithHostTrust(ctx context.Context, snapshot hosttrust.Snapshot) (ImageManifest, error) { sourceType := m.Config.Image.SourceType if sourceType == "" { sourceType = config.ImageSourceRootFSTar @@ -251,7 +243,7 @@ func (m *Manager) desiredImageManifestWithHostTrust(ctx context.Context, snapsho return manifest, nil } -func (m *Manager) refreshDockerSourceDigest(ctx context.Context) (string, error) { +func (m *Coordinator) refreshDockerSourceDigest(ctx context.Context) (string, error) { var digest string err := m.timeStartupStage("source_image_pull", func() error { var err error @@ -261,7 +253,7 @@ func (m *Manager) refreshDockerSourceDigest(ctx context.Context) (string, error) return digest, err } -func (m *Manager) refreshDockerSourceDigestUntimed(ctx context.Context) (string, error) { +func (m *Coordinator) refreshDockerSourceDigestUntimed(ctx context.Context) (string, error) { if m.DryRun { return "dry-run", nil } @@ -273,7 +265,7 @@ func (m *Manager) refreshDockerSourceDigestUntimed(ctx context.Context) (string, logPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".source.log") defer m.releaseTranscript(logPath) m.infof("refreshing Docker source image %s\n", image) - if err := pullDockerSourceCommand(m, ctx, dockerSourcePullOptions{ + if err := m.pullDockerSource(ctx, DockerSourcePullOptions{ Image: image, Platform: platform, LogPath: logPath, @@ -281,7 +273,7 @@ func (m *Manager) refreshDockerSourceDigestUntimed(ctx context.Context) (string, }); err != nil { return "", fmt.Errorf("refresh Docker source image %s: %w", image, err) } - digestsJSON, err := runHostOutputCommand(ctx, "docker", "image", "inspect", "--format", "{{json .RepoDigests}}", image) + digestsJSON, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{json .RepoDigests}}", image) if err != nil { return "", err } @@ -292,19 +284,19 @@ func (m *Manager) refreshDockerSourceDigestUntimed(ctx context.Context) (string, sort.Strings(digests) if len(digests) > 0 { digest := digests[0] - m.writeDockerPullNotice(logPath, "Docker source image digest: "+digest) + m.WriteDockerPullNotice(logPath, "Docker source image digest: "+digest) return digest, nil } - imageID, err := runHostOutputCommand(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", image) + imageID, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", image) if err != nil { return "", err } digest := strings.TrimSpace(imageID) - m.writeDockerPullNotice(logPath, "Docker source image ID: "+digest) + m.WriteDockerPullNotice(logPath, "Docker source image ID: "+digest) return digest, nil } -func (m *Manager) eparScriptDigests() ([]fileDigest, error) { +func (m *Coordinator) eparScriptDigests() ([]fileDigest, error) { var roots []string switch m.Config.Provider.Type { case "docker-container": @@ -329,7 +321,7 @@ func (m *Manager) eparScriptDigests() ([]fileDigest, error) { return out, nil } -func (m *Manager) customInstallScriptDigests() ([]fileDigest, error) { +func (m *Coordinator) customInstallScriptDigests() ([]fileDigest, error) { var out []fileDigest for _, script := range m.Config.Image.CustomInstallScripts { path, err := m.customInstallScriptHostPath(script) @@ -346,8 +338,8 @@ func (m *Manager) customInstallScriptDigests() ([]fileDigest, error) { return out, nil } -func (m *Manager) trustedCACertificateDigests() ([]fileDigest, error) { - if _, err := m.trustedCACertificates(); err != nil { +func (m *Coordinator) trustedCACertificateDigests() ([]fileDigest, error) { + if err := m.validateTrustedCACertificates(); err != nil { return nil, err } var out []fileDigest @@ -363,7 +355,7 @@ func (m *Manager) trustedCACertificateDigests() ([]fileDigest, error) { return out, nil } -func (m *Manager) fileDigestsUnder(root string) ([]fileDigest, error) { +func (m *Coordinator) fileDigestsUnder(root string) ([]fileDigest, error) { var out []fileDigest if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { @@ -385,7 +377,7 @@ func (m *Manager) fileDigestsUnder(root string) ([]fileDigest, error) { return out, nil } -func (m *Manager) fileDigest(path string) (fileDigest, error) { +func (m *Coordinator) fileDigest(path string) (fileDigest, error) { sha, err := m.fileSHA256(path) if err != nil { return fileDigest{}, err @@ -397,7 +389,7 @@ func (m *Manager) fileDigest(path string) (fileDigest, error) { return fileDigest{Path: filepath.ToSlash(filepath.Clean(rel)), SHA256: sha}, nil } -func (m *Manager) fileSHA256(path string) (string, error) { +func (m *Coordinator) fileSHA256(path string) (string, error) { if m.DryRun { return "dry-run", nil } @@ -416,50 +408,22 @@ func sortFileDigests(values []fileDigest) { } func imageManifestHash(manifest ImageManifest) (string, error) { - content, err := json.Marshal(manifest) - if err != nil { - return "", err - } - sum := sha256.Sum256(content) - return hex.EncodeToString(sum[:]), nil + return ManifestHash(manifest) } func storedImageManifestContent(manifest ImageManifest) (string, string, error) { - hash, err := imageManifestHash(manifest) - if err != nil { - return "", "", err - } - content, err := json.MarshalIndent(storedImageManifest{Hash: hash, Manifest: manifest}, "", " ") - if err != nil { - return "", "", err - } - return string(content) + "\n", hash, nil + return StoredManifestContent(manifest) } -func readStoredImageManifest(path string) (storedImageManifest, error) { - content, err := os.ReadFile(path) - if err != nil { - return storedImageManifest{}, err - } - var stored storedImageManifest - if err := json.Unmarshal(content, &stored); err != nil { - return storedImageManifest{}, err - } - return stored, nil +func readStoredImageManifest(path string) (StoredManifest, error) { + return ReadStoredManifest(path) } func writeStoredImageManifest(path string, manifest ImageManifest) error { - content, _, err := storedImageManifestContent(manifest) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - return os.WriteFile(path, []byte(content), 0644) + return WriteStoredManifest(path, manifest) } -func (m *Manager) installImageManifest(ctx context.Context, vmName string, manifest ImageManifest) error { +func (m *Coordinator) installImageManifest(ctx context.Context, vmName string, manifest ImageManifest) error { content, _, err := storedImageManifestContent(manifest) if err != nil { return err @@ -468,36 +432,21 @@ func (m *Manager) installImageManifest(ctx context.Context, vmName string, manif } func wslImageManifestSidecarPath(outputPath string) string { - return outputPath + ".epar-manifest.json" + return WSLImageManifestPath(outputPath) } func sourceCacheManifestPath(rootfsPath string) string { - return rootfsPath + ".source.json" + return SourceCacheManifestPath(rootfsPath) } func sourceCacheMatches(path string, want sourceCacheManifest) bool { - content, err := os.ReadFile(path) - if err != nil { - return false - } - var got sourceCacheManifest - if err := json.Unmarshal(content, &got); err != nil { - return false - } - return got == want + return SourceCacheMatches(path, want) } func writeSourceCacheManifest(path string, manifest sourceCacheManifest) error { - content, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(content, '\n'), 0644) + return WriteSourceCacheManifest(path, manifest) } func dockerInspectMeansMissing(err error) bool { - text := strings.ToLower(err.Error()) - return strings.Contains(text, "no such image") || - strings.Contains(text, "no such object") || - strings.Contains(text, "not found") + return DockerInspectMeansMissing(err) } diff --git a/internal/pool/image.go b/internal/image/build.go similarity index 89% rename from internal/pool/image.go rename to internal/image/build.go index 8b07921..1738868 100644 --- a/internal/pool/image.go +++ b/internal/image/build.go @@ -1,4 +1,4 @@ -package pool +package image import ( "context" @@ -33,14 +33,10 @@ type wslExporter interface { Export(ctx context.Context, name, outputPath string) error } -var ( - runHostCommand = runHost - runHostLoggedCommand = runHostLogged - runHostOutputCommand = runHostOutput - runHostQuietCommand = runHostQuiet -) - -func (m *Manager) UpdateUpstream(ctx context.Context) error { +func (m *Coordinator) UpdateUpstream(ctx context.Context) error { + if err := m.preflightStorage("source-update", sourceUpdateExpansionBytes); err != nil { + return err + } dir := config.ProjectPath(m.ProjectRoot, m.Config.Image.UpstreamDir) logPath := m.buildLogPath("runner-images.source.log") defer m.releaseTranscript(logPath) @@ -77,8 +73,11 @@ func (m *Manager) UpdateUpstream(ctx context.Context) error { return nil } -func (m *Manager) BuildImage(ctx context.Context, opts ImageBuildOptions) error { - if _, err := m.trustedCACertificates(); err != nil { +func (m *Coordinator) BuildImage(ctx context.Context, opts ImageBuildOptions) error { + if err := m.preflightStorage("image-build", imageBuildExpansionBytes); err != nil { + return err + } + if err := m.validateTrustedCACertificates(); err != nil { return err } upstreamDir := config.ProjectPath(m.ProjectRoot, m.Config.Image.UpstreamDir) @@ -102,13 +101,13 @@ func (m *Manager) BuildImage(ctx context.Context, opts ImageBuildOptions) error } } -func (m *Manager) buildDockerContainerImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { +func (m *Coordinator) buildDockerContainerImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { return m.timeStartupStage("docker_container_image_build", func() error { return m.buildDockerContainerImageUntimed(ctx, opts, upstreamDir) }) } -func (m *Manager) buildDockerContainerImageUntimed(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { +func (m *Coordinator) buildDockerContainerImageUntimed(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { buildLogPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".docker-build.log") defer m.releaseTranscript(buildLogPath) if err := resetLogs(buildLogPath); err != nil { @@ -119,6 +118,10 @@ func (m *Manager) buildDockerContainerImageUntimed(ctx context.Context, opts Ima return fmt.Errorf("docker image %s already exists; rerun with --replace", m.Config.Image.OutputImage) } } + builder, err := m.ensureBuildxBuilder(ctx) + if err != nil { + return err + } attempts := 1 if m.hostTrustEnabled() { attempts = 3 @@ -140,7 +143,7 @@ func (m *Manager) buildDockerContainerImageUntimed(ctx context.Context, opts Ima if m.hostTrustEnabled() { targetImage = temporaryDockerImageTag(targetImage, snapshot.Generation, attempt) } - if err := m.buildDockerContainerImageAttempt(ctx, upstreamDir, buildLogPath, targetImage, *manifest, snapshot); err != nil { + if err := m.buildDockerContainerImageAttempt(ctx, upstreamDir, buildLogPath, builder, targetImage, *manifest, snapshot); err != nil { return err } if m.DryRun || !m.hostTrustEnabled() { @@ -148,26 +151,26 @@ func (m *Manager) buildDockerContainerImageUntimed(ctx context.Context, opts Ima } current, err := m.resolveHostTrust(ctx) if err != nil { - _ = runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) + _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) return err } if current.Generation != snapshot.Generation { m.infof("host trust changed during image build (%s -> %s); discarding attempt %d/%d\n", snapshot.Generation, current.Generation, attempt, attempts) - _ = runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) + _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) continue } - if err := runHostCommand(ctx, "docker", "image", "tag", targetImage, m.Config.Image.OutputImage); err != nil { - _ = runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) + if err := m.runHost(ctx, "docker", "image", "tag", targetImage, m.Config.Image.OutputImage); err != nil { + _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) return err } - _ = runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) + _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) m.infof("image build complete: %s is available in `docker image ls`\n", m.Config.Image.OutputImage) return nil } return fmt.Errorf("host trust changed during all %d image build attempts; retry after the host trust store stabilizes", attempts) } -func (m *Manager) buildDockerContainerImageAttempt(ctx context.Context, upstreamDir, buildLogPath, targetImage string, manifest ImageManifest, snapshot hosttrust.Snapshot) error { +func (m *Coordinator) buildDockerContainerImageAttempt(ctx context.Context, upstreamDir, buildLogPath, builder, targetImage string, manifest ImageManifest, snapshot hosttrust.Snapshot) error { manifestContent, manifestHash, err := storedImageManifestContent(manifest) if err != nil { return err @@ -182,7 +185,7 @@ func (m *Manager) buildDockerContainerImageAttempt(ctx context.Context, upstream } m.infof("building Docker Container image %s from %s\n", targetImage, m.Config.Image.SourceImage) m.infof("log: %s\n", buildLogPath) - args := []string{"build", "-t", targetImage} + args := []string{"buildx", "build", "--builder", builder, "--load", "-t", targetImage} if m.Config.Provider.Platform != "" { args = append(args, "--platform", m.Config.Provider.Platform) } @@ -214,7 +217,7 @@ func temporaryDockerImageTag(output, generation string, attempt int) string { return fmt.Sprintf("%s-epar-build-%s-%d", output, short, attempt) } -func (m *Manager) buildTartImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { +func (m *Coordinator) buildTartImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { if opts.Manifest == nil { manifest, err := m.desiredImageManifest(ctx) if err != nil { @@ -302,13 +305,13 @@ func (m *Manager) buildTartImage(ctx context.Context, opts ImageBuildOptions, up return nil } -func (m *Manager) buildWSLImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { +func (m *Coordinator) buildWSLImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { return m.timeStartupStage("wsl_image_build", func() error { return m.buildWSLImageUntimed(ctx, opts, upstreamDir) }) } -func (m *Manager) buildWSLImageUntimed(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { +func (m *Coordinator) buildWSLImageUntimed(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { exporter, ok := m.Provider.(wslExporter) if !ok { return fmt.Errorf("provider.type=wsl requires provider export support") @@ -318,7 +321,7 @@ func (m *Manager) buildWSLImageUntimed(ctx context.Context, opts ImageBuildOptio if sourceType == "" { sourceType = config.ImageSourceRootFSTar } - buildName := RunnerName(m.Config.Pool.NamePrefix+"-image", 1, time.Now()) + buildName := m.runnerName(m.Config.Pool.NamePrefix+"-image", 1, time.Now()) buildLogPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".wsl-build.log") guestLogPath := m.buildLogPath(buildName + ".guest.log") defer m.releaseTranscript(buildLogPath) @@ -475,7 +478,7 @@ func (m *Manager) buildWSLImageUntimed(ctx context.Context, opts ImageBuildOptio return nil } -func (m *Manager) prepareWSLDockerSourceRootfs(ctx context.Context, outputPath, buildLogPath string, manifest ImageManifest) (string, string, error) { +func (m *Coordinator) prepareWSLDockerSourceRootfs(ctx context.Context, outputPath, buildLogPath string, manifest ImageManifest) (string, string, error) { image := strings.TrimSpace(m.Config.Image.SourceImage) if image == "" { return "", "", fmt.Errorf("image.sourceImage is required when image.sourceType=docker-image") @@ -544,7 +547,7 @@ func (m *Manager) prepareWSLDockerSourceRootfs(ctx context.Context, outputPath, return "", "", err } m.infof("preparing WSL source rootfs from Docker image %s\n", image) - if err := pullDockerSourceCommand(m, ctx, dockerSourcePullOptions{ + if err := m.pullDockerSource(ctx, DockerSourcePullOptions{ Image: image, Platform: platform, LogPath: buildLogPath, @@ -557,9 +560,9 @@ func (m *Manager) prepareWSLDockerSourceRootfs(ctx context.Context, outputPath, defer func() { cleanupCtx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - _ = runHostQuietCommand(cleanupCtx, "docker", "rm", "-f", containerName) + _ = m.runHostQuiet(cleanupCtx, "docker", "rm", "-f", containerName) }() - envJSON, err := runHostOutputCommand(ctx, "docker", "container", "inspect", "--format", "{{json .Config.Env}}", containerName) + envJSON, err := m.runHostOutput(ctx, "docker", "container", "inspect", "--format", "{{json .Config.Env}}", containerName) if err != nil { return "", "", err } @@ -587,8 +590,8 @@ func (m *Manager) prepareWSLDockerSourceRootfs(ctx context.Context, outputPath, return rootfsPath, envContent, nil } -func (m *Manager) dockerImageEnvContent(ctx context.Context, image string) (string, error) { - envJSON, err := runHostOutputCommand(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Env}}", image) +func (m *Coordinator) dockerImageEnvContent(ctx context.Context, image string) (string, error) { + envJSON, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Env}}", image) if err != nil { return "", err } @@ -600,30 +603,21 @@ func (m *Manager) dockerImageEnvContent(ctx context.Context, image string) (stri } func wslDockerSourceRootfsPath(outputPath string) string { - switch { - case strings.HasSuffix(outputPath, ".tar.gz"): - return strings.TrimSuffix(outputPath, ".tar.gz") + ".source.rootfs.tar" - case strings.HasSuffix(outputPath, ".tgz"): - return strings.TrimSuffix(outputPath, ".tgz") + ".source.rootfs.tar" - case strings.HasSuffix(outputPath, ".tar"): - return strings.TrimSuffix(outputPath, ".tar") + ".source.rootfs.tar" - default: - return outputPath + ".source.rootfs.tar" - } + return WSLSourceRootfsPath(outputPath) } func wslDockerSourceContainerName() string { return fmt.Sprintf("epar-wsl-source-%d-%d", os.Getpid(), time.Now().UnixNano()) } -func (m *Manager) installSourceImageEnv(ctx context.Context, vmName, content string) error { +func (m *Coordinator) installSourceImageEnv(ctx context.Context, vmName, content string) error { if strings.TrimSpace(content) == "" { return nil } return provider.CopyText(ctx, m.Provider, vmName, "/opt/epar/source-image.env", "0644", content) } -func (m *Manager) prepareWSLDockerSourceGuest(ctx context.Context, vmName string) error { +func (m *Coordinator) prepareWSLDockerSourceGuest(ctx context.Context, vmName string) error { script := `set -euo pipefail cat >/etc/fstab <<'FSTAB' # EPAR: Docker image rootfs prepared for WSL imports. @@ -659,7 +653,7 @@ done return err } -func (m *Manager) installWSLDockerEngine(ctx context.Context, vmName string) error { +func (m *Coordinator) installWSLDockerEngine(ctx context.Context, vmName string) error { if m.Config.Provider.Type != "wsl" || m.Config.Image.SourceType != config.ImageSourceDockerImage { return nil } @@ -702,7 +696,7 @@ func validShellEnvName(name string) bool { return true } -func (m *Manager) RefreshScripts(ctx context.Context) error { +func (m *Coordinator) RefreshScripts(ctx context.Context) error { switch m.Config.Provider.Type { case "tart": return m.refreshTartScripts(ctx) @@ -715,7 +709,7 @@ func (m *Manager) RefreshScripts(ctx context.Context) error { } } -func (m *Manager) refreshTartScripts(ctx context.Context) error { +func (m *Coordinator) refreshTartScripts(ctx context.Context) error { logPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".refresh.log") defer m.releaseTranscript(logPath) defer m.releaseTranscript(m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".guest.log")) @@ -756,7 +750,7 @@ func (m *Manager) refreshTartScripts(ctx context.Context) error { return nil } -func (m *Manager) refreshWSLScripts(ctx context.Context) error { +func (m *Coordinator) refreshWSLScripts(ctx context.Context) error { exporter, ok := m.Provider.(wslExporter) if !ok { return fmt.Errorf("provider.type=wsl requires provider export support") @@ -767,7 +761,7 @@ func (m *Manager) refreshWSLScripts(ctx context.Context) error { return fmt.Errorf("wsl image %s: %w", imagePath, err) } } - name := RunnerName(m.Config.Pool.NamePrefix+"-refresh", 1, time.Now()) + name := m.runnerName(m.Config.Pool.NamePrefix+"-refresh", 1, time.Now()) logPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".wsl-refresh.log") defer m.releaseTranscript(logPath) defer m.releaseTranscript(m.buildLogPath(name + ".guest.log")) @@ -811,7 +805,7 @@ func (m *Manager) refreshWSLScripts(ctx context.Context) error { return nil } -func (m *Manager) installGuestScripts(ctx context.Context, vmName string) error { +func (m *Coordinator) installGuestScripts(ctx context.Context, vmName string) error { scriptDir := filepath.Join(m.ProjectRoot, "scripts", "guest", "ubuntu") entries, err := os.ReadDir(scriptDir) if err != nil { @@ -836,8 +830,8 @@ func (m *Manager) installGuestScripts(ctx context.Context, vmName string) error return nil } -func (m *Manager) startOptions(logPath, instance string) (provider.StartOptions, error) { - transcript, err := m.transcript(logPath, instance, transcriptComponent(logPath)) +func (m *Coordinator) startOptions(logPath, instance string) (provider.StartOptions, error) { + transcript, err := m.transcript(logPath, instance, m.transcriptComponent(logPath)) if err != nil { return provider.StartOptions{}, err } @@ -850,14 +844,14 @@ func (m *Manager) startOptions(logPath, instance string) (provider.StartOptions, }, nil } -func (m *Manager) execBuildGuest(ctx context.Context, name string, command []string, opts provider.ExecOptions) (provider.ExecResult, error) { +func (m *Coordinator) execBuildGuest(ctx context.Context, name string, command []string, opts provider.ExecOptions) (provider.ExecResult, error) { if opts.LogPath == "" { opts.LogPath = m.buildLogPath(imageLogStem(name) + ".guest.log") } return m.execGuest(ctx, name, command, opts) } -func (m *Manager) installRosettaSupport(ctx context.Context, vmName string) error { +func (m *Coordinator) installRosettaSupport(ctx context.Context, vmName string) error { if m.Config.Provider.Type != "tart" || strings.TrimSpace(m.Config.Provider.RosettaTag) == "" { return nil } @@ -868,7 +862,7 @@ func (m *Manager) installRosettaSupport(ctx context.Context, vmName string) erro return err } -func (m *Manager) copyRunnerImagesSubset(ctx context.Context, vmName, upstreamDir string) error { +func (m *Coordinator) copyRunnerImagesSubset(ctx context.Context, vmName, upstreamDir string) error { type copyRoot struct { host string guest string @@ -945,7 +939,7 @@ func (m *Manager) copyRunnerImagesSubset(ctx context.Context, vmName, upstreamDi return nil } -func (m *Manager) copyRunnerImagesCommitToGuest(ctx context.Context, vmName string) error { +func (m *Coordinator) copyRunnerImagesCommitToGuest(ctx context.Context, vmName string) error { commit, err := m.runnerImagesCommit() if err != nil { return err @@ -956,11 +950,11 @@ func (m *Manager) copyRunnerImagesCommitToGuest(ctx context.Context, vmName stri return provider.CopyText(ctx, m.Provider, vmName, "/opt/epar/upstream/runner-images/epar-commit", "0644", commit+"\n") } -func (m *Manager) runnerImageBuildScripts() []string { +func (m *Coordinator) runnerImageBuildScripts() []string { return []string{"install-docker.sh", "install-google-chrome.sh", "install-nodejs.sh"} } -func (m *Manager) runnerImagesCopyMode() runnerImagesCopyMode { +func (m *Coordinator) runnerImagesCopyMode() runnerImagesCopyMode { for _, script := range m.Config.Image.CustomInstallScripts { normalized := m.normalizedCustomInstallScript(script) switch normalized { @@ -972,11 +966,11 @@ func (m *Manager) runnerImagesCopyMode() runnerImagesCopyMode { return runnerImagesCopyNone } -func (m *Manager) prepareDockerContainerBuildContext(buildCtx, upstreamDir, manifestContent string) error { +func (m *Coordinator) prepareDockerContainerBuildContext(buildCtx, upstreamDir, manifestContent string) error { return m.prepareDockerContainerBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent, hosttrust.Snapshot{}) } -func (m *Manager) prepareDockerContainerBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent string, snapshot hosttrust.Snapshot) error { +func (m *Coordinator) prepareDockerContainerBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent string, snapshot hosttrust.Snapshot) error { if err := copyDir(filepath.Join(m.ProjectRoot, "scripts", "guest", "ubuntu"), filepath.Join(buildCtx, "scripts", "guest", "ubuntu")); err != nil { return err } @@ -1061,7 +1055,7 @@ ENTRYPOINT ["/opt/epar/container-entrypoint.sh"] return os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0644) } -func (m *Manager) normalizedCustomInstallScript(script string) string { +func (m *Coordinator) normalizedCustomInstallScript(script string) string { script = strings.TrimSpace(script) if script == "" { return "" @@ -1078,7 +1072,7 @@ func (m *Manager) normalizedCustomInstallScript(script string) string { return filepath.ToSlash(filepath.Clean(script)) } -func (m *Manager) installCustomInstallScripts(ctx context.Context, vmName string) error { +func (m *Coordinator) installCustomInstallScripts(ctx context.Context, vmName string) error { scripts := m.Config.Image.CustomInstallScripts if len(scripts) == 0 { return nil @@ -1108,7 +1102,7 @@ func (m *Manager) installCustomInstallScripts(ctx context.Context, vmName string return nil } -func (m *Manager) customInstallScriptHostPath(script string) (string, error) { +func (m *Coordinator) customInstallScriptHostPath(script string) (string, error) { script = strings.TrimSpace(script) if script == "" { return "", fmt.Errorf("custom install script path is empty") @@ -1159,13 +1153,13 @@ func guestScriptName(name string) string { return b.String() } -func (m *Manager) enableWSLSystemd(ctx context.Context, name string) error { +func (m *Coordinator) enableWSLSystemd(ctx context.Context, name string) error { content := "[boot]\nsystemd=true\n\n[interop]\nappendWindowsPath=false\n\n[user]\ndefault=root\n" _, err := m.execBuildGuest(ctx, name, provider.ShellCommand("mkdir -p /etc && cat >/etc/wsl.conf"), provider.ExecOptions{Stdin: content, LogPath: m.buildLogPath(name + ".guest.log")}) return err } -func (m *Manager) waitForSystemd(ctx context.Context, name string) error { +func (m *Coordinator) waitForSystemd(ctx context.Context, name string) error { waitSeconds := m.Config.Timeouts.BootSeconds if waitSeconds <= 0 { waitSeconds = 180 @@ -1266,7 +1260,7 @@ func copyRunnerImagesSubsetToDir(upstreamDir, dest string, buildScripts []string return nil } -func (m *Manager) runnerImagesCommit() (string, error) { +func (m *Coordinator) runnerImagesCommit() (string, error) { lockPath := config.ProjectPath(m.ProjectRoot, m.Config.Image.UpstreamLock) content, err := os.ReadFile(lockPath) if err != nil { @@ -1278,7 +1272,7 @@ func (m *Manager) runnerImagesCommit() (string, error) { return strings.TrimSpace(string(content)), nil } -func (m *Manager) writeRunnerImagesCommitFile(dest string) error { +func (m *Coordinator) writeRunnerImagesCommitFile(dest string) error { commit, err := m.runnerImagesCommit() if err != nil { return err diff --git a/internal/image/buildx.go b/internal/image/buildx.go new file mode 100644 index 0000000..2223afe --- /dev/null +++ b/internal/image/buildx.go @@ -0,0 +1,170 @@ +package image + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" +) + +const buildxMetadataSchemaVersion = 1 + +type BuildxMetadata struct { + SchemaVersion int `json:"schemaVersion"` + Builder string `json:"builder"` + Driver string `json:"driver"` + ProjectRoot string `json:"projectRoot"` + CacheLimit string `json:"cacheLimit"` + ConfigPath string `json:"configPath"` + CreatedAt time.Time `json:"createdAt"` +} + +func BuildxMetadataPath(projectRoot string) string { + return filepath.Join(projectRoot, ".local", "storage", "buildx.json") +} + +func LoadBuildxMetadata(projectRoot string) (BuildxMetadata, error) { + content, err := os.ReadFile(BuildxMetadataPath(projectRoot)) + if err != nil { + return BuildxMetadata{}, err + } + var metadata BuildxMetadata + if err := json.Unmarshal(content, &metadata); err != nil { + return BuildxMetadata{}, err + } + if metadata.SchemaVersion != buildxMetadataSchemaVersion || metadata.Builder == "" || metadata.Driver != "docker-container" || metadata.ProjectRoot == "" || metadata.ConfigPath == "" { + return BuildxMetadata{}, fmt.Errorf("invalid EPAR Buildx ownership metadata") + } + return metadata, nil +} + +func buildxBuilderName(projectRoot string) string { + canonical := filepath.Clean(projectRoot) + if runtime.GOOS == "windows" { + canonical = strings.ToLower(canonical) + } + sum := sha256.Sum256([]byte(canonical)) + return "epar-" + hex.EncodeToString(sum[:6]) +} + +func (m *Coordinator) ensureBuildxBuilder(ctx context.Context) (string, error) { + builder := buildxBuilderName(m.ProjectRoot) + cacheLimit := strings.TrimSpace(m.Config.Storage.BuildCacheLimit) + if cacheLimit == "" { + cacheLimit = "64GiB" + } + limitBytes, err := config.ParseByteSize(cacheLimit) + if err != nil { + return "", fmt.Errorf("parse storage.buildCacheLimit: %w", err) + } + configPath := filepath.Join(m.ProjectRoot, ".local", "storage", "buildkitd.toml") + expected := BuildxMetadata{ + SchemaVersion: buildxMetadataSchemaVersion, + Builder: builder, + Driver: "docker-container", + ProjectRoot: filepath.Clean(m.ProjectRoot), + CacheLimit: cacheLimit, + ConfigPath: configPath, + } + if m.DryRun { + m.infof("[dry-run] ensure EPAR-owned Buildx builder %s with cache limit %s\n", builder, cacheLimit) + return builder, nil + } + + metadata, metadataErr := LoadBuildxMetadata(m.ProjectRoot) + _, inspectErr := m.runHostOutput(ctx, "docker", "buildx", "inspect", builder) + if inspectErr == nil { + if metadataErr != nil { + return "", fmt.Errorf("Buildx builder %q already exists without valid EPAR ownership metadata; refusing to adopt it: %w", builder, metadataErr) + } + if !buildxMetadataMatches(metadata, expected) { + return "", fmt.Errorf("Buildx builder %q ownership metadata does not match this project and cache policy", builder) + } + if err := m.runHost(ctx, "docker", "buildx", "inspect", "--bootstrap", builder); err != nil { + return "", fmt.Errorf("bootstrap EPAR Buildx builder %q: %w", builder, err) + } + return builder, nil + } + if metadataErr == nil && !buildxMetadataMatches(metadata, expected) { + return "", fmt.Errorf("EPAR Buildx metadata does not match this project and cache policy") + } + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return "", err + } + configContent := fmt.Sprintf("[worker.oci]\n gc = true\n gckeepstorage = %d\n", uint64(limitBytes)) + if err := writeAtomicFile(configPath, []byte(configContent), 0644); err != nil { + return "", fmt.Errorf("write EPAR BuildKit configuration: %w", err) + } + if metadataErr != nil { + expected.CreatedAt = time.Now().UTC() + content, err := json.MarshalIndent(expected, "", " ") + if err != nil { + return "", err + } + content = append(content, '\n') + if err := writeAtomicFile(BuildxMetadataPath(m.ProjectRoot), content, 0644); err != nil { + return "", fmt.Errorf("write EPAR Buildx ownership metadata: %w", err) + } + } + if err := m.runHost(ctx, "docker", "buildx", "create", "--name", builder, "--driver", "docker-container", "--buildkitd-config", configPath); err != nil { + return "", fmt.Errorf("create EPAR Buildx builder %q: %w", builder, err) + } + if err := m.runHost(ctx, "docker", "buildx", "inspect", "--bootstrap", builder); err != nil { + return "", fmt.Errorf("bootstrap EPAR Buildx builder %q: %w", builder, err) + } + return builder, nil +} + +func buildxMetadataMatches(actual, expected BuildxMetadata) bool { + return actual.SchemaVersion == expected.SchemaVersion && + actual.Builder == expected.Builder && + actual.Driver == expected.Driver && + filepath.Clean(actual.ProjectRoot) == expected.ProjectRoot && + actual.CacheLimit == expected.CacheLimit && + filepath.Clean(actual.ConfigPath) == expected.ConfigPath +} + +func writeAtomicFile(path string, content []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, content) { + return nil + } + temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(mode); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(content); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if runtime.GOOS == "windows" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + return os.Rename(temporaryPath, path) +} diff --git a/internal/image/buildx_test.go b/internal/image/buildx_test.go new file mode 100644 index 0000000..a69dc68 --- /dev/null +++ b/internal/image/buildx_test.go @@ -0,0 +1,65 @@ +package image + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestBuildxBuilderNameIsStableAndProjectScoped(t *testing.T) { + first := buildxBuilderName(filepath.Join("one", "project")) + second := buildxBuilderName(filepath.Join("one", "project")) + other := buildxBuilderName(filepath.Join("two", "project")) + if first != second { + t.Fatalf("builder names are not stable: %q != %q", first, second) + } + if first == other { + t.Fatalf("different projects share builder name %q", first) + } + if !strings.HasPrefix(first, "epar-") || len(first) != len("epar-")+12 { + t.Fatalf("builder name %q does not use the bounded EPAR identity", first) + } +} + +func TestLoadBuildxMetadataRequiresExactOwnershipFields(t *testing.T) { + root := t.TempDir() + path := BuildxMetadataPath(root) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + metadata := BuildxMetadata{ + SchemaVersion: buildxMetadataSchemaVersion, + Builder: buildxBuilderName(root), + Driver: "docker-container", + ProjectRoot: root, + CacheLimit: "64GiB", + ConfigPath: filepath.Join(root, ".local", "storage", "buildkitd.toml"), + CreatedAt: time.Now().UTC(), + } + content, err := json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + loaded, err := LoadBuildxMetadata(root) + if err != nil { + t.Fatal(err) + } + if loaded.Builder != metadata.Builder || loaded.CacheLimit != "64GiB" { + t.Fatalf("loaded metadata = %+v, want %+v", loaded, metadata) + } + + metadata.Driver = "docker" + content, _ = json.Marshal(metadata) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + if _, err := LoadBuildxMetadata(root); err == nil { + t.Fatal("LoadBuildxMetadata accepted a shared Docker driver") + } +} diff --git a/internal/image/compat.go b/internal/image/compat.go new file mode 100644 index 0000000..9918a71 --- /dev/null +++ b/internal/image/compat.go @@ -0,0 +1,89 @@ +package image + +import ( + "context" + "io" + "os" + + "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" +) + +// ImageState is the reusable-artifact state returned by CurrentImageState. +type ImageState = imageState +type RunnerImagesCopyMode = runnerImagesCopyMode + +const ( + ImageStateMissing = imageStateMissing + ImageStateCurrent = imageStateCurrent + ImageStateOutdated = imageStateOutdated + RunnerImagesCopyNone = runnerImagesCopyNone + RunnerImagesCopySubset = runnerImagesCopySubset +) + +func (m *Coordinator) DesiredImageManifest(ctx context.Context) (Manifest, error) { + return m.desiredImageManifest(ctx) +} + +func (m *Coordinator) CurrentImageState(ctx context.Context, wantedHash string) (ImageState, error) { + return m.currentImageState(ctx, wantedHash) +} + +func (m *Coordinator) PrepareDockerContainerBuildContext(buildContext, upstreamDirectory, manifestContent string) error { + return m.prepareDockerContainerBuildContext(buildContext, upstreamDirectory, manifestContent) +} + +func (m *Coordinator) PrepareDockerContainerBuildContextWithHostTrust(buildContext, upstreamDirectory, manifestContent string, snapshot hosttrust.Snapshot) error { + return m.prepareDockerContainerBuildContextWithHostTrust(buildContext, upstreamDirectory, manifestContent, snapshot) +} + +func (m *Coordinator) BuildDockerContainerImage(ctx context.Context, options ImageBuildOptions, upstreamDirectory string) error { + return m.buildDockerContainerImage(ctx, options, upstreamDirectory) +} + +func (m *Coordinator) PrepareWSLDockerSourceRootfs(ctx context.Context, outputPath, buildLogPath string, manifest Manifest) (string, string, error) { + return m.prepareWSLDockerSourceRootfs(ctx, outputPath, buildLogPath, manifest) +} + +func (m *Coordinator) WriteDockerPullProgress(logPath string, layers map[string]DockerPullProgress) { + m.writeDockerPullProgress(logPath, layers) +} + +func WriteDockerPullEvent(writer io.Writer, event DockerPullEvent) { + writeDockerPullEvent(writer, event) +} + +func ImageManifestHash(manifest Manifest) (string, error) { + return ManifestHash(manifest) +} + +func SourceImageEnvContent(environment []string) string { + return sourceImageEnvContent(environment) +} + +func CopyFile(source, destination string, mode os.FileMode) error { + return copyFile(source, destination, mode) +} + +func (m *Coordinator) RunnerImageBuildScripts() []string { + return m.runnerImageBuildScripts() +} + +func (m *Coordinator) RunnerImagesCopyMode() RunnerImagesCopyMode { + return m.runnerImagesCopyMode() +} + +func (m *Coordinator) PrepareWSLDockerSourceGuest(ctx context.Context, instance string) error { + return m.prepareWSLDockerSourceGuest(ctx, instance) +} + +func (m *Coordinator) InstallCustomInstallScripts(ctx context.Context, instance string) error { + return m.installCustomInstallScripts(ctx, instance) +} + +func (m *Coordinator) CustomInstallScriptHostPath(script string) (string, error) { + return m.customInstallScriptHostPath(script) +} + +func (m *Coordinator) EnableWSLSystemd(ctx context.Context, instance string) error { + return m.enableWSLSystemd(ctx, instance) +} diff --git a/internal/image/coordinator.go b/internal/image/coordinator.go new file mode 100644 index 0000000..afeab23 --- /dev/null +++ b/internal/image/coordinator.go @@ -0,0 +1,151 @@ +package image + +import ( + "context" + "io" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + "github.com/solutionforest/ephemeral-action-runner/internal/logging" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +const ( + imagePullExpansionBytes = 20 * storage.GiB + imageBuildExpansionBytes = 30 * storage.GiB + sourceUpdateExpansionBytes = 5 * storage.GiB + hostTrustGuestDir = "/usr/local/share/ca-certificates/epar-host" + hostTrustMarkerGuest = "/opt/epar/host-trust-generation.json" +) + +// Environment exposes the provider-neutral host and guest operations needed +// while creating reusable artifacts. The pool supplies these operations but +// does not own image policy or provider-specific build selection. +type Environment interface { + PreflightStorage(operation string, peakBytes uint64) error + BuildLogPath(name string) string + ReleaseTranscript(path string) error + Infof(format string, args ...any) + Warnf(format string, args ...any) + RunHostLogged(ctx context.Context, logPath, name string, args ...string) error + RunHost(ctx context.Context, name string, args ...string) error + RunHostOutput(ctx context.Context, name string, args ...string) (string, error) + RunHostQuiet(ctx context.Context, name string, args ...string) error + TimeStartupStage(stage string, fn func() error) error + HostTrustEnabled() bool + ResolveHostTrust(ctx context.Context) (hosttrust.Snapshot, error) + WriteHostTrustBuildInputs(buildContext string, snapshot hosttrust.Snapshot) error + ValidateRuntime(ctx context.Context, instance string) error + ExecGuest(ctx context.Context, instance string, command []string, opts provider.ExecOptions) (provider.ExecResult, error) + Transcript(path, instance, component string) (*logging.Transcript, error) + PullDockerSource(ctx context.Context, options DockerSourcePullOptions) error + LogInfo(message string, args ...any) + LogWarn(message string, args ...any) + DockerPullProgressTerminal() bool + DockerPullProgressConsole() io.Writer + RunnerName(prefix string, sequence int, now time.Time) string + TranscriptComponent(path string) string +} + +type Coordinator struct { + Config config.Config + Provider provider.Provider + Lifecycle provider.Lifecycle + ProjectRoot string + DryRun bool + environment Environment +} + +func NewCoordinator(cfg config.Config, legacy provider.Provider, lifecycle provider.Lifecycle, projectRoot string, dryRun bool, environment Environment) *Coordinator { + return &Coordinator{ + Config: cfg, + Provider: legacy, + Lifecycle: lifecycle, + ProjectRoot: projectRoot, + DryRun: dryRun, + environment: environment, + } +} + +func (m *Coordinator) preflightStorage(operation string, peakBytes uint64) error { + return m.environment.PreflightStorage(operation, peakBytes) +} + +func (m *Coordinator) buildLogPath(name string) string { + return m.environment.BuildLogPath(name) +} + +func (m *Coordinator) releaseTranscript(path string) error { + return m.environment.ReleaseTranscript(path) +} + +func (m *Coordinator) infof(format string, args ...any) { + m.environment.Infof(format, args...) +} + +func (m *Coordinator) warnf(format string, args ...any) { + m.environment.Warnf(format, args...) +} + +func (m *Coordinator) runHostLogged(ctx context.Context, logPath, name string, args ...string) error { + return m.environment.RunHostLogged(ctx, logPath, name, args...) +} + +func (m *Coordinator) runHost(ctx context.Context, name string, args ...string) error { + return m.environment.RunHost(ctx, name, args...) +} + +func (m *Coordinator) runHostOutput(ctx context.Context, name string, args ...string) (string, error) { + return m.environment.RunHostOutput(ctx, name, args...) +} + +func (m *Coordinator) runHostQuiet(ctx context.Context, name string, args ...string) error { + return m.environment.RunHostQuiet(ctx, name, args...) +} + +func (m *Coordinator) timeStartupStage(stage string, fn func() error) error { + return m.environment.TimeStartupStage(stage, fn) +} + +func (m *Coordinator) hostTrustEnabled() bool { + return m.environment.HostTrustEnabled() +} + +func (m *Coordinator) resolveHostTrust(ctx context.Context) (hosttrust.Snapshot, error) { + return m.environment.ResolveHostTrust(ctx) +} + +func (m *Coordinator) writeHostTrustBuildInputs(buildContext string, snapshot hosttrust.Snapshot) error { + return m.environment.WriteHostTrustBuildInputs(buildContext, snapshot) +} + +func (m *Coordinator) validateTrustedCACertificates() error { + _, err := m.trustedCACertificates() + return err +} + +func (m *Coordinator) validateRuntime(ctx context.Context, instance string) error { + return m.environment.ValidateRuntime(ctx, instance) +} + +func (m *Coordinator) execGuest(ctx context.Context, instance string, command []string, opts provider.ExecOptions) (provider.ExecResult, error) { + return m.environment.ExecGuest(ctx, instance, command, opts) +} + +func (m *Coordinator) transcript(path, instance, component string) (*logging.Transcript, error) { + return m.environment.Transcript(path, instance, component) +} + +func (m *Coordinator) pullDockerSource(ctx context.Context, options DockerSourcePullOptions) error { + return m.environment.PullDockerSource(ctx, options) +} + +func (m *Coordinator) runnerName(prefix string, sequence int, now time.Time) string { + return m.environment.RunnerName(prefix, sequence, now) +} + +func (m *Coordinator) transcriptComponent(path string) string { + return m.environment.TranscriptComponent(path) +} diff --git a/internal/image/docker_pull.go b/internal/image/docker_pull.go new file mode 100644 index 0000000..a243a81 --- /dev/null +++ b/internal/image/docker_pull.go @@ -0,0 +1,316 @@ +// Package image contains provider-neutral image acquisition primitives. +package image + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "reflect" + "runtime" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + gcrv1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/moby/moby/api/types/jsonstream" + "github.com/moby/moby/api/types/registry" + "github.com/moby/moby/client" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// DockerEnginePullError means Docker Engine accepted the connection but rejected the image pull. +// Callers may distinguish it from connection and platform errors that can safely use a CLI fallback. +type DockerEnginePullError struct { + Image string + Err error +} + +func (err *DockerEnginePullError) Error() string { + return fmt.Sprintf("Docker Engine pull %s: %v", err.Image, err.Err) +} + +func (err *DockerEnginePullError) Unwrap() error { return err.Err } + +// DockerPullOptions defines a Docker Engine acquisition without any pool or provider logging policy. +type DockerPullOptions struct { + Image string + Platform string + FallbackPlatform string + QueryRemoteSize bool +} + +// DockerPullResult holds the pulled stream and optional non-fatal lookup failures. +type DockerPullResult struct { + Response client.ImagePullResponse + Platform ocispec.Platform + RemoteCompressedSize int64 + RemoteCompressedError error + RegistryAuthError error +} + +// DockerPullProgress is the shared per-layer state used to summarize progress. +type DockerPullProgress struct { + Current int64 + Total int64 + Completed bool +} + +// DockerPullEvent is one decoded Docker Engine pull event. +type DockerPullEvent struct { + ID string + Status string + Progress *jsonstream.Progress + Stream string + Error error +} + +// PullDockerImage opens the Docker Engine, resolves the requested platform, optionally queries registry layer sizes, and starts a pull. +// Remote metadata and explicit registry-auth failures are non-fatal and are returned on the result for the caller to report. +func PullDockerImage(ctx context.Context, opts DockerPullOptions) (DockerPullResult, error) { + cli, err := client.New(client.FromEnv) + if err != nil { + return DockerPullResult{}, fmt.Errorf("initialize Docker Engine client: %w", err) + } + if _, err := cli.Ping(ctx, client.PingOptions{}); err != nil { + return DockerPullResult{}, fmt.Errorf("connect to Docker Engine: %w", err) + } + + platform, err := ResolveDockerPlatform(ctx, cli, opts.Platform, opts.FallbackPlatform) + if err != nil { + return DockerPullResult{}, err + } + result := DockerPullResult{Platform: platform} + if opts.QueryRemoteSize { + result.RemoteCompressedSize, result.RemoteCompressedError = RemoteCompressedLayerSize(opts.Image, platform) + } + + registryAuth, err := DockerRegistryAuth(opts.Image) + if err != nil { + result.RegistryAuthError = err + } + response, err := cli.ImagePull(ctx, opts.Image, client.ImagePullOptions{ + RegistryAuth: registryAuth, + Platforms: []ocispec.Platform{platform}, + }) + if err != nil && !nilLikeError(err) { + return result, &DockerEnginePullError{Image: opts.Image, Err: err} + } + result.Response = response + return result, nil +} + +// nilLikeError protects the Engine API boundary from an error interface that +// contains a typed nil pointer. Such a value represents no failure but compares +// non-nil as an interface and otherwise renders as the misleading text "". +func nilLikeError(err error) bool { + for err != nil { + if strings.TrimSpace(err.Error()) == "" { + return true + } + value := reflect.ValueOf(err) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + if value.IsNil() { + return true + } + } + next := errors.Unwrap(err) + if next == nil { + return false + } + err = next + } + return true +} + +// ResolveDockerPlatform chooses an explicit platform, provider fallback platform, engine platform, then the local runtime platform. +func ResolveDockerPlatform(ctx context.Context, cli *client.Client, configured, fallback string) (ocispec.Platform, error) { + if platform, ok := NormalizedDockerPlatform(configured, ""); ok { + return platform, nil + } + if platform, ok := NormalizedDockerPlatform(fallback, ""); ok { + return platform, nil + } + info, err := cli.Info(ctx, client.InfoOptions{}) + if err != nil { + return ocispec.Platform{}, fmt.Errorf("inspect Docker Engine platform: %w", err) + } + if platform, ok := NormalizedDockerPlatform(info.Info.OSType+"/"+info.Info.Architecture, ""); ok { + return platform, nil + } + if platform, ok := NormalizedDockerPlatform(runtime.GOOS+"/"+runtime.GOARCH, ""); ok { + return platform, nil + } + return ocispec.Platform{}, fmt.Errorf("Docker Engine did not report a usable platform") +} + +// NormalizedDockerPlatform parses a Docker platform and normalizes common architecture aliases. +func NormalizedDockerPlatform(value, fallbackOS string) (ocispec.Platform, bool) { + parts := strings.Split(strings.Trim(strings.ToLower(value), "/"), "/") + if len(parts) == 0 || len(parts) > 3 || parts[0] == "" { + return ocispec.Platform{}, false + } + platform := ocispec.Platform{OS: fallbackOS} + if len(parts) == 1 { + platform.Architecture = normalizeDockerArchitecture(parts[0]) + } else { + platform.OS = parts[0] + platform.Architecture = normalizeDockerArchitecture(parts[1]) + if len(parts) == 3 { + platform.Variant = parts[2] + } + } + if platform.OS == "" { + platform.OS = "linux" + } + if platform.Architecture == "" { + return ocispec.Platform{}, false + } + return platform, true +} + +func normalizeDockerArchitecture(architecture string) string { + switch architecture { + case "x86_64", "x64": + return "amd64" + case "aarch64": + return "arm64" + default: + return architecture + } +} + +// RemoteCompressedLayerSize returns the total compressed size of the selected remote image layers. +func RemoteCompressedLayerSize(image string, platform ocispec.Platform) (int64, error) { + ref, authenticator, err := DockerImageReferenceAndAuth(image) + if err != nil { + return 0, err + } + remoteImage, err := remote.Image(ref, remote.WithAuth(authenticator), remote.WithPlatform(gcrv1.Platform{ + OS: platform.OS, + Architecture: platform.Architecture, + Variant: platform.Variant, + })) + if err != nil { + return 0, err + } + layers, err := remoteImage.Layers() + if err != nil { + return 0, err + } + var total int64 + for _, layer := range layers { + size, err := layer.Size() + if err != nil { + return 0, err + } + total += size + } + return total, nil +} + +// DockerRegistryAuth returns Docker Engine's base64-encoded registry auth payload. +func DockerRegistryAuth(image string) (string, error) { + ref, authenticator, err := DockerImageReferenceAndAuth(image) + if err != nil { + return "", err + } + credentials, err := authenticator.Authorization() + if err != nil { + return "", err + } + content, err := json.Marshal(registry.AuthConfig{ + Username: credentials.Username, + Password: credentials.Password, + Auth: credentials.Auth, + ServerAddress: ref.Context().RegistryStr(), + IdentityToken: credentials.IdentityToken, + RegistryToken: credentials.RegistryToken, + }) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(content), nil +} + +// DockerImageReferenceAndAuth resolves an image reference and default credential helper. +func DockerImageReferenceAndAuth(image string) (name.Reference, authn.Authenticator, error) { + ref, err := name.ParseReference(image) + if err != nil { + return nil, nil, err + } + authenticator, err := authn.DefaultKeychain.Resolve(ref.Context().Registry) + if err != nil { + return nil, nil, err + } + return ref, authenticator, nil +} + +// ConsumeDockerPullProgress decodes an Engine pull response and delivers each event in order. +func ConsumeDockerPullProgress(ctx context.Context, response client.ImagePullResponse, handle func(DockerPullEvent) error) error { + for message, streamErr := range response.JSONMessages(ctx) { + if streamErr != nil && !nilLikeError(streamErr) { + return streamErr + } + event := DockerPullEvent{ID: message.ID, Status: message.Status, Progress: message.Progress, Stream: message.Stream, Error: message.Error} + if nilLikeError(event.Error) { + event.Error = nil + } + if err := handle(event); err != nil { + return err + } + if event.Error != nil { + return event.Error + } + } + return nil +} + +// IsDockerPullLayerComplete reports whether an Engine status marks a layer complete. +func IsDockerPullLayerComplete(status string) bool { + status = strings.ToLower(strings.TrimSpace(status)) + return status == "pull complete" || status == "already exists" || status == "exists" +} + +// DockerPullProgressSummary renders one concise progress line. +func DockerPullProgressSummary(layers map[string]DockerPullProgress) string { + var complete, known int + var currentBytes, totalBytes int64 + for _, layer := range layers { + if layer.Completed { + complete++ + } + if layer.Total > 0 { + known++ + totalBytes += layer.Total + currentBytes += min(layer.Current, layer.Total) + } + } + line := fmt.Sprintf("Docker source pull: %d/%d layers complete; %s/%s", complete, len(layers), FormatDockerPullBytes(currentBytes), FormatDockerPullBytes(totalBytes)) + if totalBytes > 0 { + line += fmt.Sprintf(" (%.0f%%)", float64(currentBytes)*100/float64(totalBytes)) + } + if known < len(layers) { + line += fmt.Sprintf("; %d layer(s) size pending", len(layers)-known) + } + return line +} + +// FormatDockerPullBytes renders byte counts used by pull progress and remote-size notices. +func FormatDockerPullBytes(value int64) string { + const unit = 1024 + if value < unit { + return fmt.Sprintf("%d B", value) + } + units := []string{"KiB", "MiB", "GiB", "TiB"} + size := float64(value) + index := -1 + for size >= unit && index+1 < len(units) { + size /= unit + index++ + } + return fmt.Sprintf("%.1f %s", size, units[index]) +} diff --git a/internal/image/docker_pull_test.go b/internal/image/docker_pull_test.go new file mode 100644 index 0000000..5b9ee7d --- /dev/null +++ b/internal/image/docker_pull_test.go @@ -0,0 +1,81 @@ +package image + +import ( + "errors" + "fmt" + "testing" +) + +type typedNilPullError struct{} + +func (*typedNilPullError) Error() string { return "pull failed" } + +type opaqueNilPullError struct{} + +func (opaqueNilPullError) Error() string { return "" } + +func TestNilLikeError(t *testing.T) { + var typedNil *typedNilPullError + if !nilLikeError(typedNil) { + t.Fatal("typed nil error was not recognized") + } + if !nilLikeError(fmt.Errorf("wrapped: %w", typedNil)) { + t.Fatal("wrapped typed nil error was not recognized") + } + if !nilLikeError(opaqueNilPullError{}) { + t.Fatal("opaque nil-rendering error was not recognized") + } + if nilLikeError(errors.New("real failure")) { + t.Fatal("real error was recognized as nil") + } +} + +func TestNormalizedDockerPlatform(t *testing.T) { + tests := []struct { + name string + input string + want string + valid bool + }{ + {name: "explicit amd64", input: "linux/x86_64", want: "linux/amd64", valid: true}, + {name: "arm variant", input: "linux/aarch64/v8", want: "linux/arm64/v8", valid: true}, + {name: "architecture only", input: "amd64", want: "linux/amd64", valid: true}, + {name: "empty", input: "", valid: false}, + {name: "too many segments", input: "linux/amd64/v8/extra", valid: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := NormalizedDockerPlatform(test.input, "") + if ok != test.valid { + t.Fatalf("valid = %t, want %t", ok, test.valid) + } + if ok && got.OS+"/"+got.Architecture+platformVariant(got.Variant) != test.want { + t.Fatalf("platform = %s/%s%s, want %s", got.OS, got.Architecture, platformVariant(got.Variant), test.want) + } + }) + } +} + +func TestDockerPullProgressSummary(t *testing.T) { + layers := map[string]DockerPullProgress{ + "completed": {Current: 1024, Total: 1024, Completed: true}, + "partial": {Current: 512, Total: 1024}, + "unknown": {Completed: true}, + } + if got, want := DockerPullProgressSummary(layers), "Docker source pull: 2/3 layers complete; 1.5 KiB/2.0 KiB (75%); 1 layer(s) size pending"; got != want { + t.Fatalf("summary = %q, want %q", got, want) + } +} + +func TestDockerImageReferenceAndAuthRejectsInvalidReference(t *testing.T) { + if _, _, err := DockerImageReferenceAndAuth("not a valid image reference"); err == nil { + t.Fatal("invalid image reference was accepted") + } +} + +func platformVariant(variant string) string { + if variant == "" { + return "" + } + return "/" + variant +} diff --git a/internal/image/manifest.go b/internal/image/manifest.go new file mode 100644 index 0000000..06d0d7f --- /dev/null +++ b/internal/image/manifest.go @@ -0,0 +1,148 @@ +package image + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" +) + +const ( + ManifestSchemaVersion = 1 + ManifestGuestPath = "/opt/epar/image-manifest.json" + ManifestLabel = "org.solutionforest.epar.manifest-sha256" +) + +type HostTrustMetadata struct { + Mode string `json:"mode"` + HostOS string `json:"hostOS"` + Scopes []string `json:"scopes"` + Generation string `json:"generation"` + CertificateCount int `json:"certificateCount"` +} + +type FileDigest struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +type Manifest struct { + SchemaVersion int `json:"schemaVersion"` + ProviderType string `json:"providerType"` + ProviderPlatform string `json:"providerPlatform,omitempty"` + ProviderRosettaTag string `json:"providerRosettaTag,omitempty"` + SourceType string `json:"sourceType,omitempty"` + SourceImage string `json:"sourceImage"` + SourcePlatform string `json:"sourcePlatform,omitempty"` + SourceDigest string `json:"sourceDigest,omitempty"` + OutputImage string `json:"outputImage"` + RunnerVersion string `json:"runnerVersion"` + UpstreamCommit string `json:"upstreamCommit,omitempty"` + EPARScripts []FileDigest `json:"eparScripts,omitempty"` + CustomInstallScripts []FileDigest `json:"customInstallScripts,omitempty"` + TrustedCACertificates []FileDigest `json:"trustedCaCertificates,omitempty"` + HostTrust *HostTrustMetadata `json:"hostTrust,omitempty"` +} + +type StoredManifest struct { + Hash string `json:"hash"` + Manifest Manifest `json:"manifest"` +} + +type SourceCacheManifest struct { + SourceImage string `json:"sourceImage"` + SourcePlatform string `json:"sourcePlatform,omitempty"` + SourceDigest string `json:"sourceDigest,omitempty"` +} + +func ManifestHash(manifest Manifest) (string, error) { + content, err := json.Marshal(manifest) + if err != nil { + return "", err + } + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]), nil +} + +func StoredManifestContent(manifest Manifest) (string, string, error) { + hash, err := ManifestHash(manifest) + if err != nil { + return "", "", err + } + content, err := json.MarshalIndent(StoredManifest{Hash: hash, Manifest: manifest}, "", " ") + if err != nil { + return "", "", err + } + return string(content) + "\n", hash, nil +} + +func ReadStoredManifest(path string) (StoredManifest, error) { + content, err := os.ReadFile(path) + if err != nil { + return StoredManifest{}, err + } + var stored StoredManifest + if err := json.Unmarshal(content, &stored); err != nil { + return StoredManifest{}, err + } + return stored, nil +} + +func WriteStoredManifest(path string, manifest Manifest) error { + content, _, err := StoredManifestContent(manifest) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0o644) +} + +func SourceCacheManifestPath(rootfsPath string) string { + return rootfsPath + ".source.json" +} + +func WSLSourceRootfsPath(outputPath string) string { + switch { + case strings.HasSuffix(outputPath, ".tar.gz"): + return strings.TrimSuffix(outputPath, ".tar.gz") + ".source.rootfs.tar" + case strings.HasSuffix(outputPath, ".tgz"): + return strings.TrimSuffix(outputPath, ".tgz") + ".source.rootfs.tar" + case strings.HasSuffix(outputPath, ".tar"): + return strings.TrimSuffix(outputPath, ".tar") + ".source.rootfs.tar" + default: + return outputPath + ".source.rootfs.tar" + } +} + +func WSLImageManifestPath(outputPath string) string { + return outputPath + ".epar-manifest.json" +} + +func SourceCacheMatches(path string, want SourceCacheManifest) bool { + content, err := os.ReadFile(path) + if err != nil { + return false + } + var got SourceCacheManifest + if err := json.Unmarshal(content, &got); err != nil { + return false + } + return got == want +} + +func WriteSourceCacheManifest(path string, manifest SourceCacheManifest) error { + content, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(content, '\n'), 0o644) +} + +func DockerInspectMeansMissing(err error) bool { + text := strings.ToLower(err.Error()) + return strings.Contains(text, "no such image") || strings.Contains(text, "no such object") || strings.Contains(text, "not found") +} diff --git a/internal/image/manifest_paths_test.go b/internal/image/manifest_paths_test.go new file mode 100644 index 0000000..3112ae8 --- /dev/null +++ b/internal/image/manifest_paths_test.go @@ -0,0 +1,20 @@ +package image + +import "testing" + +func TestWSLArtifactPaths(t *testing.T) { + tests := map[string]string{ + "runner.tar": "runner.source.rootfs.tar", + "runner.tar.gz": "runner.source.rootfs.tar", + "runner.tgz": "runner.source.rootfs.tar", + "runner": "runner.source.rootfs.tar", + } + for output, want := range tests { + if got := WSLSourceRootfsPath(output); got != want { + t.Errorf("WSLSourceRootfsPath(%q) = %q, want %q", output, got, want) + } + } + if got, want := WSLImageManifestPath("runner.tar"), "runner.tar.epar-manifest.json"; got != want { + t.Fatalf("WSLImageManifestPath() = %q, want %q", got, want) + } +} diff --git a/internal/pool/trusted_ca.go b/internal/image/trusted_ca.go similarity index 86% rename from internal/pool/trusted_ca.go rename to internal/image/trusted_ca.go index dde68f6..578ef36 100644 --- a/internal/pool/trusted_ca.go +++ b/internal/image/trusted_ca.go @@ -1,4 +1,4 @@ -package pool +package image import ( "bytes" @@ -19,13 +19,13 @@ import ( const trustedCAGuestDir = "/usr/local/share/ca-certificates/epar" -type trustedCACertificate struct { +type TrustedCACertificate struct { DestinationName string PEM []byte } -func (m *Manager) trustedCACertificates() ([]trustedCACertificate, error) { - byName := make(map[string]trustedCACertificate) +func (m *Coordinator) trustedCACertificates() ([]TrustedCACertificate, error) { + byName := make(map[string]TrustedCACertificate) for _, configuredPath := range m.Config.Image.TrustedCACertificatePaths { path := config.ProjectPath(m.ProjectRoot, strings.TrimSpace(configuredPath)) info, err := os.Stat(path) @@ -49,7 +49,7 @@ func (m *Manager) trustedCACertificates() ([]trustedCACertificate, error) { } sum := sha256.Sum256(certificate.Raw) name := "epar-" + hex.EncodeToString(sum[:12]) + ".crt" - byName[name] = trustedCACertificate{ + byName[name] = TrustedCACertificate{ DestinationName: name, PEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw}), } @@ -60,13 +60,18 @@ func (m *Manager) trustedCACertificates() ([]trustedCACertificate, error) { names = append(names, name) } sort.Strings(names) - out := make([]trustedCACertificate, 0, len(names)) + out := make([]TrustedCACertificate, 0, len(names)) for _, name := range names { out = append(out, byName[name]) } return out, nil } +// TrustedCACertificates returns validated CA material for provider-specific guest installation. +func (m *Coordinator) TrustedCACertificates() ([]TrustedCACertificate, error) { + return m.trustedCACertificates() +} + func parseTrustedCACertificateFile(content []byte) ([]*x509.Certificate, error) { trimmed := bytes.TrimSpace(content) if !bytes.HasPrefix(trimmed, []byte("-----BEGIN")) { @@ -105,7 +110,7 @@ func parseTrustedCACertificateFile(content []byte) ([]*x509.Certificate, error) return nil, fmt.Errorf("no PEM certificates found") } -func (m *Manager) copyTrustedCACertificatesToDir(destination string) error { +func (m *Coordinator) copyTrustedCACertificatesToDir(destination string) error { certificates, err := m.trustedCACertificates() if err != nil { return err @@ -121,7 +126,7 @@ func (m *Manager) copyTrustedCACertificatesToDir(destination string) error { return nil } -func (m *Manager) installTrustedCACertificates(ctx context.Context, vmName string) error { +func (m *Coordinator) installTrustedCACertificates(ctx context.Context, vmName string) error { certificates, err := m.trustedCACertificates() if err != nil { return err diff --git a/internal/invocation/invocation.go b/internal/invocation/invocation.go new file mode 100644 index 0000000..7587e06 --- /dev/null +++ b/internal/invocation/invocation.go @@ -0,0 +1,58 @@ +package invocation + +import ( + "os" + "path/filepath" + "strings" +) + +// Environment identifies the user-facing entry point selected by an EPAR +// wrapper. Values are deliberately closed so an inherited environment variable +// cannot inject arbitrary text into a suggested command. +const Environment = "EPAR_INVOCATION" + +// Command returns a command line that uses the same entry point as the current +// process. +func Command(args ...string) string { + parts := append([]string{commandPrefix(os.Getenv(Environment), os.Args[0], executablePath())}, args...) + return strings.Join(parts, " ") +} + +func executablePath() string { + path, err := os.Executable() + if err != nil { + return "" + } + return path +} + +func commandPrefix(marker, arg0, executable string) string { + switch marker { + case "start": + return "./start" + case "run-with-docker": + return "scripts/run-with-docker.sh" + case "run-with-docker-powershell": + return `scripts\run-with-docker.ps1` + } + if isGoRunExecutable(executable) { + return "go run ./cmd/ephemeral-action-runner" + } + if strings.TrimSpace(arg0) == "" { + return "ephemeral-action-runner" + } + if strings.ContainsAny(arg0, " \t") { + return `"` + arg0 + `"` + } + return arg0 +} + +func isGoRunExecutable(path string) bool { + normalized := strings.ToLower(filepath.ToSlash(strings.ReplaceAll(path, `\`, "/"))) + for _, segment := range strings.Split(normalized, "/") { + if strings.HasPrefix(segment, "go-build") { + return true + } + } + return false +} diff --git a/internal/invocation/invocation_test.go b/internal/invocation/invocation_test.go new file mode 100644 index 0000000..3b6d6a2 --- /dev/null +++ b/internal/invocation/invocation_test.go @@ -0,0 +1,35 @@ +package invocation + +import "testing" + +func TestCommandPrefix(t *testing.T) { + tests := []struct { + name string + marker string + arg0 string + executable string + want string + }{ + {name: "start wrapper", marker: "start", arg0: "ignored", want: "./start"}, + {name: "docker shell wrapper", marker: "run-with-docker", arg0: "ignored", want: "scripts/run-with-docker.sh"}, + {name: "docker PowerShell wrapper", marker: "run-with-docker-powershell", arg0: "ignored", want: `scripts\run-with-docker.ps1`}, + {name: "go run Unix", arg0: "/tmp/go-build123/b001/exe/ephemeral-action-runner", executable: "/tmp/go-build123/b001/exe/ephemeral-action-runner", want: "go run ./cmd/ephemeral-action-runner"}, + {name: "go run Windows", arg0: `C:\Temp\go-build123\b001\exe\ephemeral-action-runner.exe`, executable: `C:\Temp\go-build123\b001\exe\ephemeral-action-runner.exe`, want: "go run ./cmd/ephemeral-action-runner"}, + {name: "direct binary", arg0: `.\bin\ephemeral-action-runner.exe`, executable: `C:\repo\bin\ephemeral-action-runner.exe`, want: `.\bin\ephemeral-action-runner.exe`}, + {name: "direct binary with spaces", arg0: `C:\EPAR Tools\ephemeral-action-runner.exe`, executable: `C:\EPAR Tools\ephemeral-action-runner.exe`, want: `"C:\EPAR Tools\ephemeral-action-runner.exe"`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := commandPrefix(test.marker, test.arg0, test.executable); got != test.want { + t.Fatalf("commandPrefix() = %q, want %q", got, test.want) + } + }) + } +} + +func TestUnknownMarkerCannotReplaceCommand(t *testing.T) { + got := commandPrefix("arbitrary command", "ephemeral-action-runner", `C:\bin\ephemeral-action-runner.exe`) + if got != "ephemeral-action-runner" { + t.Fatalf("commandPrefix() = %q, want direct binary", got) + } +} diff --git a/internal/logging/paths.go b/internal/logging/paths.go index 138fbe0..8530c93 100644 --- a/internal/logging/paths.go +++ b/internal/logging/paths.go @@ -90,7 +90,7 @@ func validBuildComponent(component string) bool { func validInstanceComponent(component string) bool { switch component { - case "guest", "docker-container", "wsl", "tart": + case "guest", "docker-container", "docker-sandboxes", "wsl", "tart": return true default: return false diff --git a/internal/logging/paths_test.go b/internal/logging/paths_test.go index 38bab1f..f538fd5 100644 --- a/internal/logging/paths_test.go +++ b/internal/logging/paths_test.go @@ -14,6 +14,7 @@ func TestPathHelpersAndRecognition(t *testing.T) { category Category }{ {mustPath(InstancePath(root, "runner-1", "docker-container")), CategoryInstances}, + {mustPath(InstancePath(root, "runner-1", "docker-sandboxes")), CategoryInstances}, {mustPath(InstancePath(root, "runner-1", "guest")), CategoryInstances}, {mustPath(BuildPath(root, "ubuntu-24.04", "docker-build")), CategoryBuilds}, {mustPath(BuildPath(root, "ubuntu-24.04", "guest")), CategoryBuilds}, diff --git a/internal/logging/recognition.go b/internal/logging/recognition.go index 1c5d95f..3ebdcfb 100644 --- a/internal/logging/recognition.go +++ b/internal/logging/recognition.go @@ -9,10 +9,10 @@ import ( var ( lumberjackSuffixPattern = regexp.MustCompile(`-\d{4}-\d{2}-\d{2}[Tt]\d{2}-\d{2}-\d{2}\.\d{3}(\.log|\.jsonl)$`) errorNamePattern = regexp.MustCompile(`^epar-\d{8}-\d{6}-error\.log$`) - instanceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(guest|docker-container|wsl|tart)\.log$`) - legacyInstancePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*-\d{8}-\d{6}-\d{3}\.(guest|docker-container|wsl|tart)\.log$`) - buildNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(docker-build|wsl-build|build|source|refresh|wsl-refresh|guest)\.log$`) - legacyBuildNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(docker-build|wsl-build|build|source|refresh|wsl-refresh)\.log$`) + instanceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(guest|docker-container|docker-sandboxes|wsl|tart)\.log$`) + legacyInstancePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*-\d{8}-\d{6}-\d{3}\.(guest|docker-container|docker-sandboxes|wsl|tart)\.log$`) + buildNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(docker-build|docker-pull|wsl-build|build|source|refresh|wsl-refresh|guest)\.log$`) + legacyBuildNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(docker-build|docker-pull|wsl-build|build|source|refresh|wsl-refresh)\.log$`) benchmarkNamePattern = regexp.MustCompile(`^\d{8}[Tt]\d{6}\.\d{9}[Zz]-[A-Za-z0-9][A-Za-z0-9_-]*\.jsonl$`) ) diff --git a/internal/pool/architecture_test.go b/internal/pool/architecture_test.go new file mode 100644 index 0000000..3cf3c16 --- /dev/null +++ b/internal/pool/architecture_test.go @@ -0,0 +1,71 @@ +package pool + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" +) + +func TestPoolDoesNotImportProviderImplementations(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not locate pool package") + } + root := filepath.Dir(filename) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imported := range parsed.Imports { + value, err := strconv.Unquote(imported.Path.Value) + if err != nil { + return err + } + if strings.HasPrefix(value, "github.com/solutionforest/ephemeral-action-runner/internal/provider/") { + t.Errorf("%s imports provider implementation %q; pool may depend only on provider contracts", path, value) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestImageImplementationsStayOutsidePool(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not locate pool package") + } + poolRoot := filepath.Dir(filename) + internalRoot := filepath.Dir(poolRoot) + for _, name := range []string{"image.go", "artifact_lifecycle.go", "image_acquisition.go", "trusted_ca.go", "docker_pull.go", "image_manifest.go"} { + path := filepath.Join(poolRoot, name) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("image implementation must not live in pool: %s", path) + } + } + for _, name := range []string{"build.go", "artifact_lifecycle.go", "acquisition.go", "trusted_ca.go", "docker_pull.go", "manifest.go"} { + path := filepath.Join(internalRoot, "image", name) + info, err := os.Stat(path) + if err != nil { + t.Errorf("required image implementation %s: %v", path, err) + continue + } + if info.IsDir() { + t.Errorf("image implementation is not a file: %s", path) + } + } +} diff --git a/internal/pool/docker_pull.go b/internal/pool/docker_pull.go deleted file mode 100644 index b293193..0000000 --- a/internal/pool/docker_pull.go +++ /dev/null @@ -1,365 +0,0 @@ -package pool - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "os" - "runtime" - "strings" - "time" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - gcrv1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/moby/moby/api/types/jsonstream" - "github.com/moby/moby/api/types/registry" - "github.com/moby/moby/client" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "golang.org/x/term" -) - -const dockerPullProgressInterval = 250 * time.Millisecond - -type dockerSourcePullOptions struct { - Image string - Platform string - LogPath string - AnnounceRemoteSize bool -} - -type dockerLayerProgress struct { - current int64 - total int64 - completed bool -} - -// pullDockerSourceCommand is kept as a small seam for existing command-based -// image preparation tests. Production always uses Manager.pullDockerSource. -var pullDockerSourceCommand = (*Manager).pullDockerSource - -var dockerPullProgressTerminal = func() bool { - return term.IsTerminal(int(os.Stdout.Fd())) -} - -var dockerPullProgressConsole io.Writer = os.Stdout - -func (m *Manager) pullDockerSource(ctx context.Context, opts dockerSourcePullOptions) error { - cli, err := client.New(client.FromEnv) - if err != nil { - return m.pullDockerSourceWithCLI(ctx, opts, fmt.Errorf("initialize Docker Engine client: %w", err)) - } - if _, err := cli.Ping(ctx, client.PingOptions{}); err != nil { - return m.pullDockerSourceWithCLI(ctx, opts, fmt.Errorf("connect to Docker Engine: %w", err)) - } - - platform, err := m.resolveDockerPullPlatform(ctx, cli, opts.Platform) - if err != nil { - return m.pullDockerSourceWithCLI(ctx, opts, err) - } - if opts.AnnounceRemoteSize { - if size, err := remoteCompressedLayerSize(opts.Image, platform); err != nil { - m.writeDockerPullNotice(opts.LogPath, "warning: could not determine remote compressed layer size: "+sanitizeTimingError(err)) - } else { - m.writeDockerPullNotice(opts.LogPath, fmt.Sprintf("Remote compressed layers: %s; actual transfer may be lower when Docker reuses layers.", formatDockerPullBytes(size))) - } - } - - registryAuth, err := dockerRegistryAuth(opts.Image) - if err != nil { - m.writeDockerPullNotice(opts.LogPath, "warning: could not load Docker registry credentials; continuing without explicit credentials: "+sanitizeTimingError(err)) - } - response, err := cli.ImagePull(ctx, opts.Image, client.ImagePullOptions{ - RegistryAuth: registryAuth, - Platforms: []ocispec.Platform{platform}, - }) - if err != nil { - return fmt.Errorf("Docker Engine pull %s: %w", opts.Image, err) - } - if err := m.renderDockerPullProgress(ctx, response, opts.LogPath); err != nil { - return fmt.Errorf("Docker Engine pull %s: %w", opts.Image, err) - } - m.writeDockerPullNotice(opts.LogPath, "Docker source pull complete: "+opts.Image) - return nil -} - -func (m *Manager) pullDockerSourceWithCLI(ctx context.Context, opts dockerSourcePullOptions, apiErr error) error { - m.writeDockerPullNotice(opts.LogPath, "warning: "+sanitizeTimingError(apiErr)+"; falling back to docker pull CLI") - args := []string{"pull"} - if opts.Platform != "" { - args = append(args, "--platform", opts.Platform) - } - args = append(args, opts.Image) - return m.runHostLogged(ctx, opts.LogPath, "docker", args...) -} - -func (m *Manager) resolveDockerPullPlatform(ctx context.Context, cli *client.Client, configured string) (ocispec.Platform, error) { - if platform, ok := normalizedDockerPlatform(configured, ""); ok { - return platform, nil - } - if platform, ok := normalizedDockerPlatform(m.Config.Provider.Platform, ""); ok { - return platform, nil - } - info, err := cli.Info(ctx, client.InfoOptions{}) - if err != nil { - return ocispec.Platform{}, fmt.Errorf("inspect Docker Engine platform: %w", err) - } - if platform, ok := normalizedDockerPlatform(info.Info.OSType+"/"+info.Info.Architecture, ""); ok { - return platform, nil - } - if platform, ok := normalizedDockerPlatform(runtime.GOOS+"/"+runtime.GOARCH, ""); ok { - return platform, nil - } - return ocispec.Platform{}, fmt.Errorf("Docker Engine did not report a usable platform") -} - -func normalizedDockerPlatform(value, fallbackOS string) (ocispec.Platform, bool) { - parts := strings.Split(strings.Trim(strings.ToLower(value), "/"), "/") - if len(parts) == 0 || len(parts) > 3 || parts[0] == "" { - return ocispec.Platform{}, false - } - platform := ocispec.Platform{OS: fallbackOS} - if len(parts) == 1 { - platform.Architecture = normalizeDockerArchitecture(parts[0]) - } else { - platform.OS = parts[0] - platform.Architecture = normalizeDockerArchitecture(parts[1]) - if len(parts) == 3 { - platform.Variant = parts[2] - } - } - if platform.OS == "" { - platform.OS = "linux" - } - if platform.Architecture == "" { - return ocispec.Platform{}, false - } - return platform, true -} - -func normalizeDockerArchitecture(architecture string) string { - switch architecture { - case "x86_64", "x64": - return "amd64" - case "aarch64": - return "arm64" - default: - return architecture - } -} - -func remoteCompressedLayerSize(image string, platform ocispec.Platform) (int64, error) { - ref, authenticator, err := dockerImageReferenceAndAuth(image) - if err != nil { - return 0, err - } - remoteImage, err := remote.Image(ref, remote.WithAuth(authenticator), remote.WithPlatform(gcrv1.Platform{ - OS: platform.OS, - Architecture: platform.Architecture, - Variant: platform.Variant, - })) - if err != nil { - return 0, err - } - layers, err := remoteImage.Layers() - if err != nil { - return 0, err - } - var total int64 - for _, layer := range layers { - size, err := layer.Size() - if err != nil { - return 0, err - } - total += size - } - return total, nil -} - -func dockerRegistryAuth(image string) (string, error) { - ref, authenticator, err := dockerImageReferenceAndAuth(image) - if err != nil { - return "", err - } - credentials, err := authenticator.Authorization() - if err != nil { - return "", err - } - content, err := json.Marshal(registry.AuthConfig{ - Username: credentials.Username, - Password: credentials.Password, - Auth: credentials.Auth, - ServerAddress: ref.Context().RegistryStr(), - IdentityToken: credentials.IdentityToken, - RegistryToken: credentials.RegistryToken, - }) - if err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(content), nil -} - -func dockerImageReferenceAndAuth(image string) (name.Reference, authn.Authenticator, error) { - ref, err := name.ParseReference(image) - if err != nil { - return nil, nil, err - } - authenticator, err := authn.DefaultKeychain.Resolve(ref.Context().Registry) - if err != nil { - return nil, nil, err - } - return ref, authenticator, nil -} - -func (m *Manager) renderDockerPullProgress(ctx context.Context, response client.ImagePullResponse, logPath string) error { - transcript, err := m.transcript(logPath, "", "docker-pull") - if err != nil { - return err - } - layers := map[string]dockerLayerProgress{} - lastRender := time.Time{} - rendered := false - for message, streamErr := range response.JSONMessages(ctx) { - if streamErr != nil { - return streamErr - } - writeDockerPullEvent(transcript.Stdout, message.ID, message.Status, message.Progress, message.Stream, message.Error) - if message.Error != nil { - return message.Error - } - if message.ID != "" { - layer := layers[message.ID] - if message.Progress != nil { - layer.current = message.Progress.Current - if message.Progress.Total > 0 { - layer.total = message.Progress.Total - } - } - if dockerPullLayerComplete(message.Status) { - layer.completed = true - if layer.total > 0 { - layer.current = layer.total - } - } - layers[message.ID] = layer - } - if time.Since(lastRender) >= dockerPullProgressInterval { - m.writeDockerPullProgress(logPath, layers) - lastRender = time.Now() - rendered = true - } - } - if rendered { - m.writeDockerPullProgress(logPath, layers) - if m.dockerPullProgressIsInteractive() { - fmt.Fprintln(dockerPullProgressConsole) - } - } - return nil -} - -func (m *Manager) writeDockerPullNotice(logPath, message string) { - attributes := []any{"provider", m.Config.Provider.Type, "operation", "docker-pull", "logPath", logPath} - if strings.HasPrefix(message, "warning:") { - m.logger().Warn(strings.TrimSpace(strings.TrimPrefix(message, "warning:")), attributes...) - } else { - m.logger().Info(message, attributes...) - } - transcript, err := m.transcript(logPath, "", "docker-pull") - if err != nil { - m.logger().Warn("docker pull transcript unavailable", "operation", "docker-pull", "logPath", logPath, "error", err) - return - } - _, _ = fmt.Fprintf(transcript.Stdout, "%s\n", message) -} - -func writeDockerPullEvent(logFile io.Writer, id, status string, progress *jsonstream.Progress, stream string, pullErr error) { - if logFile == nil { - return - } - parts := make([]string, 0, 4) - if id != "" { - parts = append(parts, id) - } - if status != "" { - parts = append(parts, status) - } - if progress != nil { - parts = append(parts, fmt.Sprintf("progress=%d/%d", progress.Current, progress.Total)) - } - if stream != "" { - parts = append(parts, strings.TrimSpace(stream)) - } - if pullErr != nil { - parts = append(parts, "error="+sanitizeTimingError(pullErr)) - } - fmt.Fprintf(logFile, "%s %s\n", time.Now().UTC().Format(time.RFC3339Nano), strings.Join(parts, " ")) -} - -func dockerPullLayerComplete(status string) bool { - status = strings.ToLower(strings.TrimSpace(status)) - return status == "pull complete" || status == "already exists" || status == "exists" -} - -func (m *Manager) writeDockerPullProgress(logPath string, layers map[string]dockerLayerProgress) { - line := dockerPullProgressSummary(layers) - if m.dockerPullProgressIsInteractive() { - _, _ = fmt.Fprintf(dockerPullProgressConsole, "\r\033[2K%s", line) - return - } - m.logger().Info(line, "provider", m.Config.Provider.Type, "operation", "docker-pull", "logPath", logPath) -} - -func (m *Manager) dockerPullProgressIsInteractive() bool { - return dockerPullProgressTerminal() && containsString(m.Config.Logging.ManagerSinks, "console") && m.Config.Logging.ManagerConsoleFormat == "text" -} - -func containsString(values []string, wanted string) bool { - for _, value := range values { - if value == wanted { - return true - } - } - return false -} - -func dockerPullProgressSummary(layers map[string]dockerLayerProgress) string { - var complete, known int - var currentBytes, totalBytes int64 - for _, layer := range layers { - if layer.completed { - complete++ - } - if layer.total > 0 { - known++ - totalBytes += layer.total - currentBytes += min(layer.current, layer.total) - } - } - line := fmt.Sprintf("Docker source pull: %d/%d layers complete; %s/%s", complete, len(layers), formatDockerPullBytes(currentBytes), formatDockerPullBytes(totalBytes)) - if totalBytes > 0 { - line += fmt.Sprintf(" (%.0f%%)", float64(currentBytes)*100/float64(totalBytes)) - } - if known < len(layers) { - line += fmt.Sprintf("; %d layer(s) size pending", len(layers)-known) - } - return line -} - -func formatDockerPullBytes(value int64) string { - const unit = 1024 - if value < unit { - return fmt.Sprintf("%d B", value) - } - units := []string{"KiB", "MiB", "GiB", "TiB"} - size := float64(value) - index := -1 - for size >= unit && index+1 < len(units) { - size /= unit - index++ - } - return fmt.Sprintf("%.1f %s", size, units[index]) -} diff --git a/internal/pool/host_trust.go b/internal/pool/host_trust.go index 295c46e..364978d 100644 --- a/internal/pool/host_trust.go +++ b/internal/pool/host_trust.go @@ -1,6 +1,8 @@ package pool import ( + "archive/tar" + "bytes" "context" "encoding/json" "fmt" @@ -13,6 +15,7 @@ import ( "time" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + artifactimage "github.com/solutionforest/ephemeral-action-runner/internal/image" "github.com/solutionforest/ephemeral-action-runner/internal/provider" ) @@ -25,17 +28,17 @@ const ( hostTrustNativePoll = 15 * time.Second ) +// HostTrustMarkerGuest is the stable guest path for verified host-trust metadata. +const HostTrustMarkerGuest = hostTrustMarkerGuest + +// HostTrustLeaseLifetime is the default shared guest lease duration. +const HostTrustLeaseLifetime = hostTrustLeaseLifetime + var hostTrustRefreshInterval = 5 * time.Second var hostTrustControllerInContainer = linuxControllerInContainer var hostTrustControllerOS = runtime.GOOS -type hostTrustImageMetadata struct { - Mode string `json:"mode"` - HostOS string `json:"hostOS"` - Scopes []string `json:"scopes"` - Generation string `json:"generation"` - CertificateCount int `json:"certificateCount"` -} +type hostTrustImageMetadata = artifactimage.HostTrustMetadata type hostTrustMarker struct { SchemaVersion int `json:"schemaVersion"` @@ -55,6 +58,12 @@ type hostTrustLease struct { ExpiresAt string `json:"expiresAt"` } +// HostTrustMarker and HostTrustLease are shared payloads for specialized +// providers that install the same verified host-trust contract by another +// transport. +type HostTrustMarker = hostTrustMarker +type HostTrustLease = hostTrustLease + func (m *Manager) hostTrustEnabled() bool { return hosttrust.Enabled(m.Config.Image.HostTrustMode) } @@ -184,6 +193,11 @@ func validateHostTrustSnapshot(snapshot hosttrust.Snapshot, now time.Time) (host return snapshot, nil } +// ValidateHostTrustSnapshot verifies the shared host-trust freshness contract. +func ValidateHostTrustSnapshot(snapshot hosttrust.Snapshot, now time.Time) (hosttrust.Snapshot, error) { + return validateHostTrustSnapshot(snapshot, now) +} + func hostTrustMetadata(snapshot hosttrust.Snapshot) *hostTrustImageMetadata { if snapshot.Generation == "" { return nil @@ -268,6 +282,53 @@ func copyHostTrustCertificatesToDir(destination string, snapshot hosttrust.Snaps return nil } +func hostTrustCertificateArchive(snapshot hosttrust.Snapshot) (string, error) { + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + for _, certificate := range snapshot.Certificates { + header := &tar.Header{ + Name: certificate.Name, + Mode: 0644, + Size: int64(len(certificate.PEM)), + } + if err := writer.WriteHeader(header); err != nil { + return "", err + } + if _, err := writer.Write(certificate.PEM); err != nil { + return "", err + } + } + if err := writer.Close(); err != nil { + return "", err + } + return buffer.String(), nil +} + +func (m *Manager) installHostTrustRuntime(ctx context.Context, instanceName string, snapshot hosttrust.Snapshot) error { + if !m.hostTrustEnabled() { + return nil + } + if _, err := validateHostTrustSnapshot(snapshot, time.Now().UTC()); err != nil { + return err + } + archive, err := hostTrustCertificateArchive(snapshot) + if err != nil { + return fmt.Errorf("archive host trust certificates: %w", err) + } + script := fmt.Sprintf("sudo install -d -m 0755 %s && sudo find %s -maxdepth 1 -type f -name 'epar-*.crt' -delete && sudo tar -x -f - --no-same-owner --no-same-permissions -C %s && sudo update-ca-certificates", shellQuote(hostTrustGuestDir), shellQuote(hostTrustGuestDir), shellQuote(hostTrustGuestDir)) + if _, err := m.execGuest(ctx, instanceName, provider.ShellCommand(script), provider.ExecOptions{Stdin: archive}); err != nil { + return fmt.Errorf("install host trust certificates in runtime: %w", err) + } + content, err := hostTrustMarkerJSON(snapshot) + if err != nil { + return err + } + if err := m.copyTextGuest(ctx, instanceName, hostTrustMarkerGuest, "0644", string(content)+"\n", true); err != nil { + return fmt.Errorf("install host trust generation marker: %w", err) + } + return nil +} + func (m *Manager) writeHostTrustBuildInputs(buildContext string, snapshot hosttrust.Snapshot) error { if err := copyHostTrustCertificatesToDir(filepath.Join(buildContext, "host-trust-certificates"), snapshot); err != nil { return err @@ -298,7 +359,7 @@ func (m *Manager) issueHostTrustLease(ctx context.Context, instanceName string, if _, err := m.execGuest(ctx, instanceName, provider.ShellCommand("if command -v sudo >/dev/null 2>&1; then sudo install -d -m 0755 /run/epar; else install -d -m 0755 /run/epar; fi"), provider.ExecOptions{}); err != nil { return err } - return provider.CopyTextAtomic(ctx, m.Provider, instanceName, hostTrustLeaseGuest, "0644", string(content)+"\n") + return m.copyTextGuest(ctx, instanceName, hostTrustLeaseGuest, "0644", string(content)+"\n", true) } func (m *Manager) reconcileHostTrustRunners(ctx context.Context, active map[string]ProvisionedInstance, current hosttrust.Snapshot) int { diff --git a/internal/pool/host_trust_test.go b/internal/pool/host_trust_test.go index b64b55d..ca0da84 100644 --- a/internal/pool/host_trust_test.go +++ b/internal/pool/host_trust_test.go @@ -1,8 +1,11 @@ package pool import ( + "archive/tar" + "bytes" "context" "encoding/json" + "errors" "io" "os" "path/filepath" @@ -106,6 +109,46 @@ func TestHostTrustLeaseMatchesMarkerAndExpires(t *testing.T) { } } +func TestHostTrustCertificateArchiveContainsOnlyExactCertificates(t *testing.T) { + snapshot := hosttrust.Snapshot{ + Generation: "generation-one", + HostOS: "windows", + Scopes: []string{"system", "user"}, + Certificates: []hosttrust.Certificate{ + {Name: "epar-system-a.crt", PEM: []byte("system")}, + {Name: "epar-user-b.crt", PEM: []byte("user")}, + }, + CollectedAt: time.Now(), + } + archive, err := hostTrustCertificateArchive(snapshot) + if err != nil { + t.Fatal(err) + } + reader := tar.NewReader(bytes.NewBufferString(archive)) + var names []string + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatal(err) + } + names = append(names, header.Name) + if header.Mode != 0644 { + t.Fatalf("certificate mode = %o, want 0644", header.Mode) + } + } + if len(names) != len(snapshot.Certificates) { + t.Fatalf("archive entries = %d, want %d", len(names), len(snapshot.Certificates)) + } + for index, certificate := range snapshot.Certificates { + if names[index] != certificate.Name { + t.Fatalf("archive entry %d = %q, want %q", index, names[index], certificate.Name) + } + } +} + func TestValidateHostTrustMarkerAgainstSnapshotRejectsCloningRace(t *testing.T) { snapshot := hosttrust.Snapshot{ Generation: "g2", HostOS: "windows", Scopes: []string{"system", "user"}, @@ -350,12 +393,15 @@ func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing. builds := 0 tagged := false runHostLoggedCommand = func(_ context.Context, _ string, _, _ io.Writer, name string, args ...string) error { - if name == "docker" && len(args) > 0 && args[0] == "build" { + if name == "docker" && len(args) > 1 && args[0] == "buildx" && args[1] == "build" { builds++ } return nil } - runHostOutputCommand = func(context.Context, string, ...string) (string, error) { + runHostOutputCommand = func(_ context.Context, _ string, args ...string) (string, error) { + if len(args) > 1 && args[0] == "buildx" && args[1] == "inspect" { + return "", errors.New("builder not found") + } return `["source@sha256:1234"]`, nil } runHostQuietCommand = func(context.Context, string, ...string) error { return nil } diff --git a/internal/pool/docker_pull_test.go b/internal/pool/image_acquisition_test.go similarity index 89% rename from internal/pool/docker_pull_test.go rename to internal/pool/image_acquisition_test.go index 6090c2e..bfa6731 100644 --- a/internal/pool/docker_pull_test.go +++ b/internal/pool/image_acquisition_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/image" "github.com/solutionforest/ephemeral-action-runner/internal/logging" ) @@ -34,15 +35,15 @@ func TestDockerPullProgressUsesManagerLoggerAndPreservesSourceTranscript(t *test previousTerminal := dockerPullProgressTerminal dockerPullProgressTerminal = func() bool { return false } t.Cleanup(func() { dockerPullProgressTerminal = previousTerminal }) - manager.writeDockerPullProgress(logPath, map[string]dockerLayerProgress{ - "layer-a": {current: 512, total: 1024, completed: false}, - "layer-b": {completed: true}, + manager.writeDockerPullProgress(logPath, map[string]image.DockerPullProgress{ + "layer-a": {Current: 512, Total: 1024, Completed: false}, + "layer-b": {Completed: true}, }) transcriptWriter, err := manager.transcript(logPath, "", "docker-pull") if err != nil { t.Fatal(err) } - writeDockerPullEvent(transcriptWriter.Stdout, "layer-a", "Downloading", nil, "", nil) + writeDockerPullEvent(transcriptWriter.Stdout, image.DockerPullEvent{ID: "layer-a", Status: "Downloading"}) manager.writeDockerPullNotice(logPath, "Docker source pull complete: example.invalid/source:latest") if err := manager.releaseTranscript(logPath); err != nil { t.Fatal(err) @@ -92,7 +93,7 @@ func TestDockerPullProgressHonorsManagerJSONConsoleFormat(t *testing.T) { dockerPullProgressTerminal = func() bool { return true } t.Cleanup(func() { dockerPullProgressTerminal = previousTerminal }) logPath := filepath.Join(root, "builds", "source.docker-pull.log") - manager.writeDockerPullProgress(logPath, map[string]dockerLayerProgress{"layer-a": {current: 1, total: 2}}) + manager.writeDockerPullProgress(logPath, map[string]image.DockerPullProgress{"layer-a": {Current: 1, Total: 2}}) var record map[string]any if err := json.Unmarshal(console.Bytes(), &record); err != nil { @@ -127,7 +128,7 @@ func TestDockerPullProgressUsesSingleLineTerminalDisplayForTextManagerConsole(t }) manager := Manager{Config: config.Default(), Logging: runtime} - manager.writeDockerPullProgress("source.docker-pull.log", map[string]dockerLayerProgress{"layer-a": {current: 1, total: 2}}) + manager.writeDockerPullProgress("source.docker-pull.log", map[string]image.DockerPullProgress{"layer-a": {Current: 1, Total: 2}}) if got, want := terminalConsole.String(), "\r\033[2KDocker source pull: 0/1 layers complete; 1 B/2 B (50%)"; got != want { t.Fatalf("terminal pull progress = %q, want %q", got, want) diff --git a/internal/pool/image_service.go b/internal/pool/image_service.go new file mode 100644 index 0000000..6a97b14 --- /dev/null +++ b/internal/pool/image_service.go @@ -0,0 +1,324 @@ +package pool + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + artifactimage "github.com/solutionforest/ephemeral-action-runner/internal/image" + "github.com/solutionforest/ephemeral-action-runner/internal/logging" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "golang.org/x/term" +) + +type ImageBuildOptions = artifactimage.ImageBuildOptions +type ImageManifest = artifactimage.Manifest +type imageState = artifactimage.ImageState +type runnerImagesCopyMode = artifactimage.RunnerImagesCopyMode +type dockerSourcePullOptions = artifactimage.DockerSourcePullOptions +type sourceCacheManifest = artifactimage.SourceCacheManifest +type TrustedCACertificate = artifactimage.TrustedCACertificate + +const ( + imageStateMissing = artifactimage.ImageStateMissing + imageStateCurrent = artifactimage.ImageStateCurrent + imageStateOutdated = artifactimage.ImageStateOutdated + imageManifestSchemaVersion = artifactimage.ManifestSchemaVersion + imageManifestLabel = artifactimage.ManifestLabel + runnerImagesCopyNone = artifactimage.RunnerImagesCopyNone + runnerImagesCopySubset = artifactimage.RunnerImagesCopySubset + trustedCAGuestDir = "/usr/local/share/ca-certificates/epar" +) + +var pullDockerSourceCommand = (*Manager).pullDockerSource + +var dockerPullProgressTerminal = func() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +var dockerPullProgressConsole io.Writer = os.Stdout + +var ( + runHostCommand = runHost + runHostLoggedCommand = runHostLogged + runHostOutputCommand = runHostOutput + runHostQuietCommand = runHostQuiet +) + +func (m *Manager) imageCoordinator() *artifactimage.Coordinator { + return artifactimage.NewCoordinator(m.Config, m.Provider, m.Lifecycle, m.ProjectRoot, m.DryRun, imageEnvironment{manager: m}) +} + +func (m *Manager) UpdateUpstream(ctx context.Context) error { + return m.imageCoordinator().UpdateUpstream(ctx) +} + +func (m *Manager) BuildImage(ctx context.Context, options ImageBuildOptions) error { + return m.imageCoordinator().BuildImage(ctx, options) +} + +func (m *Manager) EnsureImage(ctx context.Context) error { + return m.imageCoordinator().EnsureImage(ctx) +} + +func (m *Manager) RefreshScripts(ctx context.Context) error { + return m.imageCoordinator().RefreshScripts(ctx) +} + +func (m *Manager) pullDockerSource(ctx context.Context, options dockerSourcePullOptions) error { + return m.imageCoordinator().PullDockerSource(ctx, options) +} + +func (m *Manager) writeDockerPullNotice(logPath, message string) { + m.imageCoordinator().WriteDockerPullNotice(logPath, message) +} + +func (m *Manager) writeDockerPullProgress(logPath string, layers map[string]artifactimage.DockerPullProgress) { + m.imageCoordinator().WriteDockerPullProgress(logPath, layers) +} + +func writeDockerPullEvent(writer io.Writer, event artifactimage.DockerPullEvent) { + artifactimage.WriteDockerPullEvent(writer, event) +} + +func (m *Manager) desiredImageManifest(ctx context.Context) (ImageManifest, error) { + return m.imageCoordinator().DesiredImageManifest(ctx) +} + +func (m *Manager) currentImageState(ctx context.Context, wantedHash string) (imageState, error) { + return m.imageCoordinator().CurrentImageState(ctx, wantedHash) +} + +func imageManifestHash(manifest ImageManifest) (string, error) { + return artifactimage.ImageManifestHash(manifest) +} + +func writeStoredImageManifest(path string, manifest ImageManifest) error { + return artifactimage.WriteStoredManifest(path, manifest) +} + +func readStoredImageManifest(path string) (artifactimage.StoredManifest, error) { + return artifactimage.ReadStoredManifest(path) +} + +func wslImageManifestSidecarPath(outputPath string) string { + return artifactimage.WSLImageManifestPath(outputPath) +} + +func sourceCacheManifestPath(rootfsPath string) string { + return artifactimage.SourceCacheManifestPath(rootfsPath) +} + +func writeSourceCacheManifest(path string, manifest sourceCacheManifest) error { + return artifactimage.WriteSourceCacheManifest(path, manifest) +} + +func wslDockerSourceRootfsPath(outputPath string) string { + return artifactimage.WSLSourceRootfsPath(outputPath) +} + +func sourceImageEnvContent(environment []string) string { + return artifactimage.SourceImageEnvContent(environment) +} + +func (m *Manager) prepareDockerContainerBuildContext(buildContext, upstreamDirectory, manifestContent string) error { + return m.imageCoordinator().PrepareDockerContainerBuildContext(buildContext, upstreamDirectory, manifestContent) +} + +func (m *Manager) prepareDockerContainerBuildContextWithHostTrust(buildContext, upstreamDirectory, manifestContent string, snapshot hosttrust.Snapshot) error { + return m.imageCoordinator().PrepareDockerContainerBuildContextWithHostTrust(buildContext, upstreamDirectory, manifestContent, snapshot) +} + +func (m *Manager) buildDockerContainerImage(ctx context.Context, options ImageBuildOptions, upstreamDirectory string) error { + return m.imageCoordinator().BuildDockerContainerImage(ctx, options, upstreamDirectory) +} + +func (m *Manager) trustedCACertificates() ([]TrustedCACertificate, error) { + return m.imageCoordinator().TrustedCACertificates() +} + +func (m *Manager) TrustedCACertificates() ([]TrustedCACertificate, error) { + return m.trustedCACertificates() +} + +func (m *Manager) prepareWSLDockerSourceRootfs(ctx context.Context, outputPath, buildLogPath string, manifest ImageManifest) (string, string, error) { + return m.imageCoordinator().PrepareWSLDockerSourceRootfs(ctx, outputPath, buildLogPath, manifest) +} + +func (m *Manager) runnerImageBuildScripts() []string { + return m.imageCoordinator().RunnerImageBuildScripts() +} + +func (m *Manager) runnerImagesCopyMode() runnerImagesCopyMode { + return m.imageCoordinator().RunnerImagesCopyMode() +} + +func (m *Manager) prepareWSLDockerSourceGuest(ctx context.Context, instance string) error { + return m.imageCoordinator().PrepareWSLDockerSourceGuest(ctx, instance) +} + +func (m *Manager) installCustomInstallScripts(ctx context.Context, instance string) error { + return m.imageCoordinator().InstallCustomInstallScripts(ctx, instance) +} + +func (m *Manager) customInstallScriptHostPath(script string) (string, error) { + return m.imageCoordinator().CustomInstallScriptHostPath(script) +} + +func (m *Manager) enableWSLSystemd(ctx context.Context, instance string) error { + return m.imageCoordinator().EnableWSLSystemd(ctx, instance) +} + +func (m *Manager) startOptions(logPath, instance string) (provider.StartOptions, error) { + transcript, err := m.transcript(logPath, instance, transcriptComponent(logPath)) + if err != nil { + return provider.StartOptions{}, err + } + return provider.StartOptions{ + Network: m.Config.Provider.Network, + RosettaTag: m.Config.Provider.RosettaTag, + LogPath: logPath, + Stdout: transcript.Stdout, + Stderr: transcript.Stderr, + }, nil +} + +type imageEnvironment struct { + manager *Manager +} + +func (environment imageEnvironment) PreflightStorage(operation string, peakBytes uint64) error { + return environment.manager.preflightStorage(operation, peakBytes) +} + +func (environment imageEnvironment) BuildLogPath(name string) string { + return environment.manager.buildLogPath(name) +} + +func (environment imageEnvironment) ReleaseTranscript(path string) error { + return environment.manager.releaseTranscript(path) +} + +func (environment imageEnvironment) Infof(format string, args ...any) { + environment.manager.infof(format, args...) +} + +func (environment imageEnvironment) Warnf(format string, args ...any) { + environment.manager.warnf(format, args...) +} + +func (environment imageEnvironment) RunHostLogged(ctx context.Context, logPath, name string, args ...string) error { + return environment.manager.runHostLogged(ctx, logPath, name, args...) +} + +func (environment imageEnvironment) RunHost(ctx context.Context, name string, args ...string) error { + return runHostCommand(ctx, name, args...) +} + +func (environment imageEnvironment) RunHostOutput(ctx context.Context, name string, args ...string) (string, error) { + return runHostOutputCommand(ctx, name, args...) +} + +func (environment imageEnvironment) RunHostQuiet(ctx context.Context, name string, args ...string) error { + return runHostQuietCommand(ctx, name, args...) +} + +func (environment imageEnvironment) TimeStartupStage(stage string, fn func() error) error { + return environment.manager.timeStartupStage(stage, fn) +} + +func (environment imageEnvironment) HostTrustEnabled() bool { + return environment.manager.hostTrustEnabled() +} + +func (environment imageEnvironment) ResolveHostTrust(ctx context.Context) (hosttrust.Snapshot, error) { + return environment.manager.resolveHostTrust(ctx) +} + +func (environment imageEnvironment) WriteHostTrustBuildInputs(buildContext string, snapshot hosttrust.Snapshot) error { + return environment.manager.writeHostTrustBuildInputs(buildContext, snapshot) +} + +func (environment imageEnvironment) ValidateRuntime(ctx context.Context, instance string) error { + return environment.manager.validateRuntime(ctx, instance) +} + +func (environment imageEnvironment) ExecGuest(ctx context.Context, instance string, command []string, options provider.ExecOptions) (provider.ExecResult, error) { + return environment.manager.execGuest(ctx, instance, command, options) +} + +func (environment imageEnvironment) Transcript(path, instance, component string) (*logging.Transcript, error) { + return environment.manager.transcript(path, instance, component) +} + +func (environment imageEnvironment) PullDockerSource(ctx context.Context, options artifactimage.DockerSourcePullOptions) error { + return pullDockerSourceCommand(environment.manager, ctx, options) +} + +func (environment imageEnvironment) LogInfo(message string, args ...any) { + environment.manager.logger().Info(message, args...) +} + +func (environment imageEnvironment) LogWarn(message string, args ...any) { + environment.manager.logger().Warn(message, args...) +} + +func (environment imageEnvironment) DockerPullProgressTerminal() bool { + return dockerPullProgressTerminal() +} + +func (environment imageEnvironment) DockerPullProgressConsole() io.Writer { + return dockerPullProgressConsole +} + +func (environment imageEnvironment) RunnerName(prefix string, sequence int, now time.Time) string { + return RunnerName(prefix, sequence, now) +} + +func (environment imageEnvironment) TranscriptComponent(path string) string { + return transcriptComponent(path) +} + +func guestText(content []byte) string { + return strings.ReplaceAll(string(content), "\r\n", "\n") +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func runHost(ctx context.Context, name string, args ...string) error { + return exec.CommandContext(ctx, name, args...).Run() +} + +func runHostOutput(ctx context.Context, name string, args ...string) (string, error) { + command := exec.CommandContext(ctx, name, args...) + output, err := command.CombinedOutput() + if err != nil { + return "", fmt.Errorf("%s %s failed: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return string(output), nil +} + +func runHostQuiet(ctx context.Context, name string, args ...string) error { + return exec.CommandContext(ctx, name, args...).Run() +} + +func runHostLogged(ctx context.Context, _ string, stdout, stderr io.Writer, name string, args ...string) error { + command := exec.CommandContext(ctx, name, args...) + command.Stdout = stdout + command.Stderr = stderr + if err := command.Run(); err != nil { + return fmt.Errorf("%s %s failed: %w", name, strings.Join(args, " "), err) + } + return nil +} + +func copyFile(source, destination string, mode os.FileMode) error { + return artifactimage.CopyFile(source, destination, mode) +} diff --git a/internal/pool/image_test.go b/internal/pool/image_test.go index bf3ad0a..22ad183 100644 --- a/internal/pool/image_test.go +++ b/internal/pool/image_test.go @@ -95,7 +95,7 @@ func TestDockerContainerDockerfileRunsBuildStepsAsRoot(t *testing.T) { } } -func TestDockerContainerBuildUsesLegacyBuilderCompatibleArgs(t *testing.T) { +func TestDockerContainerBuildUsesDedicatedBuildxBuilder(t *testing.T) { root := t.TempDir() for _, dir := range []string{ filepath.Join(root, "scripts", "guest", "ubuntu"), @@ -137,8 +137,8 @@ func TestDockerContainerBuildUsesLegacyBuilderCompatibleArgs(t *testing.T) { if strings.Contains(out, "--progress") { t.Fatalf("docker build command should not require BuildKit progress support:\n%s", out) } - if !strings.Contains(out, "docker build -t epar-docker-container-catthehacker-ubuntu --platform linux/amd64") { - t.Fatalf("docker build command missing expected base args:\n%s", out) + if !strings.Contains(out, "docker buildx build --builder epar-") || !strings.Contains(out, " --load -t epar-docker-container-catthehacker-ubuntu --platform linux/amd64") { + t.Fatalf("dedicated Buildx command missing expected base args:\n%s", out) } } diff --git a/internal/pool/lifecycle_cleanup.go b/internal/pool/lifecycle_cleanup.go new file mode 100644 index 0000000..9d420df --- /dev/null +++ b/internal/pool/lifecycle_cleanup.go @@ -0,0 +1,258 @@ +package pool + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +func (m *Manager) cleanupOwnedLifecycle(ctx context.Context) error { + records, err := m.LifecycleState.List(ctx) + if err != nil { + return fmt.Errorf("read provider-neutral lifecycle state: %w", err) + } + inventory, err := m.inventoryProvider(ctx) + if err != nil { + return fmt.Errorf("read provider inventory for exact cleanup: %w", err) + } + byName := inventoryByName(inventory) + ownedNames := make(map[string]struct{}, len(records)) + for _, record := range records { + if record.ProviderType == m.Config.Provider.Type && record.Phase != poolstate.PhaseTombstoned { + ownedNames[record.Name] = struct{}{} + } + } + for _, item := range inventory { + if !HasPrefix(item.Instance.Name, m.Config.Pool.NamePrefix) { + continue + } + if _, found := ownedNames[item.Instance.Name]; found { + continue + } + if err := m.reportUnknownInventory(ctx, item); err != nil { + return err + } + } + + var firstErr error + for _, record := range records { + if record.ProviderType != m.Config.Provider.Type || record.Phase == poolstate.PhaseTombstoned { + continue + } + if err := m.cleanupLifecycleRecord(ctx, record, byName[record.Name]); err != nil { + firstErr = errors.Join(firstErr, fmt.Errorf("cleanup %s: %w", record.Name, err)) + } + } + return firstErr +} + +func inventoryByName(items []provider.InventoryItem) map[string][]provider.InventoryItem { + result := make(map[string][]provider.InventoryItem) + for _, item := range items { + result[item.Instance.Name] = append(result[item.Instance.Name], item) + } + return result +} + +func (m *Manager) reportUnknownInventory(ctx context.Context, item provider.InventoryItem) error { + providerID := item.Instance.ProviderID + if providerID == "" { + providerID = "unidentified:" + item.Instance.Name + } + payload, _ := json.Marshal(map[string]string{"state": item.State, "source": item.Source}) + _, err := m.LifecycleState.ReportUnknown(ctx, poolstate.Discovery{ + ProviderType: m.Config.Provider.Type, + ProviderID: providerID, + ExactName: item.Instance.Name, + Receipt: poolstate.Receipt{Version: "v1", Payload: payload}, + }) + if err != nil { + return fmt.Errorf("quarantine unowned provider instance %q: %w", item.Instance.Name, err) + } + m.warnf("cleanup: quarantined unowned instance %s id=%s; prefix-only or unidentified resources are report-only\n", item.Instance.Name, providerID) + return nil +} + +func (m *Manager) cleanupLifecycleRecord(ctx context.Context, initial poolstate.Record, sameName []provider.InventoryItem) error { + return m.cleanupLifecycleRecordWithRemoteAbsence(ctx, initial, sameName, false) +} + +func (m *Manager) cleanupLifecycleRecordWithRemoteAbsence(ctx context.Context, initial poolstate.Record, sameName []provider.InventoryItem, remoteKnownAbsent bool) error { + for { + record, err := m.LifecycleState.Read(ctx, initial.Name) + if err != nil { + return err + } + if lease, protected := activeLifecycleLease(record.Leases, m.currentTime()); protected { + return fmt.Errorf("active %s lease held by %s protects the instance until %s; refusing cleanup", lease.Purpose, lease.Holder, lease.ExpiresAt.Format(time.RFC3339)) + } + switch record.Phase { + case poolstate.PhaseTombstoned: + return nil + case poolstate.PhaseReserved, poolstate.PhaseCreating: + if len(sameName) != 0 { + for _, item := range sameName { + if reportErr := m.reportUnknownInventory(ctx, item); reportErr != nil { + return reportErr + } + } + m.quarantineLifecycle(ctx, record.Name, fmt.Errorf("create was interrupted before an immutable provider identity was recorded")) + return fmt.Errorf("unidentified same-name instance is quarantined and was not deleted") + } + if m.GitHub != nil { + if _, found, err := m.GitHub.RunnerByName(ctx, record.GitHub.ExactName); err != nil { + return err + } else if found { + m.quarantineLifecycle(ctx, record.Name, fmt.Errorf("create was interrupted and a same-name GitHub runner exists without a recorded immutable id")) + return fmt.Errorf("unidentified same-name GitHub runner is quarantined and was not deleted") + } + } + _, err = m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionAbandonCreate}) + return err + case poolstate.PhaseCleanupPending: + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionResumeCleanup}); err != nil { + return err + } + case poolstate.PhaseCreated, poolstate.PhaseValidating, poolstate.PhaseStandby, poolstate.PhaseRegistering, poolstate.PhaseReady, poolstate.PhaseBusy, poolstate.PhaseDraining, poolstate.PhaseQuarantined: + if record.ProviderID == "" { + return fmt.Errorf("record has no immutable provider identity and remains report-only") + } + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionFenceIntent}); err != nil { + return err + } + case poolstate.PhaseFencing: + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionFenced}); err != nil { + return err + } + case poolstate.PhaseFenced: + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionVerifyRemoteIntent}); err != nil { + return err + } + case poolstate.PhaseRemoteReconciling: + if !remoteKnownAbsent { + if err := m.removeExactGitHubRunner(ctx, record); err != nil { + m.markCleanupPending(ctx, record.Name) + return err + } + } + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionRemoteAbsent}); err != nil { + return err + } + case poolstate.PhaseRemoteAbsent: + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionRemoveLocalIntent}); err != nil { + return err + } + case poolstate.PhaseLocalRemoving: + if err := m.removeExactProviderInstance(ctx, record, sameName); err != nil { + m.markCleanupPending(ctx, record.Name) + return err + } + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionLocalAbsent}); err != nil { + return err + } + case poolstate.PhaseLocalAbsent: + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionTombstone}); err != nil { + return err + } + paths := ProvisionedInstance{Name: record.Name, LogPath: m.instanceLogPath(record.Name, "."+m.Config.Provider.Type+".log"), GuestLogPath: m.instanceLogPath(record.Name, ".guest.log")} + if releaseErr := m.releaseInstanceTranscripts(paths); releaseErr != nil { + m.logger().Warn("instance transcript close failed after cleanup", "provider", m.Config.Provider.Type, "instance", record.Name, "operation", "cleanup", "error", releaseErr) + } + default: + return fmt.Errorf("unsupported cleanup phase %s", record.Phase) + } + } +} + +func activeLifecycleLease(leases []poolstate.Lease, now time.Time) (poolstate.Lease, bool) { + for _, lease := range leases { + if lease.ExpiresAt.After(now) { + return lease, true + } + } + return poolstate.Lease{}, false +} + +func (m *Manager) removeExactGitHubRunner(ctx context.Context, record poolstate.Record) error { + if m.GitHub == nil { + if record.GitHub.RunnerID != 0 { + return fmt.Errorf("cannot verify GitHub runner id=%d without a GitHub client", record.GitHub.RunnerID) + } + return nil + } + runner, found, err := m.GitHub.RunnerByName(ctx, record.GitHub.ExactName) + if err != nil { + return err + } + if !found { + return nil + } + if record.GitHub.RunnerID == 0 || runner.ID != record.GitHub.RunnerID { + return fmt.Errorf("same-name GitHub runner id=%d does not match recorded id=%d; refusing deletion", runner.ID, record.GitHub.RunnerID) + } + deleteCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + if err := m.GitHub.DeleteRunnerIfExists(deleteCtx, runner.ID); err != nil { + return err + } + after, found, err := m.GitHub.RunnerByName(ctx, record.GitHub.ExactName) + if err != nil { + return err + } + if found { + return fmt.Errorf("GitHub runner remains after exact deletion: name=%s id=%d", after.Name, after.ID) + } + m.infof("cleanup: deleted exact GitHub runner %s id=%d\n", runner.Name, runner.ID) + return nil +} + +func (m *Manager) removeExactProviderInstance(ctx context.Context, record poolstate.Record, sameName []provider.InventoryItem) error { + var exact *provider.Instance + for i := range sameName { + item := sameName[i] + if item.Instance.ProviderID == record.ProviderID { + copy := item.Instance + exact = © + continue + } + if item.Instance.Name == record.Name { + return fmt.Errorf("same-name provider instance id=%s does not match recorded id=%s; refusing deletion", item.Instance.ProviderID, record.ProviderID) + } + } + if exact == nil { + return nil + } + exact.ReceiptVersion = record.Receipt.Version + exact.Receipt = append([]byte(nil), record.Receipt.Payload...) + stopCtx, stopCancel := context.WithTimeout(ctx, 60*time.Second) + _ = m.stopProviderInstance(stopCtx, *exact) + stopCancel() + deleteCtx, deleteCancel := context.WithTimeout(ctx, 60*time.Second) + err := m.deleteProviderInstance(deleteCtx, *exact) + deleteCancel() + if err != nil { + return err + } + remaining, err := m.inventoryProvider(ctx) + if err != nil { + return err + } + for _, item := range remaining { + if item.Instance.ProviderID == record.ProviderID { + return fmt.Errorf("provider instance remains after exact deletion: name=%s id=%s", item.Instance.Name, item.Instance.ProviderID) + } + } + m.infof("cleanup: deleted exact owned instance %s id=%s\n", record.Name, record.ProviderID) + return nil +} + +func (m *Manager) markCleanupPending(ctx context.Context, name string) { + if _, err := m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionCleanupPending}); err != nil && !errors.Is(err, poolstate.ErrInvalidTransition) { + m.warnf("[%s] failed to persist cleanup-pending phase: %v\n", name, err) + } +} diff --git a/internal/pool/lifecycle_cleanup_test.go b/internal/pool/lifecycle_cleanup_test.go new file mode 100644 index 0000000..5ccab43 --- /dev/null +++ b/internal/pool/lifecycle_cleanup_test.go @@ -0,0 +1,256 @@ +package pool + +import ( + "context" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + gh "github.com/solutionforest/ephemeral-action-runner/internal/github" + poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +func TestLifecycleCleanupRefusesRecreatedSameNameProviderInstance(t *testing.T) { + store, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const name = "epar-test-recreated" + if _, err := store.Reserve(context.Background(), poolstate.CreateSpec{Name: name, ProviderType: "docker-container", GitHub: poolstate.GitHubIdentity{ExactName: name}}); err != nil { + t.Fatal(err) + } + for _, transition := range []poolstate.Transition{ + {Action: poolstate.ActionCreateIntent}, + {Action: poolstate.ActionCreated, ProviderID: "docker:old-id", Receipt: poolstate.Receipt{Version: "v1", Payload: []byte(`{"providerId":"docker:old-id"}`)}}, + {Action: poolstate.ActionValidateIntent}, + {Action: poolstate.ActionValidated}, + } { + if _, err := store.Transition(context.Background(), name, transition); err != nil { + t.Fatal(err) + } + } + fake := &fakeProvider{instances: []provider.Instance{{Name: name, ProviderID: "docker:new-id", State: "running"}}} + manager := Manager{ + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}}, + Provider: fake, + Lifecycle: provider.AdaptLegacy(fake), + LifecycleState: store, + ProjectRoot: t.TempDir(), + } + err = manager.cleanupOwnedLifecycle(context.Background()) + if err == nil || !strings.Contains(err.Error(), "does not match recorded") { + t.Fatalf("cleanup error = %v, want immutable identity mismatch", err) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 0 { + t.Fatalf("delete calls = %d, want 0", got) + } +} + +func TestLifecycleCleanupRefusesActiveLeaseBeforeSideEffects(t *testing.T) { + store, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const name = "epar-test-busy" + if _, err := store.Reserve(context.Background(), poolstate.CreateSpec{Name: name, ProviderType: "docker-container", GitHub: poolstate.GitHubIdentity{ExactName: name}}); err != nil { + t.Fatal(err) + } + for _, transition := range []poolstate.Transition{ + {Action: poolstate.ActionCreateIntent}, + {Action: poolstate.ActionCreated, ProviderID: "docker:busy-id", Receipt: poolstate.Receipt{Version: "v1", Payload: []byte(`{"providerId":"docker:busy-id"}`)}}, + {Action: poolstate.ActionValidateIntent}, + {Action: poolstate.ActionValidated}, + } { + if _, err := store.Transition(context.Background(), name, transition); err != nil { + t.Fatal(err) + } + } + if _, err := store.AcquireLease(context.Background(), name, poolstate.Lease{Purpose: "job", Holder: "controller-test", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + fake := &fakeProvider{instances: []provider.Instance{{Name: name, ProviderID: "docker:busy-id", State: "running"}}} + manager := Manager{ + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}}, + Provider: fake, + Lifecycle: provider.AdaptLegacy(fake), + LifecycleState: store, + ProjectRoot: t.TempDir(), + } + err = manager.cleanupOwnedLifecycle(context.Background()) + if err == nil || !strings.Contains(err.Error(), "active job lease") { + t.Fatalf("cleanup error = %v, want active lease protection", err) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 0 { + t.Fatalf("delete calls = %d, want 0", got) + } + record, err := store.Read(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if record.Phase != poolstate.PhaseStandby { + t.Fatalf("phase = %s, want %s", record.Phase, poolstate.PhaseStandby) + } +} + +func TestCleanupRecoversInterruptedProvisionLeaseAfterExclusiveLock(t *testing.T) { + store, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const name = "epar-test-interrupted" + if _, err := store.Reserve(context.Background(), poolstate.CreateSpec{Name: name, ProviderType: "docker-container", GitHub: poolstate.GitHubIdentity{ExactName: name}}); err != nil { + t.Fatal(err) + } + for _, transition := range []poolstate.Transition{ + {Action: poolstate.ActionCreateIntent}, + {Action: poolstate.ActionCreated, ProviderID: "docker:interrupted-id", Receipt: poolstate.Receipt{Version: "v1", Payload: []byte(`{"providerId":"docker:interrupted-id"}`)}}, + {Action: poolstate.ActionValidateIntent}, + } { + if _, err := store.Transition(context.Background(), name, transition); err != nil { + t.Fatal(err) + } + } + if _, err := store.AcquireLease(context.Background(), name, poolstate.Lease{Purpose: "provision", Holder: "controller", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + fake := &fakeProvider{instances: []provider.Instance{{Name: name, ProviderID: "docker:interrupted-id", State: "running"}}} + projectRoot := t.TempDir() + manager := Manager{ + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}}, + ConfigPath: "interrupted.yml", + Provider: fake, + Lifecycle: provider.AdaptLegacy(fake), + LifecycleState: store, + ProjectRoot: projectRoot, + } + + if err := manager.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 1 { + t.Fatalf("delete calls = %d, want 1", got) + } + record, err := store.Read(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if record.Phase != poolstate.PhaseTombstoned { + t.Fatalf("phase = %s, want %s", record.Phase, poolstate.PhaseTombstoned) + } + if len(record.Leases) != 0 { + t.Fatalf("leases = %+v, want none", record.Leases) + } +} + +func TestInterruptedProvisionRecoveryPreservesJobLease(t *testing.T) { + manager, store, name := readyLifecycleManager(t) + if _, err := store.Transition(context.Background(), name, poolstate.Transition{Action: poolstate.ActionJobStarted}); err != nil { + t.Fatal(err) + } + if _, err := store.AcquireLease(context.Background(), name, poolstate.Lease{Purpose: "provision", Holder: "controller", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + if _, err := store.AcquireLease(context.Background(), name, poolstate.Lease{Purpose: "job", Holder: "github-42", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + + if err := manager.recoverInterruptedProvisionLeases(context.Background()); err != nil { + t.Fatal(err) + } + + record, err := store.Read(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if len(record.Leases) != 1 || record.Leases[0].Purpose != "job" || record.Leases[0].Holder != "github-42" { + t.Fatalf("leases = %+v, want exact job lease only", record.Leases) + } +} + +func TestRemoteAbsenceReleasesExactJobLease(t *testing.T) { + manager, store, name := readyLifecycleManager(t) + if _, err := store.Transition(context.Background(), name, poolstate.Transition{Action: poolstate.ActionJobStarted}); err != nil { + t.Fatal(err) + } + holder := "github-42" + if _, err := store.AcquireLease(context.Background(), name, poolstate.Lease{Purpose: "job", Holder: holder, ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + + if err := manager.recordLifecycleRemoteAbsence(context.Background(), name); err != nil { + t.Fatal(err) + } + + record, err := store.Read(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if record.Phase != poolstate.PhaseDraining { + t.Fatalf("phase = %s, want %s", record.Phase, poolstate.PhaseDraining) + } + if lease, active := activeLifecycleLease(record.Leases, time.Now()); active { + t.Fatalf("job lease remained active after exact remote absence: %+v", lease) + } +} + +func TestReconciliationPreservesStoppedBusyRunnerProtectedByJobLease(t *testing.T) { + manager, store, name := readyLifecycleManager(t) + if _, err := store.Transition(context.Background(), name, poolstate.Transition{Action: poolstate.ActionJobStarted}); err != nil { + t.Fatal(err) + } + if _, err := store.AcquireLease(context.Background(), name, poolstate.Lease{Purpose: "job", Holder: "github-42", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + fake := &fakeProvider{instances: []provider.Instance{{Name: name, ProviderID: "docker:ready-id", State: "stopped"}}} + github := &fakeGitHub{listRunners: []gh.Runner{{Name: name, ID: 42, Status: "offline", Busy: true}}} + manager.Provider = fake + manager.Lifecycle = provider.AdaptLegacy(fake) + manager.GitHub = github + + active, err := manager.reconcilePhysicalPool(context.Background(), nil, true) + if err != nil { + t.Fatal(err) + } + if active[name].Phase != LifecycleCleanupPending { + t.Fatalf("phase = %s, want %s", active[name].Phase, LifecycleCleanupPending) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 0 { + t.Fatalf("provider delete calls = %d, want 0 while job lease is active", got) + } + if got := atomic.LoadInt32(&github.deleteCalls); got != 0 { + t.Fatalf("GitHub delete calls = %d, want 0 while exact lifecycle cleanup is pending", got) + } +} + +func readyLifecycleManager(t *testing.T) (*Manager, *poolstate.Store, string) { + t.Helper() + store, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const name = "epar-test-ready" + if _, err := store.Reserve(context.Background(), poolstate.CreateSpec{Name: name, ProviderType: "docker-container", GitHub: poolstate.GitHubIdentity{ExactName: name}}); err != nil { + t.Fatal(err) + } + for _, transition := range []poolstate.Transition{ + {Action: poolstate.ActionCreateIntent}, + {Action: poolstate.ActionCreated, ProviderID: "docker:ready-id", Receipt: poolstate.Receipt{Version: "v1", Payload: []byte(`{"providerId":"docker:ready-id"}`)}}, + {Action: poolstate.ActionValidateIntent}, + {Action: poolstate.ActionValidated}, + {Action: poolstate.ActionRegisterIntent}, + {Action: poolstate.ActionRegistered, RunnerID: 42}, + } { + if _, err := store.Transition(context.Background(), name, transition); err != nil { + t.Fatal(err) + } + } + manager := &Manager{ + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}}, + LifecycleState: store, + ProjectRoot: t.TempDir(), + } + return manager, store, name +} diff --git a/internal/pool/lifecycle_state.go b/internal/pool/lifecycle_state.go new file mode 100644 index 0000000..6dc98bc --- /dev/null +++ b/internal/pool/lifecycle_state.go @@ -0,0 +1,258 @@ +package pool + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strconv" + "time" + + gh "github.com/solutionforest/ephemeral-action-runner/internal/github" + poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +// OpenLifecycleState opens the provider-neutral state namespace for one exact +// configuration file. The namespace hash prevents two configurations in the +// same checkout from claiming each other's instances. +func OpenLifecycleState(projectRoot, configPath string) (*poolstate.Store, error) { + absoluteConfig, err := filepath.Abs(configPath) + if err != nil { + return nil, fmt.Errorf("resolve lifecycle config path: %w", err) + } + sum := sha256.Sum256([]byte(filepath.Clean(absoluteConfig))) + namespace := hex.EncodeToString(sum[:8]) + return poolstate.Open(filepath.Join(projectRoot, ".local", "state", "pools", namespace)) +} + +func (m *Manager) reserveLifecycle(ctx context.Context, name string) error { + if m.LifecycleState == nil { + return nil + } + _, err := m.LifecycleState.Reserve(ctx, poolstate.CreateSpec{ + Name: name, + ProviderType: m.Config.Provider.Type, + GitHub: poolstate.GitHubIdentity{ExactName: name}, + }) + if err != nil { + return fmt.Errorf("reserve provider-neutral lifecycle record: %w", err) + } + _, err = m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionCreateIntent}) + if err != nil { + return fmt.Errorf("record provider create intent: %w", err) + } + return nil +} + +func (m *Manager) acquireLifecycleLease(ctx context.Context, name, purpose, holder string, lifetime time.Duration) error { + if m.LifecycleState == nil { + return nil + } + _, err := m.LifecycleState.AcquireLease(ctx, name, poolstate.Lease{Purpose: purpose, Holder: holder, ExpiresAt: m.currentTime().Add(lifetime)}) + return err +} + +func (m *Manager) releaseLifecycleLease(ctx context.Context, name, purpose, holder string) { + if m.LifecycleState == nil { + return + } + if _, err := m.LifecycleState.ReleaseLease(ctx, name, purpose, holder); err != nil && !errors.Is(err, poolstate.ErrNotFound) { + m.warnf("[%s] release %s lifecycle lease failed: %v\n", name, purpose, err) + } +} + +// recoverInterruptedProvisionLeases removes only leases owned by a previous +// common controller after the caller has acquired the exclusive pool lock. +// Job and provider-specific leases remain authoritative cleanup barriers. +func (m *Manager) recoverInterruptedProvisionLeases(ctx context.Context) error { + if m.LifecycleState == nil { + return nil + } + records, err := m.LifecycleState.List(ctx) + if err != nil { + return fmt.Errorf("read lifecycle state for interrupted provisioning recovery: %w", err) + } + for _, record := range records { + if record.ProviderType != m.Config.Provider.Type || record.Phase == poolstate.PhaseTombstoned { + continue + } + for _, lease := range record.Leases { + if lease.Purpose != "provision" || lease.Holder != "controller" { + continue + } + if _, err := m.LifecycleState.ReleaseLease(ctx, record.Name, lease.Purpose, lease.Holder); err != nil { + return fmt.Errorf("release interrupted provisioning lease for %s: %w", record.Name, err) + } + m.warnf("[%s] recovered interrupted provisioning lease after acquiring the exclusive pool lock\n", record.Name) + break + } + } + return nil +} + +func (m *Manager) recordLifecycleJobObservation(ctx context.Context, runner gh.Runner) error { + if m.LifecycleState == nil { + return nil + } + record, err := m.LifecycleState.Read(ctx, runner.Name) + if err != nil { + return err + } + holder := "github-" + strconv.FormatInt(runner.ID, 10) + if runner.Busy { + if record.Phase == poolstate.PhaseReady { + if _, err := m.LifecycleState.Transition(ctx, runner.Name, poolstate.Transition{Action: poolstate.ActionJobStarted}); err != nil { + return err + } + } + return m.acquireLifecycleLease(ctx, runner.Name, "job", holder, 10*time.Minute) + } + if record.Phase == poolstate.PhaseBusy { + if _, err := m.LifecycleState.Transition(ctx, runner.Name, poolstate.Transition{Action: poolstate.ActionJobFinished}); err != nil { + return err + } + m.releaseLifecycleLease(ctx, runner.Name, "job", holder) + return nil + } + for _, lease := range record.Leases { + if lease.Purpose == "job" && lease.Holder == holder { + m.releaseLifecycleLease(ctx, runner.Name, "job", holder) + break + } + } + return nil +} + +func (m *Manager) recordLifecycleRemoteAbsence(ctx context.Context, name string) error { + if m.LifecycleState == nil { + return nil + } + record, err := m.LifecycleState.Read(ctx, name) + if err != nil { + return err + } + if record.Phase == poolstate.PhaseBusy { + if _, err := m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionJobFinished}); err != nil { + return err + } + } + if record.GitHub.RunnerID != 0 { + m.releaseLifecycleLease(ctx, name, "job", "github-"+strconv.FormatInt(record.GitHub.RunnerID, 10)) + } + return nil +} + +func (m *Manager) recordLifecycleCreated(ctx context.Context, instance provider.Instance) error { + if m.LifecycleState == nil { + return nil + } + version := instance.ReceiptVersion + payload := append([]byte(nil), instance.Receipt...) + if version == "" || len(payload) == 0 { + version = "v1" + var err error + payload, err = json.Marshal(map[string]string{"exactName": instance.Name, "providerId": instance.ProviderID, "source": instance.Source}) + if err != nil { + return fmt.Errorf("encode provider lifecycle receipt: %w", err) + } + } + _, err := m.LifecycleState.Transition(ctx, instance.Name, poolstate.Transition{ + Action: poolstate.ActionCreated, + ProviderID: instance.ProviderID, + Receipt: poolstate.Receipt{Version: version, Payload: payload}, + }) + if err != nil { + return fmt.Errorf("record provider instance creation: %w", err) + } + return nil +} + +func (m *Manager) recordLifecycleValidationIntent(ctx context.Context, name string) error { + if m.LifecycleState == nil { + return nil + } + _, err := m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionValidateIntent}) + return err +} + +func (m *Manager) recordLifecycleValidated(ctx context.Context, name string) error { + if m.LifecycleState == nil { + return nil + } + _, err := m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionValidated}) + return err +} + +func (m *Manager) recordLifecycleRegistrationIntent(ctx context.Context, name string) error { + if m.LifecycleState == nil { + return nil + } + _, err := m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionRegisterIntent}) + return err +} + +func (m *Manager) recordLifecycleRegistered(ctx context.Context, name string, runnerID int64) error { + if m.LifecycleState == nil { + return nil + } + _, err := m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionRegistered, RunnerID: runnerID}) + return err +} + +func (m *Manager) quarantineLifecycle(ctx context.Context, name string, cause error) { + if m.LifecycleState == nil || cause == nil { + return + } + if _, err := m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionQuarantine, Reason: cause.Error()}); err != nil && !errors.Is(err, poolstate.ErrInvalidTransition) { + m.warnf("[%s] provider-neutral lifecycle quarantine failed: %v\n", name, err) + } +} + +func (m *Manager) lifecycleOwns(ctx context.Context, name, providerID string) (bool, error) { + if m.LifecycleState == nil { + return true, nil + } + record, err := m.LifecycleState.Read(ctx, name) + if errors.Is(err, poolstate.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return record.ProviderType == m.Config.Provider.Type && record.ProviderID != "" && record.ProviderID == providerID && record.Phase != poolstate.PhaseTombstoned, nil +} + +func (m *Manager) lifecycleOwnsRunner(ctx context.Context, name string, runnerID int64) (bool, error) { + if m.LifecycleState == nil { + return true, nil + } + record, err := m.LifecycleState.Read(ctx, name) + if errors.Is(err, poolstate.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return record.ProviderType == m.Config.Provider.Type && record.GitHub.RunnerID != 0 && record.GitHub.RunnerID == runnerID && record.Phase != poolstate.PhaseTombstoned, nil +} + +func (m *Manager) reportUnknownLifecycle(ctx context.Context, name, providerID, source, observedState string) error { + if m.LifecycleState == nil { + return nil + } + payload, err := json.Marshal(map[string]string{"source": source, "state": observedState}) + if err != nil { + return err + } + _, err = m.LifecycleState.ReportUnknown(ctx, poolstate.Discovery{ + ProviderType: m.Config.Provider.Type, + ProviderID: providerID, + ExactName: name, + Receipt: poolstate.Receipt{Version: "v1", Payload: payload}, + }) + return err +} diff --git a/internal/pool/logging.go b/internal/pool/logging.go index f898f85..9a5f750 100644 --- a/internal/pool/logging.go +++ b/internal/pool/logging.go @@ -34,6 +34,25 @@ func (m *Manager) warnf(format string, args ...any) { m.logger().Warn(fmt.Sprintf(strings.TrimSuffix(format, "\n"), args...)) } +func (m *Manager) logPoolRunning(label string) { + m.infof("%s is running. Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.\n", label) +} + +func (m *Manager) logReplacementReady(label, name string) { + m.infof("Replacement runner %s is online; %s is ready for the next job. Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.\n", name, label) +} + +func (m *Manager) cleanupPoolWithStatus(resources string, cleanup func() error) error { + m.infof("Stopping EPAR pool. Cleaning up %s. Please wait; do not press Ctrl-C again or close this window.\n", resources) + err := cleanup() + if err != nil { + m.warnf("Cleanup did not fully complete. EPAR retained its cleanup state and will reconcile it on the next run: %v\n", err) + return err + } + m.infof("Cleanup complete. EPAR can now exit safely.\n") + return nil +} + func (m *Manager) Close() error { if m == nil || m.Logging == nil { return nil diff --git a/internal/pool/logging_test.go b/internal/pool/logging_test.go new file mode 100644 index 0000000..cbb9de8 --- /dev/null +++ b/internal/pool/logging_test.go @@ -0,0 +1,80 @@ +package pool + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/logging" +) + +func TestPoolLifecycleConsoleGuidance(t *testing.T) { + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: t.TempDir(), + ManagerSinks: logging.SinkConsole, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + manager := Manager{Logging: runtime} + + manager.logPoolRunning("Docker Sandboxes pool") + manager.logReplacementReady("Docker Sandboxes pool", "epar-test-002") + cleanupCalled := false + if err := manager.cleanupPoolWithStatus("owned runner resources", func() error { + cleanupCalled = true + return nil + }); err != nil { + t.Fatal(err) + } + if !cleanupCalled { + t.Fatal("cleanup callback was not called") + } + + output := console.String() + for _, expected := range []string{ + "Docker Sandboxes pool is running.", + "Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.", + "Replacement runner epar-test-002 is online; Docker Sandboxes pool is ready for the next job.", + "Stopping EPAR pool. Cleaning up owned runner resources.", + "Please wait; do not press Ctrl-C again or close this window.", + "Cleanup complete. EPAR can now exit safely.", + } { + if !strings.Contains(output, expected) { + t.Fatalf("lifecycle console output does not contain %q: %q", expected, output) + } + } + if strings.Index(output, "Stopping EPAR pool.") > strings.Index(output, "Cleanup complete.") { + t.Fatalf("cleanup completion preceded cleanup start: %q", output) + } +} + +func TestPoolLifecycleConsoleReportsIncompleteCleanup(t *testing.T) { + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: t.TempDir(), + ManagerSinks: logging.SinkConsole, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + manager := Manager{Logging: runtime} + cleanupErr := errors.New("remote state unavailable") + + err = manager.cleanupPoolWithStatus("owned runner resources", func() error { return cleanupErr }) + if !errors.Is(err, cleanupErr) { + t.Fatalf("cleanup error = %v, want %v", err, cleanupErr) + } + output := console.String() + if !strings.Contains(output, "Cleanup did not fully complete.") || !strings.Contains(output, "will reconcile it on the next run") || strings.Contains(output, "EPAR can now exit safely") { + t.Fatalf("incomplete cleanup console output = %q", output) + } +} diff --git a/internal/pool/manager.go b/internal/pool/manager.go index 673c7df..a6875c9 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -2,6 +2,7 @@ package pool import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -21,20 +22,30 @@ import ( gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" "github.com/solutionforest/ephemeral-action-runner/internal/logging" + poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" "github.com/solutionforest/ephemeral-action-runner/internal/provider" ) type Manager struct { - Config config.Config - Provider provider.Provider - GitHub GitHubClient - ProjectRoot string - ConfigPath string - DryRun bool - Logging *logging.Runtime - startupTiming *startupTiming - transcriptMu sync.Mutex - transcripts map[string]*logging.Transcript + Config config.Config + Provider provider.Provider + Lifecycle provider.Lifecycle + PolicyManager provider.PolicyManager + Storage provider.StorageContribution + LifecycleState *poolstate.Store + GitHub GitHubClient + ProjectRoot string + ConfigPath string + DryRun bool + Logging *logging.Runtime + // AcknowledgeFailedDiagnostics permits the explicit cleanup command to + // dispose an exact retained sandbox after the operator has captured the + // durable failed-diagnostics evidence. Normal startup and automatic cleanup + // never set this override. + AcknowledgeFailedDiagnostics bool + startupTiming *startupTiming + transcriptMu sync.Mutex + transcripts map[string]*logging.Transcript hostTrustResolver func(context.Context) (hosttrust.Snapshot, error) hostTrustImageEnsurer func(context.Context) error @@ -87,8 +98,10 @@ type ProvisionedInstance struct { LogPath string GuestLogPath string RunnerID int64 + ProviderID string HostTrustGeneration string Phase LifecyclePhase + ProviderOwned bool } var runtimeValidationRetryDelay = 5 * time.Second @@ -112,6 +125,9 @@ func (m *Manager) Verify(ctx context.Context, opts VerifyOptions) error { return err } defer poolLock.Close() + if err := m.recoverInterruptedProvisionLeases(ctx); err != nil { + return err + } controllerLock, err := m.acquireHostTrustControllerLock() if err != nil { return err @@ -192,6 +208,9 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { } defer controllerLock.Close() } + if err := m.recoverInterruptedProvisionLeases(ctx); err != nil { + return err + } if !opts.HostTrustLockHeld { controllerLock, err := m.AcquireHostTrustControllerLock() if err != nil { @@ -207,9 +226,10 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { } if ctx.Err() != nil { if opts.KeepOnExit { + m.infof("Stopping EPAR pool. --keep-on-exit is enabled, so owned runner resources will remain running.\n") return nil } - return m.cleanupWithFreshContext() + return m.cleanupPoolWithStatus("owned GitHub runner registrations and provider instances", m.cleanupWithFreshContext) } active, err := m.reconcilePhysicalPool(ctx, nil, opts.Register) if err != nil { @@ -223,9 +243,10 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { poolTrustGeneration := "" cleanup := func() error { if opts.KeepOnExit { + m.infof("Stopping EPAR pool. --keep-on-exit is enabled, so owned runner resources will remain running.\n") return nil } - return m.cleanupWithFreshContext() + return m.cleanupPoolWithStatus("owned GitHub runner registrations and provider instances", m.cleanupWithFreshContext) } leaseAdd, stopLeaseKeeper := m.startHostTrustLeaseKeeper(ctx) for len(active) < opts.Instances { @@ -257,7 +278,7 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { } stopLeaseKeeper() if !opts.Register || (!opts.ReplaceCompleted && !m.hostTrustEnabled()) { - m.infof("pool is running; press Ctrl-C to stop") + m.logPoolRunning("EPAR pool") if !m.Config.Logging.RetentionEnabled { <-ctx.Done() return cleanup() @@ -273,7 +294,8 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { } } } - m.infof("pool supervisor is running; monitoring every %s; press Ctrl-C to stop\n", opts.MonitorInterval) + m.infof("Pool supervisor is monitoring every %s.\n", opts.MonitorInterval) + m.logPoolRunning("EPAR pool") tickInterval := opts.MonitorInterval if m.hostTrustEnabled() && tickInterval > hostTrustRefreshInterval { tickInterval = hostTrustRefreshInterval @@ -461,6 +483,7 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { poolTrustGeneration = vm.HostTrustGeneration } m.infof("%s online at %s providerLog=%s guestLog=%s\n", vm.Name, vm.IP, vm.LogPath, vm.GuestLogPath) + m.logReplacementReady("EPAR pool", vm.Name) } } } @@ -497,7 +520,7 @@ func (m *Manager) reconcilePhysicalPool(ctx context.Context, known map[string]Pr } if !register { for name, vm := range reconciled { - if vm.Phase != LifecycleCleanupPending { + if vm.ProviderOwned && vm.Phase != LifecycleCleanupPending { vm.Phase = LifecycleReady reconciled[name] = vm } @@ -524,7 +547,17 @@ func (m *Manager) reconcilePhysicalPool(ctx context.Context, known map[string]Pr } } for name, vm := range reconciled { + if !vm.ProviderOwned { + delete(remoteByName, name) + vm.Phase = LifecycleQuarantined + reconciled[name] = vm + continue + } if vm.Phase == LifecycleCleanupPending { + // The local cleanup path already matched this exact lifecycle + // record. Keep its remote identity attached to the protected + // record instead of treating it as an orphan below. + delete(remoteByName, name) continue } runner, found := remoteByName[name] @@ -538,6 +571,11 @@ func (m *Manager) reconcilePhysicalPool(ctx context.Context, known map[string]Pr } } if !found { + if err := m.recordLifecycleRemoteAbsence(ctx, name); err != nil { + vm.Phase = LifecycleQuarantined + reconciled[name] = vm + return reconciled, fmt.Errorf("record GitHub runner absence for %s: %w", name, err) + } if err := m.deleteLocalInstance(context.Background(), vm); err != nil { vm.Phase = LifecycleCleanupPending reconciled[name] = vm @@ -549,6 +587,11 @@ func (m *Manager) reconcilePhysicalPool(ctx context.Context, known map[string]Pr } delete(remoteByName, name) vm.RunnerID = runner.ID + if err := m.recordLifecycleJobObservation(ctx, runner); err != nil { + vm.Phase = LifecycleQuarantined + reconciled[name] = vm + return reconciled, fmt.Errorf("record GitHub job phase for %s: %w", name, err) + } if runner.Status == "online" { vm.Phase = LifecycleReady reconciled[name] = vm @@ -584,6 +627,14 @@ func (m *Manager) reconcilePhysicalPool(ctx context.Context, known map[string]Pr } } for _, runner := range remoteByName { + owned, ownershipErr := m.lifecycleOwnsRunner(ctx, runner.Name, runner.ID) + if ownershipErr != nil { + return reconciled, ownershipErr + } + if !owned { + m.warnf("reconciliation: quarantined unowned GitHub runner %s id=%d; prefix-only resources are report-only\n", runner.Name, runner.ID) + continue + } if err := m.deleteRemoteRunner(context.Background(), runner); err != nil { return reconciled, err } @@ -634,17 +685,36 @@ func (m *Manager) reconcileLocalInventory(known map[string]ProvisionedInstance) } func (m *Manager) reconcileLocalInventoryWithContext(ctx context.Context, known map[string]ProvisionedInstance) (map[string]ProvisionedInstance, error) { - locals, err := m.Provider.List(ctx) + locals, err := m.inventoryProvider(ctx) if err != nil { return known, err } reconciled := make(map[string]ProvisionedInstance) - for _, local := range locals { + for _, item := range locals { + local := item.Instance if !HasPrefix(local.Name, m.Config.Pool.NamePrefix) { continue } vm := m.reconciledInstance(known, local.Name) - if !localInstanceStopped(local.State) { + vm.ProviderID = local.ProviderID + owned, ownershipErr := m.lifecycleOwns(ctx, local.Name, local.ProviderID) + if ownershipErr != nil { + return known, fmt.Errorf("verify lifecycle ownership for %s: %w", local.Name, ownershipErr) + } + vm.ProviderOwned = owned + if !owned { + providerID := local.ProviderID + if providerID == "" { + providerID = "unidentified:" + local.Name + } + if reportErr := m.reportUnknownLifecycle(ctx, local.Name, providerID, item.Source, item.State); reportErr != nil { + return known, fmt.Errorf("quarantine unowned provider instance %s: %w", local.Name, reportErr) + } + vm.Phase = LifecycleQuarantined + reconciled[local.Name] = vm + continue + } + if !localInstanceStopped(item.State) { reconciled[local.Name] = vm continue } @@ -662,10 +732,11 @@ func (m *Manager) reconciledInstance(known map[string]ProvisionedInstance, name return vm } return ProvisionedInstance{ - Name: name, - LogPath: m.instanceLogPath(name, "."+m.Config.Provider.Type+".log"), - GuestLogPath: m.instanceLogPath(name, ".guest.log"), - Phase: LifecycleQuarantined, + Name: name, + LogPath: m.instanceLogPath(name, "."+m.Config.Provider.Type+".log"), + GuestLogPath: m.instanceLogPath(name, ".guest.log"), + Phase: LifecycleQuarantined, + ProviderOwned: m.LifecycleState == nil, } } @@ -679,13 +750,40 @@ func localInstanceStopped(state string) bool { } func (m *Manager) deleteLocalInstance(ctx context.Context, vm ProvisionedInstance) error { + if m.LifecycleState != nil { + record, err := m.LifecycleState.Read(ctx, vm.Name) + if err != nil { + return err + } + inventory, err := m.inventoryProvider(ctx) + if err != nil { + return err + } + return m.cleanupLifecycleRecord(ctx, record, inventoryByName(inventory)[vm.Name]) + } cleanupCtx, cancel := context.WithTimeout(ctx, cleanupTimeout) defer cancel() + if vm.ProviderID == "" && m.Lifecycle == nil && m.Provider != nil { + stopCtx, stopCancel := context.WithTimeout(cleanupCtx, 60*time.Second) + _ = m.Provider.Stop(stopCtx, vm.Name) + stopCancel() + deleteCtx, deleteCancel := context.WithTimeout(cleanupCtx, 60*time.Second) + err := m.Provider.Delete(deleteCtx, vm.Name) + deleteCancel() + return err + } + instance, err := m.providerInstance(cleanupCtx, vm.Name) + if err != nil { + return err + } + if vm.ProviderID != "" && instance.ProviderID != vm.ProviderID { + return fmt.Errorf("same-name provider instance id=%s does not match expected id=%s; refusing deletion", instance.ProviderID, vm.ProviderID) + } stopCtx, stopCancel := context.WithTimeout(cleanupCtx, 60*time.Second) - _ = m.Provider.Stop(stopCtx, vm.Name) + _ = m.stopProviderInstance(stopCtx, instance) stopCancel() deleteCtx, deleteCancel := context.WithTimeout(cleanupCtx, 60*time.Second) - err := m.Provider.Delete(deleteCtx, vm.Name) + err = m.deleteProviderInstance(deleteCtx, instance) deleteCancel() if err != nil { return err @@ -864,6 +962,9 @@ func (m *Manager) ProvisionPool(ctx context.Context, instances int, register boo return nil, err } defer poolLock.Close() + if err := m.recoverInterruptedProvisionLeases(ctx); err != nil { + return nil, err + } hostTrustLock, err := m.AcquireHostTrustControllerLock() if err != nil { return nil, err @@ -900,25 +1001,43 @@ func (m *Manager) Cleanup(ctx context.Context) error { return err } defer poolLock.Close() + if err := m.recoverInterruptedProvisionLeases(ctx); err != nil { + return err + } return m.cleanupUnlocked(ctx) } func (m *Manager) cleanupUnlocked(ctx context.Context) error { + if m.LifecycleState != nil { + return m.cleanupOwnedLifecycle(ctx) + } + if m.Lifecycle == nil && m.Provider != nil { + return m.cleanupLegacyTestProvider(ctx) + } var firstErr error - vms, err := m.Provider.List(ctx) + items, err := m.inventoryProvider(ctx) if err != nil { firstErr = err } - for _, vm := range vms { + for _, item := range items { + vm := item.Instance if !HasPrefix(vm.Name, m.Config.Pool.NamePrefix) { continue } + if vm.ProviderID == "" { + m.warnf("cleanup: provider instance %s has no immutable identity; leaving it report-only\n", vm.Name) + continue + } + if m.DryRun { + m.infof("[dry-run] cleanup would delete exact provider instance %s id=%s\n", vm.Name, vm.ProviderID) + continue + } m.infof("cleanup: deleting instance %s\n", vm.Name) stopCtx, stopCancel := context.WithTimeout(ctx, 60*time.Second) - _ = m.Provider.Stop(stopCtx, vm.Name) + _ = m.stopProviderInstance(stopCtx, vm) stopCancel() deleteCtx, deleteCancel := context.WithTimeout(ctx, 60*time.Second) - deleteErr := m.Provider.Delete(deleteCtx, vm.Name) + deleteErr := m.deleteProviderInstance(deleteCtx, vm) if deleteErr != nil && firstErr == nil { firstErr = deleteErr } @@ -948,16 +1067,52 @@ func (m *Manager) cleanupUnlocked(ctx context.Context) error { return firstErr } +// cleanupLegacyTestProvider keeps the old in-memory test seam isolated from +// production. Every registry-constructed manager has Lifecycle and durable +// state, so real cleanup always uses immutable provider and GitHub identities. +func (m *Manager) cleanupLegacyTestProvider(ctx context.Context) error { + var firstErr error + vms, err := m.Provider.List(ctx) + if err != nil { + firstErr = err + } + for _, vm := range vms { + if !HasPrefix(vm.Name, m.Config.Pool.NamePrefix) { + continue + } + m.infof("cleanup: deleting instance %s\n", vm.Name) + stopCtx, stopCancel := context.WithTimeout(ctx, 60*time.Second) + _ = m.Provider.Stop(stopCtx, vm.Name) + stopCancel() + deleteCtx, deleteCancel := context.WithTimeout(ctx, 60*time.Second) + deleteErr := m.Provider.Delete(deleteCtx, vm.Name) + deleteCancel() + if deleteErr != nil && firstErr == nil { + firstErr = deleteErr + } + } + if m.GitHub != nil { + deleteCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + _, err := m.GitHub.DeleteRunnersByPrefix(deleteCtx, m.Config.Pool.NamePrefix) + if err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + func (m *Manager) Status(ctx context.Context) (string, error) { var b strings.Builder - vms, err := m.Provider.List(ctx) + items, err := m.inventoryProvider(ctx) if err != nil { return "", err } b.WriteString("Instances:\n") - for _, vm := range vms { + for _, item := range items { + vm := item.Instance if HasPrefix(vm.Name, m.Config.Pool.NamePrefix) { - fmt.Fprintf(&b, " %s\t%s\n", vm.Name, vm.State) + fmt.Fprintf(&b, " %s\t%s\tid=%s\n", vm.Name, item.State, emptyDash(vm.ProviderID)) } } if m.GitHub != nil { @@ -978,77 +1133,97 @@ func (m *Manager) Status(ctx context.Context) (string, error) { var errHostTrustImageMismatch = errors.New("runner image host trust generation does not match current host trust") func (m *Manager) provisionOne(ctx context.Context, name string, register, allowBusy bool) (ProvisionedInstance, error) { - const attempts = 3 - var lastErr error - for attempt := 1; attempt <= attempts; attempt++ { - vm, err := m.provisionOneAttempt(ctx, name, register, allowBusy) - if err == nil || !errors.Is(err, errHostTrustImageMismatch) { - return vm, err - } - lastErr = err - if isPhysicalPhase(vm.Phase) { - _ = m.retireInstance(context.Background(), vm, "discarding stale host-trust image generation") - } - if attempt == attempts { - break - } - m.infof("[%s] host trust changed before runner publication; rebuilding image (attempt %d/%d)\n", name, attempt+1, attempts) - if err := m.ensureHostTrustImage(ctx); err != nil { - return vm, fmt.Errorf("rebuild image after host trust changed during provisioning: %w", err) - } - } - return ProvisionedInstance{Name: name}, fmt.Errorf("provision runner after %d host trust image stabilization attempts: %w", attempts, lastErr) + return m.provisionOneAttempt(ctx, name, register, allowBusy) } -func (m *Manager) deleteRemoteRunnerByNameBestEffort(name string) { - if m.GitHub == nil { - return +func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register, allowBusy bool) (vm ProvisionedInstance, err error) { + logPath := m.instanceLogPath(name, "."+m.Config.Provider.Type+".log") + guestLogPath := m.instanceLogPath(name, ".guest.log") + vm = ProvisionedInstance{Name: name, LogPath: logPath, GuestLogPath: guestLogPath, ProviderOwned: true} + if register && m.GitHub != nil && !m.DryRun && m.LifecycleState != nil { + if runner, found, lookupErr := m.GitHub.RunnerByName(ctx, name); lookupErr != nil { + return vm, fmt.Errorf("verify exact GitHub runner name is unallocated: %w", lookupErr) + } else if found { + return vm, fmt.Errorf("GitHub runner name %q is already allocated to id=%d", name, runner.ID) + } } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - runner, found, err := m.GitHub.RunnerByName(ctx, name) - if err != nil { - m.warnf("[%s] deferred exact-name GitHub reconciliation after rollback: %v\n", name, err) - return + if err := m.preflightStorage("instance-create", m.instanceCreateExpansion()); err != nil { + return vm, err } - if !found { - return + if err := m.reserveLifecycle(ctx, name); err != nil { + return vm, err } - if err := m.GitHub.DeleteRunnerIfExists(ctx, runner.ID); err != nil { - m.warnf("[%s] deferred exact-name GitHub runner deletion after rollback: %v\n", name, err) + vm.Phase = LifecycleProvisioning + if err := m.acquireLifecycleLease(ctx, name, "provision", "controller", 2*time.Hour); err != nil { + return vm, fmt.Errorf("acquire provisioning lifecycle lease: %w", err) } -} - -func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register, allowBusy bool) (vm ProvisionedInstance, err error) { - logPath := m.instanceLogPath(name, "."+m.Config.Provider.Type+".log") - guestLogPath := m.instanceLogPath(name, ".guest.log") - vm = ProvisionedInstance{Name: name, LogPath: logPath, GuestLogPath: guestLogPath, Phase: LifecycleProvisioning} - localMayExist := false configureAttempted := false listenerMayBeRunning := false defer func() { + m.releaseLifecycleLease(context.Background(), name, "provision", "controller") if err == nil { vm.Phase = LifecycleReady return } - if !localMayExist { - vm.Phase = "" + if listenerMayBeRunning { + m.quarantineLifecycle(context.Background(), name, err) + vm.Phase = LifecycleQuarantined return } - if listenerMayBeRunning { + remoteKnownAbsent := !configureAttempted + if configureAttempted && m.GitHub != nil { + runner, found, lookupErr := m.GitHub.RunnerByName(context.Background(), name) + if lookupErr != nil { + m.quarantineLifecycle(context.Background(), name, fmt.Errorf("%w; exact GitHub registration lookup failed: %v", err, lookupErr)) + vm.Phase = LifecycleQuarantined + return + } + remoteKnownAbsent = !found + if found { + vm.RunnerID = runner.ID + if recordErr := m.recordLifecycleRegistered(context.Background(), name, runner.ID); recordErr != nil { + m.quarantineLifecycle(context.Background(), name, fmt.Errorf("%w; exact GitHub runner id=%d could not be recorded: %v", err, runner.ID, recordErr)) + vm.Phase = LifecycleQuarantined + return + } + } + } + if m.LifecycleState == nil { + if vm.RunnerID != 0 && m.GitHub != nil { + if deleteErr := m.GitHub.DeleteRunnerIfExists(context.Background(), vm.RunnerID); deleteErr != nil { + vm.Phase = LifecycleCleanupPending + err = errors.Join(err, fmt.Errorf("rollback exact GitHub runner id=%d: %w", vm.RunnerID, deleteErr)) + return + } + } + cleanupErr := m.deleteLocalInstance(context.Background(), vm) + if cleanupErr != nil { + vm.Phase = LifecycleCleanupPending + err = errors.Join(err, fmt.Errorf("rollback local instance %s: %w", name, cleanupErr)) + } else { + vm.Phase = "" + } + return + } + record, recordErr := m.LifecycleState.Read(context.Background(), name) + if recordErr != nil { + vm.Phase = LifecycleCleanupPending + err = errors.Join(err, fmt.Errorf("read lifecycle for rollback: %w", recordErr)) + return + } + inventory, inventoryErr := m.inventoryProvider(context.Background()) + if inventoryErr != nil { + m.quarantineLifecycle(context.Background(), name, errors.Join(err, inventoryErr)) vm.Phase = LifecycleQuarantined return } - cleanupErr := m.deleteLocalInstance(context.Background(), vm) + cleanupErr := m.cleanupLifecycleRecordWithRemoteAbsence(context.Background(), record, inventoryByName(inventory)[name], remoteKnownAbsent) if cleanupErr != nil { vm.Phase = LifecycleCleanupPending err = errors.Join(err, fmt.Errorf("rollback local instance %s: %w", name, cleanupErr)) return } vm.Phase = "" - if configureAttempted { - m.deleteRemoteRunnerByNameBestEffort(name) - } }() var trustSnapshot hosttrust.Snapshot if m.hostTrustEnabled() { @@ -1063,35 +1238,73 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register return vm, err } m.logger().Info("cloning instance", "provider", m.Config.Provider.Type, "instance", name, "operation", "clone", "sourceImage", m.Config.Provider.SourceImage, "logPath", logPath) - localMayExist = true + var created provider.Instance if err := m.timeFirstInstanceStage(name, "instance_container_create", func() error { - return m.Provider.Clone(ctx, m.Config.Provider.SourceImage, name) + var createErr error + created, createErr = m.createProviderInstance(ctx, name) + return createErr }); err != nil { return vm, err } + if created.Name != name || created.ProviderID == "" { + return vm, fmt.Errorf("provider create returned no immutable identity for %q", name) + } + vm.ProviderID = created.ProviderID + if created.ReceiptVersion != "" && len(created.Receipt) != 0 { + var providerReceipt map[string]any + if json.Unmarshal(created.Receipt, &providerReceipt) != nil || providerReceipt == nil { + return vm, fmt.Errorf("provider create returned an invalid versioned receipt for %q", name) + } + } + if err := m.recordLifecycleCreated(ctx, created); err != nil { + return vm, err + } + if err := m.recordLifecycleValidationIntent(ctx, name); err != nil { + return vm, fmt.Errorf("record runtime validation intent: %w", err) + } + if err := m.applyProviderNetworkPolicy(ctx, created); err != nil { + return vm, fmt.Errorf("apply provider network policy: %w", err) + } + if err := m.verifyProviderAdmission(ctx, created); err != nil { + return vm, err + } m.logger().Info("starting instance", "provider", m.Config.Provider.Type, "instance", name, "operation", "start", "logPath", logPath) if err := m.timeFirstInstanceStage(name, m.startupInstanceStartStage(), func() error { startOptions, startOptionsErr := m.startOptions(logPath, name) if startOptionsErr != nil { return startOptionsErr } - _, err := m.Provider.Start(ctx, name, startOptions) + _, err := m.startProviderInstance(ctx, created, startOptions) return err }); err != nil { return vm, err } - ip, err := m.Provider.IP(ctx, name, m.Config.Timeouts.BootSeconds) + ip, available, err := m.providerAddress(ctx, created, m.Config.Timeouts.BootSeconds) if err != nil { return vm, err } vm.IP = ip - m.logger().Info("instance reachable", "provider", m.Config.Provider.Type, "instance", name, "operation", "wait-reachable", "address", ip) + if available { + m.logger().Info("instance reachable", "provider", m.Config.Provider.Type, "instance", name, "operation", "wait-reachable", "address", ip) + } else { + m.logger().Info("instance uses delegated provider execution", "provider", m.Config.Provider.Type, "instance", name, "operation", "wait-reachable") + } + if m.hostTrustEnabled() { + trustSnapshot, err = m.resolveHostTrust(ctx) + if err != nil { + return vm, fmt.Errorf("refresh host trust before runtime installation: %w", err) + } + if err := m.installHostTrustRuntime(ctx, name, trustSnapshot); err != nil { + return vm, err + } + vm.HostTrustGeneration = trustSnapshot.Generation + } m.logger().Info("validating runner runtime", "provider", m.Config.Provider.Type, "instance", name, "operation", "validate-runtime", "stage", "start") if err := m.timeFirstInstanceStage(name, "runtime_validation", func() error { if err := m.configureDockerRegistryMirrors(ctx, name); err != nil { return err } - return m.validateRuntimeWithRetry(ctx, name, guestLogPath) + return m.verifyProviderRuntimeWithRetry(ctx, created, guestLogPath) }); err != nil { return vm, err } @@ -1106,7 +1319,16 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register return vm, fmt.Errorf("%w: refresh host trust after runtime validation: %v", errHostTrustImageMismatch, err) } if err := validateHostTrustMarkerAgainstSnapshot(marker, currentTrust); err != nil { - return vm, fmt.Errorf("%w: %v", errHostTrustImageMismatch, err) + if installErr := m.installHostTrustRuntime(ctx, name, currentTrust); installErr != nil { + return vm, fmt.Errorf("%w: %v; runtime refresh failed: %v", errHostTrustImageMismatch, err, installErr) + } + marker, err = m.readInstanceHostTrustMarker(ctx, name) + if err != nil { + return vm, fmt.Errorf("%w: read refreshed marker: %v", errHostTrustImageMismatch, err) + } + if err := validateHostTrustMarkerAgainstSnapshot(marker, currentTrust); err != nil { + return vm, fmt.Errorf("%w: refreshed runtime marker: %v", errHostTrustImageMismatch, err) + } } // Track the immutable generation read from the cloned image, not merely // the pre-clone snapshot. This prevents a trust-store change racing image @@ -1114,10 +1336,19 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register vm.HostTrustGeneration = marker.Generation trustSnapshot = currentTrust } + if err := m.recordLifecycleValidated(ctx, name); err != nil { + return vm, fmt.Errorf("record validated runtime: %w", err) + } if register { + if err := m.recordLifecycleRegistrationIntent(ctx, name); err != nil { + return vm, fmt.Errorf("record GitHub registration intent: %w", err) + } if err := m.issueHostTrustLease(ctx, name, trustSnapshot); err != nil { return vm, fmt.Errorf("issue host trust lease: %w", err) } + if err := m.verifyProviderAdmission(ctx, created); err != nil { + return vm, err + } if m.GitHub == nil { if m.DryRun { m.infof("[dry-run] would register GitHub runner %s with labels %s\n", name, strings.Join(m.Config.Runner.Labels, ",")) @@ -1168,6 +1399,9 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register return vm, err } vm.RunnerID = runner.ID + if err := m.recordLifecycleRegistered(ctx, name, runner.ID); err != nil { + return vm, fmt.Errorf("record exact GitHub runner identity: %w", err) + } m.infof("[%s] GitHub runner %s id=%d busy=%t\n", name, readiness, runner.ID, runner.Busy) m.finishFirstRunnerReady(name) } else { @@ -1254,6 +1488,15 @@ func (m *Manager) waitRunnerReadyAndHealthy(ctx context.Context, vm ProvisionedI case result := <-resultCh: return result.runner, result.err case <-ticker.C: + instance, instanceErr := m.providerInstance(waitCtx, vm.Name) + if instanceErr != nil { + cancel() + return gh.Runner{}, instanceErr + } + if err := m.verifyProviderAdmission(waitCtx, instance); err != nil { + cancel() + return gh.Runner{}, err + } if m.hostTrustEnabled() && !time.Now().Before(nextLeaseRefresh) { current, err := m.resolveHostTrust(waitCtx) if err != nil { @@ -1317,6 +1560,15 @@ func (m *Manager) captureRunnerReadinessDiagnostics(name, guestLogPath string) { } func (m *Manager) runnerAlive(ctx context.Context, vm ProvisionedInstance) (bool, string, error) { + if _, globalAdmission := m.Lifecycle.(provider.AdmissionVerifier); globalAdmission { + instance, err := m.providerInstance(ctx, vm.Name) + if err != nil { + return false, "provider instance identity is unavailable", err + } + if err := m.verifyProviderAdmission(ctx, instance); err != nil { + return false, "provider admission changed", err + } + } if m.GitHub != nil { runner, found, err := m.GitHub.RunnerByName(ctx, vm.Name) if err != nil { @@ -1325,6 +1577,9 @@ func (m *Manager) runnerAlive(ctx context.Context, vm ProvisionedInstance) (bool } } else { if !found { + if err := m.recordLifecycleRemoteAbsence(ctx, vm.Name); err != nil { + return false, "GitHub runner record is gone", fmt.Errorf("record GitHub runner absence: %w", err) + } return false, "GitHub runner record is gone", nil } if runner.Busy { @@ -1358,7 +1613,21 @@ func isTransientGitHubLivenessError(err error) bool { } func (m *Manager) retireInstance(ctx context.Context, vm ProvisionedInstance, reason string) error { + if m.LifecycleState != nil && !vm.ProviderOwned { + return fmt.Errorf("refusing to retire unowned provider instance %q; prefix-only resources are report-only", vm.Name) + } m.infof("[%s] retiring instance: %s\n", vm.Name, reason) + if m.LifecycleState != nil { + record, err := m.LifecycleState.Read(ctx, vm.Name) + if err != nil { + return err + } + inventory, err := m.inventoryProvider(ctx) + if err != nil { + return err + } + return m.cleanupLifecycleRecord(ctx, record, inventoryByName(inventory)[vm.Name]) + } var firstErr error if m.GitHub != nil && vm.RunnerID != 0 { deleteCtx, cancel := context.WithTimeout(ctx, 60*time.Second) @@ -1368,11 +1637,32 @@ func (m *Manager) retireInstance(ctx context.Context, vm ProvisionedInstance, re } cancel() } + if vm.ProviderID == "" && m.Lifecycle == nil && m.Provider != nil { + stopCtx, stopCancel := context.WithTimeout(ctx, 60*time.Second) + _ = m.Provider.Stop(stopCtx, vm.Name) + stopCancel() + deleteCtx, deleteCancel := context.WithTimeout(ctx, 60*time.Second) + deleteErr := m.Provider.Delete(deleteCtx, vm.Name) + deleteCancel() + if deleteErr == nil { + if releaseErr := m.releaseInstanceTranscripts(vm); releaseErr != nil { + m.logger().Warn("instance transcript close failed after retirement", "provider", m.Config.Provider.Type, "instance", vm.Name, "operation", "retire", "error", releaseErr) + } + } + return deleteErr + } + instance, err := m.providerInstance(ctx, vm.Name) + if err != nil { + return err + } + if vm.ProviderID != "" && instance.ProviderID != vm.ProviderID { + return fmt.Errorf("same-name provider instance id=%s does not match expected id=%s; refusing retirement", instance.ProviderID, vm.ProviderID) + } stopCtx, stopCancel := context.WithTimeout(ctx, 60*time.Second) - _ = m.Provider.Stop(stopCtx, vm.Name) + _ = m.stopProviderInstance(stopCtx, instance) stopCancel() deleteCtx, deleteCancel := context.WithTimeout(ctx, 60*time.Second) - deleteErr := m.Provider.Delete(deleteCtx, vm.Name) + deleteErr := m.deleteProviderInstance(deleteCtx, instance) if deleteErr != nil && firstErr == nil { firstErr = deleteErr } @@ -1390,11 +1680,11 @@ func (m *Manager) validateRuntime(ctx context.Context, name string) error { return err } -func (m *Manager) validateRuntimeWithRetry(ctx context.Context, name, guestLogPath string) error { +func (m *Manager) verifyProviderRuntimeWithRetry(ctx context.Context, instance provider.Instance, guestLogPath string) error { const attempts = 2 var lastErr error for attempt := 1; attempt <= attempts; attempt++ { - err := m.validateRuntime(ctx, name) + err := m.verifyProviderRuntime(ctx, instance) if err == nil { return nil } @@ -1405,8 +1695,8 @@ func (m *Manager) validateRuntimeWithRetry(ctx context.Context, name, guestLogPa if attempt == attempts { break } - m.warnf("[%s] runtime validation attempt %d/%d failed: %v\n", name, attempt, attempts, err) - m.infof("[%s] retrying runtime validation in %s; guest log: %s\n", name, runtimeValidationRetryDelay, guestLogPath) + m.warnf("[%s] runtime validation attempt %d/%d failed: %v\n", instance.Name, attempt, attempts, err) + m.infof("[%s] retrying runtime validation in %s; guest log: %s\n", instance.Name, runtimeValidationRetryDelay, guestLogPath) select { case <-ctx.Done(): return ctx.Err() @@ -1426,7 +1716,7 @@ func (m *Manager) configureDockerRegistryMirrors(ctx context.Context, name strin if err != nil { return fmt.Errorf("read Docker daemon configuration script %s: %w", hostPath, err) } - if err := provider.CopyText(ctx, m.Provider, name, "/opt/epar/configure-docker-daemon.sh", "0755", guestText(content)); err != nil { + if err := m.copyTextGuest(ctx, name, "/opt/epar/configure-docker-daemon.sh", "0755", guestText(content), false); err != nil { return err } _, err = m.execGuest(ctx, name, []string{"sudo", "-E", "bash", "/opt/epar/configure-docker-daemon.sh"}, provider.ExecOptions{ @@ -1453,6 +1743,19 @@ func (m *Manager) execGuest(ctx context.Context, name string, cmd []string, opts opts.Stderr = transcript.Stderr cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + if m.Lifecycle != nil { + instance, err := m.providerInstance(cctx, name) + if err != nil { + return provider.ExecResult{}, err + } + providerOpts := opts + providerOpts.LogPath = "" + providerOpts.Env = nil + return m.Lifecycle.Exec(cctx, instance, provider.EnvCommand(opts.Env, cmd), providerOpts) + } + if m.Provider == nil { + return provider.ExecResult{}, fmt.Errorf("provider lifecycle is required") + } return m.Provider.Exec(cctx, name, cmd, opts) } diff --git a/internal/pool/manager_test.go b/internal/pool/manager_test.go index 1730ddc..5491051 100644 --- a/internal/pool/manager_test.go +++ b/internal/pool/manager_test.go @@ -396,7 +396,6 @@ func TestRunPoolAddsCurrentTrustCapacityWhileOldGenerationDrains(t *testing.T) { GitHub: github, ProjectRoot: t.TempDir(), } - var resolveCalls int32 snapshot := func(generation string) hosttrust.Snapshot { return hosttrust.Snapshot{ Generation: generation, HostOS: "linux", Scopes: []string{"system"}, @@ -405,7 +404,7 @@ func TestRunPoolAddsCurrentTrustCapacityWhileOldGenerationDrains(t *testing.T) { } } manager.hostTrustResolver = func(context.Context) (hosttrust.Snapshot, error) { - if atomic.AddInt32(&resolveCalls, 1) <= 2 { + if atomic.LoadInt32(&github.waitOnlineCalls)+atomic.LoadInt32(&github.waitOnlineIdleCalls) == 0 { return snapshot("g1"), nil } return snapshot("g2"), nil @@ -773,6 +772,29 @@ func TestProvisionOneCapturesReadinessTimeoutAndPreservesCause(t *testing.T) { } } +func TestCommonLifecycleOwnsGuestTranscriptPath(t *testing.T) { + fake := &fakeProvider{instances: []provider.Instance{{Name: "epar-test-1", State: "running"}}} + manager := newRegisteredTestManager(t, fake, nil) + manager.Lifecycle = provider.AdaptLegacy(fake) + + if _, err := manager.execGuest(context.Background(), "epar-test-1", []string{"true"}, provider.ExecOptions{Env: map[string]string{"EPAR_TEST": "value"}}); err != nil { + t.Fatal(err) + } + if got := fake.logPathFor("true"); got != "" { + t.Fatalf("provider received common host transcript path %q", got) + } + fake.mu.Lock() + command := fake.commands[len(fake.commands)-1] + options := fake.execOptions[len(fake.execOptions)-1] + fake.mu.Unlock() + if len(options.Env) != 0 { + t.Fatalf("provider received ambient host environment: %#v", options.Env) + } + if !strings.Contains(command, "env EPAR_TEST=value true") { + t.Fatalf("provider command = %q, want explicit guest environment", command) + } +} + func TestProvisionOneReadinessSucceedsWhileRunnerProcessStaysHealthy(t *testing.T) { oldInterval := runnerReadinessHealthCheckInterval runnerReadinessHealthCheckInterval = time.Millisecond @@ -1286,7 +1308,7 @@ type fakeProvider struct { func (p *fakeProvider) Clone(_ context.Context, source, name string) error { atomic.AddInt32(&p.cloneCalls, 1) p.mu.Lock() - p.instances = append(p.instances, provider.Instance{Name: name, Source: source, State: "running"}) + p.instances = append(p.instances, provider.Instance{Name: name, ProviderID: "fake:" + name, Source: source, State: "running"}) if len(p.instances) > int(atomic.LoadInt32(&p.maxInventory)) { atomic.StoreInt32(&p.maxInventory, int32(len(p.instances))) } @@ -1386,7 +1408,13 @@ func (p *fakeProvider) List(ctx context.Context) ([]provider.Instance, error) { } p.mu.Lock() defer p.mu.Unlock() - return append([]provider.Instance(nil), p.instances...), p.listErr + result := append([]provider.Instance(nil), p.instances...) + for i := range result { + if result[i].ProviderID == "" { + result[i].ProviderID = "fake:" + result[i].Name + } + } + return result, p.listErr } type fakeGitHub struct { diff --git a/internal/pool/provider_lifecycle.go b/internal/pool/provider_lifecycle.go new file mode 100644 index 0000000..ed6e99e --- /dev/null +++ b/internal/pool/provider_lifecycle.go @@ -0,0 +1,180 @@ +package pool + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +func (m *Manager) copyTextGuest(ctx context.Context, name, path, mode, content string, atomic bool) error { + staging := "/tmp/epar-copy" + script := fmt.Sprintf("cat > %s && if command -v sudo >/dev/null 2>&1; then sudo install -m %s %s %s; else install -m %s %s %s; fi && rm -f %s", shellQuote(staging), shellQuote(mode), shellQuote(staging), shellQuote(path), shellQuote(mode), shellQuote(staging), shellQuote(path), shellQuote(staging)) + if atomic { + temporary := path + ".tmp" + script = fmt.Sprintf("cat > %s && if command -v sudo >/dev/null 2>&1; then sudo install -m %s %s %s && sudo mv -f %s %s; else install -m %s %s %s && mv -f %s %s; fi && rm -f %s", shellQuote(staging), shellQuote(mode), shellQuote(staging), shellQuote(temporary), shellQuote(temporary), shellQuote(path), shellQuote(mode), shellQuote(staging), shellQuote(temporary), shellQuote(temporary), shellQuote(path), shellQuote(staging)) + } + _, err := m.execGuest(ctx, name, provider.ShellCommand(script), provider.ExecOptions{Stdin: content}) + return err +} + +func (m *Manager) createProviderInstance(ctx context.Context, name string) (provider.Instance, error) { + lifecycle := m.providerLifecycle() + if lifecycle == nil { + return provider.Instance{}, fmt.Errorf("provider lifecycle is required") + } + return lifecycle.Create(ctx, provider.CreateRequest{ + Name: name, + Source: m.Config.Provider.SourceImage, + Template: m.Config.DockerSandboxes.Template, + TemplateDigest: m.Config.DockerSandboxes.TemplateDigest, + StagingPath: filepath.Join(config.ProjectPath(m.ProjectRoot, m.Config.DockerSandboxes.StagingRoot), name), + CPUs: m.Config.DockerSandboxes.CPUs, + Memory: m.Config.DockerSandboxes.Memory, + RootDisk: m.Config.DockerSandboxes.RootDisk, + DockerDisk: m.Config.DockerSandboxes.DockerDisk, + }) +} + +func (m *Manager) startProviderInstance(ctx context.Context, instance provider.Instance, opts provider.StartOptions) (*provider.RunningProcess, error) { + lifecycle := m.providerLifecycle() + if lifecycle == nil { + return nil, fmt.Errorf("provider lifecycle is required") + } + return lifecycle.Start(ctx, instance, opts) +} + +func (m *Manager) providerAddress(ctx context.Context, instance provider.Instance, waitSeconds int) (string, bool, error) { + lifecycle := m.providerLifecycle() + if lifecycle == nil { + return "", false, fmt.Errorf("provider lifecycle is required") + } + return lifecycle.Address(ctx, instance, waitSeconds) +} + +func (m *Manager) verifyProviderRuntime(ctx context.Context, instance provider.Instance) error { + if m.Lifecycle == nil { + if m.Provider == nil { + return fmt.Errorf("provider lifecycle is required") + } + return m.validateRuntime(ctx, instance.Name) + } + lifecycle := m.providerLifecycle() + if lifecycle == nil { + return fmt.Errorf("provider lifecycle is required") + } + info, err := lifecycle.VerifyRuntime(ctx, instance) + if err != nil { + return err + } + if !info.Ready { + return fmt.Errorf("provider runtime for %q is not ready", instance.Name) + } + return nil +} + +func (m *Manager) verifyProviderAdmission(ctx context.Context, instance provider.Instance) error { + if verifier, ok := m.Lifecycle.(provider.AdmissionVerifier); ok { + if err := verifier.VerifyAdmission(ctx); err != nil { + return fmt.Errorf("provider-wide admission failed: %w", err) + } + } + if verifier, ok := m.Lifecycle.(provider.InstanceAdmissionVerifier); ok { + if err := verifier.VerifyInstanceAdmission(ctx, instance); err != nil { + return fmt.Errorf("instance admission failed: %w", err) + } + } + return nil +} + +func (m *Manager) applyProviderNetworkPolicy(ctx context.Context, instance provider.Instance) error { + if m.PolicyManager == nil { + return nil + } + var rules []provider.NetworkPolicyRule + if m.Config.DockerSandboxes.NetworkBaseline == config.DockerSandboxesNetworkBaselineOpen { + rules = append(rules, + provider.NetworkPolicyRule{Name: "epar-public-egress", Decision: provider.NetworkPolicyAllow, Resources: []string{"**"}}, + provider.NetworkPolicyRule{Name: "epar-host-alias-guardrails", Decision: provider.NetworkPolicyDeny, Resources: config.DockerSandboxesOpenDefaultDenyResources()}, + ) + } + if len(m.Config.DockerSandboxes.AdditionalAllow) != 0 { + rules = append(rules, provider.NetworkPolicyRule{Name: "epar-additional-allow", Decision: provider.NetworkPolicyAllow, Resources: append([]string(nil), m.Config.DockerSandboxes.AdditionalAllow...)}) + } + if len(m.Config.DockerSandboxes.AdditionalDeny) != 0 { + rules = append(rules, provider.NetworkPolicyRule{Name: "epar-additional-deny", Decision: provider.NetworkPolicyDeny, Resources: append([]string(nil), m.Config.DockerSandboxes.AdditionalDeny...)}) + } + if err := m.PolicyManager.ApplyNetworkPolicy(ctx, instance, rules); err != nil { + return err + } + _, err := m.PolicyManager.ReadNetworkPolicy(ctx, instance) + return err +} + +func (m *Manager) inventoryProvider(ctx context.Context) ([]provider.InventoryItem, error) { + lifecycle := m.providerLifecycle() + if lifecycle == nil { + return nil, fmt.Errorf("provider lifecycle is required") + } + return lifecycle.Inventory(ctx) +} + +func (m *Manager) providerInstance(ctx context.Context, name string) (provider.Instance, error) { + if m.LifecycleState != nil { + record, err := m.LifecycleState.Read(ctx, name) + if err == nil { + if record.ProviderType != m.Config.Provider.Type || record.ProviderID == "" { + return provider.Instance{}, fmt.Errorf("lifecycle record for %q has no exact provider identity", name) + } + return provider.Instance{ + Name: name, + ProviderID: record.ProviderID, + ReceiptVersion: record.Receipt.Version, + Receipt: append([]byte(nil), record.Receipt.Payload...), + }, nil + } + if !errors.Is(err, poolstate.ErrNotFound) { + return provider.Instance{}, err + } + } + items, err := m.inventoryProvider(ctx) + if err != nil { + return provider.Instance{}, err + } + for _, item := range items { + if item.Instance.Name == name { + return item.Instance, nil + } + } + return provider.Instance{}, fmt.Errorf("provider instance %q is missing", name) +} + +func (m *Manager) stopProviderInstance(ctx context.Context, instance provider.Instance) error { + lifecycle := m.providerLifecycle() + if lifecycle == nil { + return fmt.Errorf("provider lifecycle is required") + } + return lifecycle.Stop(ctx, instance) +} + +func (m *Manager) deleteProviderInstance(ctx context.Context, instance provider.Instance) error { + lifecycle := m.providerLifecycle() + if lifecycle == nil { + return fmt.Errorf("provider lifecycle is required") + } + return lifecycle.Delete(ctx, instance) +} + +func (m *Manager) providerLifecycle() provider.Lifecycle { + if m.Lifecycle != nil { + return m.Lifecycle + } + if m.Provider == nil { + return nil + } + return provider.AdaptLegacy(m.Provider, m.DryRun) +} diff --git a/internal/pool/runner_script_test.go b/internal/pool/runner_script_test.go index cef619a..0fc1f78 100644 --- a/internal/pool/runner_script_test.go +++ b/internal/pool/runner_script_test.go @@ -441,6 +441,54 @@ func TestHostTrustGenerationHookAcceptsCurrentLease(t *testing.T) { } } +func TestHostTrustGenerationHookProductionPathsCannotBeRedirectedByWorkflowEnvironment(t *testing.T) { + paths := []string{ + filepath.Join("..", "..", "scripts", "guest", "ubuntu", "check-host-trust-generation.sh"), + filepath.Join("..", "..", "templates", "docker-sandboxes", "guest", "check-host-trust-generation.sh"), + } + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"EPAR_HOST_TRUST_MARKER", "EPAR_HOST_TRUST_LEASE"} { + if strings.Contains(string(content), forbidden) { + t.Fatalf("%s permits workflow-controlled path override %q", path, forbidden) + } + } + for _, required := range []string{ + "marker=\"/opt/epar/host-trust-generation.json\"", + "lease=\"/run/epar/host-trust-lease.json\"", + "/usr/bin/env -i PATH=/usr/bin:/bin LANG=C.UTF-8 /usr/bin/python3 -I -S -", + } { + if !strings.Contains(string(content), required) { + t.Fatalf("%s omitted fixed production invariant %q", path, required) + } + } + } +} + +func TestDockerSandboxesRunnerAlwaysRequiresAdmissionHook(t *testing.T) { + path := filepath.Join("..", "..", "templates", "docker-sandboxes", "guest", "run-runner.sh") + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(content) + for _, required := range []string{ + "[[ -s /opt/epar/host-trust-generation.json ]]", + "ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/epar/check-host-trust-generation.sh", + "PATH=/opt/epar/hook-bin:", + } { + if !strings.Contains(text, required) { + t.Fatalf("Docker Sandboxes runner omitted always-on admission invariant %q", required) + } + } + if strings.Contains(text, "if [[ -s /opt/epar/host-trust-generation.json ]]") { + t.Fatal("Docker Sandboxes runner made its admission hook conditional") + } +} + func TestHostTrustGenerationHookRejectsMismatchAndExpiry(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("the guest hook requires the Linux image's python3 runtime") @@ -479,8 +527,8 @@ func runHostTrustGenerationHook(t *testing.T, marker, lease string) (string, err if err := os.WriteFile(leasePath, []byte(lease), 0644); err != nil { t.Fatal(err) } - cmd := exec.Command(gitBashForRunnerScriptTest(t), bashPath(hookPath)) - cmd.Env = append(os.Environ(), "EPAR_HOST_TRUST_MARKER="+bashPath(markerPath), "EPAR_HOST_TRUST_LEASE="+bashPath(leasePath)) + cmd := exec.Command(gitBashForRunnerScriptTest(t), bashPath(hookPath), bashPath(markerPath), bashPath(leasePath)) + cmd.Env = append(os.Environ(), "EPAR_HOST_TRUST_MARKER=/workflow/forged-marker.json", "EPAR_HOST_TRUST_LEASE=/workflow/forged-lease.json") output, runErr := cmd.CombinedOutput() return string(output), runErr } diff --git a/internal/pool/state/doc.go b/internal/pool/state/doc.go new file mode 100644 index 0000000..b971144 --- /dev/null +++ b/internal/pool/state/doc.go @@ -0,0 +1,20 @@ +// Package state provides a provider-neutral, durable ownership ledger for pool +// instances. It records intents before side effects and only permits exact-name +// records to progress through the lifecycle. Provider-specific details belong +// in Receipt, an explicitly versioned opaque JSON object. +// +// Unknown provider inventory is stored as a Discovery. Discoveries are +// quarantine/report-only: they cannot be converted into an owned Record or +// deleted through this package. +// +// The primary transition table is: +// +// reserved -> creating -> created -> validating -> standby -> registering +// -> ready -> busy -> draining -> fencing -> fenced +// -> remote-reconciling -> remote-absent -> local-removing +// -> local-absent -> tombstoned +// +// Quarantine is terminal for normal allocation and registration. It can only +// proceed through fencing and exact cleanup. Cleanup failures become +// cleanup-pending and resume only at fencing. +package state diff --git a/internal/pool/state/store.go b/internal/pool/state/store.go new file mode 100644 index 0000000..2f46bf6 --- /dev/null +++ b/internal/pool/state/store.go @@ -0,0 +1,615 @@ +package state + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/filelock" +) + +const snapshotFilename = "pool-lifecycle-v1.json" +const lockFilename = ".pool-lifecycle.lock" + +type diskState struct { + SchemaVersion int `json:"schemaVersion"` + Generation uint64 `json:"generation"` + Records []Record `json:"records"` + Discoveries []Discovery `json:"discoveries"` + Checksum string `json:"checksum"` +} + +type checksumState struct { + SchemaVersion int `json:"schemaVersion"` + Generation uint64 `json:"generation"` + Records []Record `json:"records"` + Discoveries []Discovery `json:"discoveries"` +} + +// Store serializes state across goroutines and cooperating controller processes. +type Store struct { + directory string + path string + lockPath string + mu sync.Mutex + now func() time.Time + fault func(string) error // test-only crash boundary injection +} + +func Open(directory string) (*Store, error) { + if directory == "" { + return nil, fmt.Errorf("%w: state directory is empty", ErrInvalidRecord) + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, fmt.Errorf("create lifecycle state directory: %w", err) + } + directory, err := filepath.Abs(directory) + if err != nil { + return nil, fmt.Errorf("resolve lifecycle state directory: %w", err) + } + store := &Store{directory: directory, path: filepath.Join(directory, snapshotFilename), lockPath: filepath.Join(directory, lockFilename), now: time.Now} + if err := store.withLock(context.Background(), func() error { _, err := store.load(); return err }); err != nil { + return nil, err + } + return store, nil +} + +func (s *Store) Path() string { return s.path } + +func (s *Store) Reserve(ctx context.Context, spec CreateSpec) (Record, error) { + if err := validateName("instance name", spec.Name); err != nil { + return Record{}, err + } + if err := validateName("provider type", spec.ProviderType); err != nil { + return Record{}, err + } + if err := validateName("GitHub runner name", spec.GitHub.ExactName); err != nil { + return Record{}, err + } + var result Record + err := s.mutate(ctx, func(state *diskState, now time.Time) error { + if _, found := findRecord(state.Records, spec.Name); found { + return fmt.Errorf("%w: %s", ErrAlreadyExists, spec.Name) + } + state.Generation++ + result = Record{Name: spec.Name, ProviderType: spec.ProviderType, GitHub: spec.GitHub, Phase: PhaseReserved, Generation: state.Generation, CreatedAt: now, UpdatedAt: now} + state.Records = append(state.Records, result) + sort.Slice(state.Records, func(i, j int) bool { return state.Records[i].Name < state.Records[j].Name }) + return nil + }) + return cloneRecord(result), err +} + +func (s *Store) Transition(ctx context.Context, name string, transition Transition) (Record, error) { + if err := validateName("instance name", name); err != nil { + return Record{}, err + } + var result Record + err := s.mutate(ctx, func(state *diskState, now time.Time) error { + index, found := findRecord(state.Records, name) + if !found { + return fmt.Errorf("%w: %s", ErrNotFound, name) + } + record := cloneRecord(state.Records[index]) + if err := applyTransition(&record, transition, now); err != nil { + return err + } + state.Generation++ + record.Generation, record.UpdatedAt = state.Generation, now + state.Records[index], result = record, record + return nil + }) + return cloneRecord(result), err +} + +func (s *Store) AcquireLease(ctx context.Context, name string, lease Lease) (Record, error) { + if err := validateName("instance name", name); err != nil { + return Record{}, err + } + if err := validateName("lease purpose", lease.Purpose); err != nil { + return Record{}, err + } + if err := validateName("lease holder", lease.Holder); err != nil { + return Record{}, err + } + var result Record + err := s.mutate(ctx, func(state *diskState, now time.Time) error { + index, found := findRecord(state.Records, name) + if !found { + return fmt.Errorf("%w: %s", ErrNotFound, name) + } + record := cloneRecord(state.Records[index]) + if record.Phase == PhaseTombstoned || !lease.ExpiresAt.After(now) { + return fmt.Errorf("%w: cannot acquire lease", ErrInvalidTransition) + } + retained := record.Leases[:0] + for _, existing := range record.Leases { + if existing.ExpiresAt.After(now) && !(existing.Purpose == lease.Purpose && existing.Holder == lease.Holder) { + retained = append(retained, existing) + } + } + record.Leases = append(retained, Lease{Purpose: lease.Purpose, Holder: lease.Holder, ExpiresAt: lease.ExpiresAt.UTC()}) + state.Generation++ + record.Generation, record.UpdatedAt = state.Generation, now + state.Records[index], result = record, record + return nil + }) + return cloneRecord(result), err +} + +func (s *Store) ReleaseLease(ctx context.Context, name, purpose, holder string) (Record, error) { + if err := validateName("instance name", name); err != nil { + return Record{}, err + } + if err := validateName("lease purpose", purpose); err != nil { + return Record{}, err + } + if err := validateName("lease holder", holder); err != nil { + return Record{}, err + } + var result Record + err := s.mutate(ctx, func(state *diskState, now time.Time) error { + index, found := findRecord(state.Records, name) + if !found { + return fmt.Errorf("%w: %s", ErrNotFound, name) + } + record := cloneRecord(state.Records[index]) + retained := record.Leases[:0] + for _, lease := range record.Leases { + if !(lease.Purpose == purpose && lease.Holder == holder) && lease.ExpiresAt.After(now) { + retained = append(retained, lease) + } + } + record.Leases = retained + state.Generation++ + record.Generation, record.UpdatedAt = state.Generation, now + state.Records[index], result = record, record + return nil + }) + return cloneRecord(result), err +} + +// ReportUnknown records inventory that was not created by this state store. +// It is intentionally not exposed to Transition or cleanup operations. +func (s *Store) ReportUnknown(ctx context.Context, discovery Discovery) (Discovery, error) { + if err := validateName("provider type", discovery.ProviderType); err != nil { + return Discovery{}, err + } + if err := validateName("provider id", discovery.ProviderID); err != nil { + return Discovery{}, err + } + if err := validateName("discovered instance name", discovery.ExactName); err != nil { + return Discovery{}, err + } + if err := validateReceipt(discovery.Receipt); err != nil { + return Discovery{}, err + } + err := s.mutate(ctx, func(state *diskState, now time.Time) error { + discovery.ObservedAt = now + state.Generation++ + for i, existing := range state.Discoveries { + if existing.ProviderType == discovery.ProviderType && existing.ProviderID == discovery.ProviderID { + state.Discoveries[i] = discovery + return nil + } + } + state.Discoveries = append(state.Discoveries, discovery) + sort.Slice(state.Discoveries, func(i, j int) bool { return discoveryKey(state.Discoveries[i]) < discoveryKey(state.Discoveries[j]) }) + return nil + }) + return discovery, err +} + +func (s *Store) Read(ctx context.Context, name string) (Record, error) { + if err := validateName("instance name", name); err != nil { + return Record{}, err + } + var result Record + err := s.withLock(ctx, func() error { + state, err := s.load() + if err != nil { + return err + } + index, found := findRecord(state.Records, name) + if !found { + return fmt.Errorf("%w: %s", ErrNotFound, name) + } + result = cloneRecord(state.Records[index]) + return nil + }) + return result, err +} +func (s *Store) List(ctx context.Context) ([]Record, error) { + var result []Record + err := s.withLock(ctx, func() error { + state, err := s.load() + if err != nil { + return err + } + result = make([]Record, len(state.Records)) + for i := range state.Records { + result[i] = cloneRecord(state.Records[i]) + } + return nil + }) + return result, err +} +func (s *Store) Discoveries(ctx context.Context) ([]Discovery, error) { + var result []Discovery + err := s.withLock(ctx, func() error { + state, err := s.load() + if err != nil { + return err + } + result = append([]Discovery(nil), state.Discoveries...) + return nil + }) + return result, err +} + +func (s *Store) mutate(ctx context.Context, operation func(*diskState, time.Time) error) error { + return s.withLock(ctx, func() error { + state, err := s.load() + if err != nil { + return err + } + if err := operation(&state, s.now().UTC()); err != nil { + return err + } + if err := validateState(state); err != nil { + return err + } + return s.save(state) + }) +} +func (s *Store) withLock(ctx context.Context, operation func() error) error { + s.mu.Lock() + defer s.mu.Unlock() + for { + lock, err := filelock.Acquire(s.lockPath) + if err == nil { + defer lock.Close() + return operation() + } + if !errors.Is(err, filelock.ErrLocked) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } +} + +func (s *Store) load() (diskState, error) { + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return diskState{SchemaVersion: SchemaVersion, Records: []Record{}, Discoveries: []Discovery{}}, nil + } + if err != nil { + return diskState{}, err + } + var state diskState + if err := json.Unmarshal(data, &state); err != nil { + return diskState{}, fmt.Errorf("%w: decode: %v", ErrCorrupt, err) + } + if state.SchemaVersion != SchemaVersion { + return diskState{}, fmt.Errorf("%w: schema %d", ErrCorrupt, state.SchemaVersion) + } + sum, err := checksum(state) + if err != nil || state.Checksum != hex.EncodeToString(sum) { + return diskState{}, fmt.Errorf("%w: checksum", ErrCorrupt) + } + if err := validateState(state); err != nil { + return diskState{}, fmt.Errorf("%w: %v", ErrCorrupt, err) + } + return state, nil +} +func (s *Store) save(state diskState) error { + sum, err := checksum(state) + if err != nil { + return err + } + state.Checksum = hex.EncodeToString(sum) + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if s.fault != nil { + if err := s.fault("before-temp"); err != nil { + return err + } + } + file, err := os.CreateTemp(s.directory, ".pool-lifecycle-*.tmp") + if err != nil { + return err + } + temp := file.Name() + committed := false + defer func() { + _ = file.Close() + if !committed { + _ = os.Remove(temp) + } + }() + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + if s.fault != nil { + if err := s.fault("before-rename"); err != nil { + return err + } + } + if err := os.Rename(temp, s.path); err != nil { + return err + } + committed = true + return nil +} +func checksum(state diskState) ([]byte, error) { + encoded, err := json.Marshal(checksumState{SchemaVersion: state.SchemaVersion, Generation: state.Generation, Records: state.Records, Discoveries: state.Discoveries}) + if err != nil { + return nil, err + } + result := sha256.Sum256(encoded) + return result[:], nil +} + +func findRecord(records []Record, name string) (int, bool) { + index := sort.Search(len(records), func(i int) bool { return records[i].Name >= name }) + return index, index < len(records) && records[index].Name == name +} +func discoveryKey(discovery Discovery) string { + return discovery.ProviderType + "\x00" + discovery.ProviderID +} +func timePtr(value time.Time) *time.Time { copy := value; return © } + +func applyTransition(record *Record, transition Transition, now time.Time) error { + if record.Phase == PhaseTombstoned { + return invalid(record, transition.Action) + } + switch transition.Action { + case ActionCreateIntent: + return move(record, PhaseReserved, PhaseCreating, transition.Action) + case ActionAbandonCreate: + if record.Phase != PhaseReserved && record.Phase != PhaseCreating { + return invalid(record, transition.Action) + } + if record.ProviderID != "" || !emptyReceipt(record.Receipt) || len(activeLeases(record.Leases, now)) != 0 { + return invalid(record, transition.Action) + } + record.Cleanup.RemoteVerifyAt = timePtr(now) + record.Cleanup.RemoteAbsentAt = timePtr(now) + record.Cleanup.LocalRemoveIntentAt = timePtr(now) + record.Cleanup.LocalAbsentAt = timePtr(now) + record.Phase = PhaseTombstoned + record.TombstonedAt = timePtr(now) + case ActionCreated: + if record.Phase != PhaseCreating || validateName("provider id", transition.ProviderID) != nil || validateReceipt(transition.Receipt) != nil { + return invalid(record, transition.Action) + } + record.ProviderID, record.Receipt, record.Phase = transition.ProviderID, transition.Receipt, PhaseCreated + case ActionValidateIntent: + return move(record, PhaseCreated, PhaseValidating, transition.Action) + case ActionValidated: + return move(record, PhaseValidating, PhaseStandby, transition.Action) + case ActionRegisterIntent: + return move(record, PhaseStandby, PhaseRegistering, transition.Action) + case ActionRegistered: + if record.Phase != PhaseRegistering || transition.RunnerID <= 0 { + return invalid(record, transition.Action) + } + record.GitHub.RunnerID, record.Phase = transition.RunnerID, PhaseReady + case ActionJobStarted: + return move(record, PhaseReady, PhaseBusy, transition.Action) + case ActionJobFinished: + return move(record, PhaseBusy, PhaseDraining, transition.Action) + case ActionQuarantine: + if transition.Reason == "" || !quarantineAllowed(record.Phase) { + return invalid(record, transition.Action) + } + record.Phase, record.Quarantine = PhaseQuarantined, &Quarantine{Reason: transition.Reason, ReportedAt: now} + case ActionFenceIntent: + if !fenceAllowed(record.Phase) { + return invalid(record, transition.Action) + } + record.Phase, record.Cleanup.FenceIntentAt = PhaseFencing, timePtr(now) + case ActionFenced: + return move(record, PhaseFencing, PhaseFenced, transition.Action) + case ActionVerifyRemoteIntent: + if record.Phase != PhaseFenced { + return invalid(record, transition.Action) + } + record.Phase, record.Cleanup.RemoteVerifyAt = PhaseRemoteReconciling, timePtr(now) + case ActionRemoteAbsent: + if record.Phase != PhaseRemoteReconciling { + return invalid(record, transition.Action) + } + record.Phase, record.Cleanup.RemoteAbsentAt = PhaseRemoteAbsent, timePtr(now) + case ActionRemoveLocalIntent: + if record.Phase != PhaseRemoteAbsent { + return invalid(record, transition.Action) + } + record.Phase, record.Cleanup.LocalRemoveIntentAt = PhaseLocalRemoving, timePtr(now) + case ActionLocalAbsent: + if record.Phase != PhaseLocalRemoving { + return invalid(record, transition.Action) + } + record.Phase, record.Cleanup.LocalAbsentAt = PhaseLocalAbsent, timePtr(now) + case ActionCleanupPending: + if record.Phase != PhaseFencing && record.Phase != PhaseRemoteReconciling && record.Phase != PhaseLocalRemoving { + return invalid(record, transition.Action) + } + record.Phase = PhaseCleanupPending + case ActionResumeCleanup: + if record.Phase != PhaseCleanupPending { + return invalid(record, transition.Action) + } + switch { + case record.Cleanup.LocalAbsentAt != nil: + record.Phase = PhaseLocalAbsent + case record.Cleanup.LocalRemoveIntentAt != nil: + record.Phase = PhaseLocalRemoving + case record.Cleanup.RemoteAbsentAt != nil: + record.Phase = PhaseRemoteAbsent + case record.Cleanup.RemoteVerifyAt != nil: + record.Phase = PhaseRemoteReconciling + default: + record.Phase = PhaseFencing + } + case ActionTombstone: + if record.Phase != PhaseLocalAbsent || record.Cleanup.RemoteAbsentAt == nil || record.Cleanup.LocalAbsentAt == nil || len(activeLeases(record.Leases, now)) != 0 { + return invalid(record, transition.Action) + } + record.Phase, record.TombstonedAt = PhaseTombstoned, timePtr(now) + default: + return fmt.Errorf("%w: unknown action %q", ErrInvalidTransition, transition.Action) + } + return nil +} + +func move(record *Record, from, to Phase, action Action) error { + if record.Phase != from { + return invalid(record, action) + } + record.Phase = to + return nil +} +func invalid(record *Record, action Action) error { + return fmt.Errorf("%w: %s from %s", ErrInvalidTransition, action, record.Phase) +} +func fenceAllowed(phase Phase) bool { + switch phase { + case PhaseCreated, PhaseValidating, PhaseStandby, PhaseRegistering, PhaseReady, PhaseBusy, PhaseDraining, PhaseQuarantined: + return true + default: + return false + } +} +func quarantineAllowed(phase Phase) bool { + switch phase { + case PhaseReserved, PhaseCreating, PhaseCreated, PhaseValidating, PhaseStandby, PhaseRegistering, PhaseReady, PhaseBusy, PhaseDraining: + return true + default: + return false + } +} +func activeLeases(leases []Lease, now time.Time) []Lease { + result := make([]Lease, 0, len(leases)) + for _, lease := range leases { + if lease.ExpiresAt.After(now) { + result = append(result, lease) + } + } + return result +} + +func validateState(state diskState) error { + if state.SchemaVersion != SchemaVersion { + return fmt.Errorf("%w: schema", ErrInvalidRecord) + } + for i := range state.Records { + record := state.Records[i] + if err := validateRecord(record); err != nil { + return err + } + if i > 0 && state.Records[i-1].Name >= record.Name { + return fmt.Errorf("%w: records are not sorted", ErrInvalidRecord) + } + } + for i, discovery := range state.Discoveries { + if err := validateDiscovery(discovery); err != nil { + return err + } + if i > 0 && discoveryKey(state.Discoveries[i-1]) >= discoveryKey(discovery) { + return fmt.Errorf("%w: discoveries are not sorted", ErrInvalidRecord) + } + } + return nil +} +func validateRecord(record Record) error { + if err := validateName("instance name", record.Name); err != nil { + return err + } + if err := validateName("provider type", record.ProviderType); err != nil { + return err + } + if err := validateName("GitHub runner name", record.GitHub.ExactName); err != nil { + return err + } + if !validPhase(record.Phase) || record.Generation == 0 || record.CreatedAt.IsZero() || record.UpdatedAt.IsZero() || record.UpdatedAt.Before(record.CreatedAt) { + return fmt.Errorf("%w: record phase, generation, or time", ErrInvalidRecord) + } + if record.Phase != PhaseReserved && record.Phase != PhaseCreating && !(record.Phase == PhaseQuarantined && record.ProviderID == "" && emptyReceipt(record.Receipt)) && !(record.Phase == PhaseTombstoned && record.ProviderID == "" && emptyReceipt(record.Receipt)) { + if err := validateName("provider id", record.ProviderID); err != nil { + return err + } + if err := validateReceipt(record.Receipt); err != nil { + return err + } + } + for _, lease := range record.Leases { + if err := validateName("lease purpose", lease.Purpose); err != nil { + return err + } + if err := validateName("lease holder", lease.Holder); err != nil { + return err + } + if lease.ExpiresAt.IsZero() { + return fmt.Errorf("%w: lease expiry", ErrInvalidRecord) + } + } + if record.Quarantine != nil && (record.Quarantine.Reason == "" || record.Quarantine.ReportedAt.IsZero()) { + return fmt.Errorf("%w: quarantine", ErrInvalidRecord) + } + if record.Phase == PhaseTombstoned && (record.TombstonedAt == nil || record.Cleanup.RemoteAbsentAt == nil || record.Cleanup.LocalAbsentAt == nil) { + return fmt.Errorf("%w: tombstone needs exact absence", ErrInvalidRecord) + } + return nil +} + +func emptyReceipt(receipt Receipt) bool { + return receipt.Version == "" && (len(receipt.Payload) == 0 || string(receipt.Payload) == "null") +} +func validateDiscovery(discovery Discovery) error { + if err := validateName("provider type", discovery.ProviderType); err != nil { + return err + } + if err := validateName("provider id", discovery.ProviderID); err != nil { + return err + } + if err := validateName("discovered instance name", discovery.ExactName); err != nil { + return err + } + if err := validateReceipt(discovery.Receipt); err != nil { + return err + } + if discovery.ObservedAt.IsZero() { + return fmt.Errorf("%w: discovery time", ErrInvalidRecord) + } + return nil +} +func validPhase(phase Phase) bool { + switch phase { + case PhaseReserved, PhaseCreating, PhaseCreated, PhaseValidating, PhaseStandby, PhaseRegistering, PhaseReady, PhaseBusy, PhaseDraining, PhaseQuarantined, PhaseCleanupPending, PhaseFencing, PhaseFenced, PhaseRemoteReconciling, PhaseRemoteAbsent, PhaseLocalRemoving, PhaseLocalAbsent, PhaseTombstoned: + return true + default: + return false + } +} diff --git a/internal/pool/state/store_test.go b/internal/pool/state/store_test.go new file mode 100644 index 0000000..06acf5c --- /dev/null +++ b/internal/pool/state/store_test.go @@ -0,0 +1,281 @@ +package state + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + "time" +) + +func receipt(t *testing.T) Receipt { + t.Helper() + payload, err := json.Marshal(map[string]string{"external": "exact-id"}) + if err != nil { + t.Fatal(err) + } + return Receipt{Version: "v1", Payload: payload} +} +func reserve(t *testing.T, store *Store, name string) Record { + t.Helper() + record, err := store.Reserve(context.Background(), CreateSpec{Name: name, ProviderType: "test-provider", GitHub: GitHubIdentity{ExactName: name + "-runner"}}) + if err != nil { + t.Fatal(err) + } + return record +} + +func TestLifecycleAllowsOnlyDocumentedTransitions(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + record := reserve(t, store, "instance-a") + sequence := []Transition{{Action: ActionCreateIntent}, {Action: ActionCreated, ProviderID: "provider:1", Receipt: receipt(t)}, {Action: ActionValidateIntent}, {Action: ActionValidated}, {Action: ActionRegisterIntent}, {Action: ActionRegistered, RunnerID: 42}, {Action: ActionJobStarted}, {Action: ActionJobFinished}, {Action: ActionFenceIntent}, {Action: ActionFenced}, {Action: ActionVerifyRemoteIntent}, {Action: ActionRemoteAbsent}, {Action: ActionRemoveLocalIntent}, {Action: ActionLocalAbsent}, {Action: ActionTombstone}} + for _, transition := range sequence { + record, err = store.Transition(context.Background(), record.Name, transition) + if err != nil { + t.Fatalf("%s from %s: %v", transition.Action, record.Phase, err) + } + } + if record.Phase != PhaseTombstoned { + t.Fatalf("phase = %s", record.Phase) + } + if _, err := store.Transition(context.Background(), record.Name, Transition{Action: ActionJobStarted}); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("terminal transition error = %v", err) + } +} + +func TestForbiddenTransitionsAndQuarantineResume(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + record := reserve(t, store, "instance-b") + for _, transition := range []Transition{{Action: ActionValidated}, {Action: ActionRegistered, RunnerID: 1}, {Action: ActionTombstone}} { + if _, err := store.Transition(context.Background(), record.Name, transition); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("%s error = %v", transition.Action, err) + } + } + if _, err := store.Transition(context.Background(), record.Name, Transition{Action: ActionQuarantine, Reason: "create outcome is uncertain"}); err != nil { + t.Fatalf("quarantine uncertain create = %v", err) + } + record = reserve(t, store, "instance-b2") + if _, err := store.Transition(context.Background(), record.Name, Transition{Action: ActionCreateIntent}); err != nil { + t.Fatal(err) + } + record, err = store.Transition(context.Background(), record.Name, Transition{Action: ActionCreated, ProviderID: "provider:quarantine", Receipt: receipt(t)}) + if err != nil { + t.Fatal(err) + } + record, err = store.Transition(context.Background(), record.Name, Transition{Action: ActionQuarantine, Reason: "provider inventory differed"}) + if err != nil { + t.Fatal(err) + } + for _, transition := range []Transition{{Action: ActionFenceIntent}, {Action: ActionCleanupPending}, {Action: ActionResumeCleanup}} { + record, err = store.Transition(context.Background(), record.Name, transition) + if err != nil { + t.Fatalf("%s: %v", transition.Action, err) + } + } + if record.Phase != PhaseFencing { + t.Fatalf("phase = %s", record.Phase) + } +} + +func TestAbandonCreateRequiresNoProviderIdentityAndRecordsExactAbsence(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"reserved-instance", "creating-instance"} { + record := reserve(t, store, name) + if name == "creating-instance" { + record, err = store.Transition(context.Background(), name, Transition{Action: ActionCreateIntent}) + if err != nil { + t.Fatal(err) + } + } + record, err = store.Transition(context.Background(), name, Transition{Action: ActionAbandonCreate}) + if err != nil { + t.Fatalf("abandon %s: %v", name, err) + } + if record.Phase != PhaseTombstoned || record.Cleanup.RemoteAbsentAt == nil || record.Cleanup.LocalAbsentAt == nil { + t.Fatalf("abandoned record = %#v", record) + } + } +} + +func TestProviderReceiptAndUnknownDiscoveryRoundTrip(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + record := reserve(t, store, "instance-c") + if _, err = store.Transition(context.Background(), record.Name, Transition{Action: ActionCreateIntent}); err != nil { + t.Fatal(err) + } + want := receipt(t) + if _, err = store.Transition(context.Background(), record.Name, Transition{Action: ActionCreated, ProviderID: "provider:2", Receipt: want}); err != nil { + t.Fatal(err) + } + if _, err = store.ReportUnknown(context.Background(), Discovery{ProviderType: "test-provider", ProviderID: "foreign:1", ExactName: "foreign-instance", Receipt: want}); err != nil { + t.Fatal(err) + } + reopened, err := Open(filepath.Dir(store.Path())) + if err != nil { + t.Fatal(err) + } + got, err := reopened.Read(context.Background(), record.Name) + if err != nil { + t.Fatal(err) + } + if !jsonEqual(got.Receipt.Payload, want.Payload) { + t.Fatalf("receipt = %s", got.Receipt.Payload) + } + discoveries, err := reopened.Discoveries(context.Background()) + if err != nil || len(discoveries) != 1 || discoveries[0].ExactName != "foreign-instance" { + t.Fatalf("discoveries = %#v err=%v", discoveries, err) + } + if _, err = reopened.Transition(context.Background(), "foreign-instance", Transition{Action: ActionFenceIntent}); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown resource became owned: %v", err) + } +} + +func jsonEqual(left, right json.RawMessage) bool { + var leftValue any + var rightValue any + if json.Unmarshal(left, &leftValue) != nil || json.Unmarshal(right, &rightValue) != nil { + return false + } + return reflect.DeepEqual(leftValue, rightValue) +} + +func TestLeaseExpiryAndTombstoneProtection(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + fixed := time.Now().UTC() + store.now = func() time.Time { return fixed } + record := reserve(t, store, "instance-d") + if _, err = store.AcquireLease(context.Background(), record.Name, Lease{Purpose: "cleanup", Holder: "controller", ExpiresAt: fixed.Add(time.Hour)}); err != nil { + t.Fatal(err) + } + for _, transition := range []Transition{{Action: ActionCreateIntent}, {Action: ActionCreated, ProviderID: "provider:3", Receipt: receipt(t)}, {Action: ActionFenceIntent}, {Action: ActionFenced}, {Action: ActionVerifyRemoteIntent}, {Action: ActionRemoteAbsent}, {Action: ActionRemoveLocalIntent}, {Action: ActionLocalAbsent}} { + if _, err = store.Transition(context.Background(), record.Name, transition); err != nil { + t.Fatalf("%s: %v", transition.Action, err) + } + } + if _, err = store.Transition(context.Background(), record.Name, Transition{Action: ActionTombstone}); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("tombstone with lease = %v", err) + } + if _, err = store.ReleaseLease(context.Background(), record.Name, "cleanup", "controller"); err != nil { + t.Fatal(err) + } + if _, err = store.AcquireLease(context.Background(), record.Name, Lease{Purpose: "audit", Holder: "controller", ExpiresAt: fixed.Add(time.Hour)}); err != nil { + t.Fatal(err) + } + fixed = fixed.Add(2 * time.Hour) + if _, err = store.Transition(context.Background(), record.Name, Transition{Action: ActionTombstone}); err != nil { + t.Fatal(err) + } +} + +func TestPartialWriteKeepsPriorSnapshotAndCorruptionFailsClosed(t *testing.T) { + directory := t.TempDir() + store, err := Open(directory) + if err != nil { + t.Fatal(err) + } + reserve(t, store, "instance-e") + store.fault = func(point string) error { + if point == "before-rename" { + return errors.New("simulated crash") + } + return nil + } + if _, err = store.Reserve(context.Background(), CreateSpec{Name: "instance-f", ProviderType: "test-provider", GitHub: GitHubIdentity{ExactName: "instance-f-runner"}}); err == nil { + t.Fatal("faulted write succeeded") + } + store.fault = nil + reopened, err := Open(directory) + if err != nil { + t.Fatal(err) + } + records, err := reopened.List(context.Background()) + if err != nil || len(records) != 1 || records[0].Name != "instance-e" { + t.Fatalf("recovery records=%#v err=%v", records, err) + } + if err := os.WriteFile(reopened.Path(), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Open(directory); !errors.Is(err, ErrCorrupt) { + t.Fatalf("corrupt open error = %v", err) + } +} + +func TestRejectsUnknownPhaseFromDisk(t *testing.T) { + directory := t.TempDir() + store, err := Open(directory) + if err != nil { + t.Fatal(err) + } + reserve(t, store, "instance-g") + state, err := store.load() + if err != nil { + t.Fatal(err) + } + state.Records[0].Phase = "invented" + sum, err := checksum(state) + if err != nil { + t.Fatal(err) + } + state.Checksum = hex.EncodeToString(sum) + data, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.Path(), data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := Open(directory); !errors.Is(err, ErrCorrupt) { + t.Fatalf("unknown phase error = %v", err) + } +} + +func TestConcurrentReservationsAreSerialized(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const count = 12 + var wg sync.WaitGroup + errorsCh := make(chan error, count) + for index := 0; index < count; index++ { + index := index + wg.Add(1) + go func() { + defer wg.Done() + _, err := store.Reserve(context.Background(), CreateSpec{Name: fmt.Sprintf("instance-%d", index), ProviderType: "test-provider", GitHub: GitHubIdentity{ExactName: fmt.Sprintf("runner-%d", index)}}) + errorsCh <- err + }() + } + wg.Wait() + close(errorsCh) + for err := range errorsCh { + if err != nil { + t.Fatal(err) + } + } + records, err := store.List(context.Background()) + if err != nil || len(records) != count { + t.Fatalf("records=%d err=%v", len(records), err) + } +} diff --git a/internal/pool/state/types.go b/internal/pool/state/types.go new file mode 100644 index 0000000..e853f0b --- /dev/null +++ b/internal/pool/state/types.go @@ -0,0 +1,172 @@ +package state + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +const SchemaVersion = 1 + +var ( + ErrAlreadyExists = errors.New("pool lifecycle record already exists") + ErrNotFound = errors.New("pool lifecycle record not found") + ErrInvalidRecord = errors.New("invalid pool lifecycle record") + ErrInvalidTransition = errors.New("invalid pool lifecycle transition") + ErrCorrupt = errors.New("pool lifecycle state is corrupt") +) + +type Phase string + +const ( + PhaseReserved Phase = "reserved" + PhaseCreating Phase = "creating" + PhaseCreated Phase = "created" + PhaseValidating Phase = "validating" + PhaseStandby Phase = "standby" + PhaseRegistering Phase = "registering" + PhaseReady Phase = "ready" + PhaseBusy Phase = "busy" + PhaseDraining Phase = "draining" + PhaseQuarantined Phase = "quarantined" + PhaseCleanupPending Phase = "cleanup-pending" + PhaseFencing Phase = "fencing" + PhaseFenced Phase = "fenced" + PhaseRemoteReconciling Phase = "remote-reconciling" + PhaseRemoteAbsent Phase = "remote-absent" + PhaseLocalRemoving Phase = "local-removing" + PhaseLocalAbsent Phase = "local-absent" + PhaseTombstoned Phase = "tombstoned" +) + +type Action string + +const ( + ActionCreateIntent Action = "create-intent" + ActionAbandonCreate Action = "abandon-create" + ActionCreated Action = "created" + ActionValidateIntent Action = "validate-intent" + ActionValidated Action = "validated" + ActionRegisterIntent Action = "register-intent" + ActionRegistered Action = "registered" + ActionJobStarted Action = "job-started" + ActionJobFinished Action = "job-finished" + ActionQuarantine Action = "quarantine" + ActionFenceIntent Action = "fence-intent" + ActionFenced Action = "fenced" + ActionVerifyRemoteIntent Action = "verify-remote-absent-intent" + ActionRemoteAbsent Action = "remote-absent" + ActionRemoveLocalIntent Action = "remove-local-intent" + ActionLocalAbsent Action = "local-absent" + ActionCleanupPending Action = "cleanup-pending" + ActionResumeCleanup Action = "resume-cleanup" + ActionTombstone Action = "tombstone" +) + +// Receipt is provider-owned, versioned state. It is intentionally opaque to +// the shared lifecycle package and must not contain credentials. +type Receipt struct { + Version string `json:"version"` + Payload json.RawMessage `json:"payload"` +} + +type GitHubIdentity struct { + ExactName string `json:"exactName"` + RunnerID int64 `json:"runnerId,omitempty"` +} + +type Lease struct { + Purpose string `json:"purpose"` + Holder string `json:"holder"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type Quarantine struct { + Reason string `json:"reason"` + ReportedAt time.Time `json:"reportedAt"` +} + +type Cleanup struct { + FenceIntentAt *time.Time `json:"fenceIntentAt,omitempty"` + RemoteVerifyAt *time.Time `json:"remoteVerifyAt,omitempty"` + RemoteAbsentAt *time.Time `json:"remoteAbsentAt,omitempty"` + LocalRemoveIntentAt *time.Time `json:"localRemoveIntentAt,omitempty"` + LocalAbsentAt *time.Time `json:"localAbsentAt,omitempty"` +} + +type Record struct { + Name string `json:"name"` + ProviderType string `json:"providerType"` + ProviderID string `json:"providerId,omitempty"` + Receipt Receipt `json:"receipt"` + GitHub GitHubIdentity `json:"github"` + Phase Phase `json:"phase"` + Leases []Lease `json:"leases"` + Quarantine *Quarantine `json:"quarantine,omitempty"` + Cleanup Cleanup `json:"cleanup"` + Generation uint64 `json:"generation"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + TombstonedAt *time.Time `json:"tombstonedAt,omitempty"` +} + +type CreateSpec struct { + Name string + ProviderType string + GitHub GitHubIdentity +} + +type Transition struct { + Action Action + ProviderID string + Receipt Receipt + RunnerID int64 + Reason string +} + +// Discovery stores an unowned provider resource. It is never a cleanup target. +type Discovery struct { + ProviderType string `json:"providerType"` + ProviderID string `json:"providerId"` + ExactName string `json:"exactName"` + Receipt Receipt `json:"receipt"` + ObservedAt time.Time `json:"observedAt"` +} + +func validateName(label, value string) error { + if len(value) < 1 || len(value) > 128 || strings.TrimSpace(value) != value { + return fmt.Errorf("%w: invalid %s", ErrInvalidRecord, label) + } + for _, r := range value { + if !(r == '-' || r == '_' || r == '.' || r == ':' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return fmt.Errorf("%w: invalid %s", ErrInvalidRecord, label) + } + } + return nil +} + +func validateReceipt(receipt Receipt) error { + if err := validateName("receipt version", receipt.Version); err != nil { + return err + } + if len(receipt.Payload) == 0 || !json.Valid(receipt.Payload) { + return fmt.Errorf("%w: receipt payload must be valid JSON", ErrInvalidRecord) + } + var object map[string]json.RawMessage + if err := json.Unmarshal(receipt.Payload, &object); err != nil || object == nil { + return fmt.Errorf("%w: receipt payload must be a JSON object", ErrInvalidRecord) + } + return nil +} + +func cloneRecord(record Record) Record { + record.Receipt.Payload = append(json.RawMessage(nil), record.Receipt.Payload...) + record.Leases = append([]Lease(nil), record.Leases...) + if record.Quarantine != nil { + copy := *record.Quarantine + record.Quarantine = © + } + return record +} diff --git a/internal/pool/storage_preflight.go b/internal/pool/storage_preflight.go new file mode 100644 index 0000000..9c2b95c --- /dev/null +++ b/internal/pool/storage_preflight.go @@ -0,0 +1,102 @@ +package pool + +import ( + "context" + "fmt" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/invocation" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +const ( + instanceCreateExpansionBytes = 10 * storage.GiB + imagePullExpansionBytes = 20 * storage.GiB + imageBuildExpansionBytes = 30 * storage.GiB + sourceUpdateExpansionBytes = 5 * storage.GiB +) + +func (m *Manager) preflightStorage(operation string, peakBytes uint64) error { + minimumFree, err := config.EffectiveMinimumFreeBytes(m.Config) + if err != nil { + return err + } + if m.Storage != nil { + snapshot, err := m.Storage.StorageSnapshot(context.Background(), provider.StorageRequest{ + Operation: operation, + Now: m.currentTime(), + PeakBytes: peakBytes, + MinimumFreeBytes: minimumFree, + }) + if err != nil { + return fmt.Errorf("provider storage surface cannot be measured before %s: %w\n\nInspect storage with:\n %s", operation, err, invocation.Command("storage", "status", "--provider", m.Config.Provider.Type)) + } + if len(snapshot.Surfaces) == 0 || len(snapshot.Requirements) == 0 { + return fmt.Errorf("provider %q returned no required storage surface for %s", m.Config.Provider.Type, operation) + } + surfaces := make(map[string]storage.Surface, len(snapshot.Surfaces)) + for _, surface := range snapshot.Surfaces { + surfaces[surface.ID] = surface + } + for _, requirement := range snapshot.Requirements { + surface, found := surfaces[requirement.SurfaceID] + if !found { + return fmt.Errorf("provider storage requirement %q references unknown surface %q", requirement.ID, requirement.SurfaceID) + } + check, err := storage.EvaluateCapacity(surface, requirement) + if err != nil { + return fmt.Errorf("evaluate storage capacity before %s: %w", operation, err) + } + if check.Status != storage.CapacityReady { + return storageAdmissionError(operation, surface, requirement, check, m.Config.Provider.Type) + } + } + return nil + } + capacity, err := storage.ProbeFilesystemCapacity(m.ProjectRoot, m.currentTime()) + if err != nil { + return fmt.Errorf("storage surface %q cannot be measured before %s: %w\n\nInspect storage with:\n %s", m.ProjectRoot, operation, err, invocation.Command("storage", "status", "--provider", m.Config.Provider.Type)) + } + surface := storage.Surface{ + ID: "project", + Provider: m.Config.Provider.Type, + Kind: storage.SurfaceHostFilesystem, + Location: m.ProjectRoot, + Capacity: capacity, + } + requirement := storage.Requirement{ + ID: operation, + Provider: m.Config.Provider.Type, + SurfaceID: surface.ID, + PeakBytes: peakBytes, + MinimumFreeBytes: minimumFree, + } + check, err := storage.EvaluateCapacity(surface, requirement) + if err != nil { + return fmt.Errorf("evaluate storage capacity before %s: %w", operation, err) + } + if check.Status != storage.CapacityReady { + return storageAdmissionError(operation, surface, requirement, check, m.Config.Provider.Type) + } + return nil +} + +func (m *Manager) instanceCreateExpansion() uint64 { + return uint64(instanceCreateExpansionBytes) +} + +func storageAdmissionError(operation string, surface storage.Surface, requirement storage.Requirement, check storage.CapacityCheck, providerType string) error { + action := "complete " + strings.ReplaceAll(operation, "-", " ") + if operation == "instance-create" { + action = "initialize the runner" + } + return storage.CapacityAdmissionError(action, surface, requirement, check, invocation.Command("storage", "prune", "--provider", providerType)) +} + +// PreflightProviderStorage lets provider-side controllers apply the same +// fail-closed reserve rule to an exact operation before provider side effects. +func (m *Manager) PreflightProviderStorage(operation string, peakBytes uint64) error { + return m.preflightStorage(operation, peakBytes) +} diff --git a/internal/pool/storage_preflight_test.go b/internal/pool/storage_preflight_test.go new file mode 100644 index 0000000..4accb34 --- /dev/null +++ b/internal/pool/storage_preflight_test.go @@ -0,0 +1,93 @@ +package pool + +import ( + "context" + "strings" + "sync/atomic" + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +type insufficientStorage struct{} + +func (insufficientStorage) StorageSnapshot(context.Context, provider.StorageRequest) (provider.StorageSnapshot, error) { + return provider.StorageSnapshot{ + Surfaces: []storage.Surface{{ + ID: "provider-backing", + Provider: "docker-sandboxes", + Kind: storage.SurfaceHostFilesystem, + Location: "test-backing", + Capacity: storage.Capacity{Known: true, TotalBytes: 100 * storage.GiB, AvailableBytes: 30 * storage.GiB}, + }}, + Requirements: []storage.Requirement{{ + ID: "instance-create-provider-backing", + Provider: "docker-sandboxes", + SurfaceID: "provider-backing", + PeakBytes: 20 * storage.GiB, + MinimumFreeBytes: 20 * storage.GiB, + }}, + }, nil +} + +func TestRunPoolCapacityRejectionDoesNotCleanupUncreatedInstance(t *testing.T) { + t.Setenv("EPAR_INVOCATION", "start") + state, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + fake := &fakeProvider{} + manager := Manager{ + Config: config.Config{ + Provider: config.ProviderConfig{Type: "docker-sandboxes"}, + Pool: config.PoolConfig{Instances: 1, NamePrefix: "epar-capacity"}, + Logging: config.LoggingConfig{Directory: t.TempDir()}, + Storage: config.StorageConfig{MinimumFree: "20GiB"}, + }, + Provider: fake, + Lifecycle: provider.AdaptLegacy(fake), + LifecycleState: state, + Storage: insufficientStorage{}, + ProjectRoot: t.TempDir(), + } + + err = manager.RunPool(context.Background(), RunOptions{Instances: 1, PoolLockHeld: true, HostTrustLockHeld: true}) + if err == nil || !strings.Contains(err.Error(), "not enough disk space to initialize the runner") { + t.Fatalf("RunPool() error = %v, want capacity rejection", err) + } + for _, want := range []string{ + "Storage location: test-backing", + "Available: 30.00 GiB", + "Estimated operation growth: 20.00 GiB", + "Free-space reserve: 20.00 GiB", + "Required before starting: 40.00 GiB", + "Additional space needed: 10.00 GiB", + "./start storage prune --provider docker-sandboxes", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("RunPool() error = %q, want %q", err, want) + } + } + if strings.Contains(err.Error(), "32212254720") { + t.Fatalf("RunPool() error contains raw byte count: %v", err) + } + if strings.Contains(err.Error(), "lifecycle record not found") { + t.Fatalf("RunPool() appended cleanup error for an uncreated instance: %v", err) + } + if got := atomic.LoadInt32(&fake.stopCalls); got != 0 { + t.Fatalf("provider stop calls = %d, want 0", got) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 0 { + t.Fatalf("provider delete calls = %d, want 0", got) + } + records, err := state.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(records) != 0 { + t.Fatalf("lifecycle records = %+v, want none before capacity admission", records) + } +} diff --git a/internal/provider/dockercontainer/docker_container.go b/internal/provider/dockercontainer/docker_container.go index 71fcc03..bada219 100644 --- a/internal/provider/dockercontainer/docker_container.go +++ b/internal/provider/dockercontainer/docker_container.go @@ -125,7 +125,7 @@ func (p *Provider) Delete(ctx context.Context, name string) error { } func (p *Provider) List(ctx context.Context) ([]provider.Instance, error) { - result, err := p.run(ctx, nil, "ps", "-a", "--filter", "label="+labelProvider, "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}") + result, err := p.run(ctx, nil, "ps", "-a", "--no-trunc", "--filter", "label="+labelProvider, "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}") if err != nil { return nil, err } @@ -135,11 +135,11 @@ func (p *Provider) List(ctx context.Context) ([]provider.Instance, error) { if line == "" { continue } - fields := strings.SplitN(line, "\t", 3) - if len(fields) < 3 { + fields := strings.SplitN(line, "\t", 4) + if len(fields) < 4 || strings.TrimSpace(fields[0]) == "" { continue } - out = append(out, provider.Instance{Name: fields[0], Source: fields[1], State: fields[2]}) + out = append(out, provider.Instance{ProviderID: "docker:" + fields[0], Name: fields[1], Source: fields[2], State: fields[3]}) } return out, nil } diff --git a/internal/provider/dockercontainer/docker_container_test.go b/internal/provider/dockercontainer/docker_container_test.go index d3561fe..649ba17 100644 --- a/internal/provider/dockercontainer/docker_container_test.go +++ b/internal/provider/dockercontainer/docker_container_test.go @@ -135,16 +135,16 @@ func TestExecRedactsSecretAssignmentsWithoutSensitiveValues(t *testing.T) { func TestListParsesDockerPSOutput(t *testing.T) { p := New("docker", "", false) p.runCommand = func(_ context.Context, _ io.Reader, _ string, _, _ io.Writer, args ...string) (provider.ExecResult, error) { - if strings.Join(args, " ") != "ps -a --filter label=epar.provider=docker-container --format {{.Names}}\t{{.Image}}\t{{.Status}}" { + if strings.Join(args, " ") != "ps -a --no-trunc --filter label=epar.provider=docker-container --format {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}" { t.Fatalf("unexpected list args: %#v", args) } - return provider.ExecResult{Stdout: "epar-docker-container-1\trunner-image\tUp 2 minutes\n"}, nil + return provider.ExecResult{Stdout: "0123456789abcdef\tepar-docker-container-1\trunner-image\tUp 2 minutes\n"}, nil } instances, err := p.List(context.Background()) if err != nil { t.Fatal(err) } - want := []provider.Instance{{Name: "epar-docker-container-1", Source: "runner-image", State: "Up 2 minutes"}} + want := []provider.Instance{{Name: "epar-docker-container-1", ProviderID: "docker:0123456789abcdef", Source: "runner-image", State: "Up 2 minutes"}} if !reflect.DeepEqual(instances, want) { t.Fatalf("instances = %#v, want %#v", instances, want) } diff --git a/internal/provider/dockersandboxes/capacity/capacity.go b/internal/provider/dockersandboxes/capacity/capacity.go new file mode 100644 index 0000000..dc25f75 --- /dev/null +++ b/internal/provider/dockersandboxes/capacity/capacity.go @@ -0,0 +1,186 @@ +// Package capacity derives Docker Sandboxes resource floors and evaluates +// host admission without performing external side effects. +package capacity + +import ( + "errors" + "fmt" + "math" +) + +const ( + GiB uint64 = 1 << 30 + MinimumRootDisk = 20 * GiB + RootWritableHeadroom = 20 * GiB + DockerDeletionHeadroom = 20 * GiB + MinimumDockerDisk = 100 * GiB + MinimumHostFreeSpace = 50 * GiB + rootRoundingQuantum = 10 * GiB +) + +// Requirements contains minimum sizes derived from one measured template and +// workload. These values are evidence inputs to a promotion manifest, not +// guesses made at provisioning time. +type Requirements struct { + RootDisk uint64 + DockerDisk uint64 + MinHostFreeSpace uint64 +} + +type HostSpace struct { + AvailableBytes uint64 + TotalBytes uint64 +} + +// HostWatermark returns the effective physical free-space floor for the +// backing volume. A configured or promoted floor may strengthen the rule, but +// can never weaken the larger of 50 GiB and ten percent of the current volume. +func HostWatermark(configured, backingVolumeSize uint64) (uint64, error) { + if backingVolumeSize == 0 { + return 0, errors.New("backing volume size must be greater than zero") + } + watermark := ceilDiv(backingVolumeSize, 10) + if watermark < MinimumHostFreeSpace { + watermark = MinimumHostFreeSpace + } + if configured > watermark { + watermark = configured + } + return watermark, nil +} + +// Derive calculates the resource floors required by the Docker Sandboxes +// promotion plan. The 25 percent additions use ceiling arithmetic so integer +// truncation can never weaken a floor. +func Derive(measuredRootPeak, representativeDockerPeak, backingVolumeSize uint64) (Requirements, error) { + if measuredRootPeak == 0 { + return Requirements{}, errors.New("measured root peak must be greater than zero") + } + if representativeDockerPeak == 0 { + return Requirements{}, errors.New("representative Docker peak must be greater than zero") + } + if backingVolumeSize == 0 { + return Requirements{}, errors.New("backing volume size must be greater than zero") + } + + rootDisk, err := DeriveRootDisk(measuredRootPeak, RootWritableHeadroom) + if err != nil { + return Requirements{}, fmt.Errorf("derive root disk: %w", err) + } + dockerDisk, err := addPercentAndHeadroom(representativeDockerPeak, DockerDeletionHeadroom) + if err != nil { + return Requirements{}, fmt.Errorf("derive Docker disk: %w", err) + } + if dockerDisk < MinimumDockerDisk { + dockerDisk = MinimumDockerDisk + } + hostWatermark, err := HostWatermark(0, backingVolumeSize) + if err != nil { + return Requirements{}, fmt.Errorf("derive host watermark: %w", err) + } + return Requirements{RootDisk: rootDisk, DockerDisk: dockerDisk, MinHostFreeSpace: hostWatermark}, nil +} + +// DeriveRootDisk turns a measured guest root-filesystem peak and operator +// headroom into the total root-disk capacity passed to Docker Sandboxes. The +// cached template size is deliberately not an input because it is host-cache +// storage, not measured guest root usage. +func DeriveRootDisk(measuredRootPeak, writableHeadroom uint64) (uint64, error) { + if measuredRootPeak == 0 { + return 0, errors.New("measured root peak must be greater than zero") + } + if writableHeadroom < RootWritableHeadroom { + return 0, fmt.Errorf("writable root headroom must be at least %d", RootWritableHeadroom) + } + rootWithMargin, err := addPercentAndHeadroom(measuredRootPeak, writableHeadroom) + if err != nil { + return 0, err + } + rootDisk, err := roundUp(rootWithMargin, rootRoundingQuantum) + if err != nil { + return 0, err + } + if rootDisk < MinimumRootDisk { + rootDisk = MinimumRootDisk + } + return rootDisk, nil +} + +func addPercentAndHeadroom(measured, headroom uint64) (uint64, error) { + quarter := ceilDiv(measured, 4) + if measured > math.MaxUint64-quarter { + return 0, errors.New("25 percent margin overflows uint64") + } + withMargin := measured + quarter + if withMargin > math.MaxUint64-headroom { + return 0, errors.New("headroom addition overflows uint64") + } + return withMargin + headroom, nil +} + +func roundUp(value, quantum uint64) (uint64, error) { + if quantum == 0 { + return 0, errors.New("rounding quantum must be greater than zero") + } + remainder := value % quantum + if remainder == 0 { + return value, nil + } + increment := quantum - remainder + if value > math.MaxUint64-increment { + return 0, errors.New("rounding overflows uint64") + } + return value + increment, nil +} + +func ceilDiv(value, divisor uint64) uint64 { + return value/divisor + boolToUint64(value%divisor != 0) +} + +func boolToUint64(value bool) uint64 { + if value { + return 1 + } + return 0 +} + +// Admission is a point-in-time host capacity decision. ReservedBytes includes +// all live and uncertain ledger reservations; uncertainty must never release a +// reservation. +type Admission struct { + HostFreeBytes uint64 + ReservedBytes uint64 + RequestedBytes uint64 + MinHostFreeSpace uint64 + ActiveCreates int + MaxConcurrentCreates int +} + +// Check rejects an admission when either the create-concurrency ceiling or the +// post-reservation physical free-space watermark would be crossed. +func (a Admission) Check() error { + if a.MaxConcurrentCreates <= 0 { + return errors.New("max concurrent creates must be greater than zero") + } + if a.ActiveCreates < 0 { + return errors.New("active creates must not be negative") + } + if a.ActiveCreates >= a.MaxConcurrentCreates { + return fmt.Errorf("create concurrency exhausted: %d active, limit %d", a.ActiveCreates, a.MaxConcurrentCreates) + } + if a.MinHostFreeSpace < MinimumHostFreeSpace { + return fmt.Errorf("host free-space watermark %d is below the hard minimum %d", a.MinHostFreeSpace, MinimumHostFreeSpace) + } + if a.ReservedBytes > a.HostFreeBytes { + return errors.New("existing reservations exceed reported host free space") + } + available := a.HostFreeBytes - a.ReservedBytes + if a.RequestedBytes > available { + return fmt.Errorf("requested reservation %d exceeds unreserved host free space %d", a.RequestedBytes, available) + } + remaining := available - a.RequestedBytes + if remaining < a.MinHostFreeSpace { + return fmt.Errorf("requested reservation would leave %d bytes, below watermark %d", remaining, a.MinHostFreeSpace) + } + return nil +} diff --git a/internal/provider/dockersandboxes/capacity/capacity_test.go b/internal/provider/dockersandboxes/capacity/capacity_test.go new file mode 100644 index 0000000..00d1f87 --- /dev/null +++ b/internal/provider/dockersandboxes/capacity/capacity_test.go @@ -0,0 +1,141 @@ +package capacity + +import ( + "math" + "testing" +) + +func TestDerivePromotionRequirementsFromMeasuredGuestUsage(t *testing.T) { + requirements, err := Derive(72*GiB, 80*GiB, 2_000*GiB) + if err != nil { + t.Fatal(err) + } + if got, want := requirements.RootDisk, 110*GiB; got != want { + t.Fatalf("root disk = %d, want %d", got, want) + } + if got, want := requirements.DockerDisk, 120*GiB; got != want { + t.Fatalf("Docker disk = %d, want %d", got, want) + } + if got, want := requirements.MinHostFreeSpace, 200*GiB; got != want { + t.Fatalf("host watermark = %d, want %d", got, want) + } +} + +func TestDeriveRootDiskSeparatesMeasuredPeakFromHeadroom(t *testing.T) { + got, err := DeriveRootDisk(324_780_032, RootWritableHeadroom) + if err != nil { + t.Fatal(err) + } + if want := uint64(30 * GiB); got != want { + t.Fatalf("root disk = %d, want %d", got, want) + } + for _, test := range []struct { + name string + peak uint64 + headroom uint64 + }{ + {name: "missing peak", headroom: RootWritableHeadroom}, + {name: "headroom below preview floor", peak: GiB, headroom: GiB}, + {name: "overflow", peak: math.MaxUint64, headroom: RootWritableHeadroom}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := DeriveRootDisk(test.peak, test.headroom); err == nil { + t.Fatal("DeriveRootDisk accepted invalid input") + } + }) + } +} + +func TestDeriveEnforcesAbsoluteFloors(t *testing.T) { + requirements, err := Derive(1*GiB, 1*GiB, 100*GiB) + if err != nil { + t.Fatal(err) + } + if got, want := requirements.RootDisk, 30*GiB; got != want { + t.Fatalf("root disk = %d, want %d", got, want) + } + if got, want := requirements.DockerDisk, MinimumDockerDisk; got != want { + t.Fatalf("Docker disk = %d, want %d", got, want) + } + if got, want := requirements.MinHostFreeSpace, MinimumHostFreeSpace; got != want { + t.Fatalf("host watermark = %d, want %d", got, want) + } +} + +func TestDeriveRejectsMissingAndOverflowingEvidence(t *testing.T) { + for _, test := range []struct { + name string + template, peak, disk uint64 + }{ + {name: "template", template: 0, peak: GiB, disk: GiB}, + {name: "peak", template: GiB, peak: 0, disk: GiB}, + {name: "volume", template: GiB, peak: GiB, disk: 0}, + {name: "overflow", template: math.MaxUint64, peak: GiB, disk: GiB}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := Derive(test.template, test.peak, test.disk); err == nil { + t.Fatal("Derive accepted invalid evidence") + } + }) + } +} + +func TestHostWatermarkUsesStrongestFloor(t *testing.T) { + tests := []struct { + name string + configured uint64 + volume uint64 + want uint64 + }{ + {name: "absolute floor", volume: 100 * GiB, want: 50 * GiB}, + {name: "volume percentage", volume: 2_000 * GiB, want: 200 * GiB}, + {name: "configured strengthening", configured: 250 * GiB, volume: 2_000 * GiB, want: 250 * GiB}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := HostWatermark(test.configured, test.volume) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("watermark = %d, want %d", got, test.want) + } + }) + } + if _, err := HostWatermark(0, 0); err == nil { + t.Fatal("HostWatermark accepted a zero-sized backing volume") + } +} + +func TestAdmissionAccountsForReservationsAndWatermark(t *testing.T) { + valid := Admission{ + HostFreeBytes: 500 * GiB, + ReservedBytes: 100 * GiB, + RequestedBytes: 120 * GiB, + MinHostFreeSpace: 200 * GiB, + ActiveCreates: 1, + MaxConcurrentCreates: 2, + } + if err := valid.Check(); err != nil { + t.Fatalf("valid admission rejected: %v", err) + } + + tests := []struct { + name string + mutate func(*Admission) + }{ + {name: "concurrency", mutate: func(a *Admission) { a.ActiveCreates = 2 }}, + {name: "weak watermark", mutate: func(a *Admission) { a.MinHostFreeSpace = 49 * GiB }}, + {name: "uncertain reservations", mutate: func(a *Admission) { a.ReservedBytes = 450 * GiB }}, + {name: "post-reservation watermark", mutate: func(a *Admission) { a.RequestedBytes = 250 * GiB }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := valid + test.mutate(&candidate) + if err := candidate.Check(); err == nil { + t.Fatal("admission unexpectedly passed") + } + }) + } +} diff --git a/internal/provider/dockersandboxes/capacity/storage_darwin.go b/internal/provider/dockersandboxes/capacity/storage_darwin.go new file mode 100644 index 0000000..0df8c26 --- /dev/null +++ b/internal/provider/dockersandboxes/capacity/storage_darwin.go @@ -0,0 +1,22 @@ +//go:build darwin + +package capacity + +import ( + "errors" + "os" + "path/filepath" +) + +// DockerSandboxesStorageRoot returns the documented macOS state root that +// contains Docker Sandboxes' persistent sandbox data. +func DockerSandboxesStorageRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if !filepath.IsAbs(home) { + return "", errors.New("home directory is not an absolute path") + } + return filepath.Join(home, "Library", "Application Support", "com.docker.sandboxes"), nil +} diff --git a/internal/provider/dockersandboxes/capacity/storage_linux.go b/internal/provider/dockersandboxes/capacity/storage_linux.go new file mode 100644 index 0000000..a013bdd --- /dev/null +++ b/internal/provider/dockersandboxes/capacity/storage_linux.go @@ -0,0 +1,26 @@ +//go:build linux + +package capacity + +import ( + "errors" + "os" + "path/filepath" +) + +// DockerSandboxesStorageRoot returns the documented XDG state root that +// contains Docker Sandboxes' persistent sandbox data. +func DockerSandboxesStorageRoot() (string, error) { + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + stateHome = filepath.Join(home, ".local", "state") + } + if !filepath.IsAbs(stateHome) { + return "", errors.New("XDG state home is not an absolute path") + } + return filepath.Join(stateHome, "sandboxes"), nil +} diff --git a/internal/provider/dockersandboxes/capacity/storage_linux_test.go b/internal/provider/dockersandboxes/capacity/storage_linux_test.go new file mode 100644 index 0000000..1812c1a --- /dev/null +++ b/internal/provider/dockersandboxes/capacity/storage_linux_test.go @@ -0,0 +1,27 @@ +//go:build linux + +package capacity + +import ( + "path/filepath" + "testing" +) + +func TestDockerSandboxesStorageRootUsesXDGStateHome(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + got, err := DockerSandboxesStorageRoot() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(stateHome, "sandboxes"); got != want { + t.Fatalf("storage root = %q, want %q", got, want) + } +} + +func TestDockerSandboxesStorageRootRejectsRelativeXDGStateHome(t *testing.T) { + t.Setenv("XDG_STATE_HOME", "relative-state") + if _, err := DockerSandboxesStorageRoot(); err == nil { + t.Fatal("relative XDG_STATE_HOME was accepted") + } +} diff --git a/internal/provider/dockersandboxes/capacity/storage_other.go b/internal/provider/dockersandboxes/capacity/storage_other.go new file mode 100644 index 0000000..71a5b39 --- /dev/null +++ b/internal/provider/dockersandboxes/capacity/storage_other.go @@ -0,0 +1,12 @@ +//go:build !windows && !linux && !darwin + +package capacity + +import ( + "fmt" + "runtime" +) + +func DockerSandboxesStorageRoot() (string, error) { + return "", fmt.Errorf("Docker Sandboxes storage discovery is unsupported on %s", runtime.GOOS) +} diff --git a/internal/provider/dockersandboxes/capacity/storage_windows.go b/internal/provider/dockersandboxes/capacity/storage_windows.go new file mode 100644 index 0000000..b84afe0 --- /dev/null +++ b/internal/provider/dockersandboxes/capacity/storage_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package capacity + +import ( + "errors" + "os" + "path/filepath" +) + +// DockerSandboxesStorageRoot returns the documented Windows root that contains +// Docker Sandboxes' persistent state, cache, configuration, and sandbox data. +func DockerSandboxesStorageRoot() (string, error) { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + return "", errors.New("LOCALAPPDATA is unavailable") + } + if !filepath.IsAbs(localAppData) { + return "", errors.New("LOCALAPPDATA is not an absolute path") + } + return filepath.Join(localAppData, "DockerSandboxes"), nil +} diff --git a/internal/provider/dockersandboxes/doc.go b/internal/provider/dockersandboxes/doc.go new file mode 100644 index 0000000..1347be5 --- /dev/null +++ b/internal/provider/dockersandboxes/doc.go @@ -0,0 +1,7 @@ +// Package dockersandboxes implements Docker Sandboxes host integration. +// +// Provider-owned capacity, staging, ownership, policy, and certification logic +// lives in the child packages below this directory. Pool-owned GitHub +// registration, readiness, replacement, status, and cleanup orchestration +// remains in internal/pool. +package dockersandboxes diff --git a/internal/provider/dockersandboxes/json_parsing.go b/internal/provider/dockersandboxes/json_parsing.go new file mode 100644 index 0000000..f229e2c --- /dev/null +++ b/internal/provider/dockersandboxes/json_parsing.go @@ -0,0 +1,285 @@ +package dockersandboxes + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +var cachedTemplateIDPattern = regexp.MustCompile(`^[a-f0-9]{12}$`) + +type cachedTemplate struct { + ID string + Repository string + Tag string + Flavor string + CreatedAt time.Time + Size int64 +} + +func parseTemplateInventory(data []byte) ([]cachedTemplate, error) { + var wrapper map[string]json.RawMessage + if err := decodeStrictJSON(data, &wrapper); err != nil { + return nil, fmt.Errorf("docker sandbox template inventory returned an unsupported json schema") + } + rawImages, ok := wrapper["images"] + if !ok || bytes.Equal(bytes.TrimSpace(rawImages), []byte("null")) { + return nil, fmt.Errorf("docker sandbox template inventory returned an unsupported json schema") + } + var records []map[string]json.RawMessage + if err := json.Unmarshal(rawImages, &records); err != nil { + return nil, fmt.Errorf("docker sandbox template inventory returned an unsupported json schema") + } + images := make([]cachedTemplate, 0, len(records)) + seenIDs := make(map[string]struct{}, len(records)) + seenReferences := make(map[string]struct{}, len(records)) + for _, record := range records { + id, idErr := requiredJSONString(record, "id") + repository, repositoryErr := requiredJSONString(record, "repository") + tag, tagErr := requiredJSONString(record, "tag") + flavor, flavorErr := optionalJSONString(record, "flavor") + createdAtText, createdAtErr := requiredJSONString(record, "created_at") + createdAt, timestampErr := time.Parse(time.RFC3339, createdAtText) + var size json.Number + rawSize, sizePresent := record["size"] + sizeErr := json.Unmarshal(rawSize, &size) + sizeValue, integerErr := strconv.ParseInt(size.String(), 10, 64) + if idErr != nil || !cachedTemplateIDPattern.MatchString(id) || repositoryErr != nil || tagErr != nil || flavorErr != nil || createdAtErr != nil || timestampErr != nil || !sizePresent || len(rawSize) == 0 || rawSize[0] < '0' || rawSize[0] > '9' || sizeErr != nil || integerErr != nil || sizeValue <= 0 { + return nil, fmt.Errorf("docker sandbox template inventory returned an unsupported image schema") + } + if !templatePattern.MatchString(repository) || !profilePattern.MatchString(tag) || flavor != "" && !profilePattern.MatchString(flavor) { + return nil, fmt.Errorf("docker sandbox template inventory returned an invalid image identity") + } + reference := repository + ":" + tag + if _, duplicate := seenIDs[id]; duplicate { + return nil, fmt.Errorf("docker sandbox template inventory returned a duplicate image id") + } + if _, duplicate := seenReferences[reference]; duplicate { + return nil, fmt.Errorf("docker sandbox template inventory returned a duplicate image reference") + } + seenIDs[id] = struct{}{} + seenReferences[reference] = struct{}{} + images = append(images, cachedTemplate{ID: id, Repository: repository, Tag: tag, Flavor: flavor, CreatedAt: createdAt, Size: sizeValue}) + } + return images, nil +} + +func parseLocalTemplateImage(data []byte) (LocalTemplateImage, error) { + var record map[string]json.RawMessage + if err := decodeStrictJSON(data, &record); err != nil { + return LocalTemplateImage{}, fmt.Errorf("local docker image inspection returned an unsupported json schema") + } + digest, digestErr := requiredJSONString(record, "Id") + osName, osErr := requiredJSONString(record, "Os") + architecture, architectureErr := requiredJSONString(record, "Architecture") + if digestErr != nil || osErr != nil || architectureErr != nil || !validFullTemplateDigest(digest) { + return LocalTemplateImage{}, fmt.Errorf("local docker image inspection returned an unsupported image schema") + } + platform := osName + "/" + architecture + if platform != "linux/amd64" && platform != "linux/arm64" { + return LocalTemplateImage{}, fmt.Errorf("local docker image inspection returned an unsupported linux template platform") + } + return LocalTemplateImage{Digest: digest, Platform: platform}, nil +} + +func parseInventory(data []byte) ([]provider.InventoryItem, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var wrapper map[string]json.RawMessage + if err := decoder.Decode(&wrapper); err != nil { + return nil, fmt.Errorf("docker sandboxes inventory returned an unsupported json schema") + } + if err := requireJSONEOF(decoder); err != nil { + return nil, fmt.Errorf("docker sandboxes inventory returned an unsupported json schema") + } + var records []map[string]json.RawMessage + rawSandboxes, ok := wrapper["sandboxes"] + if !ok || bytes.Equal(bytes.TrimSpace(rawSandboxes), []byte("null")) || json.Unmarshal(rawSandboxes, &records) != nil { + return nil, fmt.Errorf("docker sandboxes inventory returned an unsupported json schema") + } + items := make([]provider.InventoryItem, 0, len(records)) + seenNames := make(map[string]struct{}, len(records)) + seenIDs := make(map[string]struct{}, len(records)) + for _, record := range records { + id, err := requiredJSONString(record, "id") + if err != nil || !providerIDPattern.MatchString(id) { + return nil, fmt.Errorf("docker sandboxes inventory omitted a valid stable id") + } + name, err := requiredJSONString(record, "name") + if err != nil || !sandboxNamePattern.MatchString(name) { + return nil, fmt.Errorf("docker sandboxes inventory contained an invalid name") + } + status, err := requiredJSONString(record, "status") + if err != nil || strings.TrimSpace(status) == "" { + return nil, fmt.Errorf("docker sandboxes inventory omitted status") + } + agent, err := optionalJSONString(record, "agent") + if err != nil { + return nil, fmt.Errorf("docker sandboxes inventory returned an unsupported agent field") + } + workspaces, err := requiredStringArray(record, "workspaces") + if err != nil || len(workspaces) == 0 { + return nil, fmt.Errorf("docker sandboxes inventory omitted workspaces") + } + if _, duplicate := seenNames[name]; duplicate { + return nil, fmt.Errorf("docker sandboxes inventory returned a duplicate name") + } + if _, duplicate := seenIDs[id]; duplicate { + return nil, fmt.Errorf("docker sandboxes inventory returned a duplicate stable id") + } + seenNames[name] = struct{}{} + seenIDs[id] = struct{}{} + instance := provider.Instance{Name: name, ProviderID: id, Source: agent, State: status} + items = append(items, provider.InventoryItem{Instance: instance, State: status, Source: agent, Workspaces: workspaces}) + } + return items, nil +} + +func requiredStringArray(record map[string]json.RawMessage, key string) ([]string, error) { + raw, ok := record[key] + if !ok { + return nil, fmt.Errorf("missing field") + } + var values []string + if err := json.Unmarshal(raw, &values); err != nil || len(values) == 0 { + return nil, fmt.Errorf("invalid field") + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if strings.TrimSpace(value) == "" || strings.TrimSpace(value) != value || strings.ContainsRune(value, 0) { + return nil, fmt.Errorf("invalid field") + } + if _, duplicate := seen[value]; duplicate { + return nil, fmt.Errorf("duplicate field value") + } + seen[value] = struct{}{} + } + return values, nil +} + +func parseDaemonStatus(data []byte) (state string, healthy bool, err error) { + var record map[string]json.RawMessage + if err := decodeStrictJSON(data, &record); err != nil { + return "", false, fmt.Errorf("docker sandboxes daemon status returned an unsupported json schema") + } + raw, ok := record["status"] + if !ok || json.Unmarshal(raw, &state) != nil || strings.TrimSpace(state) == "" { + return "", false, fmt.Errorf("docker sandboxes daemon status returned an unsupported json schema") + } + normalized := strings.ToLower(state) + return state, normalized == "running", nil +} + +func parseDiagnose(data []byte) (passed, warned, failed, skipped int, err error) { + var record map[string]json.RawMessage + if err := decodeStrictJSON(data, &record); err != nil { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics returned an unsupported json schema") + } + version, versionErr := requiredJSONString(record, "version") + if versionErr != nil || version != "1.0" { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics returned an unsupported schema version") + } + rawChecks, ok := record["checks"] + if !ok { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics returned an unsupported json schema") + } + var checks []map[string]json.RawMessage + if err := json.Unmarshal(rawChecks, &checks); err != nil { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics returned an unsupported json schema") + } + for _, check := range checks { + if _, fieldErr := requiredJSONString(check, "name"); fieldErr != nil { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics returned an unsupported check schema") + } + for _, field := range []string{"message", "detail", "hint"} { + if _, fieldErr := requiredStringField(check, field); fieldErr != nil { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics returned an unsupported check schema") + } + } + status, statusErr := requiredJSONString(check, "status") + if statusErr != nil { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics returned an unsupported check schema") + } + switch strings.ToLower(status) { + case "pass": + passed++ + case "warn": + warned++ + case "fail": + failed++ + case "skip": + skipped++ + default: + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics returned an unknown check status") + } + } + var summary map[string]json.RawMessage + rawSummary, ok := record["summary"] + if !ok || json.Unmarshal(rawSummary, &summary) != nil { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics summary did not match its checks") + } + summaryCounts := make([]int, 4) + for index, key := range []string{"pass", "warn", "fail", "skip"} { + rawCount, present := summary[key] + if !present || json.Unmarshal(rawCount, &summaryCounts[index]) != nil || summaryCounts[index] < 0 { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics summary did not match its checks") + } + } + if summaryCounts[0] != passed || summaryCounts[1] != warned || summaryCounts[2] != failed || summaryCounts[3] != skipped { + return 0, 0, 0, 0, fmt.Errorf("docker sandboxes diagnostics summary did not match its checks") + } + return passed, warned, failed, skipped, nil +} + +func requiredJSONString(record map[string]json.RawMessage, key string) (string, error) { + raw, ok := record[key] + if !ok { + return "", fmt.Errorf("missing field") + } + var value string + if err := json.Unmarshal(raw, &value); err != nil || value == "" { + return "", fmt.Errorf("invalid field") + } + return value, nil +} + +func optionalJSONString(record map[string]json.RawMessage, key string) (string, error) { + raw, ok := record[key] + if !ok || string(raw) == "null" { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + return value, nil +} + +func requiredStringField(record map[string]json.RawMessage, key string) (string, error) { + raw, ok := record[key] + if !ok { + return "", fmt.Errorf("missing field") + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("invalid field") + } + return value, nil +} + +func requireJSONEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err == nil { + return fmt.Errorf("unexpected trailing json") + } else if err != io.EOF { + return err + } + return nil +} diff --git a/internal/provider/dockersandboxes/live_test.go b/internal/provider/dockersandboxes/live_test.go new file mode 100644 index 0000000..279bad5 --- /dev/null +++ b/internal/provider/dockersandboxes/live_test.go @@ -0,0 +1,200 @@ +package dockersandboxes + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + sandboxpolicy "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/policy" + sandboxfs "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/staging" +) + +func TestLiveCandidateAIsolation(t *testing.T) { + if os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES") != "1" { + t.Skip("set EPAR_LIVE_DOCKER_SANDBOXES=1 to run the destructive live Docker Sandboxes proof") + } + template := os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES_TEMPLATE") + digest := os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES_TEMPLATE_DIGEST") + stagingRoot := os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES_STAGING_ROOT") + if template == "" || digest == "" || stagingRoot == "" { + t.Fatal("live Docker Sandboxes template, digest, and absolute staging root are required") + } + staging, err := sandboxfs.Open(stagingRoot) + if err != nil { + t.Fatal(err) + } + name := fmt.Sprintf("epar-live-%d", time.Now().UnixNano()) + ownedStaging, err := staging.CreateOwned(name) + if err != nil { + t.Fatal(err) + } + path := ownedStaging.Path + rootDisk := os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES_ROOT_SIZE") + if rootDisk == "" { + rootDisk = "30GiB" + } + dockerDisk := os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES_DOCKER_SIZE") + if dockerDisk == "" { + dockerDisk = "100GiB" + } + p := New("sbx") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + createStarted := time.Now() + instance, err := p.Create(ctx, provider.CreateRequest{ + Name: name, + Template: template, + TemplateDigest: digest, + StagingPath: path, + CPUs: 4, + Memory: "8GiB", + RootDisk: rootDisk, + DockerDisk: dockerDisk, + }) + if err != nil { + t.Fatal(err) + } + t.Logf("cached sandbox create duration=%s", time.Since(createStarted)) + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cleanupCancel() + cleanupStarted := time.Now() + if stopErr := p.Stop(cleanupCtx, instance); stopErr != nil { + t.Errorf("stop exact live sandbox: %v", stopErr) + } + if deleteErr := p.Delete(cleanupCtx, instance); deleteErr != nil { + t.Errorf("delete exact live sandbox: %v", deleteErr) + } + if _, verifyErr := staging.VerifyOwnedEmpty(name, ownedStaging.Identity); verifyErr != nil { + t.Errorf("verify live staging remains empty: %v", verifyErr) + return + } + if removeErr := staging.RemoveEmptyOwned(name, ownedStaging.Identity); removeErr != nil { + t.Errorf("remove exact live staging: %v", removeErr) + } + t.Logf("exact stop, force-remove, and staging absence duration=%s", time.Since(cleanupStarted)) + }() + if _, err := p.Start(ctx, instance, provider.StartOptions{}); err != nil { + t.Fatal(err) + } + runtimeInfo, err := p.VerifyRuntime(ctx, instance) + if err != nil || !runtimeInfo.Ready || runtimeInfo.Runtime != "docker" || runtimeInfo.Version == "" { + t.Fatalf("private Docker runtime = %#v, error = %v", runtimeInfo, err) + } + daemonManagement, err := p.Exec(ctx, instance, []string{"bash", "-lc", `set -euo pipefail +mapfile -t pids < <(pgrep -x dockerd) +[[ "${#pids[@]}" == "1" ]] +ps -o pid=,ppid=,args= -p "${pids[0]}" +printf 'pid1=' +tr '\0' ' ' /dev/null 2>&1 || true + docker image rm "$image" >/dev/null 2>&1 || true + rm -rf -- "$workdir" +} +trap cleanup EXIT +mkdir -p "$workdir/build" +printf 'FROM scratch\nLABEL org.opencontainers.image.title="EPAR Docker Sandboxes Buildx probe"\n' >"$workdir/build/Dockerfile" +cat >"$workdir/compose.yml" <<'YAML' +services: + probe: + image: docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce + command: ["sleep", "120"] +YAML +docker buildx version +docker buildx inspect default +docker buildx build --builder default --load --tag "$image" "$workdir/build" +docker image inspect "$image" >/dev/null +docker compose version +docker compose --project-name "$project" --file "$workdir/compose.yml" up --detach +[[ "$(docker compose --project-name "$project" --file "$workdir/compose.yml" ps --status running --services)" == "probe" ]] +docker compose --project-name "$project" --file "$workdir/compose.yml" down --remove-orphans --volumes +trap - EXIT +docker image rm "$image" >/dev/null +rm -rf -- "$workdir" +`, "--", name + "-compose"}, provider.ExecOptions{}) + if err != nil { + t.Fatalf("representative Buildx and Compose workload failed: %v\n%s", err, representativeResult.Stderr) + } + t.Logf("representative Buildx and Compose workload duration=%s\n%s", time.Since(representativeStarted), strings.TrimSpace(representativeResult.Stdout)) + diskUsageAfter, err := p.Exec(ctx, instance, []string{"df", "-B1", "--output=used,target", "/", "/var/lib/docker"}, provider.ExecOptions{}) + if err != nil { + t.Fatal(err) + } + t.Logf("sandbox disk usage after representative nested workload:\n%s", strings.TrimSpace(diskUsageAfter.Stdout)) + if _, err := os.Lstat(filepath.Join(path, ".runner")); err == nil { + t.Fatal("runner registration state escaped into the canonical host staging directory") + } else if !os.IsNotExist(err) { + t.Fatal(err) + } +} diff --git a/internal/provider/dockersandboxes/network_policy.go b/internal/provider/dockersandboxes/network_policy.go new file mode 100644 index 0000000..aa2fdc5 --- /dev/null +++ b/internal/provider/dockersandboxes/network_policy.go @@ -0,0 +1,376 @@ +package dockersandboxes + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +func (p *Provider) ApplyNetworkPolicy(ctx context.Context, instance provider.Instance, rules []provider.NetworkPolicyRule) error { + if len(rules) == 0 { + return nil + } + present, err := p.assertIdentity(ctx, instance) + if err != nil { + return err + } + if !present { + return fmt.Errorf("docker sandbox is missing") + } + for _, rule := range rules { + if err := validateNetworkRule(rule, false); err != nil { + return err + } + args := []string{"policy", string(rule.Decision), "network", "--sandbox", instance.Name, strings.Join(rule.Resources, ",")} + result, runErr := p.run(ctx, commandRequest{args: args, operation: "apply docker sandbox network policy"}) + if runErr != nil && !strings.Contains(strings.ToLower(result.Stdout+"\n"+result.Stderr), "already covered") { + return runErr + } + } + actual, err := p.readNetworkPolicyVerified(ctx, instance) + if err != nil { + return err + } + for _, expected := range rules { + if !containsSandboxPolicyRule(actual, expected, instance.Name) { + return fmt.Errorf("docker sandbox network policy readback did not contain an applied rule") + } + } + return nil +} + +func (p *Provider) ReadNetworkPolicy(ctx context.Context, instance provider.Instance) ([]provider.NetworkPolicyRule, error) { + present, err := p.assertIdentity(ctx, instance) + if err != nil { + return nil, err + } + if !present { + return nil, fmt.Errorf("docker sandbox is missing") + } + return p.readNetworkPolicyVerified(ctx, instance) +} + +// ReadGlobalNetworkPolicy returns only the provider-wide policy baseline. It +// never changes policy and preserves the attribution supplied by Docker +// Sandboxes for every returned rule. +func (p *Provider) ReadGlobalNetworkPolicy(ctx context.Context) ([]provider.NetworkPolicyRule, error) { + if err := p.ensureVersion(ctx); err != nil { + return nil, err + } + result, err := p.run(ctx, commandRequest{ + args: []string{"policy", "ls", "--include-inactive", "--json"}, + operation: "read docker sandboxes global network policy", + outputLimit: diagnosticOutputLimit, + }) + if err != nil { + return nil, err + } + return parseGlobalNetworkPolicy([]byte(result.Stdout)) +} + +func (p *Provider) readNetworkPolicyVerified(ctx context.Context, instance provider.Instance) ([]provider.NetworkPolicyRule, error) { + result, err := p.run(ctx, commandRequest{ + args: []string{"policy", "ls", instance.Name, "--include-inactive", "--json"}, + operation: "read docker sandbox network policy", + }) + if err != nil { + return nil, err + } + return parseNetworkPolicy([]byte(result.Stdout), instance.Name) +} + +func (p *Provider) RemoveNetworkPolicy(ctx context.Context, instance provider.Instance, rules []provider.NetworkPolicyRule) error { + if len(rules) == 0 { + return nil + } + present, err := p.assertIdentity(ctx, instance) + if err != nil || !present { + return err + } + actual, err := p.readNetworkPolicyVerified(ctx, instance) + if err != nil { + return err + } + seenIDs := make(map[string]struct{}, len(rules)) + for _, rule := range rules { + if err := validateNetworkRule(rule, true); err != nil { + return err + } + if _, duplicate := seenIDs[rule.ID]; duplicate { + continue + } + seenIDs[rule.ID] = struct{}{} + matched, found := findPolicyRuleID(actual, rule.ID) + if !found { + continue + } + if !isRemovableSandboxPolicyRule(matched, instance.Name) { + return fmt.Errorf("refusing to remove a network policy rule that is not an editable local rule scoped to this sandbox") + } + if !sameStablePolicyRuleIdentity(matched, rule) { + return fmt.Errorf("refusing to remove a network policy rule whose stable identity changed") + } + result, runErr := p.run(ctx, commandRequest{ + args: []string{"policy", "rm", "network", "--sandbox", instance.Name, "--id", rule.ID}, + operation: "remove docker sandbox network policy", + }) + if runErr != nil && !isMissingPolicyRule(result.Stdout+"\n"+result.Stderr+"\n"+runErr.Error()) { + return runErr + } + } + remaining, err := p.readNetworkPolicyVerified(ctx, instance) + if err != nil { + return err + } + for _, removed := range rules { + if containsPolicyRuleID(remaining, removed.ID) { + return fmt.Errorf("docker sandbox network policy rule remained after exact removal") + } + } + return nil +} + +func parseGlobalNetworkPolicy(data []byte) ([]provider.NetworkPolicyRule, error) { + rules, err := parseNetworkPolicy(data, "") + if err != nil { + return nil, err + } + global := make([]provider.NetworkPolicyRule, 0, len(rules)) + for _, rule := range rules { + if rule.Scope == "global" && rule.AppliesTo == "all" { + global = append(global, rule) + } + } + return global, nil +} + +func parseNetworkPolicy(data []byte, sandboxName string) ([]provider.NetworkPolicyRule, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil || requireJSONEOF(decoder) != nil { + return nil, fmt.Errorf("docker sandbox network policy returned an unsupported json schema") + } + var records []map[string]json.RawMessage + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(raw, &wrapper); err != nil { + return nil, fmt.Errorf("docker sandbox network policy returned an unsupported json schema") + } + rulesJSON, ok := wrapper["rules"] + if !ok || bytes.Equal(bytes.TrimSpace(rulesJSON), []byte("null")) || json.Unmarshal(rulesJSON, &records) != nil { + return nil, fmt.Errorf("docker sandbox network policy returned an unsupported json schema") + } + + out := make([]provider.NetworkPolicyRule, 0, len(records)) + seenIDs := make(map[string]struct{}, len(records)) + for _, record := range records { + ruleType, err := requiredJSONString(record, "resource_type") + if err != nil { + return nil, fmt.Errorf("docker sandbox network policy rule omitted its type") + } + id, err := requiredJSONString(record, "id") + if err != nil || !providerIDPattern.MatchString(id) { + return nil, fmt.Errorf("docker sandbox network policy rule omitted a valid id") + } + decisionText, err := requiredJSONString(record, "decision") + if err != nil { + return nil, fmt.Errorf("docker sandbox network policy rule omitted its decision") + } + decision := provider.NetworkPolicyDecision(strings.ToLower(decisionText)) + if decision != provider.NetworkPolicyAllow && decision != provider.NetworkPolicyDeny { + return nil, fmt.Errorf("docker sandbox network policy rule used an unknown decision") + } + name, err := requiredJSONString(record, "name") + if err != nil { + return nil, fmt.Errorf("docker sandbox network policy rule omitted its name") + } + policyID, err := requiredJSONString(record, "policy_id") + if err != nil { + return nil, fmt.Errorf("docker sandbox network policy rule omitted its policy id") + } + scope, err := requiredJSONString(record, "scope") + if err != nil { + return nil, fmt.Errorf("docker sandbox network policy rule omitted its scope") + } + appliesTo, err := requiredJSONString(record, "applies_to") + if err != nil { + return nil, fmt.Errorf("docker sandbox network policy rule omitted its target") + } + resources, err := policyRuleResources(record) + if err != nil { + return nil, err + } + status, active, err := policyRuleStatus(record) + if err != nil { + return nil, err + } + origin, err := requiredJSONString(record, "origin") + if err != nil { + return nil, fmt.Errorf("docker sandbox network policy rule omitted its origin") + } + var editable bool + if rawEditable, ok := record["editable"]; !ok || json.Unmarshal(rawEditable, &editable) != nil { + return nil, fmt.Errorf("docker sandbox network policy rule omitted editable state") + } + if ruleType == "network" { + for _, resource := range resources { + if err := validateReadbackPolicyResource(resource); err != nil { + return nil, err + } + } + } + if _, duplicate := seenIDs[id]; duplicate { + return nil, fmt.Errorf("docker sandbox network policy returned a duplicate rule id") + } + seenIDs[id] = struct{}{} + if scope != "global" { + sandboxID, sandboxIDErr := requiredJSONString(record, "sandbox_id") + if sandboxIDErr != nil || scope != "sandbox:"+sandboxID || appliesTo != scope { + return nil, fmt.Errorf("docker sandbox policy returned an unsupported sandbox attribution") + } + } + relevant, targetErr := isRelevantPolicyTarget(scope, appliesTo, sandboxName) + if targetErr != nil { + return nil, targetErr + } + if !relevant { + continue + } + out = append(out, provider.NetworkPolicyRule{ + ID: id, + Name: name, + PolicyID: policyID, + Scope: scope, + AppliesTo: appliesTo, + ResourceType: ruleType, + Resources: append([]string(nil), resources...), + Decision: decision, + Origin: origin, + Status: status, + Editable: editable, + Active: active, + }) + } + return out, nil +} + +func validateReadbackPolicyResource(resource string) error { + if resource == "" || resource != strings.TrimSpace(resource) || len(resource) > 2048 || strings.ContainsAny(resource, "\x00\r\n\t") { + return fmt.Errorf("docker sandbox network policy returned an invalid resource") + } + return nil +} + +func policyRuleResources(record map[string]json.RawMessage) ([]string, error) { + raw := record["resources"] + if len(raw) == 0 { + return nil, fmt.Errorf("docker sandbox network policy rule omitted its resource") + } + var many []string + if json.Unmarshal(raw, &many) != nil || len(many) == 0 { + return nil, fmt.Errorf("docker sandbox network policy rule returned an invalid resource") + } + return many, nil +} + +func isSandboxPolicyTarget(scope, appliesTo, sandboxName string) bool { + target := "sandbox:" + sandboxName + return scope == target && appliesTo == target +} + +func isRelevantPolicyTarget(scope, appliesTo, sandboxName string) (bool, error) { + switch scope { + case "global": + if appliesTo != "all" { + return false, fmt.Errorf("docker sandbox policy returned an unsupported global target") + } + return true, nil + default: + if !strings.HasPrefix(scope, "sandbox:") || appliesTo != scope { + return false, fmt.Errorf("docker sandbox policy returned an unsupported sandbox target") + } + targetName := strings.TrimPrefix(scope, "sandbox:") + if !sandboxNamePattern.MatchString(targetName) { + return false, fmt.Errorf("docker sandbox policy returned an unsupported sandbox target") + } + return targetName == sandboxName, nil + } +} + +func policyRuleStatus(record map[string]json.RawMessage) (string, bool, error) { + status, err := requiredJSONString(record, "status") + if err != nil { + return "", false, fmt.Errorf("docker sandbox network policy rule omitted status") + } + switch strings.ToLower(status) { + case "active": + return status, true, nil + case "inactive": + return status, false, nil + default: + return "", false, fmt.Errorf("docker sandbox network policy rule returned unknown status") + } +} + +func containsSandboxPolicyRule(rules []provider.NetworkPolicyRule, expected provider.NetworkPolicyRule, sandboxName string) bool { + for _, expectedResource := range expected.Resources { + found := false + for _, rule := range rules { + if rule.Decision != expected.Decision || !isSandboxPolicyTarget(rule.Scope, rule.AppliesTo, sandboxName) { + continue + } + for _, actualResource := range rule.Resources { + if actualResource == expectedResource { + found = true + break + } + } + if found { + break + } + } + if !found { + return false + } + } + return true +} + +func containsPolicyRuleID(rules []provider.NetworkPolicyRule, id string) bool { + _, found := findPolicyRuleID(rules, id) + return found +} + +func findPolicyRuleID(rules []provider.NetworkPolicyRule, id string) (provider.NetworkPolicyRule, bool) { + for _, rule := range rules { + if rule.ID == id { + return rule, true + } + } + return provider.NetworkPolicyRule{}, false +} + +func isRemovableSandboxPolicyRule(rule provider.NetworkPolicyRule, sandboxName string) bool { + return rule.ResourceType == "network" && isSandboxPolicyTarget(rule.Scope, rule.AppliesTo, sandboxName) && strings.EqualFold(rule.Origin, "scoped") && rule.Editable +} + +func sameStablePolicyRuleIdentity(actual, expected provider.NetworkPolicyRule) bool { + if actual.ID != expected.ID || actual.PolicyID != expected.PolicyID || actual.Scope != expected.Scope || actual.AppliesTo != expected.AppliesTo || actual.ResourceType != expected.ResourceType || actual.Decision != expected.Decision || !strings.EqualFold(actual.Origin, expected.Origin) || actual.Editable != expected.Editable { + return false + } + actualResources := append([]string(nil), actual.Resources...) + expectedResources := append([]string(nil), expected.Resources...) + sort.Strings(actualResources) + sort.Strings(expectedResources) + return slices.Equal(actualResources, expectedResources) +} + +func isMissingPolicyRule(text string) bool { + text = strings.ToLower(text) + return strings.Contains(text, "rule not found") || strings.Contains(text, "no matching rule") || strings.Contains(text, "status 404") +} diff --git a/internal/provider/dockersandboxes/policy/policy.go b/internal/provider/dockersandboxes/policy/policy.go new file mode 100644 index 0000000..b1716ab --- /dev/null +++ b/internal/provider/dockersandboxes/policy/policy.go @@ -0,0 +1,262 @@ +// Package policy canonicalizes and verifies the complete effective +// Docker Sandboxes policy before a runner registration token is requested. +package policy + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +type canonicalRule struct { + ID string `json:"id"` + Name string `json:"name"` + PolicyID string `json:"policyId"` + Scope string `json:"scope"` + AppliesTo string `json:"appliesTo"` + ResourceType string `json:"resourceType"` + Resources []string `json:"resources"` + Decision provider.NetworkPolicyDecision `json:"decision"` + Origin string `json:"origin"` + Status string `json:"status"` + Editable bool `json:"editable"` + Active bool `json:"active"` +} + +// Fingerprint hashes all supplied rules and attribution fields in a stable +// order. It intentionally includes automatic and non-editable rules. +func Fingerprint(rules []provider.NetworkPolicyRule) (string, error) { + canonical := make([]canonicalRule, len(rules)) + for index, rule := range rules { + if err := validateAttributedRule(rule); err != nil { + return "", err + } + resources := append([]string(nil), rule.Resources...) + sort.Strings(resources) + canonical[index] = canonicalRule{ + ID: rule.ID, + Name: rule.Name, + PolicyID: rule.PolicyID, + Scope: rule.Scope, + AppliesTo: rule.AppliesTo, + ResourceType: rule.ResourceType, + Resources: resources, + Decision: rule.Decision, + Origin: rule.Origin, + Status: rule.Status, + Editable: rule.Editable, + Active: rule.Active, + } + } + sort.Slice(canonical, func(left, right int) bool { + leftJSON, _ := json.Marshal(canonical[left]) + rightJSON, _ := json.Marshal(canonical[right]) + return string(leftJSON) < string(rightJSON) + }) + encoded, err := json.Marshal(canonical) + if err != nil { + return "", fmt.Errorf("encode canonical Docker Sandboxes policy: %w", err) + } + digest := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(digest[:]), nil +} + +// VerifyBaseline requires a complete active global baseline plus only the +// exact built-in rule that Docker Sandboxes v0.35.0 may attach to a shell +// sandbox. The global content fingerprint remains stable across sandbox names +// and provider-generated rule IDs; the built-in rule is verified structurally. +func VerifyBaseline(expectedFingerprint, sandboxName string, rules []provider.NetworkPolicyRule) error { + global, scoped, err := partition(sandboxName, rules) + if err != nil { + return err + } + if err := verifyBalancedGlobal(global); err != nil { + return err + } + if err := verifyExpectedBuiltins(sandboxName, scoped); err != nil { + return err + } + actual, err := Fingerprint(global) + if err != nil { + return err + } + if actual != expectedFingerprint { + return fmt.Errorf("Docker Sandboxes balanced policy fingerprint mismatch: got %s, want %s", actual, expectedFingerprint) + } + return nil +} + +// VerifyEffective proves that the global baseline is unchanged and that every +// exact sandbox-scoped policy resource is active, scoped, editable, network-only, +// and represented by the configured allow/deny sets with no extras. +func VerifyEffective(expectedBaseline, sandboxName string, rules []provider.NetworkPolicyRule, allow, deny []string) error { + global, scoped, err := partition(sandboxName, rules) + if err != nil { + return err + } + if err := verifyBalancedGlobal(global); err != nil { + return err + } + actualBaseline, err := Fingerprint(global) + if err != nil { + return err + } + if actualBaseline != expectedBaseline { + return fmt.Errorf("Docker Sandboxes global policy changed after scoped policy application: got %s, want %s", actualBaseline, expectedBaseline) + } + want := make(map[string]provider.NetworkPolicyDecision, len(allow)+len(deny)) + for _, resource := range allow { + want[resource] = provider.NetworkPolicyAllow + } + for _, resource := range deny { + want[resource] = provider.NetworkPolicyDeny + } + seen := make(map[string]provider.NetworkPolicyDecision, len(want)) + builtinCount := 0 + for _, rule := range scoped { + if IsExpectedBuiltinRule(sandboxName, rule) { + builtinCount++ + continue + } + if rule.ResourceType != "network" || !strings.EqualFold(rule.Origin, "scoped") || !rule.Editable { + return fmt.Errorf("Docker Sandboxes policy contained an unmanaged sandbox-scoped rule %q", rule.ID) + } + for _, resource := range rule.Resources { + expectedDecision, exists := want[resource] + if !exists || expectedDecision != rule.Decision { + return fmt.Errorf("Docker Sandboxes policy contained unexpected sandbox-scoped resource %q", resource) + } + if previous, duplicate := seen[resource]; duplicate && previous != rule.Decision { + return fmt.Errorf("Docker Sandboxes policy contained conflicting decisions for %q", resource) + } + seen[resource] = rule.Decision + } + } + if builtinCount > 1 { + return fmt.Errorf("Docker Sandboxes policy contained %d duplicate built-in shell rules", builtinCount) + } + for resource, decision := range want { + if actual, exists := seen[resource]; !exists || actual != decision { + return fmt.Errorf("Docker Sandboxes policy did not activate configured %s resource %q", decision, resource) + } + } + return nil +} + +func verifyBalancedGlobal(rules []provider.NetworkPolicyRule) error { + networkAllowFound := false + for _, rule := range rules { + if rule.ResourceType != "network" { + continue + } + for _, resource := range rule.Resources { + host := resource + if separator := strings.LastIndex(resource, ":"); separator >= 0 { + host = resource[:separator] + } + if host == "*" || host == "**" { + if rule.Decision == provider.NetworkPolicyDeny { + return fmt.Errorf("Docker Sandboxes global policy is locked down rather than balanced") + } + if rule.Decision == provider.NetworkPolicyAllow { + return fmt.Errorf("Docker Sandboxes global policy is open rather than balanced") + } + } + if rule.Decision == provider.NetworkPolicyAllow { + networkAllowFound = true + } + } + } + if !networkAllowFound { + return fmt.Errorf("Docker Sandboxes global policy is locked down rather than balanced") + } + return nil +} + +func partition(sandboxName string, rules []provider.NetworkPolicyRule) (global, scoped []provider.NetworkPolicyRule, err error) { + if strings.TrimSpace(sandboxName) == "" { + return nil, nil, fmt.Errorf("Docker Sandboxes policy sandbox name is required") + } + seenIDs := make(map[string]struct{}, len(rules)) + for _, rule := range rules { + if err := validateAttributedRule(rule); err != nil { + return nil, nil, err + } + if _, duplicate := seenIDs[rule.ID]; duplicate { + return nil, nil, fmt.Errorf("Docker Sandboxes policy contained duplicate rule id %q", rule.ID) + } + seenIDs[rule.ID] = struct{}{} + if !rule.Active || !strings.EqualFold(rule.Status, "active") { + return nil, nil, fmt.Errorf("Docker Sandboxes policy rule %q is inactive", rule.ID) + } + switch { + case rule.Scope == "global" && rule.AppliesTo == "all": + global = append(global, rule) + case isExactSandboxScope(rule, sandboxName): + scoped = append(scoped, rule) + default: + return nil, nil, fmt.Errorf("Docker Sandboxes policy contained rule %q with unexpected scope %q and target %q", rule.ID, rule.Scope, rule.AppliesTo) + } + } + if len(global) == 0 { + return nil, nil, fmt.Errorf("Docker Sandboxes policy omitted its global baseline") + } + return global, scoped, nil +} + +func validateAttributedRule(rule provider.NetworkPolicyRule) error { + for key, value := range map[string]string{ + "id": rule.ID, "name": rule.Name, "policy id": rule.PolicyID, "scope": rule.Scope, "target": rule.AppliesTo, "resource type": rule.ResourceType, "origin": rule.Origin, "status": rule.Status, + } { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("Docker Sandboxes policy rule omitted %s", key) + } + } + if rule.Decision != provider.NetworkPolicyAllow && rule.Decision != provider.NetworkPolicyDeny { + return fmt.Errorf("Docker Sandboxes policy rule %q used unsupported decision %q", rule.ID, rule.Decision) + } + if len(rule.Resources) == 0 { + return fmt.Errorf("Docker Sandboxes policy rule %q omitted resources", rule.ID) + } + for _, resource := range rule.Resources { + if strings.TrimSpace(resource) == "" { + return fmt.Errorf("Docker Sandboxes policy rule %q contained an empty resource", rule.ID) + } + } + return nil +} + +func isExactSandboxScope(rule provider.NetworkPolicyRule, sandboxName string) bool { + target := "sandbox:" + sandboxName + return rule.Scope == target && rule.AppliesTo == target +} + +// IsExpectedBuiltinRule identifies the exact non-editable shell-kit egress +// rule observed in Docker Sandboxes v0.35.0. It deliberately checks every +// stable semantic field while allowing provider-generated IDs to vary. +func IsExpectedBuiltinRule(sandboxName string, rule provider.NetworkPolicyRule) bool { + return isExactSandboxScope(rule, sandboxName) && + rule.Name == "kit:"+sandboxName && + rule.ResourceType == "network" && + rule.Decision == provider.NetworkPolicyAllow && + len(rule.Resources) == 1 && rule.Resources[0] == "openrouter.ai" && + strings.EqualFold(rule.Origin, "scoped") && + strings.EqualFold(rule.Status, "active") && rule.Active && !rule.Editable +} + +func verifyExpectedBuiltins(sandboxName string, rules []provider.NetworkPolicyRule) error { + if len(rules) > 1 { + return fmt.Errorf("Docker Sandboxes policy contained %d unexpected pre-existing sandbox-scoped rules", len(rules)) + } + for _, rule := range rules { + if !IsExpectedBuiltinRule(sandboxName, rule) { + return fmt.Errorf("Docker Sandboxes policy contained unexpected pre-existing sandbox-scoped rule %q", rule.ID) + } + } + return nil +} diff --git a/internal/provider/dockersandboxes/policy/policy_test.go b/internal/provider/dockersandboxes/policy/policy_test.go new file mode 100644 index 0000000..e95d631 --- /dev/null +++ b/internal/provider/dockersandboxes/policy/policy_test.go @@ -0,0 +1,128 @@ +package policy + +import ( + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +func TestFingerprintIsOrderIndependentAndIncludesAttribution(t *testing.T) { + rules := baselineRules() + first, err := Fingerprint(rules) + if err != nil { + t.Fatal(err) + } + reversed := []provider.NetworkPolicyRule{rules[1], rules[0]} + second, err := Fingerprint(reversed) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatalf("fingerprint changed with rule order: %s != %s", first, second) + } + changed := append([]provider.NetworkPolicyRule(nil), rules...) + changed[0].Origin = "organization" + third, err := Fingerprint(changed) + if err != nil { + t.Fatal(err) + } + if first == third { + t.Fatal("fingerprint ignored policy attribution") + } +} + +func TestVerifyBaselineRejectsUnexpectedOrInactiveRules(t *testing.T) { + rules := baselineRules() + fingerprint, err := Fingerprint(rules) + if err != nil { + t.Fatal(err) + } + if err := VerifyBaseline(fingerprint, "epar-1", rules); err != nil { + t.Fatalf("valid baseline rejected: %v", err) + } + withBuiltin := append(append([]provider.NetworkPolicyRule(nil), rules...), builtinRule("epar-1")) + if err := VerifyBaseline(fingerprint, "epar-1", withBuiltin); err != nil { + t.Fatalf("exact v0.35.0 shell-kit baseline rejected: %v", err) + } + unexpected := append(append([]provider.NetworkPolicyRule(nil), rules...), scopedRule("epar-1", "rule-1", provider.NetworkPolicyAllow, "example.test")) + if err := VerifyBaseline(fingerprint, "epar-1", unexpected); err == nil { + t.Fatal("pre-existing scoped rule accepted") + } + inactive := append([]provider.NetworkPolicyRule(nil), rules...) + inactive[0].Active = false + inactive[0].Status = "inactive" + if err := VerifyBaseline(fingerprint, "epar-1", inactive); err == nil { + t.Fatal("inactive baseline rule accepted") + } +} + +func TestVerifyBaselineRejectsOpenAndLockedDownGlobalModes(t *testing.T) { + open := baselineRules() + open[0].Resources = append(open[0].Resources, "**") + openFingerprint, err := Fingerprint(open) + if err != nil { + t.Fatal(err) + } + if err := VerifyBaseline(openFingerprint, "epar-1", open); err == nil { + t.Fatal("open global policy accepted as balanced") + } + + lockedDown := baselineRules()[1:] + lockedDownFingerprint, err := Fingerprint(lockedDown) + if err != nil { + t.Fatal(err) + } + if err := VerifyBaseline(lockedDownFingerprint, "epar-1", lockedDown); err == nil { + t.Fatal("locked-down global policy accepted as balanced") + } + + denyAll := baselineRules() + denyAll = append(denyAll, provider.NetworkPolicyRule{ID: "deny-all", Name: "deny-all", PolicyID: "local-policy", Scope: "global", AppliesTo: "all", ResourceType: "network", Resources: []string{"**"}, Decision: provider.NetworkPolicyDeny, Origin: "local", Status: "active", Editable: true, Active: true}) + denyAllFingerprint, err := Fingerprint(denyAll) + if err != nil { + t.Fatal(err) + } + if err := VerifyBaseline(denyAllFingerprint, "epar-1", denyAll); err == nil { + t.Fatal("bounded allow plus universal deny accepted as balanced") + } +} + +func TestVerifyEffectiveRequiresExactScopedResources(t *testing.T) { + global := baselineRules() + fingerprint, err := Fingerprint(global) + if err != nil { + t.Fatal(err) + } + effective := append(append([]provider.NetworkPolicyRule(nil), global...), + builtinRule("epar-1"), + scopedRule("epar-1", "allow-1", provider.NetworkPolicyAllow, "api.example.test"), + scopedRule("epar-1", "deny-1", provider.NetworkPolicyDeny, "telemetry.example.test"), + ) + if err := VerifyEffective(fingerprint, "epar-1", effective, []string{"api.example.test"}, []string{"telemetry.example.test"}); err != nil { + t.Fatalf("valid effective policy rejected: %v", err) + } + extra := append(append([]provider.NetworkPolicyRule(nil), effective...), scopedRule("epar-1", "extra", provider.NetworkPolicyAllow, "unexpected.example.test")) + if err := VerifyEffective(fingerprint, "epar-1", extra, []string{"api.example.test"}, []string{"telemetry.example.test"}); err == nil { + t.Fatal("unexpected scoped rule accepted") + } + if err := VerifyEffective(fingerprint, "epar-1", effective[:len(effective)-1], []string{"api.example.test"}, []string{"telemetry.example.test"}); err == nil { + t.Fatal("missing configured rule accepted") + } +} + +func baselineRules() []provider.NetworkPolicyRule { + return []provider.NetworkPolicyRule{ + {ID: "network", Name: "network", PolicyID: "local-policy", Scope: "global", AppliesTo: "all", ResourceType: "network", Resources: []string{"github.com:443", "**.github.com:443"}, Decision: provider.NetworkPolicyAllow, Origin: "local", Status: "active", Editable: true, Active: true}, + {ID: "filesystem", Name: "filesystem", PolicyID: "local-policy", Scope: "global", AppliesTo: "all", ResourceType: "filesystem:read", Resources: []string{"**"}, Decision: provider.NetworkPolicyAllow, Origin: "local", Status: "active", Editable: false, Active: true}, + } +} + +func scopedRule(sandboxName, id string, decision provider.NetworkPolicyDecision, resource string) provider.NetworkPolicyRule { + target := "sandbox:" + sandboxName + return provider.NetworkPolicyRule{ID: id, Name: id, PolicyID: "local-policy", Scope: target, AppliesTo: target, ResourceType: "network", Resources: []string{resource}, Decision: decision, Origin: "scoped", Status: "active", Editable: true, Active: true} +} + +func builtinRule(sandboxName string) provider.NetworkPolicyRule { + target := "sandbox:" + sandboxName + return provider.NetworkPolicyRule{ID: "kit-rule", Name: "kit:" + sandboxName, PolicyID: "kit-policy", Scope: target, AppliesTo: target, ResourceType: "network", Resources: []string{"openrouter.ai"}, Decision: provider.NetworkPolicyAllow, Origin: "scoped", Status: "active", Editable: false, Active: true} +} diff --git a/internal/provider/dockersandboxes/process_other.go b/internal/provider/dockersandboxes/process_other.go new file mode 100644 index 0000000..f4fcf03 --- /dev/null +++ b/internal/provider/dockersandboxes/process_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package dockersandboxes + +import "os/exec" + +func isolateKeepaliveProcess(*exec.Cmd) {} diff --git a/internal/provider/dockersandboxes/process_windows.go b/internal/provider/dockersandboxes/process_windows.go new file mode 100644 index 0000000..93c9007 --- /dev/null +++ b/internal/provider/dockersandboxes/process_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package dockersandboxes + +import ( + "os/exec" + "syscall" +) + +func isolateKeepaliveProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + NoInheritHandles: true, + } +} diff --git a/internal/provider/dockersandboxes/promotion/preflight.go b/internal/provider/dockersandboxes/promotion/preflight.go new file mode 100644 index 0000000..15d0185 --- /dev/null +++ b/internal/provider/dockersandboxes/promotion/preflight.go @@ -0,0 +1,549 @@ +package promotion + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + sandboxcapacity "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/capacity" + sandboxpolicy "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/policy" +) + +const ( + DisableEnvironment = "EPAR_DISABLE_DOCKER_SANDBOXES" + preflightOutputLimit = 256 << 10 +) + +var ( + versionOutputPattern = regexp.MustCompile(`^(?:Docker Sandboxes|docker sandboxes|sbx) version:? v?([0-9]+\.[0-9]+\.[0-9]+)(?: [a-f0-9]{40})?\r?\n?$`) + sandboxScopePattern = regexp.MustCompile(`^sandbox:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) +) + +type Failure struct { + Gate string + Detail string + Resolution string +} + +type PreflightResult struct { + Failures []Failure +} + +type HostSpace = sandboxcapacity.HostSpace + +func (result PreflightResult) Passed() bool { + return len(result.Failures) == 0 +} + +type PreflightOptions struct { + ProjectRoot string + StorageRoot string + NativeController bool + ControllerRevision string + RunSBX func(context.Context, []string) ([]byte, error) + InspectTemplate func(context.Context, string) (string, error) + HostSpace func(string) (HostSpace, error) + CheckVirtualization func() error +} + +func LocalPreflight(ctx context.Context, record Record, projectRoot string, nativeController bool, controllerRevision string) PreflightResult { + if record.Platform != CurrentPlatform() { + return PreflightResult{Failures: []Failure{{ + Gate: "promoted platform", + Detail: fmt.Sprintf("promotion record targets %s, but the native controller is running on %s", record.Platform, CurrentPlatform()), + Resolution: "Explicitly choose another provider; never reuse a Docker Sandboxes promotion record across platforms.", + }}} + } + if os.Getenv(DisableEnvironment) == "1" { + return PreflightResult{Failures: []Failure{{ + Gate: "operator kill switch", + Detail: DisableEnvironment + "=1 disables Docker Sandboxes admission and automatic selection", + Resolution: "Unset the kill switch only after the Docker Sandboxes issue is resolved, or explicitly choose another provider.", + }}} + } + storageRoot, err := sandboxcapacity.DockerSandboxesStorageRoot() + if err != nil { + return PreflightResult{Failures: []Failure{{ + Gate: "resource availability", + Detail: fmt.Sprintf("cannot locate Docker Sandboxes provider storage: %v", err), + Resolution: "Use another explicitly selected provider until Docker Sandboxes storage can be located for capacity admission.", + }}} + } + return RunPreflight(ctx, record, PreflightOptions{ + ProjectRoot: projectRoot, + StorageRoot: storageRoot, + NativeController: nativeController, + ControllerRevision: controllerRevision, + RunSBX: runSBXCommand, + InspectTemplate: inspectLocalTemplateImage, + HostSpace: sandboxHostSpace, + CheckVirtualization: sandboxVirtualizationAvailable, + }) +} + +func RunPreflight(ctx context.Context, record Record, opts PreflightOptions) PreflightResult { + var result PreflightResult + add := func(gate, detail, resolution string) { + result.Failures = append(result.Failures, Failure{Gate: gate, Detail: detail, Resolution: resolution}) + } + if err := Validate(record); err != nil { + add("promotion record", err.Error(), "Use Docker Container or another explicitly selected provider until a valid platform promotion record is embedded.") + return result + } + if !validSHA256(opts.ControllerRevision) { + add("controller revision", "the running native controller does not report an exact clean source/build sha256 identity", "Rebuild the native controller from the exact clean promoted source tree with scripts/build-native-controller, then rerun setup.") + } else if opts.ControllerRevision != record.EPARRevision { + add("controller revision", fmt.Sprintf("the running native controller identity is %s, but the promotion record requires %s", opts.ControllerRevision, record.EPARRevision), "Rebuild and run the exact promoted native controller revision, or use another explicitly selected provider.") + } + if !opts.NativeController { + add("native controller", "EPAR is running inside the legacy controller container, where Docker Sandboxes host state is unavailable.", "Run EPAR through ./start or scripts/build-native-controller so the controller executes on the host.") + } + if opts.CheckVirtualization == nil { + add("virtualization", "the native virtualization check is unavailable", "Use Docker Container or another provider until EPAR can verify host virtualization.") + } else if err := opts.CheckVirtualization(); err != nil { + add("virtualization", err.Error(), "Enable the platform virtualization facility and ensure the current host user can access it, then rerun setup.") + } + if opts.StorageRoot == "" { + add("resource availability", "the Docker Sandboxes provider-storage path is unavailable", "Use another provider until EPAR can locate the actual Docker Sandboxes storage volume.") + } else if opts.HostSpace == nil { + add("resource availability", "the host free-space check is unavailable", "Use Docker Container or another provider until EPAR can verify host capacity.") + } else { + space, err := opts.HostSpace(opts.StorageRoot) + required, overflow := requiredHostFreeBytes(record, space.TotalBytes) + switch { + case err != nil: + add("resource availability", fmt.Sprintf("cannot read free space for Docker Sandboxes provider storage %s: %v", opts.StorageRoot, err), "Make the Docker Sandboxes storage filesystem available to the native controller, then rerun setup.") + case overflow: + add("resource availability", "the promoted disk reservation overflows the supported byte range", "Use Docker Container and report the invalid promotion record.") + case space.AvailableBytes < required: + add("resource availability", fmt.Sprintf("Docker Sandboxes storage free space is %d bytes; at least %d bytes are required for one promoted root disk, Docker disk, and the stronger of the promoted watermark or 10%% of the %d-byte provider-storage volume", space.AvailableBytes, required, space.TotalBytes), "Free space on the Docker Sandboxes provider-storage volume, then rerun setup.") + } + } + if opts.RunSBX == nil { + add("sbx command", "the Docker Sandboxes command runner is unavailable", "Install exactly sbx v0.35.0 on the native host, then rerun setup.") + return result + } + + versionOutput, versionErr := opts.RunSBX(ctx, []string{"version"}) + if versionErr != nil { + add("sbx version", versionErr.Error(), "Install exactly sbx v0.35.0 on the native host, then rerun setup.") + } else if !isExactVersion(versionOutput, record.SBXVersion) { + add("sbx version", fmt.Sprintf("installed version output does not prove exactly v%s", record.SBXVersion), "Install exactly sbx v0.35.0 on the native host, then rerun setup.") + } + + daemonOutput, daemonErr := opts.RunSBX(ctx, []string{"daemon", "status", "--json"}) + if daemonErr != nil { + add("daemon health", daemonErr.Error(), "Start or repair the Docker Sandboxes daemon, confirm sbx daemon status --json reports running, then rerun setup.") + } else if err := verifyDaemonRunning(daemonOutput); err != nil { + add("daemon health", err.Error(), "Start or repair the Docker Sandboxes daemon, confirm sbx daemon status --json reports running, then rerun setup.") + } + + diagnoseOutput, diagnoseErr := opts.RunSBX(ctx, []string{"diagnose", "--output", "json"}) + if diagnoseErr != nil { + add("daemon diagnostics", diagnoseErr.Error(), "Resolve every sbx diagnostic warning, failure, and skipped check, then rerun setup.") + add("authentication", "Docker authentication could not be verified because sbx diagnostics failed", "Run sbx login on the native host, confirm the Authentication diagnostic passes, then rerun setup.") + } else { + checks, err := parseDiagnostics(diagnoseOutput) + if err != nil { + add("daemon diagnostics", err.Error(), "Use exactly sbx v0.35.0 and resolve its diagnostics before rerunning setup.") + add("authentication", "Docker authentication could not be verified from the diagnostic result", "Run sbx login on the native host, confirm the Authentication diagnostic passes, then rerun setup.") + } else { + if failed := nonPassingChecks(checks); len(failed) != 0 { + add("daemon diagnostics", "non-passing checks: "+strings.Join(failed, ", "), "Resolve every sbx diagnostic warning, failure, and skipped check, then rerun setup.") + } + if err := verifyAuthenticationDiagnostic(checks); err != nil { + add("authentication", err.Error(), "Run sbx login on the native host, confirm the Authentication diagnostic passes, then rerun setup.") + } + } + } + + templateOutput, templateErr := opts.RunSBX(ctx, []string{"template", "ls", "--json"}) + if templateErr != nil { + add("promoted template", templateErr.Error(), fmt.Sprintf("Build and load the exact promoted template %s, then rerun setup.", record.Template)) + } else if err := verifyPromotedTemplate(templateOutput, record.Template, record.TemplateCacheID); err != nil { + add("promoted template", err.Error(), fmt.Sprintf("Build and load the exact promoted template %s with cache ID %s, then rerun setup.", record.Template, record.TemplateCacheID)) + } + if opts.InspectTemplate == nil { + add("promoted template evidence", "the independent local Docker image identity reader is unavailable", "Keep the full promoted template image in the local Docker image store, then rerun setup.") + } else { + fullIdentity, inspectErr := opts.InspectTemplate(ctx, record.Template) + fullIdentity = strings.TrimSpace(fullIdentity) + switch { + case inspectErr != nil: + add("promoted template evidence", inspectErr.Error(), "Restore the exact locally built and hash-anchored promoted template image, then rerun setup.") + case !validSHA256(fullIdentity): + add("promoted template evidence", "local Docker image inspection did not return a full lowercase sha256 identity", "Restore the exact locally built and hash-anchored promoted template image, then rerun setup.") + case fullIdentity != record.TemplateDigest: + add("promoted template evidence", fmt.Sprintf("full local Docker image identity is %s, want %s", fullIdentity, record.TemplateDigest), "Restore the exact locally built and hash-anchored promoted template image, then rerun setup.") + } + } + + policyOutput, policyErr := opts.RunSBX(ctx, []string{"policy", "ls", "--include-inactive", "--json"}) + if policyErr != nil { + add("promoted policy", policyErr.Error(), "Restore the exact promoted Docker Sandboxes global policy and rerun setup.") + } else if err := verifyPromotedPolicy(policyOutput, record.PolicyFingerprint); err != nil { + add("promoted policy", err.Error(), "Restore the exact promoted Docker Sandboxes global policy and rerun setup.") + } + return result +} + +func requiredHostFreeBytes(record Record, backingVolumeSize uint64) (uint64, bool) { + watermark, err := sandboxcapacity.HostWatermark(record.MinHostFreeSpaceBytes, backingVolumeSize) + if err != nil { + return 0, true + } + if record.RootDiskBytes > math.MaxUint64-record.DockerDiskBytes { + return 0, true + } + required := record.RootDiskBytes + record.DockerDiskBytes + if required > math.MaxUint64-watermark { + return 0, true + } + return required + watermark, false +} + +func runSBXCommand(ctx context.Context, args []string) ([]byte, error) { + if len(args) == 0 { + return nil, errors.New("refusing to invoke sbx without a subcommand") + } + if args[0] == "tui" || args[0] == "reset" { + return nil, fmt.Errorf("refusing to invoke forbidden sbx subcommand %q", args[0]) + } + command := exec.CommandContext(ctx, "sbx", args...) + command.Env = sandboxCommandEnvironment() + stdout := &preflightBuffer{limit: preflightOutputLimit} + stderr := &preflightBuffer{limit: preflightOutputLimit} + command.Stdout = stdout + command.Stderr = stderr + err := command.Run() + if stdout.overflow || stderr.overflow { + err = errors.Join(err, errors.New("sbx preflight output limit exceeded")) + } + if err != nil { + if text := strings.TrimSpace(stderr.String()); text != "" { + return nil, fmt.Errorf("sbx %s failed: %w: %s", args[0], err, text) + } + return nil, fmt.Errorf("sbx %s failed: %w", args[0], err) + } + return append([]byte(nil), stdout.Bytes()...), nil +} + +func inspectLocalTemplateImage(ctx context.Context, reference string) (string, error) { + command := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", reference) + command.Env = sandboxCommandEnvironment() + stdout := &preflightBuffer{limit: 1024} + stderr := &preflightBuffer{limit: 4096} + command.Stdout = stdout + command.Stderr = stderr + err := command.Run() + if stdout.overflow || stderr.overflow { + err = errors.Join(err, errors.New("Docker image inspection output limit exceeded")) + } + if err != nil { + if detail := strings.TrimSpace(stderr.String()); detail != "" { + return "", fmt.Errorf("docker image inspect failed: %w: %s", err, detail) + } + return "", fmt.Errorf("docker image inspect failed: %w", err) + } + return stdout.String(), nil +} + +func sandboxCommandEnvironment() []string { + environment := make([]string, 0, len(os.Environ())) + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + if strings.HasPrefix(strings.ToUpper(key), "DOCKER_SANDBOXES_") { + continue + } + environment = append(environment, item) + } + return environment +} + +type preflightBuffer struct { + bytes.Buffer + limit int + overflow bool +} + +func (buffer *preflightBuffer) Write(data []byte) (int, error) { + remaining := buffer.limit - buffer.Len() + if remaining <= 0 { + buffer.overflow = true + return len(data), nil + } + if len(data) > remaining { + _, _ = buffer.Buffer.Write(data[:remaining]) + buffer.overflow = true + return len(data), nil + } + return buffer.Buffer.Write(data) +} + +func isExactVersion(output []byte, expected string) bool { + matches := versionOutputPattern.FindSubmatch(output) + return len(matches) == 2 && string(matches[1]) == expected +} + +func decodeStrictJSON(data []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(value); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); err == nil { + return errors.New("unexpected trailing JSON value") + } else if !errors.Is(err, io.EOF) { + return err + } + return nil +} + +func verifyDaemonRunning(data []byte) error { + var record map[string]json.RawMessage + if err := decodeStrictJSON(data, &record); err != nil { + return errors.New("Docker Sandboxes daemon status returned unsupported JSON") + } + var status string + if raw, ok := record["status"]; !ok || json.Unmarshal(raw, &status) != nil || !strings.EqualFold(strings.TrimSpace(status), "running") { + return fmt.Errorf("Docker Sandboxes daemon status is not running") + } + return nil +} + +type diagnosticCheck struct { + Name string + Status string +} + +func parseDiagnostics(data []byte) ([]diagnosticCheck, error) { + var record map[string]json.RawMessage + if err := decodeStrictJSON(data, &record); err != nil { + return nil, errors.New("Docker Sandboxes diagnostics returned unsupported JSON") + } + var version string + if raw, ok := record["version"]; !ok || json.Unmarshal(raw, &version) != nil || version != "1.0" { + return nil, errors.New("Docker Sandboxes diagnostics returned an unsupported schema version") + } + var rawChecks []map[string]json.RawMessage + if raw, ok := record["checks"]; !ok || json.Unmarshal(raw, &rawChecks) != nil || len(rawChecks) == 0 { + return nil, errors.New("Docker Sandboxes diagnostics omitted its checks") + } + checks := make([]diagnosticCheck, 0, len(rawChecks)) + counts := map[string]int{"pass": 0, "warn": 0, "fail": 0, "skip": 0} + for _, rawCheck := range rawChecks { + name, err := requiredString(rawCheck, "name", false) + if err != nil { + return nil, errors.New("Docker Sandboxes diagnostics returned an unsupported check schema") + } + status, err := requiredString(rawCheck, "status", false) + if err != nil { + return nil, errors.New("Docker Sandboxes diagnostics returned an unsupported check schema") + } + for _, field := range []string{"message", "detail", "hint"} { + if _, err := requiredString(rawCheck, field, true); err != nil { + return nil, errors.New("Docker Sandboxes diagnostics returned an unsupported check schema") + } + } + status = strings.ToLower(status) + if _, ok := counts[status]; !ok { + return nil, errors.New("Docker Sandboxes diagnostics returned an unknown check status") + } + counts[status]++ + checks = append(checks, diagnosticCheck{Name: name, Status: status}) + } + var summary map[string]json.RawMessage + if raw, ok := record["summary"]; !ok || json.Unmarshal(raw, &summary) != nil { + return nil, errors.New("Docker Sandboxes diagnostic summary is missing") + } + for _, key := range []string{"pass", "warn", "fail", "skip"} { + var count int + if raw, ok := summary[key]; !ok || json.Unmarshal(raw, &count) != nil || count != counts[key] { + return nil, errors.New("Docker Sandboxes diagnostic summary did not match its checks") + } + } + return checks, nil +} + +func nonPassingChecks(checks []diagnosticCheck) []string { + var failed []string + for _, check := range checks { + if check.Status != "pass" { + failed = append(failed, check.Name+"="+check.Status) + } + } + return failed +} + +func verifyAuthenticationDiagnostic(checks []diagnosticCheck) error { + for _, check := range checks { + if strings.EqualFold(strings.TrimSpace(check.Name), "Authentication") { + if check.Status != "pass" { + return fmt.Errorf("Docker Sandboxes Authentication diagnostic is %s", check.Status) + } + return nil + } + } + return errors.New("Docker Sandboxes diagnostics omitted the Authentication check") +} + +func verifyPromotedTemplate(data []byte, reference, cacheID string) error { + repository, tag, err := splitTemplateReference(reference) + if err != nil { + return err + } + if !validTemplateCacheID(cacheID) { + return errors.New("promoted Docker Sandboxes template cache ID is invalid") + } + var wrapper map[string]json.RawMessage + if err := decodeStrictJSON(data, &wrapper); err != nil { + return errors.New("Docker Sandboxes template inventory returned unsupported JSON") + } + var images []map[string]json.RawMessage + if raw, ok := wrapper["images"]; !ok || json.Unmarshal(raw, &images) != nil { + return errors.New("Docker Sandboxes template inventory omitted images") + } + for _, image := range images { + actualRepository, repositoryErr := requiredString(image, "repository", false) + actualTag, tagErr := requiredString(image, "tag", false) + actualID, idErr := requiredString(image, "id", false) + createdAt, createdAtErr := requiredString(image, "created_at", false) + var size json.Number + sizeErr := json.Unmarshal(image["size"], &size) + sizeValue, integerErr := strconv.ParseInt(size.String(), 10, 64) + if repositoryErr != nil || tagErr != nil || idErr != nil || !validTemplateCacheID(actualID) || createdAtErr != nil || sizeErr != nil || integerErr != nil || sizeValue <= 0 { + return errors.New("Docker Sandboxes template inventory returned an unsupported image schema") + } + if _, err := time.Parse(time.RFC3339, createdAt); err != nil { + return errors.New("Docker Sandboxes template inventory returned an invalid creation time") + } + if actualRepository == repository && actualTag == tag { + if actualID != cacheID { + return fmt.Errorf("cached template cache ID %s does not match promoted cache ID %s", actualID, cacheID) + } + return nil + } + } + return fmt.Errorf("promoted template %s is not present in the Docker Sandboxes cache", reference) +} + +func splitTemplateReference(reference string) (string, string, error) { + separator := strings.LastIndex(reference, ":") + if separator <= strings.LastIndex(reference, "/") || separator == len(reference)-1 || strings.Contains(reference, "@") { + return "", "", errors.New("promoted Docker Sandboxes template must be an exact repository:tag reference") + } + repository, tag := reference[:separator], reference[separator+1:] + if repository == "" || tag == "" { + return "", "", errors.New("promoted Docker Sandboxes template must be an exact repository:tag reference") + } + if !strings.Contains(repository, "/") { + repository = "docker.io/library/" + repository + } else { + first := strings.SplitN(repository, "/", 2)[0] + if first != "localhost" && !strings.ContainsAny(first, ".:") { + repository = "docker.io/" + repository + } + } + return repository, tag, nil +} + +func verifyPromotedPolicy(data []byte, expected string) error { + rules, err := parseGlobalPolicy(data) + if err != nil { + return err + } + actual, err := sandboxpolicy.Fingerprint(rules) + if err != nil { + return fmt.Errorf("fingerprint Docker Sandboxes global policy: %w", err) + } + if actual != expected { + return fmt.Errorf("Docker Sandboxes global policy fingerprint is %s, want %s", actual, expected) + } + return nil +} + +func parseGlobalPolicy(data []byte) ([]provider.NetworkPolicyRule, error) { + var wrapper map[string]json.RawMessage + if err := decodeStrictJSON(data, &wrapper); err != nil { + return nil, errors.New("Docker Sandboxes policy inventory returned unsupported JSON") + } + var records []map[string]json.RawMessage + if raw, ok := wrapper["rules"]; !ok || json.Unmarshal(raw, &records) != nil { + return nil, errors.New("Docker Sandboxes policy inventory omitted rules") + } + var global []provider.NetworkPolicyRule + seen := make(map[string]struct{}, len(records)) + for _, record := range records { + id, idErr := requiredString(record, "id", false) + name, nameErr := requiredString(record, "name", false) + policyID, policyErr := requiredString(record, "policy_id", false) + scope, scopeErr := requiredString(record, "scope", false) + appliesTo, targetErr := requiredString(record, "applies_to", false) + resourceType, typeErr := requiredString(record, "resource_type", false) + decisionText, decisionErr := requiredString(record, "decision", false) + origin, originErr := requiredString(record, "origin", false) + status, statusErr := requiredString(record, "status", false) + var resources []string + resourcesErr := json.Unmarshal(record["resources"], &resources) + var editable bool + editableErr := json.Unmarshal(record["editable"], &editable) + if idErr != nil || nameErr != nil || policyErr != nil || scopeErr != nil || targetErr != nil || typeErr != nil || decisionErr != nil || originErr != nil || statusErr != nil || resourcesErr != nil || len(resources) == 0 || editableErr != nil { + return nil, errors.New("Docker Sandboxes policy inventory returned an unsupported rule schema") + } + if _, duplicate := seen[id]; duplicate { + return nil, fmt.Errorf("Docker Sandboxes policy inventory returned duplicate rule id %q", id) + } + seen[id] = struct{}{} + decision := provider.NetworkPolicyDecision(strings.ToLower(decisionText)) + if decision != provider.NetworkPolicyAllow && decision != provider.NetworkPolicyDeny { + return nil, fmt.Errorf("Docker Sandboxes policy rule %q has unsupported decision %q", id, decisionText) + } + if scope != "global" { + if !sandboxScopePattern.MatchString(scope) || appliesTo != scope { + return nil, fmt.Errorf("Docker Sandboxes policy rule %q has unsupported scope", id) + } + if !strings.EqualFold(status, "active") { + return nil, fmt.Errorf("Docker Sandboxes sandbox-scoped policy rule %q is not active", id) + } + continue + } + if appliesTo != "all" { + return nil, fmt.Errorf("Docker Sandboxes global policy rule %q has unsupported target %q", id, appliesTo) + } + if !strings.EqualFold(status, "active") { + return nil, fmt.Errorf("Docker Sandboxes global policy rule %q is not active", id) + } + global = append(global, provider.NetworkPolicyRule{ + ID: id, Name: name, PolicyID: policyID, Scope: scope, AppliesTo: appliesTo, ResourceType: resourceType, Resources: resources, + Decision: decision, Origin: origin, Status: status, Editable: editable, Active: true, + }) + } + if len(global) == 0 { + return nil, errors.New("Docker Sandboxes policy inventory omitted its global baseline") + } + return global, nil +} + +func requiredString(record map[string]json.RawMessage, key string, allowEmpty bool) (string, error) { + raw, ok := record[key] + if !ok { + return "", errors.New("missing field") + } + var value string + if json.Unmarshal(raw, &value) != nil || !allowEmpty && strings.TrimSpace(value) == "" { + return "", errors.New("invalid field") + } + return value, nil +} diff --git a/internal/provider/dockersandboxes/promotion/preflight_test.go b/internal/provider/dockersandboxes/promotion/preflight_test.go new file mode 100644 index 0000000..22c9608 --- /dev/null +++ b/internal/provider/dockersandboxes/promotion/preflight_test.go @@ -0,0 +1,341 @@ +package promotion + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "testing" + "time" + + sandboxpolicy "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/policy" +) + +func TestRunPreflightPassesEveryIndependentGateWithExactReadOnlyArgv(t *testing.T) { + record, outputs := validPreflightFixture(t) + var commands [][]string + storageRoot := t.TempDir() + result := RunPreflight(context.Background(), record, PreflightOptions{ + ProjectRoot: t.TempDir(), + StorageRoot: storageRoot, + NativeController: true, + ControllerRevision: record.EPARRevision, + RunSBX: func(_ context.Context, args []string) ([]byte, error) { + commands = append(commands, append([]string(nil), args...)) + output, ok := outputs[strings.Join(args, "\x00")] + if !ok { + return nil, fmt.Errorf("unexpected command %#v", args) + } + return append([]byte(nil), output...), nil + }, + InspectTemplate: func(_ context.Context, reference string) (string, error) { + if reference != record.Template { + t.Fatalf("inspected template = %q, want %q", reference, record.Template) + } + return record.TemplateDigest, nil + }, + HostSpace: func(path string) (HostSpace, error) { + if path != storageRoot { + t.Fatalf("capacity path = %q, want provider storage %q", path, storageRoot) + } + required, overflow := requiredHostFreeBytes(record, 500<<30) + if overflow { + t.Fatal("valid fixture resource total overflowed") + } + return HostSpace{AvailableBytes: required, TotalBytes: 500 << 30}, nil + }, + CheckVirtualization: func() error { return nil }, + }) + if !result.Passed() { + t.Fatalf("preflight failures = %+v", result.Failures) + } + want := [][]string{ + {"version"}, + {"daemon", "status", "--json"}, + {"diagnose", "--output", "json"}, + {"template", "ls", "--json"}, + {"policy", "ls", "--include-inactive", "--json"}, + } + if !slices.EqualFunc(commands, want, slices.Equal[[]string]) { + t.Fatalf("commands = %#v, want %#v", commands, want) + } + for _, args := range commands { + if len(args) == 0 || args[0] == "tui" || args[0] == "reset" { + t.Fatalf("unsafe Docker Sandboxes argv invoked: %#v", args) + } + } +} + +func TestRunPreflightFailsClosedForEveryAdmissionGate(t *testing.T) { + tests := []struct { + name string + gate string + edit func(*Record, map[string][]byte, *PreflightOptions) + }{ + { + name: "unknown controller revision", + gate: "controller revision", + edit: func(_ *Record, _ map[string][]byte, opts *PreflightOptions) { + opts.ControllerRevision = "unknown" + }, + }, + { + name: "stale controller revision", + gate: "controller revision", + edit: func(_ *Record, _ map[string][]byte, opts *PreflightOptions) { + opts.ControllerRevision = "sha256:" + strings.Repeat("8", 64) + }, + }, + { + name: "native controller", + gate: "native controller", + edit: func(_ *Record, _ map[string][]byte, opts *PreflightOptions) { opts.NativeController = false }, + }, + { + name: "virtualization", + gate: "virtualization", + edit: func(_ *Record, _ map[string][]byte, opts *PreflightOptions) { + opts.CheckVirtualization = func() error { return errors.New("hardware virtualization unavailable") } + }, + }, + { + name: "resource availability", + gate: "resource availability", + edit: func(_ *Record, _ map[string][]byte, opts *PreflightOptions) { + opts.HostSpace = func(string) (HostSpace, error) { return HostSpace{AvailableBytes: 1, TotalBytes: 500 << 30}, nil } + }, + }, + { + name: "provider storage path", + gate: "resource availability", + edit: func(_ *Record, _ map[string][]byte, opts *PreflightOptions) { + opts.StorageRoot = "" + }, + }, + { + name: "version", + gate: "sbx version", + edit: func(_ *Record, outputs map[string][]byte, _ *PreflightOptions) { + outputs["version"] = []byte("sbx version: v0.35.1 01e01520456e4126a9653471e7072e4d9b280321\n") + }, + }, + { + name: "daemon", + gate: "daemon health", + edit: func(_ *Record, outputs map[string][]byte, _ *PreflightOptions) { + outputs["daemon\x00status\x00--json"] = []byte(`{"status":"stopped"}`) + }, + }, + { + name: "authentication", + gate: "authentication", + edit: func(_ *Record, outputs map[string][]byte, _ *PreflightOptions) { + outputs["diagnose\x00--output\x00json"] = []byte(diagnosticsFixture("Authentication", "fail")) + }, + }, + { + name: "diagnostics warning", + gate: "daemon diagnostics", + edit: func(_ *Record, outputs map[string][]byte, _ *PreflightOptions) { + outputs["diagnose\x00--output\x00json"] = []byte(diagnosticsFixture("Storage directories", "warn")) + }, + }, + { + name: "template", + gate: "promoted template", + edit: func(_ *Record, outputs map[string][]byte, _ *PreflightOptions) { + outputs["template\x00ls\x00--json"] = []byte(`{"images":[{"id":"bbbbbbbbbbbb","repository":"docker.io/library/epar-template","tag":"promoted","flavor":"shell","created_at":"2026-07-23T00:00:00Z","size":1024}]}`) + }, + }, + { + name: "full template evidence", + gate: "promoted template evidence", + edit: func(_ *Record, _ map[string][]byte, opts *PreflightOptions) { + opts.InspectTemplate = func(context.Context, string) (string, error) { + return "sha256:" + strings.Repeat("9", 64), nil + } + }, + }, + { + name: "policy", + gate: "promoted policy", + edit: func(record *Record, _ map[string][]byte, _ *PreflightOptions) { + record.PolicyFingerprint = "sha256:" + strings.Repeat("9", 64) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + record, outputs := validPreflightFixture(t) + required, overflow := requiredHostFreeBytes(record, 500<<30) + if overflow { + t.Fatal("valid fixture resource total overflowed") + } + opts := PreflightOptions{ + ProjectRoot: t.TempDir(), + StorageRoot: t.TempDir(), + NativeController: true, + ControllerRevision: record.EPARRevision, + RunSBX: fixtureCommandRunner(outputs), + InspectTemplate: func(context.Context, string) (string, error) { + return record.TemplateDigest, nil + }, + HostSpace: func(string) (HostSpace, error) { + return HostSpace{AvailableBytes: required, TotalBytes: 500 << 30}, nil + }, + CheckVirtualization: func() error { return nil }, + } + test.edit(&record, outputs, &opts) + result := RunPreflight(context.Background(), record, opts) + if result.Passed() { + t.Fatal("preflight unexpectedly passed") + } + found := false + for _, failure := range result.Failures { + if failure.Gate == test.gate && failure.Detail != "" && failure.Resolution != "" { + found = true + break + } + } + if !found { + t.Fatalf("preflight failures = %+v, want actionable %q failure", result.Failures, test.gate) + } + }) + } +} + +func TestRunPreflightDoesNotInferVirtualizationFromDiagnostics(t *testing.T) { + record, outputs := validPreflightFixture(t) + required, _ := requiredHostFreeBytes(record, 500<<30) + result := RunPreflight(context.Background(), record, PreflightOptions{ + ProjectRoot: t.TempDir(), + StorageRoot: t.TempDir(), + NativeController: true, + ControllerRevision: record.EPARRevision, + RunSBX: fixtureCommandRunner(outputs), + InspectTemplate: func(context.Context, string) (string, error) { + return record.TemplateDigest, nil + }, + HostSpace: func(string) (HostSpace, error) { + return HostSpace{AvailableBytes: required, TotalBytes: 500 << 30}, nil + }, + CheckVirtualization: func() error { return errors.New("independent host virtualization proof failed") }, + }) + if result.Passed() { + t.Fatal("preflight passed using diagnostics that contain no virtualization check") + } + for _, failure := range result.Failures { + if failure.Gate == "virtualization" && strings.Contains(failure.Detail, "independent host") { + return + } + } + t.Fatalf("preflight failures = %+v, want independent virtualization failure", result.Failures) +} + +func TestLocalPreflightRejectsCrossPlatformRecordAndKillSwitchBeforeCommands(t *testing.T) { + record, _ := validPreflightFixture(t) + if record.Platform == CurrentPlatform() { + record.Platform = DarwinARM64 + if record.Platform == CurrentPlatform() { + record.Platform = LinuxAMD64 + } + } + result := LocalPreflight(context.Background(), record, t.TempDir(), true, record.EPARRevision) + if result.Passed() || len(result.Failures) != 1 || result.Failures[0].Gate != "promoted platform" { + t.Fatalf("cross-platform preflight result = %+v", result) + } + + record.Platform = CurrentPlatform() + t.Setenv(DisableEnvironment, "1") + result = LocalPreflight(context.Background(), record, t.TempDir(), true, record.EPARRevision) + if result.Passed() || len(result.Failures) != 1 || result.Failures[0].Gate != "operator kill switch" { + t.Fatalf("kill-switch preflight result = %+v", result) + } +} + +func fixtureCommandRunner(outputs map[string][]byte) func(context.Context, []string) ([]byte, error) { + return func(_ context.Context, args []string) ([]byte, error) { + output, ok := outputs[strings.Join(args, "\x00")] + if !ok { + return nil, fmt.Errorf("unexpected command %#v", args) + } + return append([]byte(nil), output...), nil + } +} + +func validPreflightFixture(t *testing.T) (Record, map[string][]byte) { + t.Helper() + policyJSON := []byte(`{"rules":[{"id":"global-1","name":"automatic baseline","policy_id":"local-policy","scope":"global","applies_to":"all","resource_type":"network","decision":"allow","resources":["api.github.com"],"origin":"local","status":"active","editable":true}]}`) + rules, err := parseGlobalPolicy(policyJSON) + if err != nil { + t.Fatal(err) + } + policyFingerprint, err := sandboxpolicy.Fingerprint(rules) + if err != nil { + t.Fatal(err) + } + digest := func(character string) string { return "sha256:" + strings.Repeat(character, 64) } + record := Record{ + Platform: WindowsAMD64, + EPARRevision: digest("1"), + SBXVersion: "0.35.0", + Template: "epar-template:promoted", + TemplateDigest: digest("a"), + TemplateCacheID: strings.Repeat("a", 12), + TemplateMetadataDigest: digest("b"), + TemplateArchiveDigest: digest("b"), + PolicyFingerprint: policyFingerprint, + EvidenceDigest: digest("c"), + SBOMDigest: digest("d"), + ProvenanceDigest: digest("e"), + SoftwareInventoryDigest: digest("f"), + VerifiedAt: time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC), + Verifier: "independent-test-verifier", + Gates: GateResults{Local: true, Functional: true, Recovery: true, Security: true, Policy: true, Cleanup: true, SecretScanning: true, ConcurrentProvisioning: true, IndependentSecurityReview: true}, + RootDiskBytes: 120 << 30, + DockerDiskBytes: 100 << 30, + MinHostFreeSpaceBytes: 50 << 30, + ReliabilityJobs: 25, + ReliabilityDuration: 2 * time.Hour, + CachedCreateP95: 30 * time.Second, + QueueToOnlineP95: 90 * time.Second, + ForceRemoveP95: 60 * time.Second, + BuildxComposeSlowdownPct: 10, + } + outputs := map[string][]byte{ + "version": []byte("sbx version: v0.35.0 01e01520456e4126a9653471e7072e4d9b280321\n"), + "daemon\x00status\x00--json": []byte(`{"status":"running","socket":"test","logs":"test"}`), + "diagnose\x00--output\x00json": []byte(diagnosticsFixture("", "")), + "template\x00ls\x00--json": []byte(`{"images":[{"id":"aaaaaaaaaaaa","repository":"docker.io/library/epar-template","tag":"promoted","flavor":"shell","created_at":"2026-07-23T00:00:00Z","size":1024}]}`), + "policy\x00ls\x00--include-inactive\x00--json": policyJSON, + } + return record, outputs +} + +func diagnosticsFixture(changedCheck, changedStatus string) string { + names := []string{"CLI binary", "Binary version", "Daemon", "Daemon diagnostics", "Version match", "Storage directories", "Directory permissions", "Socket", "Authentication"} + checks := make([]string, 0, len(names)) + passed := 0 + failed := 0 + warned := 0 + skipped := 0 + for _, name := range names { + status := "pass" + if name == changedCheck { + status = changedStatus + } + switch status { + case "pass": + passed++ + case "fail": + failed++ + case "warn": + warned++ + case "skip": + skipped++ + } + checks = append(checks, fmt.Sprintf(`{"name":%q,"status":%q,"message":"ok","detail":"","hint":""}`, name, status)) + } + return fmt.Sprintf(`{"version":"1.0","checks":[%s],"summary":{"pass":%d,"warn":%d,"fail":%d,"skip":%d}}`, strings.Join(checks, ","), passed, warned, failed, skipped) +} diff --git a/internal/provider/dockersandboxes/promotion/promotion.go b/internal/provider/dockersandboxes/promotion/promotion.go new file mode 100644 index 0000000..36436b9 --- /dev/null +++ b/internal/provider/dockersandboxes/promotion/promotion.go @@ -0,0 +1,188 @@ +// Package promotion records independently certified Docker Sandboxes +// platform decisions. Wizard default selection is based on local capabilities. +package promotion + +import ( + "fmt" + "runtime" + "strings" + "time" +) + +type Platform string + +const ( + WindowsAMD64 Platform = "windows/amd64" + DarwinARM64 Platform = "darwin/arm64" + LinuxAMD64 Platform = "linux/amd64" +) + +type Record struct { + Platform Platform + EPARRevision string + SBXVersion string + Template string + TemplateDigest string + TemplateCacheID string + TemplateMetadataDigest string + TemplateArchiveDigest string + PolicyFingerprint string + EvidenceDigest string + SBOMDigest string + ProvenanceDigest string + SoftwareInventoryDigest string + VerifiedAt time.Time + Verifier string + Gates GateResults + RootDiskBytes uint64 + DockerDiskBytes uint64 + MinHostFreeSpaceBytes uint64 + ReliabilityJobs int + ReliabilityDuration time.Duration + CachedCreateP95 time.Duration + QueueToOnlineP95 time.Duration + ForceRemoveP95 time.Duration + BuildxComposeSlowdownPct float64 +} + +// GateResults records the non-performance, non-soak promotion gates. Every +// field is non-waivable; the evidence digest binds the detailed transcripts. +type GateResults struct { + Local bool + Functional bool + Recovery bool + Security bool + Policy bool + Cleanup bool + SecretScanning bool + ConcurrentProvisioning bool + IndependentSecurityReview bool +} + +// embeddedRecords deliberately starts empty. A record represents the stronger +// independently reviewed certification for one exact source and artifact +// identity; it is not required for operator-accepted first-run default status. +var embeddedRecords = map[Platform]Record{} + +func CurrentPlatform() Platform { + return Platform(runtime.GOOS + "/" + runtime.GOARCH) +} + +func Lookup(platform Platform) (Record, bool) { + record, ok := embeddedRecords[platform] + return record, ok +} + +func Validate(record Record) error { + switch record.Platform { + case WindowsAMD64, DarwinARM64, LinuxAMD64: + default: + return fmt.Errorf("unsupported Docker Sandboxes promotion platform %q", record.Platform) + } + for key, value := range map[string]string{ + "EPAR revision": record.EPARRevision, + "sbx version": record.SBXVersion, + "template": record.Template, + "template digest": record.TemplateDigest, + "template cache ID": record.TemplateCacheID, + "template metadata": record.TemplateMetadataDigest, + "template archive": record.TemplateArchiveDigest, + "policy fingerprint": record.PolicyFingerprint, + "evidence digest": record.EvidenceDigest, + "SBOM digest": record.SBOMDigest, + "provenance digest": record.ProvenanceDigest, + "inventory digest": record.SoftwareInventoryDigest, + "verifier": record.Verifier, + } { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("Docker Sandboxes promotion record %s is required", key) + } + } + if record.SBXVersion != "0.35.0" { + return fmt.Errorf("Docker Sandboxes promotion record requires sbx version 0.35.0") + } + if !validSHA256(record.EPARRevision) { + return fmt.Errorf("Docker Sandboxes promotion record EPAR revision must be an exact clean source/build sha256 identity") + } + for key, digest := range map[string]string{ + "template digest": record.TemplateDigest, + "template metadata digest": record.TemplateMetadataDigest, + "template archive digest": record.TemplateArchiveDigest, + "policy fingerprint": record.PolicyFingerprint, + "evidence digest": record.EvidenceDigest, + "SBOM digest": record.SBOMDigest, + "provenance digest": record.ProvenanceDigest, + "inventory digest": record.SoftwareInventoryDigest, + } { + if !validSHA256(digest) { + return fmt.Errorf("Docker Sandboxes promotion record %s must be sha256:<64 lowercase hex>", key) + } + } + if !validTemplateCacheID(record.TemplateCacheID) { + return fmt.Errorf("Docker Sandboxes promotion record template cache ID must be exactly 12 lowercase hexadecimal characters") + } + if record.TemplateCacheID != strings.TrimPrefix(record.TemplateDigest, "sha256:")[:12] { + return fmt.Errorf("Docker Sandboxes promotion record template cache ID must match the first 12 hexadecimal characters of the full template identity") + } + if record.VerifiedAt.IsZero() { + return fmt.Errorf("Docker Sandboxes promotion record verification time is required") + } + for gate, passed := range map[string]bool{ + "local": record.Gates.Local, + "functional": record.Gates.Functional, + "recovery": record.Gates.Recovery, + "security": record.Gates.Security, + "policy": record.Gates.Policy, + "cleanup": record.Gates.Cleanup, + "secret scanning": record.Gates.SecretScanning, + "concurrent provisioning": record.Gates.ConcurrentProvisioning, + "independent security review": record.Gates.IndependentSecurityReview, + } { + if !passed { + return fmt.Errorf("Docker Sandboxes promotion record %s gate did not pass", gate) + } + } + if record.RootDiskBytes == 0 || record.DockerDiskBytes < 100<<30 || record.MinHostFreeSpaceBytes < 50<<30 { + return fmt.Errorf("Docker Sandboxes promotion record resource floors are incomplete") + } + if record.ReliabilityJobs < 25 || record.ReliabilityDuration < 2*time.Hour { + return fmt.Errorf("Docker Sandboxes promotion record reliability gate is incomplete") + } + if record.CachedCreateP95 <= 0 || record.CachedCreateP95 > 60*time.Second { + return fmt.Errorf("Docker Sandboxes promotion record cached-create p95 failed") + } + if record.QueueToOnlineP95 <= 0 || record.QueueToOnlineP95 > 180*time.Second { + return fmt.Errorf("Docker Sandboxes promotion record queue-to-online p95 failed") + } + if record.ForceRemoveP95 <= 0 || record.ForceRemoveP95 > 120*time.Second { + return fmt.Errorf("Docker Sandboxes promotion record force-remove p95 failed") + } + if record.BuildxComposeSlowdownPct < 0 || record.BuildxComposeSlowdownPct > 25 { + return fmt.Errorf("Docker Sandboxes promotion record Buildx/Compose slowdown failed") + } + return nil +} + +func validSHA256(value string) bool { + if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { + return false + } + for _, character := range value[len("sha256:"):] { + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { + return false + } + } + return true +} + +func validTemplateCacheID(value string) bool { + if len(value) != 12 { + return false + } + for _, character := range value { + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { + return false + } + } + return true +} diff --git a/internal/provider/dockersandboxes/promotion/promotion_test.go b/internal/provider/dockersandboxes/promotion/promotion_test.go new file mode 100644 index 0000000..05caffc --- /dev/null +++ b/internal/provider/dockersandboxes/promotion/promotion_test.go @@ -0,0 +1,94 @@ +package promotion + +import ( + "strings" + "testing" + "time" +) + +func TestNoPlatformIsPromotedWithoutEmbeddedEvidence(t *testing.T) { + for _, platform := range []Platform{WindowsAMD64, DarwinARM64, LinuxAMD64} { + if _, promoted := Lookup(platform); promoted { + t.Fatalf("%s unexpectedly has a Docker Sandboxes promotion record", platform) + } + } +} + +func TestValidateCompleteRecord(t *testing.T) { + record := validRecord() + if err := Validate(record); err != nil { + t.Fatalf("valid promotion record rejected: %v", err) + } +} + +func TestValidateRejectsEveryNonWaivableGate(t *testing.T) { + tests := []struct { + name string + mutate func(*Record) + }{ + {name: "unknown platform", mutate: func(record *Record) { record.Platform = "plan9/amd64" }}, + {name: "wrong sbx", mutate: func(record *Record) { record.SBXVersion = "0.36.0" }}, + {name: "unknown EPAR revision", mutate: func(record *Record) { record.EPARRevision = "unknown" }}, + {name: "wrong template cache ID", mutate: func(record *Record) { record.TemplateCacheID = "bbbbbbbbbbbb" }}, + {name: "unverified", mutate: func(record *Record) { record.Verifier = "" }}, + {name: "weak Docker disk", mutate: func(record *Record) { record.DockerDiskBytes = 99 << 30 }}, + {name: "too few jobs", mutate: func(record *Record) { record.ReliabilityJobs = 24 }}, + {name: "short soak", mutate: func(record *Record) { record.ReliabilityDuration = 119 * time.Minute }}, + {name: "slow create", mutate: func(record *Record) { record.CachedCreateP95 = 61 * time.Second }}, + {name: "slow online", mutate: func(record *Record) { record.QueueToOnlineP95 = 181 * time.Second }}, + {name: "slow remove", mutate: func(record *Record) { record.ForceRemoveP95 = 121 * time.Second }}, + {name: "slow workload", mutate: func(record *Record) { record.BuildxComposeSlowdownPct = 25.01 }}, + {name: "missing artifact digest", mutate: func(record *Record) { record.SBOMDigest = "" }}, + {name: "failed recovery gate", mutate: func(record *Record) { record.Gates.Recovery = false }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + record := validRecord() + test.mutate(&record) + if err := Validate(record); err == nil { + t.Fatal("invalid promotion record accepted") + } + }) + } +} + +func validRecord() Record { + digest := "sha256:" + strings.Repeat("a", 64) + return Record{ + Platform: WindowsAMD64, + EPARRevision: digest, + SBXVersion: "0.35.0", + Template: "epar-docker-sandboxes-catthehacker-full:version", + TemplateDigest: digest, + TemplateCacheID: strings.Repeat("a", 12), + TemplateMetadataDigest: digest, + TemplateArchiveDigest: digest, + PolicyFingerprint: digest, + EvidenceDigest: digest, + SBOMDigest: digest, + ProvenanceDigest: digest, + SoftwareInventoryDigest: digest, + VerifiedAt: time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC), + Verifier: "independent-security-verifier", + Gates: GateResults{ + Local: true, + Functional: true, + Recovery: true, + Security: true, + Policy: true, + Cleanup: true, + SecretScanning: true, + ConcurrentProvisioning: true, + IndependentSecurityReview: true, + }, + RootDiskBytes: 120 << 30, + DockerDiskBytes: 100 << 30, + MinHostFreeSpaceBytes: 50 << 30, + ReliabilityJobs: 25, + ReliabilityDuration: 2 * time.Hour, + CachedCreateP95: 60 * time.Second, + QueueToOnlineP95: 180 * time.Second, + ForceRemoveP95: 120 * time.Second, + BuildxComposeSlowdownPct: 25, + } +} diff --git a/internal/provider/dockersandboxes/promotion/space_unix.go b/internal/provider/dockersandboxes/promotion/space_unix.go new file mode 100644 index 0000000..f6847a7 --- /dev/null +++ b/internal/provider/dockersandboxes/promotion/space_unix.go @@ -0,0 +1,49 @@ +//go:build !windows + +package promotion + +import ( + "fmt" + "math" + "os" + "os/exec" + "runtime" + "strings" + "syscall" +) + +func sandboxHostSpace(path string) (HostSpace, error) { + var stats syscall.Statfs_t + if err := syscall.Statfs(path, &stats); err != nil { + return HostSpace{}, err + } + blockSize := uint64(stats.Bsize) + available := uint64(stats.Bavail) + total := uint64(stats.Blocks) + if blockSize != 0 && (available > math.MaxUint64/blockSize || total > math.MaxUint64/blockSize) { + return HostSpace{}, fmt.Errorf("filesystem space result overflow") + } + return HostSpace{AvailableBytes: available * blockSize, TotalBytes: total * blockSize}, nil +} + +func sandboxVirtualizationAvailable() error { + switch runtime.GOOS { + case "linux": + file, err := os.OpenFile("/dev/kvm", os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("open /dev/kvm read/write: %w", err) + } + return file.Close() + case "darwin": + output, err := exec.Command("/usr/sbin/sysctl", "-n", "kern.hv_support").Output() + if err != nil { + return fmt.Errorf("query kern.hv_support: %w", err) + } + if strings.TrimSpace(string(output)) != "1" { + return fmt.Errorf("kern.hv_support did not report 1") + } + return nil + default: + return fmt.Errorf("unsupported native virtualization platform %s", runtime.GOOS) + } +} diff --git a/internal/provider/dockersandboxes/promotion/space_windows.go b/internal/provider/dockersandboxes/promotion/space_windows.go new file mode 100644 index 0000000..8b16ca7 --- /dev/null +++ b/internal/provider/dockersandboxes/promotion/space_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package promotion + +import ( + "errors" + "fmt" + + "golang.org/x/sys/windows" +) + +const processorFeatureVirtualizationFirmwareEnabled = 21 + +func sandboxHostSpace(path string) (HostSpace, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return HostSpace{}, err + } + var available uint64 + var total uint64 + var free uint64 + if err := windows.GetDiskFreeSpaceEx(pathPointer, &available, &total, &free); err != nil { + return HostSpace{}, fmt.Errorf("GetDiskFreeSpaceEx: %w", err) + } + return HostSpace{AvailableBytes: available, TotalBytes: total}, nil +} + +func sandboxVirtualizationAvailable() error { + if !windows.IsProcessorFeaturePresent(processorFeatureVirtualizationFirmwareEnabled) { + return errors.New("Windows did not report firmware virtualization as enabled and available to the operating system") + } + return nil +} diff --git a/internal/provider/dockersandboxes/provider.go b/internal/provider/dockersandboxes/provider.go new file mode 100644 index 0000000..18610fb --- /dev/null +++ b/internal/provider/dockersandboxes/provider.go @@ -0,0 +1,1025 @@ +package dockersandboxes + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/staging" +) + +const ( + SupportedVersion = "0.35.0" + defaultOutputLimit = 8 << 20 + diagnosticOutputLimit = 256 << 10 + versionCheckAttempts = 2 + versionRetryDelay = 200 * time.Millisecond + commandWaitDelay = 5 * time.Second + keepaliveStartupDelay = 500 * time.Millisecond +) + +const directWorkspaceVerificationScript = `set -euo pipefail +test -z "${SSH_AUTH_SOCK:-}" +test -z "${SSH_AGENT_PID:-}" +workspace="$(pwd -P)" +test -n "${workspace}" +source_options="$(findmnt -T "${workspace}" -n -o OPTIONS)" +case ",${source_options}," in + *,rw,*) ;; + *) echo "dedicated host staging workspace is not read-write" >&2; exit 1 ;; +esac +test ! -e .git +test -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" +test ! -e /run/sandbox/source +! pgrep -x git-daemon >/dev/null` + +const runtimeVerificationScript = `set -euo pipefail +test -x /opt/epar/verify-template.sh +test -s /opt/epar/helpers.sha256 +cd /opt/epar +sha256sum -c helpers.sha256 >/dev/null +/opt/epar/verify-template.sh >/dev/null +docker info --format '{{json .ServerVersion}}'` + +type Provider struct { + Binary string + + versionMu sync.Mutex + versionVerified bool + runCommand runCommandFunc + inspectImage inspectImageFunc + inspectTemplate inspectLocalTemplateFunc +} + +type instanceReceipt struct { + SchemaVersion int `json:"schemaVersion"` + StagingPath string `json:"stagingPath"` + StagingIdentity string `json:"stagingIdentity"` + Template string `json:"template"` + TemplateDigest string `json:"templateDigest"` +} + +// CachedTemplate is one image retained in the Docker Sandboxes template cache. +// CacheID is the provider's short cache identity, not a content digest. +type CachedTemplate struct { + Reference string + CacheID string + CreatedAt time.Time + SizeBytes int64 +} + +// LocalTemplateImage is the independently read local Docker image identity +// and guest platform for a repository:tag template reference. +type LocalTemplateImage struct { + Digest string + Platform string +} + +// HostReadiness is the validated machine-readable summary returned by +// `sbx diagnose --output json`. +type HostReadiness struct { + ChecksPassed int + ChecksWarned int + ChecksFailed int + ChecksSkipped int +} + +type commandRequest struct { + args []string + stdin io.Reader + environment map[string]string + stdout io.Writer + stderr io.Writer + sensitiveValues []string + operation string + outputLimit int +} + +type runCommandFunc func(ctx context.Context, request commandRequest) (provider.ExecResult, error) +type inspectImageFunc func(context.Context, string) (string, error) +type inspectLocalTemplateFunc func(context.Context, string) (LocalTemplateImage, error) + +func New(binary string) *Provider { + if binary == "" { + binary = "sbx" + } + return &Provider{Binary: binary} +} + +// EnsureArtifacts records that Docker Sandboxes templates are built and +// imported explicitly. Create verifies the exact configured template digest +// against the provider cache before it allocates an instance. +func (*Provider) EnsureArtifacts(_ context.Context, dryRun bool) (bool, error) { + if dryRun { + return true, fmt.Errorf("docker-sandboxes does not support dry-run because the exact prewarmed template must be read back from sbx") + } + return true, nil +} + +// VerifyVersion checks that the installed Docker Sandboxes CLI is the exact +// version supported by this EPAR build without performing any lifecycle action. +func (p *Provider) VerifyVersion(ctx context.Context) error { + return p.ensureVersion(ctx) +} + +// VerifyHostReadiness checks the exact supported CLI version and requires +// machine-readable Docker Sandboxes diagnostics to contain at least one +// passing check and no failed checks. Warnings such as an available update do +// not make an otherwise healthy installation unavailable. +func (p *Provider) VerifyHostReadiness(ctx context.Context) (HostReadiness, error) { + if err := p.ensureVersion(ctx); err != nil { + return HostReadiness{}, err + } + readiness, err := p.readHostReadiness(ctx) + if err != nil { + return HostReadiness{}, err + } + if readiness.ChecksFailed != 0 { + return HostReadiness{}, fmt.Errorf("docker sandboxes diagnostics reported %d failed check(s)", readiness.ChecksFailed) + } + if readiness.ChecksPassed == 0 { + return HostReadiness{}, fmt.Errorf("docker sandboxes diagnostics reported no passing checks") + } + return readiness, nil +} + +func (p *Provider) Create(ctx context.Context, request provider.CreateRequest) (provider.Instance, error) { + if err := validateCreateRequest(request); err != nil { + return provider.Instance{}, err + } + if err := p.VerifyAdmission(ctx); err != nil { + return provider.Instance{}, err + } + if err := p.verifyCachedTemplate(ctx, request.Template, request.TemplateDigest); err != nil { + return provider.Instance{}, err + } + items, err := p.inventoryVerified(ctx) + if err != nil { + return provider.Instance{}, err + } + for _, item := range items { + if item.Instance.Name == request.Name { + return provider.Instance{}, fmt.Errorf("docker sandbox name is already allocated") + } + } + var ownedStaging staging.OwnedDirectory + if p.runCommand == nil { + stagingRoot, openErr := staging.Open(filepath.Dir(request.StagingPath)) + if openErr != nil { + return provider.Instance{}, openErr + } + if filepath.Clean(request.StagingPath) != filepath.Join(stagingRoot.Root(), request.Name) { + return provider.Instance{}, fmt.Errorf("Docker Sandboxes staging path must be the exact provider-owned path for %q", request.Name) + } + ownedStaging, err = stagingRoot.CreateOwned(request.Name) + if err != nil { + return provider.Instance{}, err + } + } else { + ownedStaging = staging.OwnedDirectory{Path: request.StagingPath, Identity: "test-staging-identity"} + } + + args := []string{ + "create", + "--name", request.Name, + "--cpus", strconv.Itoa(request.CPUs), + "--memory", request.Memory, + "--template", request.Template, + } + args = append(args, "shell", request.StagingPath) + environment := make(map[string]string, 2) + if request.RootDisk != "" { + environment["DOCKER_SANDBOXES_ROOT_SIZE"] = request.RootDisk + } + if request.DockerDisk != "" { + environment["DOCKER_SANDBOXES_DOCKER_SIZE"] = request.DockerDisk + } + if _, err := p.run(ctx, commandRequest{args: args, environment: environment, operation: "create docker sandbox"}); err != nil { + return provider.Instance{}, err + } + items, err = p.inventoryVerified(ctx) + if err != nil { + return provider.Instance{}, fmt.Errorf("docker sandbox was created but identity readback failed: %w", err) + } + for _, item := range items { + if item.Instance.Name == request.Name { + if item.Instance.ProviderID == "" { + return provider.Instance{}, fmt.Errorf("docker sandbox inventory omitted the stable provider id") + } + if item.Source != "shell" || !containsExactWorkspace(item.Workspaces, request.StagingPath) { + return provider.Instance{}, fmt.Errorf("docker sandbox inventory did not bind the exact shell workspace") + } + if err := p.verifyNoPublishedPorts(ctx, item.Instance); err != nil { + return provider.Instance{}, err + } + if err := p.verifyInspection(ctx, item.Instance, &request); err != nil { + return provider.Instance{}, err + } + if err := p.verifyDirectWorkspace(ctx, item.Instance); err != nil { + return provider.Instance{}, err + } + receipt, encodeErr := json.Marshal(instanceReceipt{ + SchemaVersion: 1, + StagingPath: ownedStaging.Path, + StagingIdentity: ownedStaging.Identity, + Template: request.Template, + TemplateDigest: request.TemplateDigest, + }) + if encodeErr != nil { + return provider.Instance{}, encodeErr + } + instance := item.Instance + instance.ReceiptVersion = "v1" + instance.Receipt = receipt + return instance, nil + } + } + return provider.Instance{}, fmt.Errorf("docker sandbox was not present in inventory after create") +} + +// VerifyAdmission fail-closes on provider-wide channels Docker Sandboxes can +// inject into every sandbox. EPAR deliberately does not consume global secrets; +// repository and workflow input can never opt out of this check. +func (p *Provider) VerifyAdmission(ctx context.Context) error { + p.versionMu.Lock() + err := p.verifyVersionLocked(ctx) + p.versionMu.Unlock() + if err != nil { + return err + } + return p.verifyNoGlobalSecrets(ctx) +} + +func (p *Provider) VerifyInstanceAdmission(ctx context.Context, instance provider.Instance) error { + present, err := p.assertIdentity(ctx, instance) + if err != nil { + return err + } + if !present { + return fmt.Errorf("docker sandbox is missing") + } + if err := p.verifyNoPublishedPorts(ctx, instance); err != nil { + return err + } + return p.verifyInspection(ctx, instance, nil) +} + +func (p *Provider) verifyInspection(ctx context.Context, instance provider.Instance, expected *provider.CreateRequest) error { + result, err := p.run(ctx, commandRequest{ + args: []string{"inspect", "--json", instance.Name}, + operation: "verify docker sandbox attached capabilities", + outputLimit: diagnosticOutputLimit, + }) + if err != nil { + return err + } + var inspection map[string]json.RawMessage + if err := decodeStrictJSON([]byte(result.Stdout), &inspection); err != nil { + return fmt.Errorf("docker sandbox inspection returned an unsupported JSON schema") + } + if stringValue(inspection["name"]) != instance.Name || stringValue(inspection["agent"]) != "shell" || stringValue(inspection["daemon_version"]) != "v"+SupportedVersion { + return fmt.Errorf("docker sandbox inspection did not match the exact shell runtime") + } + var mcpGateway bool + if raw, ok := inspection["mcp_gateway"]; !ok || json.Unmarshal(raw, &mcpGateway) != nil || mcpGateway { + return fmt.Errorf("docker sandbox inspection reported an enabled MCP gateway") + } + if expected != nil && (stringValue(inspection["image"]) != expected.Template || stringValue(inspection["image_digest"]) != expected.TemplateDigest || stringValue(inspection["workspace"]) != expected.StagingPath) { + return fmt.Errorf("docker sandbox inspection did not bind the exact template identity and staging path") + } + for _, field := range []string{"kits", "secrets", "published_ports", "ports", "auth", "auth_mode", "docker_auth"} { + value, ok := inspection[field] + if field == "kits" && !ok { + return fmt.Errorf("docker sandbox inspection omitted required attached-capability field %q", field) + } + if ok && !emptyJSONValue(value) { + return fmt.Errorf("docker sandbox inspection reported forbidden attached capability %q", field) + } + } + return nil +} + +func stringValue(raw json.RawMessage) string { + var value string + if len(raw) == 0 || json.Unmarshal(raw, &value) != nil { + return "" + } + return value +} + +func emptyJSONValue(raw json.RawMessage) bool { + if len(raw) == 0 { + return true + } + var value any + if json.Unmarshal(raw, &value) != nil { + return false + } + switch typed := value.(type) { + case nil: + return true + case bool: + return !typed + case string: + return typed == "" + case float64: + return typed == 0 + case []any: + return len(typed) == 0 + case map[string]any: + return len(typed) == 0 + default: + return false + } +} + +func (p *Provider) verifyNoPublishedPorts(ctx context.Context, instance provider.Instance) error { + result, err := p.run(ctx, commandRequest{ + args: []string{"ports", instance.Name, "--json"}, + operation: "verify docker sandbox has no published ports", + outputLimit: diagnosticOutputLimit, + }) + if err != nil { + return err + } + var ports []map[string]json.RawMessage + if err := decodeStrictJSON([]byte(result.Stdout), &ports); err != nil || ports == nil { + return fmt.Errorf("docker sandbox published-port inventory returned an unsupported JSON schema") + } + if len(ports) != 0 { + return fmt.Errorf("docker sandbox reported a forbidden published port") + } + return nil +} + +func (p *Provider) verifyNoGlobalSecrets(ctx context.Context) error { + result, err := p.run(ctx, commandRequest{ + args: []string{"secret", "ls", "-g"}, + operation: "verify docker sandboxes global secret isolation", + outputLimit: 64 << 10, + }) + if err != nil { + return err + } + if strings.TrimSpace(strings.ReplaceAll(result.Stdout, "\r\n", "\n")) != `No secrets found for scope "(global)".` { + return fmt.Errorf("docker sandboxes global secrets are configured; EPAR refuses to expose shared registry or service credentials to workflow sandboxes") + } + return nil +} + +func (p *Provider) verifyDirectWorkspace(ctx context.Context, instance provider.Instance) error { + result, err := p.run(ctx, commandRequest{ + args: []string{"exec", "-i", instance.Name, "--", "bash", "-lc", directWorkspaceVerificationScript}, + stdin: strings.NewReader(""), + operation: "verify dedicated docker sandbox staging workspace", + }) + if err != nil { + return err + } + if strings.TrimSpace(result.Stdout) != "" { + return fmt.Errorf("dedicated docker sandbox staging verification returned unexpected output") + } + return nil +} + +func containsExactWorkspace(workspaces []string, expected string) bool { + for _, workspace := range workspaces { + if workspace == expected { + return true + } + } + return false +} + +func (p *Provider) Start(ctx context.Context, instance provider.Instance, opts provider.StartOptions) (*provider.RunningProcess, error) { + present, err := p.assertIdentity(ctx, instance) + if err != nil || !present { + if err == nil { + return nil, fmt.Errorf("docker sandbox is missing") + } + return nil, err + } + request := commandRequest{ + args: []string{"exec", "-i", instance.Name, "--", "/bin/sleep", "infinity"}, + stdin: strings.NewReader(""), + stdout: opts.Stdout, + stderr: opts.Stderr, + operation: "start docker sandbox with a managed keepalive", + } + if p.runCommand != nil { + if _, err := p.run(ctx, request); err != nil { + return nil, err + } + return &provider.RunningProcess{Name: instance.Name}, nil + } + return p.startKeepalive(ctx, instance.Name, request) +} + +func (p *Provider) startKeepalive(ctx context.Context, name string, request commandRequest) (*provider.RunningProcess, error) { + if err := validateCommandRequest(request); err != nil { + return nil, err + } + command := exec.CommandContext(ctx, p.Binary, request.args...) + isolateKeepaliveProcess(command) + command.WaitDelay = commandWaitDelay + command.Stdin = request.stdin + command.Env = childEnvironment(request.environment) + stdout := &boundedBuffer{limit: defaultOutputLimit} + stderr := &boundedBuffer{limit: defaultOutputLimit} + command.Stdout = captureWriter(stdout, request.stdout) + command.Stderr = captureWriter(stderr, request.stderr) + if err := command.Start(); err != nil { + return nil, fmt.Errorf("%s failed: %w", request.operation, err) + } + finished := make(chan error, 1) + go func() { + finished <- command.Wait() + }() + timer := time.NewTimer(keepaliveStartupDelay) + defer timer.Stop() + select { + case err := <-finished: + detail := strings.TrimSpace(stderr.String()) + if err == nil { + err = fmt.Errorf("keepalive command exited before startup completed") + } + if detail != "" { + return nil, fmt.Errorf("%s failed: %w: %s", request.operation, err, detail) + } + return nil, fmt.Errorf("%s failed: %w", request.operation, err) + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + return &provider.RunningProcess{Name: name, PID: command.Process.Pid}, nil + } +} + +func (p *Provider) VerifyRuntime(ctx context.Context, instance provider.Instance) (provider.RuntimeInfo, error) { + present, err := p.assertIdentity(ctx, instance) + if err != nil { + return provider.RuntimeInfo{}, err + } + if !present { + return provider.RuntimeInfo{}, fmt.Errorf("docker sandbox is missing") + } + result, err := p.run(ctx, commandRequest{ + args: []string{"exec", "-i", instance.Name, "--", "bash", "-lc", runtimeVerificationScript}, + stdin: strings.NewReader(""), + operation: "verify docker sandbox runtime", + }) + if err != nil { + return provider.RuntimeInfo{}, err + } + var version string + if err := decodeStrictJSON([]byte(strings.TrimSpace(result.Stdout)), &version); err != nil || strings.TrimSpace(version) == "" { + return provider.RuntimeInfo{}, fmt.Errorf("docker sandbox runtime returned an unsupported verification schema") + } + return provider.RuntimeInfo{Ready: true, Runtime: "docker", Version: version}, nil +} + +func (*Provider) Address(context.Context, provider.Instance, int) (string, bool, error) { + return "", false, nil +} + +func (p *Provider) Exec(ctx context.Context, instance provider.Instance, command []string, opts provider.ExecOptions) (provider.ExecResult, error) { + if err := validateGuestCommand(command, opts); err != nil { + return provider.ExecResult{}, err + } + present, err := p.assertIdentity(ctx, instance) + if err != nil { + return provider.ExecResult{}, err + } + if !present { + return provider.ExecResult{}, fmt.Errorf("docker sandbox is missing") + } + args := make([]string, 0, len(command)+5) + args = append(args, "exec", "-i", instance.Name, "--") + args = append(args, command...) + return p.run(ctx, commandRequest{ + args: args, + stdin: strings.NewReader(opts.Stdin), + stdout: opts.Stdout, + stderr: opts.Stderr, + sensitiveValues: opts.SensitiveValues, + operation: "execute in docker sandbox", + }) +} + +func (p *Provider) Diagnostics(ctx context.Context, instance provider.Instance) (provider.Diagnostics, error) { + present, err := p.assertIdentity(ctx, instance) + if err != nil { + return provider.Diagnostics{}, err + } + if !present { + return provider.Diagnostics{}, fmt.Errorf("docker sandbox is missing") + } + statusResult, err := p.run(ctx, commandRequest{ + args: []string{"daemon", "status", "--json"}, + operation: "read docker sandbox daemon status", + outputLimit: diagnosticOutputLimit, + }) + if err != nil { + return provider.Diagnostics{}, err + } + daemonState, daemonHealthy, err := parseDaemonStatus([]byte(statusResult.Stdout)) + if err != nil { + return provider.Diagnostics{}, err + } + readiness, err := p.readHostReadiness(ctx) + if err != nil { + return provider.Diagnostics{}, err + } + return provider.Diagnostics{ + Healthy: daemonHealthy && readiness.ChecksWarned == 0 && readiness.ChecksFailed == 0 && readiness.ChecksSkipped == 0, + DaemonState: daemonState, + ChecksPassed: readiness.ChecksPassed, + ChecksWarned: readiness.ChecksWarned, + ChecksFailed: readiness.ChecksFailed, + ChecksSkipped: readiness.ChecksSkipped, + }, nil +} + +func (p *Provider) readHostReadiness(ctx context.Context) (HostReadiness, error) { + diagnoseResult, err := p.run(ctx, commandRequest{ + args: []string{"diagnose", "--output", "json"}, + operation: "diagnose docker sandboxes", + outputLimit: diagnosticOutputLimit, + }) + if err != nil { + return HostReadiness{}, err + } + passed, warned, failed, skipped, err := parseDiagnose([]byte(diagnoseResult.Stdout)) + if err != nil { + return HostReadiness{}, err + } + return HostReadiness{ + ChecksPassed: passed, + ChecksWarned: warned, + ChecksFailed: failed, + ChecksSkipped: skipped, + }, nil +} + +func (p *Provider) Stop(ctx context.Context, instance provider.Instance) error { + present, err := p.assertIdentity(ctx, instance) + if err != nil || !present { + return err + } + result, err := p.run(ctx, commandRequest{args: []string{"stop", instance.Name}, operation: "stop docker sandbox"}) + if err != nil && isMissingSandbox(result.Stdout+"\n"+result.Stderr+"\n"+err.Error()) { + return nil + } + return err +} + +func (p *Provider) Delete(ctx context.Context, instance provider.Instance) error { + present, err := p.assertIdentity(ctx, instance) + if err != nil || !present { + return err + } + var receipt instanceReceipt + if p.runCommand == nil { + if instance.ReceiptVersion != "v1" || json.Unmarshal(instance.Receipt, &receipt) != nil || receipt.SchemaVersion != 1 || receipt.StagingPath == "" || receipt.StagingIdentity == "" { + return fmt.Errorf("refusing Docker Sandbox deletion without an exact staging ownership receipt") + } + } + result, err := p.run(ctx, commandRequest{args: []string{"rm", "--force", instance.Name}, operation: "delete docker sandbox"}) + if err != nil && isMissingSandbox(result.Stdout+"\n"+result.Stderr+"\n"+err.Error()) { + err = nil + } + if err != nil { + return err + } + if p.runCommand == nil { + stagingRoot, openErr := staging.Open(filepath.Dir(receipt.StagingPath)) + if openErr != nil { + return openErr + } + if filepath.Clean(receipt.StagingPath) != filepath.Join(stagingRoot.Root(), instance.Name) { + return fmt.Errorf("refusing Docker Sandbox staging cleanup outside the exact owned path") + } + if purgeErr := stagingRoot.PurgeOwned(instance.Name, receipt.StagingIdentity); purgeErr != nil { + return purgeErr + } + } + return nil +} + +func (p *Provider) Inventory(ctx context.Context) ([]provider.InventoryItem, error) { + if err := p.ensureVersion(ctx); err != nil { + return nil, err + } + return p.inventoryVerified(ctx) +} + +func (p *Provider) inventoryVerified(ctx context.Context) ([]provider.InventoryItem, error) { + result, err := p.run(ctx, commandRequest{args: []string{"ls", "--json"}, operation: "inventory docker sandboxes"}) + if err != nil { + return nil, err + } + return parseInventory([]byte(result.Stdout)) +} + +// CachedTemplates returns the strictly parsed, host-level Docker Sandboxes +// template cache inventory. It does not create, load, or otherwise mutate a +// template. +func (p *Provider) CachedTemplates(ctx context.Context) ([]CachedTemplate, error) { + if err := p.ensureVersion(ctx); err != nil { + return nil, err + } + result, err := p.run(ctx, commandRequest{ + args: []string{"template", "ls", "--json"}, + operation: "read docker sandbox template cache", + outputLimit: diagnosticOutputLimit, + }) + if err != nil { + return nil, err + } + images, err := parseTemplateInventory([]byte(result.Stdout)) + if err != nil { + return nil, err + } + templates := make([]CachedTemplate, 0, len(images)) + for _, image := range images { + templates = append(templates, CachedTemplate{ + Reference: image.Repository + ":" + image.Tag, + CacheID: image.ID, + CreatedAt: image.CreatedAt, + SizeBytes: image.Size, + }) + } + return templates, nil +} + +// InspectLocalTemplate independently reads a local Docker image's full +// identity and guest platform. The supplied reference must be a repository:tag +// reference; digests, untagged repositories, and shell-style input are refused. +func (p *Provider) InspectLocalTemplate(ctx context.Context, reference string) (LocalTemplateImage, error) { + if err := validateLocalTemplateReference(reference); err != nil { + return LocalTemplateImage{}, err + } + inspect := p.inspectTemplate + if inspect == nil { + inspect = inspectLocalDockerImage + } + image, err := inspect(ctx, reference) + if err != nil { + return LocalTemplateImage{}, err + } + if !validFullTemplateDigest(image.Digest) { + return LocalTemplateImage{}, fmt.Errorf("local docker image inspection did not return a full lowercase sha256 identity") + } + if image.Platform != "linux/amd64" && image.Platform != "linux/arm64" { + return LocalTemplateImage{}, fmt.Errorf("local docker image inspection did not return a supported linux template platform") + } + return image, nil +} + +func (p *Provider) verifyCachedTemplate(ctx context.Context, reference, digest string) error { + if err := p.verifyLocalTemplateImage(ctx, reference, digest); err != nil { + return err + } + result, err := p.run(ctx, commandRequest{args: []string{"template", "ls", "--json"}, operation: "verify cached docker sandbox template"}) + if err != nil { + return err + } + images, err := parseTemplateInventory([]byte(result.Stdout)) + if err != nil { + return err + } + repository, tag, err := splitTemplateReference(reference) + if err != nil { + return err + } + wantCacheID := strings.TrimPrefix(digest, "sha256:")[:12] + for _, image := range images { + if image.Repository == repository && image.Tag == tag { + if image.ID != wantCacheID { + return fmt.Errorf("cached docker sandbox template cache ID did not match the first 12 hexadecimal characters of the independently verified full local image identity") + } + return nil + } + } + return fmt.Errorf("configured docker sandbox template was not present in the local cache") +} + +func (p *Provider) verifyLocalTemplateImage(ctx context.Context, reference, expectedDigest string) error { + inspect := p.inspectImage + if inspect == nil { + inspect = inspectLocalDockerImageDigest + } + actualDigest, err := inspect(ctx, reference) + if err != nil { + return fmt.Errorf("verify full local docker sandbox template image identity: %w", err) + } + actualDigest = strings.TrimSpace(actualDigest) + if !validFullTemplateDigest(actualDigest) { + return fmt.Errorf("local docker image inspection did not return a full lowercase sha256 identity") + } + if actualDigest != expectedDigest { + return fmt.Errorf("full local docker sandbox template image identity %s did not match configured identity %s", actualDigest, expectedDigest) + } + return nil +} + +func inspectLocalDockerImageDigest(ctx context.Context, reference string) (string, error) { + image, err := inspectLocalDockerImage(ctx, reference) + if err != nil { + return "", err + } + return image.Digest, nil +} + +func inspectLocalDockerImage(ctx context.Context, reference string) (LocalTemplateImage, error) { + command := exec.CommandContext(ctx, "docker", localTemplateInspectArgs(reference)...) + command.Env = childEnvironment(nil) + stdout := &boundedBuffer{limit: diagnosticOutputLimit} + stderr := &boundedBuffer{limit: 4096} + command.Stdout = stdout + command.Stderr = stderr + err := command.Run() + if stdout.exceeded || stderr.exceeded { + err = errors.Join(err, fmt.Errorf("docker image inspection output limit exceeded")) + } + if err != nil { + if detail := strings.TrimSpace(stderr.String()); detail != "" { + return LocalTemplateImage{}, fmt.Errorf("docker image inspect failed: %w: %s", err, detail) + } + return LocalTemplateImage{}, fmt.Errorf("docker image inspect failed: %w", err) + } + return parseLocalTemplateImage([]byte(stdout.String())) +} + +func localTemplateInspectArgs(reference string) []string { + return []string{"image", "inspect", "--format", "{{json .}}", reference} +} + +func validFullTemplateDigest(value string) bool { + if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { + return false + } + for _, character := range value[len("sha256:"):] { + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { + return false + } + } + return true +} + +func (p *Provider) assertIdentity(ctx context.Context, instance provider.Instance) (bool, error) { + if err := validateInstance(instance, true); err != nil { + return false, err + } + if err := p.ensureVersion(ctx); err != nil { + return false, err + } + items, err := p.inventoryVerified(ctx) + if err != nil { + return false, err + } + for _, item := range items { + if item.Instance.Name != instance.Name { + continue + } + if item.Instance.ProviderID != instance.ProviderID { + return false, fmt.Errorf("docker sandbox identity changed") + } + return true, nil + } + return false, nil +} + +func (p *Provider) ensureVersion(ctx context.Context) error { + p.versionMu.Lock() + defer p.versionMu.Unlock() + if p.versionVerified { + return nil + } + return p.verifyVersionLocked(ctx) +} + +func (p *Provider) verifyVersionLocked(ctx context.Context) error { + for attempt := 1; attempt <= versionCheckAttempts; attempt++ { + result, err := p.run(ctx, commandRequest{args: []string{"version"}, operation: "check docker sandboxes version"}) + if err != nil { + return err + } + if isSupportedVersion(result.Stdout) { + p.versionVerified = true + return nil + } + if attempt < versionCheckAttempts { + timer := time.NewTimer(versionRetryDelay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + } + return fmt.Errorf("unsupported docker sandboxes version after %d checks; exactly v%s is required", versionCheckAttempts, SupportedVersion) +} + +func (p *Provider) run(ctx context.Context, request commandRequest) (provider.ExecResult, error) { + if err := validateCommandRequest(request); err != nil { + return provider.ExecResult{}, err + } + if request.outputLimit == 0 { + request.outputLimit = defaultOutputLimit + } + if err := ctx.Err(); err != nil { + return provider.ExecResult{}, err + } + bufferedStdout, bufferedStderr, flush := provider.BufferSensitiveSinks(request.sensitiveValues, request.stdout, request.stderr) + request.stdout = bufferedStdout + request.stderr = bufferedStderr + + var result provider.ExecResult + var runErr error + if p.runCommand != nil { + result, runErr = p.runCommand(ctx, request) + } else { + result, runErr = p.runRaw(ctx, request) + } + if len(result.Stdout) > request.outputLimit || len(result.Stderr) > request.outputLimit { + runErr = errors.Join(runErr, fmt.Errorf("%s exceeded the output limit", request.operation)) + result.Stdout = truncate(result.Stdout, request.outputLimit) + result.Stderr = truncate(result.Stderr, request.outputLimit) + } + if ctxErr := ctx.Err(); ctxErr != nil { + runErr = errors.Join(ctxErr, runErr) + } + result, finishErr := provider.FinishSensitiveExecution(result, runErr, flush(), request.sensitiveValues) + if finishErr != nil { + detail := strings.TrimSpace(result.Stderr) + if detail != "" { + finishErr = fmt.Errorf("%s failed: %w: %s", request.operation, finishErr, detail) + } else { + finishErr = fmt.Errorf("%s failed: %w", request.operation, finishErr) + } + finishErr = provider.RedactError(finishErr, request.sensitiveValues...) + } + return result, finishErr +} + +func (p *Provider) runRaw(ctx context.Context, request commandRequest) (provider.ExecResult, error) { + cmd := exec.CommandContext(ctx, p.Binary, request.args...) + cmd.WaitDelay = commandWaitDelay + var cancellationKilledProcess atomic.Bool + defaultCancel := cmd.Cancel + cmd.Cancel = func() error { + err := defaultCancel() + if err == nil { + cancellationKilledProcess.Store(true) + } + return err + } + cmd.Stdin = request.stdin + cmd.Env = childEnvironment(request.environment) + stdout := &boundedBuffer{limit: request.outputLimit} + stderr := &boundedBuffer{limit: request.outputLimit} + cmd.Stdout = captureWriter(stdout, request.stdout) + cmd.Stderr = captureWriter(stderr, request.stderr) + err := cmd.Run() + if cancellationKilledProcess.Load() { + if ctxErr := ctx.Err(); ctxErr != nil { + err = ctxErr + } + } + result := provider.ExecResult{Stdout: stdout.String(), Stderr: stderr.String()} + if stdout.exceeded || stderr.exceeded { + err = errors.Join(err, fmt.Errorf("output limit exceeded")) + } + return result, err +} + +func validateCommandRequest(request commandRequest) error { + if len(request.args) == 0 { + return fmt.Errorf("refusing to invoke docker sandboxes without a subcommand") + } + if request.operation == "" { + return fmt.Errorf("docker sandboxes operation label is required") + } + switch request.args[0] { + case "version", "create", "exec", "daemon", "diagnose", "stop", "rm", "ls", "template", "policy", "inspect", "ports": + case "secret": + if len(request.args) != 3 || request.args[1] != "ls" || request.args[2] != "-g" { + return fmt.Errorf("only exact global-secret absence verification is permitted") + } + default: + return fmt.Errorf("docker sandboxes command %q is not permitted", request.args[0]) + } + if request.args[0] == "inspect" && (len(request.args) != 3 || request.args[1] != "--json" || !sandboxNamePattern.MatchString(request.args[2])) { + return fmt.Errorf("only exact machine-readable sandbox inspection is permitted") + } + if request.args[0] == "ports" && (len(request.args) != 3 || !sandboxNamePattern.MatchString(request.args[1]) || request.args[2] != "--json") { + return fmt.Errorf("only exact machine-readable published-port absence verification is permitted") + } + for _, arg := range request.args { + if strings.ContainsRune(arg, 0) { + return fmt.Errorf("docker sandboxes argument contains a null byte") + } + } + for key := range request.environment { + if key != "DOCKER_SANDBOXES_ROOT_SIZE" && key != "DOCKER_SANDBOXES_DOCKER_SIZE" { + return fmt.Errorf("docker sandboxes child environment contains a forbidden override") + } + } + return nil +} + +func childEnvironment(additions map[string]string) []string { + environment := make([]string, 0, len(os.Environ())+len(additions)) + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + upperKey := strings.ToUpper(key) + if strings.HasPrefix(upperKey, "DOCKER_SANDBOXES_") || upperKey == "SSH_AUTH_SOCK" || upperKey == "SSH_AGENT_PID" { + continue + } + environment = append(environment, item) + } + for _, key := range []string{"DOCKER_SANDBOXES_ROOT_SIZE", "DOCKER_SANDBOXES_DOCKER_SIZE"} { + if value := additions[key]; value != "" { + environment = append(environment, key+"="+value) + } + } + return environment +} + +func captureWriter(capture io.Writer, sink io.Writer) io.Writer { + if sink == nil { + return capture + } + return io.MultiWriter(capture, sink) +} + +type boundedBuffer struct { + buffer bytes.Buffer + limit int + exceeded bool +} + +func (buffer *boundedBuffer) Write(data []byte) (int, error) { + written := len(data) + remaining := buffer.limit - buffer.buffer.Len() + if remaining > 0 { + if len(data) < remaining { + remaining = len(data) + } + _, _ = buffer.buffer.Write(data[:remaining]) + } + if len(data) > remaining { + buffer.exceeded = true + } + return written, nil +} + +func (buffer *boundedBuffer) String() string { return buffer.buffer.String() } + +func truncate(value string, limit int) string { + if len(value) <= limit { + return value + } + return value[:limit] +} + +func isMissingSandbox(text string) bool { + text = strings.ToLower(text) + return strings.Contains(text, "sandbox not found") || strings.Contains(text, "no such sandbox") || strings.Contains(text, "status 404") +} + +func decodeStrictJSON(data []byte, destination any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(destination); err != nil { + return err + } + if decoder.More() { + return fmt.Errorf("unexpected trailing json value") + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("unexpected trailing json value") + } + return err + } + return nil +} + +var _ provider.Lifecycle = (*Provider)(nil) +var _ provider.AdmissionVerifier = (*Provider)(nil) +var _ provider.InstanceAdmissionVerifier = (*Provider)(nil) +var _ provider.PolicyManager = (*Provider)(nil) diff --git a/internal/provider/dockersandboxes/provider_test.go b/internal/provider/dockersandboxes/provider_test.go new file mode 100644 index 0000000..6857d9e --- /dev/null +++ b/internal/provider/dockersandboxes/provider_test.go @@ -0,0 +1,1050 @@ +package dockersandboxes + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +const ( + testName = "epar-sandbox-1" + testID = "9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b" + testWorkspace = "/var/lib/epar/staging/job-1" + testTemplate = "docker.io/docker/sandbox-templates:shell-docker" + testDigest = "sha256:39cf20eca8610000000000000000000000000000000000000000000000000000" + readyListJSON = `{"sandboxes":[{"id":"9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b","name":"epar-sandbox-1","status":"running","workspaces":["/var/lib/epar/staging/job-1"],"agent":"shell","additive_field":true}]}` + emptyPortsJSON = `[]` + templateListJSON = `{"images":[{"id":"39cf20eca861","repository":"docker.io/docker/sandbox-templates","tag":"shell-docker","flavor":"shell-docker","created_at":"2026-07-22T07:13:19Z","size":599103243}]}` + inspectionJSON = `{"name":"epar-sandbox-1","agent":"shell","kits":[],"state":"running","image":"docker.io/docker/sandbox-templates:shell-docker","image_digest":"sha256:39cf20eca8610000000000000000000000000000000000000000000000000000","workspace":"/var/lib/epar/staging/job-1","network":"epar-sandbox-1","network_policy":{"scope":"global"},"proxy":"172.17.0.1:3128","mcp_gateway":false,"sessions":0,"daemon_version":"v0.35.0","daemon_uptime":"1h"}` +) + +var testInstance = provider.Instance{Name: testName, ProviderID: testID, Source: "shell", State: "running"} + +type commandStep struct { + args []string + result provider.ExecResult + err error + environment map[string]string + stdin string + streamOut string + streamErr string +} + +type cancellationSignalWriter struct { + once sync.Once + started chan struct{} +} + +func (writer *cancellationSignalWriter) Write(data []byte) (int, error) { + writer.once.Do(func() { close(writer.started) }) + return len(data), nil +} + +func scriptedProvider(t *testing.T, steps ...commandStep) (*Provider, func()) { + t.Helper() + p := New("sbx-test-double") + p.inspectImage = func(_ context.Context, reference string) (string, error) { + t.Helper() + if reference != testTemplate { + t.Fatalf("inspected image reference = %q, want %q", reference, testTemplate) + } + return testDigest, nil + } + index := 0 + p.runCommand = func(_ context.Context, request commandRequest) (provider.ExecResult, error) { + t.Helper() + if index >= len(steps) { + t.Fatalf("unexpected command: %#v", request.args) + } + step := steps[index] + index++ + if !reflect.DeepEqual(request.args, step.args) { + t.Fatalf("command %d args = %#v, want %#v", index, request.args, step.args) + } + if !reflect.DeepEqual(request.environment, step.environment) { + t.Fatalf("command %d environment = %#v, want %#v", index, request.environment, step.environment) + } + if request.stdin != nil { + data, err := io.ReadAll(request.stdin) + if err != nil { + t.Fatal(err) + } + if string(data) != step.stdin { + t.Fatalf("command %d stdin = %q, want %q", index, data, step.stdin) + } + } else if step.stdin != "" { + t.Fatalf("command %d did not receive stdin", index) + } + if request.stdout != nil { + _, _ = io.WriteString(request.stdout, step.streamOut) + } + if request.stderr != nil { + _, _ = io.WriteString(request.stderr, step.streamErr) + } + return step.result, step.err + } + return p, func() { + t.Helper() + if index != len(steps) { + t.Fatalf("executed %d commands, want %d", index, len(steps)) + } + } +} + +func TestCreateUsesOnlyPinnedVersionAndExactArgv(t *testing.T) { + wantCreate := []string{ + "create", "--name", testName, + "--cpus", "4", + "--memory", "8g", + "--template", "docker.io/docker/sandbox-templates:shell-docker", + "shell", testWorkspace, + } + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: templateListJSON}}, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: `{"sandboxes":[]}`}}, + commandStep{ + args: wantCreate, + environment: map[string]string{ + "DOCKER_SANDBOXES_ROOT_SIZE": "40g", + "DOCKER_SANDBOXES_DOCKER_SIZE": "60g", + }, + }, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"ports", testName, "--json"}, result: provider.ExecResult{Stdout: emptyPortsJSON}}, + commandStep{args: []string{"inspect", "--json", testName}, result: provider.ExecResult{Stdout: inspectionJSON}}, + commandStep{args: []string{"exec", "-i", testName, "--", "bash", "-lc", directWorkspaceVerificationScript}}, + ) + instance, err := p.Create(context.Background(), provider.CreateRequest{ + Name: testName, + Template: testTemplate, + TemplateDigest: testDigest, + StagingPath: testWorkspace, + CPUs: 4, + Memory: "8g", + RootDisk: "40g", + DockerDisk: "60g", + }) + if err != nil { + t.Fatal(err) + } + if instance.Name != testName || instance.ProviderID != testID { + t.Fatalf("instance = %#v", instance) + } + done() +} + +func TestSplitTemplateReferenceCanonicalizesDockerHubNames(t *testing.T) { + tests := map[string]string{ + "epar-template:version": "docker.io/library/epar-template", + "docker/sandbox-templates:shell-docker": "docker.io/docker/sandbox-templates", + "docker.io/library/epar-template:version": "docker.io/library/epar-template", + "registry.example.test/team/template:version": "registry.example.test/team/template", + "localhost:5000/team/template:version": "localhost:5000/team/template", + } + for reference, expectedRepository := range tests { + repository, _, err := splitTemplateReference(reference) + if err != nil { + t.Fatalf("splitTemplateReference(%q): %v", reference, err) + } + if repository != expectedRepository { + t.Fatalf("splitTemplateReference(%q) repository = %q, want %q", reference, repository, expectedRepository) + } + } +} + +func TestCreateFailsClosedOnCachedTemplateIdentityMismatch(t *testing.T) { + mismatch := strings.Replace(templateListJSON, "39cf20eca861", "aaaaaaaaaaaa", 1) + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: mismatch}}, + ) + if _, err := p.Create(context.Background(), validCreateRequest()); err == nil || !strings.Contains(err.Error(), "did not match") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestCreateFailsClosedWhenFullLocalImageIdentityDiffersDespiteMatchingCacheID(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, + ) + p.inspectImage = func(context.Context, string) (string, error) { + return "sha256:39cf20eca861ffffffffffffffffffffffffffffffffffffffffffffffffffff", nil + } + if _, err := p.Create(context.Background(), validCreateRequest()); err == nil || !strings.Contains(err.Error(), "full local docker sandbox template image identity") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestCreateFailsClosedWhenFullLocalImageIdentityCannotBeRead(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, + ) + p.inspectImage = func(context.Context, string) (string, error) { + return "", errors.New("local image missing") + } + if _, err := p.Create(context.Background(), validCreateRequest()); err == nil || !strings.Contains(err.Error(), "local image missing") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestCreateFailsClosedWithoutEchoingGlobalSecretMetadata(t *testing.T) { + const listedMetadata = "github registry masked-prefix masked-suffix" + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: listedMetadata}}, + ) + _, err := p.Create(context.Background(), validCreateRequest()) + if err == nil || !strings.Contains(err.Error(), "global secrets are configured") || strings.Contains(err.Error(), listedMetadata) { + t.Fatalf("global-secret preflight error = %v", err) + } + done() +} + +func TestInstanceAdmissionUsesExactInspectionAndRejectsAttachedCapabilities(t *testing.T) { + t.Run("empty", func(t *testing.T) { + p, done := identityAdmissionScript(t, commandStep{args: []string{"inspect", "--json", testName}, result: provider.ExecResult{Stdout: inspectionJSON}}) + if err := p.VerifyInstanceAdmission(context.Background(), testInstance); err != nil { + t.Fatal(err) + } + done() + }) + for _, mutation := range []struct { + name string + old string + new string + }{ + {name: "kit", old: `"kits":[]`, new: `"kits":[{"name":"docker-auth"}]`}, + {name: "mcp", old: `"mcp_gateway":false`, new: `"mcp_gateway":true`}, + {name: "secret", old: `"state":"running"`, new: `"state":"running","secrets":["registry"]`}, + {name: "port", old: `"state":"running"`, new: `"state":"running","published_ports":["8080:80"]`}, + {name: "auth", old: `"state":"running"`, new: `"state":"running","auth_mode":"docker-login"`}, + } { + t.Run(mutation.name, func(t *testing.T) { + fixture := strings.Replace(inspectionJSON, mutation.old, mutation.new, 1) + p, done := identityAdmissionScript(t, commandStep{args: []string{"inspect", "--json", testName}, result: provider.ExecResult{Stdout: fixture}}) + if err := p.VerifyInstanceAdmission(context.Background(), testInstance); err == nil { + t.Fatal("attached capability was accepted") + } + done() + }) + } +} + +func TestInstanceAdmissionRejectsPublishedPortInventory(t *testing.T) { + for _, test := range []struct { + name string + fixture string + message string + }{ + {name: "published", fixture: `[{"host_ip":"127.0.0.1","host_port":60002,"sandbox_port":9418,"protocol":"tcp"}]`, message: "forbidden published port"}, + {name: "null", fixture: `null`, message: "unsupported JSON schema"}, + {name: "wrapper", fixture: `{"ports":[]}`, message: "unsupported JSON schema"}, + {name: "trailing-json", fixture: `[] {}`, message: "unsupported JSON schema"}, + } { + t.Run(test.name, func(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"ports", testName, "--json"}, result: provider.ExecResult{Stdout: test.fixture}}, + ) + p.versionVerified = true + if err := p.VerifyInstanceAdmission(context.Background(), testInstance); err == nil || !strings.Contains(err.Error(), test.message) { + t.Fatalf("published port admission error = %v", err) + } + done() + }) + } +} + +func TestChildEnvironmentStripsHostSSHAgent(t *testing.T) { + t.Setenv("SSH_AUTH_SOCK", "/host/agent.sock") + t.Setenv("SSH_AGENT_PID", "4242") + environment := childEnvironment(nil) + for _, item := range environment { + key, _, _ := strings.Cut(item, "=") + if strings.EqualFold(key, "SSH_AUTH_SOCK") || strings.EqualFold(key, "SSH_AGENT_PID") { + t.Fatalf("host SSH agent variable survived child environment filtering: %q", key) + } + } +} + +func TestTemplateInventorySchemaFailsClosed(t *testing.T) { + for _, fixture := range []string{ + `[]`, + `{"templates":[]}`, + `{"images":null}`, + `{"images":[{"id":"39cf20eca861","repository":"docker.io/docker/sandbox-templates","tag":"shell-docker","flavor":"shell-docker","created_at":"not-a-time","size":599103243}]}`, + `{"images":[{"id":"39cf20eca861","repository":"docker.io/docker/sandbox-templates","tag":"shell-docker","flavor":"shell-docker","created_at":"2026-07-22T07:13:19Z","size":"599103243"}]}`, + } { + if _, err := parseTemplateInventory([]byte(fixture)); err == nil { + t.Fatalf("template schema drift was accepted: %s", fixture) + } + } +} + +func TestParseTemplateInventoryAcceptsCustomTemplateWithoutFlavor(t *testing.T) { + images, err := parseTemplateInventory([]byte(`{"images":[{"id":"f40a31d1d676","repository":"docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04","tag":"20260715-amd64","created_at":"2026-07-23T04:37:43Z","size":1019288120}]}`)) + if err != nil { + t.Fatal(err) + } + if len(images) != 1 || images[0].Flavor != "" || images[0].ID != "f40a31d1d676" { + t.Fatalf("custom template inventory = %#v", images) + } +} + +func TestCachedTemplatesUsesExactVersionAndMachineReadableInventory(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: templateListJSON}}, + ) + templates, err := p.CachedTemplates(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(templates) != 1 { + t.Fatalf("templates = %#v", templates) + } + template := templates[0] + if template.Reference != testTemplate || template.CacheID != "39cf20eca861" || !template.CreatedAt.Equal(time.Date(2026, time.July, 22, 7, 13, 19, 0, time.UTC)) || template.SizeBytes != 599103243 { + t.Fatalf("cached template = %#v", template) + } + done() +} + +func TestCachedTemplatesFailsClosedOnMalformedInventory(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: `{"images":[{"id":"not-a-cache-id"}]}`}}, + ) + if _, err := p.CachedTemplates(context.Background()); err == nil { + t.Fatal("malformed template inventory was accepted") + } + done() +} + +func TestReadGlobalNetworkPolicyUsesGlobalOnlyReadback(t *testing.T) { + fixture := policyFixture(`[ + {"id":"global-1","name":"global","policy_id":"local","scope":"global","applies_to":"all","resource_type":"network","decision":"allow","resources":["api.example.com"],"origin":"local","status":"active","editable":true}, + {"id":"sandbox-1","name":"sandbox","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"deny","resources":["**"],"origin":"scoped","status":"inactive","editable":true,"sandbox_id":"epar-sandbox-1"} + ]`) + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"policy", "ls", "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: fixture}}, + ) + rules, err := p.ReadGlobalNetworkPolicy(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rules) != 1 || rules[0].ID != "global-1" || rules[0].Scope != "global" || rules[0].AppliesTo != "all" || !rules[0].Active { + t.Fatalf("global policy rules = %#v", rules) + } + done() +} + +func TestReadGlobalNetworkPolicyFailsClosedOnMalformedJSON(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"policy", "ls", "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: `{"rules":null}`}}, + ) + if _, err := p.ReadGlobalNetworkPolicy(context.Background()); err == nil { + t.Fatal("malformed global policy was accepted") + } + done() +} + +func TestLocalTemplateInspectionUsesExactDockerImageInspectArgv(t *testing.T) { + if got, want := localTemplateInspectArgs(testTemplate), []string{"image", "inspect", "--format", "{{json .}}", testTemplate}; !reflect.DeepEqual(got, want) { + t.Fatalf("docker image inspect args = %#v, want %#v", got, want) + } +} + +func TestParseLocalTemplateImageValidatesFullIdentityAndPlatform(t *testing.T) { + valid := `{"Id":"` + testDigest + `","Os":"linux","Architecture":"amd64"}` + image, err := parseLocalTemplateImage([]byte(valid)) + if err != nil { + t.Fatal(err) + } + if image.Digest != testDigest || image.Platform != "linux/amd64" { + t.Fatalf("local template image = %#v", image) + } + for _, fixture := range []string{ + `{"Id":"sha256:short","Os":"linux","Architecture":"amd64"}`, + `{"Id":"` + testDigest + `","Os":"linux","Architecture":"s390x"}`, + `{"Id":"` + testDigest + `","Os":"windows","Architecture":"amd64"}`, + `{"Id":"` + testDigest + `","Os":"linux","Architecture":"amd64"} trailing`, + } { + if _, err := parseLocalTemplateImage([]byte(fixture)); err == nil { + t.Fatalf("invalid local image inspection was accepted: %s", fixture) + } + } +} + +func TestInspectLocalTemplateRejectsNonRepositoryTagReference(t *testing.T) { + p := New("sbx-test-double") + p.inspectTemplate = func(context.Context, string) (LocalTemplateImage, error) { + t.Fatal("invalid local template reference reached Docker image inspection") + return LocalTemplateImage{}, nil + } + for _, reference := range []string{"", "template", "template@sha256:deadbeef", "template:tag/with-slash", "-template:tag", "template:tag;other"} { + if _, err := p.InspectLocalTemplate(context.Background(), reference); err == nil { + t.Fatalf("invalid local template reference was accepted: %q", reference) + } + } +} + +func TestInspectLocalTemplatePreservesValidatedIdentityAndPlatform(t *testing.T) { + p := New("sbx-test-double") + p.inspectTemplate = func(_ context.Context, reference string) (LocalTemplateImage, error) { + if reference != testTemplate { + t.Fatalf("inspected local template = %q, want %q", reference, testTemplate) + } + return LocalTemplateImage{Digest: testDigest, Platform: "linux/arm64"}, nil + } + image, err := p.InspectLocalTemplate(context.Background(), testTemplate) + if err != nil { + t.Fatal(err) + } + if image.Digest != testDigest || image.Platform != "linux/arm64" { + t.Fatalf("local template = %#v", image) + } + for _, invalid := range []LocalTemplateImage{ + {Digest: "sha256:short", Platform: "linux/amd64"}, + {Digest: testDigest, Platform: "linux/s390x"}, + } { + p.inspectTemplate = func(context.Context, string) (LocalTemplateImage, error) { return invalid, nil } + if _, err := p.InspectLocalTemplate(context.Background(), testTemplate); err == nil { + t.Fatalf("invalid local template metadata was accepted: %#v", invalid) + } + } +} + +func TestVersionGateFailsClosedBeforeMutation(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.36.0\n"}}, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.36.0\n"}}, + ) + _, err := p.Create(context.Background(), validCreateRequest()) + if err == nil || !strings.Contains(err.Error(), "exactly v0.35.0") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestVersionGateRetriesOneTransientUnsupportedRead(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: ""}}, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, + ) + if err := p.VerifyAdmission(context.Background()); err != nil { + t.Fatal(err) + } + done() +} + +func TestVersionGateAcceptsExactInstalledV035Output(t *testing.T) { + if !isSupportedVersion("sbx version: v0.35.0 01e01520456e4126a9653471e7072e4d9b280321\r\n") { + t.Fatal("installed v0.35.0 output was rejected") + } + for _, output := range []string{ + "sbx version: v0.35.0 untrusted-suffix\n", + "sbx version: v0.35.1 01e01520456e4126a9653471e7072e4d9b280321\n", + "v0.35.0\n", + } { + if isSupportedVersion(output) { + t.Fatalf("unsupported version output was accepted: %q", output) + } + } +} + +func TestAdmissionRechecksVersionAfterSuccessfulCache(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.36.0\n"}}, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.36.0\n"}}, + ) + if err := p.VerifyAdmission(context.Background()); err != nil { + t.Fatal(err) + } + if err := p.VerifyAdmission(context.Background()); err == nil || !strings.Contains(err.Error(), "exactly v0.35.0") { + t.Fatalf("in-place sbx version drift was accepted: %v", err) + } + done() +} + +func TestInventoryParsesV035WrapperAndFailsClosedOnSchemaDrift(t *testing.T) { + items, err := parseInventory([]byte(readyListJSON)) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Instance.Name != testInstance.Name || items[0].Instance.ProviderID != testInstance.ProviderID || !reflect.DeepEqual(items[0].Workspaces, []string{testWorkspace}) { + t.Fatalf("items = %#v", items) + } + for _, fixture := range []string{ + `[]`, + `{"items":[]}`, + `{"sandboxes":null}`, + `{"sandboxes":[{"name":"epar-sandbox-1","status":"running","workspaces":["/staging"]}]}`, + `{"sandboxes":[{"id":"id-1","name":"epar-sandbox-1","state":"running","workspaces":["/staging"]}]}`, + `{"sandboxes":[{"id":"id-1","name":"epar-sandbox-1","status":"running"}]}`, + `{"sandboxes":[{"id":"id-1","name":"epar-sandbox-1","status":"running","workspaces":[]}]}`, + `{"sandboxes":[{"id":"id-1","name":"epar-sandbox-1","status":"running","workspaces":["/staging","/staging"]}]}`, + } { + if _, err := parseInventory([]byte(fixture)); err == nil { + t.Fatalf("schema drift was accepted: %s", fixture) + } + } +} + +func TestLifecycleCommandsUseExactIdentityAndArgv(t *testing.T) { + t.Run("start", func(t *testing.T) { + p, done := identityScript(t, commandStep{args: []string{"exec", "-i", testName, "--", "/bin/sleep", "infinity"}}) + if _, err := p.Start(context.Background(), testInstance, provider.StartOptions{}); err != nil { + t.Fatal(err) + } + done() + }) + t.Run("verify", func(t *testing.T) { + p, done := identityScript(t, commandStep{ + args: []string{"exec", "-i", testName, "--", "bash", "-lc", runtimeVerificationScript}, + result: provider.ExecResult{Stdout: `"29.5.3"`}, + }) + info, err := p.VerifyRuntime(context.Background(), testInstance) + if err != nil || !info.Ready || info.Runtime != "docker" || info.Version != "29.5.3" { + t.Fatalf("info = %#v, err = %v", info, err) + } + done() + }) + t.Run("stop_preserves_state", func(t *testing.T) { + p, done := identityScript(t, commandStep{args: []string{"stop", testName}}) + if err := p.Stop(context.Background(), testInstance); err != nil { + t.Fatal(err) + } + done() + }) + t.Run("delete_is_exact_force_remove", func(t *testing.T) { + p, done := identityScript(t, commandStep{args: []string{"rm", "--force", testName}}) + if err := p.Delete(context.Background(), testInstance); err != nil { + t.Fatal(err) + } + done() + }) + t.Run("address_is_unavailable_without_command", func(t *testing.T) { + p, done := scriptedProvider(t) + address, available, err := p.Address(context.Background(), testInstance, 30) + if err != nil || available || address != "" { + t.Fatalf("address = %q, available = %v, err = %v", address, available, err) + } + done() + }) +} + +func TestExecPreservesGuestArgvStdinAndRedactsAllSurfaces(t *testing.T) { + const secret = "sentinel-sandbox-secret" + var stdout, stderr bytes.Buffer + p, done := identityScript(t, commandStep{ + args: []string{"exec", "-i", testName, "--", "sh", "-c", "printf '%s' \"$1\"", "sh", "semi;colon", "--privileged"}, + stdin: "payload\n", + streamOut: "stream " + secret, + streamErr: "TOKEN=" + secret, + result: provider.ExecResult{ + Stdout: "result " + secret, + Stderr: "SECRET=" + secret, + }, + err: errors.New("exit status 17: " + secret), + }) + result, err := p.Exec(context.Background(), testInstance, + []string{"sh", "-c", "printf '%s' \"$1\"", "sh", "semi;colon", "--privileged"}, + provider.ExecOptions{Stdin: "payload\n", SensitiveValues: []string{secret}, Stdout: &stdout, Stderr: &stderr}, + ) + if err == nil { + t.Fatal("expected fake execution error") + } + combined := stdout.String() + stderr.String() + result.Stdout + result.Stderr + err.Error() + if strings.Contains(combined, secret) { + t.Fatalf("secret leaked: %q", combined) + } + if !strings.Contains(combined, "[REDACTED]") { + t.Fatalf("redaction marker missing: %q", combined) + } + done() +} + +func TestExecCancellationPropagatesWithoutRealSbx(t *testing.T) { + p := New("sbx-test-double") + p.versionVerified = true + call := 0 + p.runCommand = func(ctx context.Context, request commandRequest) (provider.ExecResult, error) { + call++ + switch call { + case 1: + if !reflect.DeepEqual(request.args, []string{"ls", "--json"}) { + t.Fatalf("identity args = %#v", request.args) + } + return provider.ExecResult{Stdout: readyListJSON}, nil + case 2: + if !reflect.DeepEqual(request.args, []string{"exec", "-i", testName, "--", "sleep", "30"}) { + t.Fatalf("exec args = %#v", request.args) + } + <-ctx.Done() + return provider.ExecResult{}, ctx.Err() + default: + t.Fatalf("unexpected call %d", call) + return provider.ExecResult{}, nil + } + } + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(10*time.Millisecond, cancel) + _, err := p.Exec(ctx, testInstance, []string{"sleep", "30"}, provider.ExecOptions{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v", err) + } +} + +func TestRunRawNormalizesCommandContextKillToCancellation(t *testing.T) { + if os.Getenv("EPAR_DOCKER_SANDBOXES_RUN_RAW_HELPER") == "1" { + _, _ = os.Stdout.WriteString("ready\n") + time.Sleep(30 * time.Second) + return + } + t.Setenv("EPAR_DOCKER_SANDBOXES_RUN_RAW_HELPER", "1") + + p := New(os.Args[0]) + ctx, cancel := context.WithCancel(context.Background()) + started := make(chan struct{}) + type rawResult struct { + result provider.ExecResult + err error + } + finished := make(chan rawResult, 1) + go func() { + result, err := p.runRaw(ctx, commandRequest{ + args: []string{"-test.run=^TestRunRawNormalizesCommandContextKillToCancellation$"}, + operation: "test helper", + outputLimit: defaultOutputLimit, + stdout: &cancellationSignalWriter{started: started}, + }) + finished <- rawResult{result: result, err: err} + }() + select { + case <-started: + case <-time.After(5 * time.Second): + cancel() + t.Fatal("runRaw helper process did not start") + } + cancel() + select { + case outcome := <-finished: + if !errors.Is(outcome.err, context.Canceled) { + t.Fatalf("runRaw cancellation error = %v, want context.Canceled", outcome.err) + } + var exitErr *exec.ExitError + if errors.As(outcome.err, &exitErr) { + t.Fatalf("runRaw retained cancellation-induced process exit as a concrete error: %v", outcome.err) + } + case <-time.After(5 * time.Second): + t.Fatal("runRaw did not return after cancellation") + } +} + +func TestStopAndDeleteAreIdempotentWhenInventorySaysMissing(t *testing.T) { + for _, test := range []struct { + name string + call func(*Provider) error + }{ + {name: "stop", call: func(p *Provider) error { return p.Stop(context.Background(), testInstance) }}, + {name: "delete", call: func(p *Provider) error { return p.Delete(context.Background(), testInstance) }}, + } { + t.Run(test.name, func(t *testing.T) { + p, done := scriptedProvider(t, commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: `{"sandboxes":[]}`}}) + p.versionVerified = true + if err := test.call(p); err != nil { + t.Fatal(err) + } + done() + }) + } +} + +func TestIdentityMismatchFailsBeforeStateMutation(t *testing.T) { + mismatch := strings.Replace(readyListJSON, testID, "different-id", 1) + p, done := scriptedProvider(t, commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: mismatch}}) + p.versionVerified = true + if err := p.Delete(context.Background(), testInstance); err == nil || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestDiagnosticsUsesBoundedMachineReadableCommands(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"daemon", "status", "--json"}, result: provider.ExecResult{Stdout: `{"status":"running","socket":"\\\\.\\pipe\\docker_kaname_sandboxd","logs":"C:\\logs\\daemon.log"}`}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"ok","detail":"","hint":""}],"summary":{"pass":1,"warn":0,"fail":0,"skip":0}}`}}, + ) + p.versionVerified = true + diagnostics, err := p.Diagnostics(context.Background(), testInstance) + if err != nil || !diagnostics.Healthy || diagnostics.ChecksPassed != 1 { + t.Fatalf("diagnostics = %#v, err = %v", diagnostics, err) + } + done() +} + +func TestVerifyHostReadinessAcceptsWarningsWithPassingChecksAndNoFailures(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"healthy","detail":"","hint":""},{"name":"binary version","status":"warn","message":"update available","detail":"","hint":""}],"summary":{"pass":1,"warn":1,"fail":0,"skip":0}}`}}, + ) + readiness, err := p.VerifyHostReadiness(context.Background()) + if err != nil { + t.Fatal(err) + } + if readiness.ChecksPassed != 1 || readiness.ChecksWarned != 1 || readiness.ChecksFailed != 0 || readiness.ChecksSkipped != 0 { + t.Fatalf("readiness = %#v", readiness) + } + done() +} + +func TestVerifyHostReadinessRejectsFailedOrEmptyDiagnostics(t *testing.T) { + tests := []struct { + name string + fixture string + want string + }{ + { + name: "failed check", + fixture: `{"version":"1.0","checks":[{"name":"daemon","status":"fail","message":"unhealthy","detail":"","hint":""}],"summary":{"pass":0,"warn":0,"fail":1,"skip":0}}`, + want: "1 failed check", + }, + { + name: "no passing check", + fixture: `{"version":"1.0","checks":[{"name":"optional","status":"skip","message":"not applicable","detail":"","hint":""}],"summary":{"pass":0,"warn":0,"fail":0,"skip":1}}`, + want: "no passing checks", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: test.fixture}}, + ) + if _, err := p.VerifyHostReadiness(context.Background()); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("VerifyHostReadiness() error = %v, want %q", err, test.want) + } + done() + }) + } +} + +func TestDiagnosticsRequiresEverySummaryCount(t *testing.T) { + fixture := `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"ok","detail":"","hint":""}],"summary":{"pass":1,"warn":0,"fail":0}}` + if _, _, _, _, err := parseDiagnose([]byte(fixture)); err == nil { + t.Fatal("diagnostics accepted a missing summary count") + } +} + +func TestDiagnosticsRejectsOversizedOutput(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"daemon", "status", "--json"}, result: provider.ExecResult{Stdout: strings.Repeat("x", diagnosticOutputLimit+1)}}, + ) + p.versionVerified = true + if _, err := p.Diagnostics(context.Background(), testInstance); err == nil || !strings.Contains(err.Error(), "output limit") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestPolicyCommandsAreSandboxScopedAndReadBackExactRules(t *testing.T) { + globalRule := `{"id":"global-1","name":"automatic baseline","policy_id":"local-policy","scope":"global","applies_to":"all","resource_type":"network","decision":"allow","resources":["openrouter.ai"],"origin":"local","status":"active","editable":true}` + sandboxRule := `{"id":"rule-1","name":"job allowlist","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"allow","resources":["api.example.com","*.packages.example.com:443"],"origin":"scoped","status":"active","editable":true,"sandbox_id":"epar-sandbox-1","additive":42}` + policyJSON := policyFixture(`[` + globalRule + `,` + sandboxRule + `]`) + rule := provider.NetworkPolicyRule{Decision: provider.NetworkPolicyAllow, Resources: []string{"api.example.com", "*.packages.example.com:443"}} + t.Run("apply", func(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"policy", "allow", "network", "--sandbox", testName, "api.example.com,*.packages.example.com:443"}}, + commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: policyJSON}}, + ) + p.versionVerified = true + if err := p.ApplyNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{rule}); err != nil { + t.Fatal(err) + } + done() + }) + t.Run("apply_open_public_egress", func(t *testing.T) { + openRule := `{"id":"rule-open","name":"open public egress","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"allow","resources":["**"],"origin":"scoped","status":"active","editable":true,"sandbox_id":"epar-sandbox-1"}` + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"policy", "allow", "network", "--sandbox", testName, "**"}}, + commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: policyFixture(`[` + globalRule + `,` + openRule + `]`)}}, + ) + p.versionVerified = true + if err := p.ApplyNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{{ + Decision: provider.NetworkPolicyAllow, + Resources: []string{"**"}, + }}); err != nil { + t.Fatal(err) + } + done() + }) + t.Run("remove_by_stable_rule_id", func(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: policyJSON}}, + commandStep{args: []string{"policy", "rm", "network", "--sandbox", testName, "--id", "rule-1"}}, + commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: policyFixture(`[` + globalRule + `]`)}}, + ) + p.versionVerified = true + remove := provider.NetworkPolicyRule{ + ID: "rule-1", + PolicyID: "local", + Scope: "sandbox:" + testName, + AppliesTo: "sandbox:" + testName, + ResourceType: "network", + Resources: append([]string(nil), rule.Resources...), + Decision: provider.NetworkPolicyAllow, + Origin: "scoped", + Editable: true, + } + if err := p.RemoveNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{remove}); err != nil { + t.Fatal(err) + } + done() + }) +} + +func TestPolicyRemovalRefusesChangedStableIdentity(t *testing.T) { + fixture := policyFixture(`[{"id":"rule-1","name":"job allowlist","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"allow","resources":["api.example.com"],"origin":"scoped","status":"inactive","editable":true,"sandbox_id":"epar-sandbox-1"}]`) + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: fixture}}, + ) + p.versionVerified = true + changed := provider.NetworkPolicyRule{ + ID: "rule-1", + PolicyID: "local", + Scope: "sandbox:" + testName, + AppliesTo: "sandbox:" + testName, + ResourceType: "network", + Resources: []string{"different.example.com"}, + Decision: provider.NetworkPolicyAllow, + Origin: "scoped", + Editable: true, + } + if err := p.RemoveNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{changed}); err == nil || !strings.Contains(err.Error(), "stable identity changed") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestPolicyReadPreservesGlobalAndSandboxAttribution(t *testing.T) { + fixture := policyFixture(`[ + {"id":"global-1","name":"automatic baseline","policy_id":"local-policy","scope":"global","applies_to":"all","resource_type":"network","decision":"allow","resources":["openrouter.ai"],"origin":"local","status":"active","editable":true}, + {"id":"rule-1","name":"job allowlist","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"deny","resources":["**"],"origin":"scoped","status":"inactive","editable":true,"sandbox_id":"epar-sandbox-1"}, + {"id":"other-1","name":"another sandbox","policy_id":"local","scope":"sandbox:other-sandbox","applies_to":"sandbox:other-sandbox","resource_type":"network","decision":"allow","resources":["other.example.com"],"origin":"scoped","status":"active","editable":true,"sandbox_id":"other-sandbox"}, + {"id":"fs-1","name":"filesystem baseline","policy_id":"local-policy","scope":"global","applies_to":"all","resource_type":"filesystem","decision":"allow","resources":["/workspace"],"origin":"local","status":"active","editable":false} + ]`) + rules, err := parseNetworkPolicy([]byte(fixture), testName) + if err != nil { + t.Fatal(err) + } + if len(rules) != 3 { + t.Fatalf("rules = %#v", rules) + } + global := rules[0] + if global.ID != "global-1" || global.Name != "automatic baseline" || global.PolicyID != "local-policy" || global.Scope != "global" || global.AppliesTo != "all" || global.ResourceType != "network" || global.Origin != "local" || global.Status != "active" || !global.Editable || !global.Active { + t.Fatalf("global attribution was not preserved: %#v", global) + } + local := rules[1] + if local.Scope != "sandbox:"+testName || local.AppliesTo != "sandbox:"+testName || local.Origin != "scoped" || local.Status != "inactive" || local.Active { + t.Fatalf("sandbox attribution was not preserved: %#v", local) + } + filesystem := rules[2] + if filesystem.ID != "fs-1" || filesystem.ResourceType != "filesystem" || filesystem.Scope != "global" || filesystem.AppliesTo != "all" || filesystem.Editable { + t.Fatalf("filesystem attribution was not preserved: %#v", filesystem) + } +} + +func TestPolicyReadPreservesExactV035KitAttribution(t *testing.T) { + fixture := policyFixture(`[{"id":"kit-rule-1","name":"kit:epar-sandbox-1","policy_id":"kit-policy-1","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"allow","resources":["openrouter.ai"],"origin":"scoped","status":"active","editable":false,"sandbox_id":"epar-sandbox-1"}]`) + rules, err := parseNetworkPolicy([]byte(fixture), testName) + if err != nil { + t.Fatal(err) + } + if len(rules) != 1 || rules[0].Scope != "sandbox:"+testName || rules[0].AppliesTo != "sandbox:"+testName || rules[0].Origin != "scoped" || rules[0].Editable || !rules[0].Active { + t.Fatalf("kit attribution was not preserved: %#v", rules) + } +} + +func TestPolicyReadRejectsMismatchedV035SandboxAttribution(t *testing.T) { + for _, fixture := range []string{ + policyFixture(`[{"id":"rule-1","name":"rule","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:other-sandbox","resource_type":"network","decision":"allow","resources":["api.example.com"],"origin":"scoped","status":"active","editable":true,"sandbox_id":"epar-sandbox-1"}]`), + policyFixture(`[{"id":"rule-1","name":"rule","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"allow","resources":["api.example.com"],"origin":"scoped","status":"active","editable":true,"sandbox_id":"other-sandbox"}]`), + } { + if _, err := parseNetworkPolicy([]byte(fixture), testName); err == nil { + t.Fatalf("mismatched v0.35.0 sandbox attribution was accepted: %s", fixture) + } + } +} + +func TestPolicyRemovalRefusesGlobalRule(t *testing.T) { + global := provider.NetworkPolicyRule{ID: "global-1", PolicyID: "local-policy", Scope: "global", AppliesTo: "all", ResourceType: "network", Resources: []string{"openrouter.ai"}, Decision: provider.NetworkPolicyAllow, Origin: "local", Editable: true} + fixture := policyFixture(`[{"id":"global-1","name":"automatic baseline","policy_id":"local-policy","scope":"global","applies_to":"all","resource_type":"network","decision":"allow","resources":["openrouter.ai"],"origin":"local","status":"active","editable":true}]`) + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: fixture}}, + ) + p.versionVerified = true + if err := p.RemoveNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{global}); err == nil || !strings.Contains(err.Error(), "refusing to remove") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestPolicyRemovalRefusesFilesystemRule(t *testing.T) { + filesystem := provider.NetworkPolicyRule{ID: "fs-1", PolicyID: "local-policy", Scope: "global", AppliesTo: "all", ResourceType: "filesystem", Resources: []string{"/workspace"}, Decision: provider.NetworkPolicyAllow, Origin: "local", Editable: false} + fixture := policyFixture(`[{"id":"fs-1","name":"filesystem baseline","policy_id":"local-policy","scope":"global","applies_to":"all","resource_type":"filesystem","decision":"allow","resources":["/workspace"],"origin":"local","status":"active","editable":false}]`) + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: fixture}}, + ) + p.versionVerified = true + if err := p.RemoveNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{filesystem}); err == nil || !strings.Contains(err.Error(), "refusing to remove") { + t.Fatalf("err = %v", err) + } + done() +} + +func TestPolicySchemaFailsClosed(t *testing.T) { + for _, fixture := range []string{ + `[]`, + `{"policies":[]}`, + `{"rules":null}`, + policyFixture(`[{"id":"rule-1","name":"rule","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"permit","resources":["api.example.com"],"origin":"scoped","status":"active","editable":true,"sandbox_id":"epar-sandbox-1"}]`), + policyFixture(`[{"id":"rule-1","name":"rule","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"allow","resources":["api.example.com"],"origin":"scoped","status":"unknown","editable":true,"sandbox_id":"epar-sandbox-1"}]`), + } { + if _, err := parseNetworkPolicy([]byte(fixture), testName); err == nil { + t.Fatalf("policy schema drift was accepted: %s", fixture) + } + } +} + +func TestPolicyReadbackAcceptsAttributedProviderBaselinePatterns(t *testing.T) { + fixture := policyFixture(`[{"id":"default-cert-validation","name":"default-cert-validation","policy_id":"local-policy","scope":"global","applies_to":"all","resource_type":"network","decision":"allow","resources":["**.openai.com:443","crl*.digicert.com:80","**"],"origin":"local","status":"active","editable":true}]`) + rules, err := parseNetworkPolicy([]byte(fixture), testName) + if err != nil { + t.Fatal(err) + } + if len(rules) != 1 || !reflect.DeepEqual(rules[0].Resources, []string{"**.openai.com:443", "crl*.digicert.com:80", "**"}) { + t.Fatalf("provider baseline resources = %#v", rules) + } +} + +func TestInjectionCorpusIsRejectedBeforeCommandExecution(t *testing.T) { + for _, name := range []string{"-other", "../other", "other/name", "name;rm", "name\nnext", "name+other", ""} { + request := validCreateRequest() + request.Name = name + if err := validateCreateRequest(request); err == nil { + t.Fatalf("name injection accepted: %q", name) + } + } + for _, resource := range []string{"-api.example.com", "api.example.com,evil.example.com", "api.example.com --sandbox other", "api.example.com;reset", "api.example.com\n**", "https://api.example.com", "127.0.0.1", "10.0.0.0/8", "[::1]:443", "*.localhost"} { + rule := provider.NetworkPolicyRule{Decision: provider.NetworkPolicyAllow, Resources: []string{resource}} + if err := validateNetworkRule(rule, false); err == nil { + t.Fatalf("resource injection accepted: %q", resource) + } + } + openRule := provider.NetworkPolicyRule{Decision: provider.NetworkPolicyAllow, Resources: []string{"**"}} + if err := validateNetworkRule(openRule, false); err != nil { + t.Fatalf("owned sandbox-scoped open rule was rejected: %v", err) + } + denyAllRule := provider.NetworkPolicyRule{Decision: provider.NetworkPolicyDeny, Resources: []string{"**"}} + if err := validateNetworkRule(denyAllRule, false); err == nil { + t.Fatal("unbounded deny wildcard was accepted") + } +} + +func TestCommandBoundaryRejectsInteractiveAndDestructiveGlobalCommands(t *testing.T) { + for _, command := range []string{"tui", "reset", "run", "kit", "secret", "login", "logout", "setup", "ssh", "cp", "completion", "help"} { + err := validateCommandRequest(commandRequest{args: []string{command}, operation: "test forbidden command"}) + if err == nil { + t.Fatalf("forbidden Docker Sandboxes command %q was accepted", command) + } + } + for _, arguments := range [][]string{{"inspect"}, {"inspect", testName}, {"inspect", "--json", "../other"}, {"inspect", "--debug", testName}} { + if err := validateCommandRequest(commandRequest{args: arguments, operation: "test forbidden inspection"}); err == nil { + t.Fatalf("non-exact Docker Sandboxes inspection was accepted: %q", arguments) + } + } + for _, arguments := range [][]string{{"ports"}, {"ports", testName}, {"ports", "../other", "--json"}, {"ports", testName, "--json", "extra"}, {"ports", testName, "--unpublish"}} { + if err := validateCommandRequest(commandRequest{args: arguments, operation: "test forbidden port command"}); err == nil { + t.Fatalf("non-exact Docker Sandboxes published-port inspection was accepted: %q", arguments) + } + } +} + +func TestExecRejectsHostPathsAndEnvironmentPassthrough(t *testing.T) { + p, done := scriptedProvider(t) + if _, err := p.Exec(context.Background(), testInstance, []string{"true"}, provider.ExecOptions{LogPath: "/host/log"}); err == nil { + t.Fatal("host log path was accepted") + } + if _, err := p.Exec(context.Background(), testInstance, []string{"true"}, provider.ExecOptions{Env: map[string]string{"TOKEN": "secret"}}); err == nil { + t.Fatal("guest environment passthrough was accepted") + } + done() +} + +func identityScript(t *testing.T, operation commandStep) (*Provider, func()) { + t.Helper() + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + operation, + ) + p.versionVerified = true + return p, done +} + +func identityAdmissionScript(t *testing.T, inspection commandStep) (*Provider, func()) { + t.Helper() + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"ports", testName, "--json"}, result: provider.ExecResult{Stdout: emptyPortsJSON}}, + inspection, + ) + p.versionVerified = true + return p, done +} + +func validCreateRequest() provider.CreateRequest { + return provider.CreateRequest{ + Name: testName, + Template: testTemplate, + TemplateDigest: testDigest, + StagingPath: testWorkspace, + CPUs: 4, + Memory: "8g", + } +} + +func policyFixture(rules string) string { + return `{"rules":` + rules + `}` +} diff --git a/internal/provider/dockersandboxes/staging/identity_other.go b/internal/provider/dockersandboxes/staging/identity_other.go new file mode 100644 index 0000000..2f6c6c4 --- /dev/null +++ b/internal/provider/dockersandboxes/staging/identity_other.go @@ -0,0 +1,21 @@ +//go:build !windows + +package staging + +import ( + "fmt" + "os" + "syscall" +) + +func platformDirectoryIdentity(path string) (string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", fmt.Errorf("filesystem did not expose a stable directory identity") + } + return fmt.Sprintf("unix:%x:%x", uint64(stat.Dev), uint64(stat.Ino)), nil +} diff --git a/internal/provider/dockersandboxes/staging/identity_windows.go b/internal/provider/dockersandboxes/staging/identity_windows.go new file mode 100644 index 0000000..584bb82 --- /dev/null +++ b/internal/provider/dockersandboxes/staging/identity_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package staging + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +func platformDirectoryIdentity(path string) (string, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + handle, err := windows.CreateFile(pointer, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return "", err + } + defer windows.CloseHandle(handle) + var information windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return "", err + } + return fmt.Sprintf("windows:%08x:%08x%08x", information.VolumeSerialNumber, information.FileIndexHigh, information.FileIndexLow), nil +} diff --git a/internal/provider/dockersandboxes/staging/permissions_other.go b/internal/provider/dockersandboxes/staging/permissions_other.go new file mode 100644 index 0000000..7c7cee8 --- /dev/null +++ b/internal/provider/dockersandboxes/staging/permissions_other.go @@ -0,0 +1,27 @@ +//go:build !windows + +package staging + +import ( + "fmt" + "os" + "syscall" +) + +func restrictPlatformPermissions(path string) error { + return os.Chmod(path, 0o700) +} + +func validatePlatformPermissions(_ string, info os.FileInfo) error { + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("mode %04o permits group or other access; require owner-only access", info.Mode().Perm()) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("filesystem did not expose staging ownership") + } + if stat.Uid != uint32(os.Geteuid()) { + return fmt.Errorf("owner uid %d does not match controller uid %d", stat.Uid, os.Geteuid()) + } + return nil +} diff --git a/internal/provider/dockersandboxes/staging/permissions_unix_test.go b/internal/provider/dockersandboxes/staging/permissions_unix_test.go new file mode 100644 index 0000000..f5e556c --- /dev/null +++ b/internal/provider/dockersandboxes/staging/permissions_unix_test.go @@ -0,0 +1,25 @@ +//go:build !windows + +package staging + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRejectsForeignUnixOwnerWhenPrivileged(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires a privileged Unix test process") + } + root := filepath.Join(t.TempDir(), "foreign-owner") + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal(err) + } + if err := os.Chown(root, 1, -1); err != nil { + t.Skipf("cannot create a foreign-owned directory: %v", err) + } + if _, err := Open(root); err == nil { + t.Fatal("foreign-owned staging root accepted") + } +} diff --git a/internal/provider/dockersandboxes/staging/permissions_windows.go b/internal/provider/dockersandboxes/staging/permissions_windows.go new file mode 100644 index 0000000..d25f1de --- /dev/null +++ b/internal/provider/dockersandboxes/staging/permissions_windows.go @@ -0,0 +1,91 @@ +//go:build windows + +package staging + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/sys/windows" +) + +func restrictPlatformPermissions(path string) error { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return fmt.Errorf("get current process user: %w", err) + } + sddl := fmt.Sprintf("D:P(A;OICI;FA;;;%s)(A;OICI;FA;;;SY)", user.User.Sid.String()) + descriptor, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return fmt.Errorf("build private staging security descriptor: %w", err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + return fmt.Errorf("read private staging DACL: %w", err) + } + information := windows.SECURITY_INFORMATION(windows.DACL_SECURITY_INFORMATION | windows.PROTECTED_DACL_SECURITY_INFORMATION) + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, information, nil, nil, dacl, nil); err != nil { + return fmt.Errorf("set private staging DACL: %w", err) + } + return nil +} + +func validatePlatformPermissions(path string, _ os.FileInfo) error { + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read staging DACL: %w", err) + } + control, _, err := descriptor.Control() + if err != nil { + return fmt.Errorf("read staging DACL control: %w", err) + } + if control&windows.SE_DACL_PROTECTED == 0 { + return fmt.Errorf("DACL inherits access from a parent") + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return fmt.Errorf("get current process user: %w", err) + } + sddl := descriptor.String() + currentSID := user.User.Sid.String() + remaining := sddl + seenCurrent := false + seenSystem := false + aceCount := 0 + for { + start := strings.IndexByte(remaining, '(') + if start < 0 { + break + } + remaining = remaining[start+1:] + end := strings.IndexByte(remaining, ')') + if end < 0 { + return fmt.Errorf("DACL contains malformed SDDL") + } + fields := strings.Split(remaining[:end], ";") + remaining = remaining[end+1:] + if len(fields) != 6 || fields[0] != "A" || fields[2] != "FA" { + return fmt.Errorf("DACL contains an unexpected access rule") + } + aceCount++ + switch fields[5] { + case currentSID: + if seenCurrent { + return fmt.Errorf("DACL contains duplicate current-user access") + } + seenCurrent = true + case "SY", "S-1-5-18": + if seenSystem { + return fmt.Errorf("DACL contains duplicate SYSTEM access") + } + seenSystem = true + default: + return fmt.Errorf("DACL grants an unexpected trustee") + } + } + if aceCount != 2 || !seenCurrent || !seenSystem { + return fmt.Errorf("DACL must grant full access only to the current process identity and SYSTEM") + } + return nil +} diff --git a/internal/provider/dockersandboxes/staging/permissions_windows_test.go b/internal/provider/dockersandboxes/staging/permissions_windows_test.go new file mode 100644 index 0000000..3fe82d3 --- /dev/null +++ b/internal/provider/dockersandboxes/staging/permissions_windows_test.go @@ -0,0 +1,42 @@ +//go:build windows + +package staging + +import ( + "fmt" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func TestRejectsUnexpectedWindowsDACLTrustee(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + owned, err := staging.CreateOwned("weak") + if err != nil { + t.Fatal(err) + } + path := owned.Path + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatal(err) + } + descriptor, err := windows.SecurityDescriptorFromString(fmt.Sprintf("D:P(A;OICI;FA;;;%s)(A;OICI;FA;;;SY)(A;OICI;GR;;;WD)", user.User.Sid.String())) + if err != nil { + t.Fatal(err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatal(err) + } + information := windows.SECURITY_INFORMATION(windows.DACL_SECURITY_INFORMATION | windows.PROTECTED_DACL_SECURITY_INFORMATION) + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, information, nil, nil, dacl, nil); err != nil { + t.Fatal(err) + } + if _, err := staging.VerifyOwnedEmpty("weak", owned.Identity); err == nil { + t.Fatal("staging directory with an unexpected Everyone ACE was accepted") + } +} diff --git a/internal/provider/dockersandboxes/staging/staging.go b/internal/provider/dockersandboxes/staging/staging.go new file mode 100644 index 0000000..67f8680 --- /dev/null +++ b/internal/provider/dockersandboxes/staging/staging.go @@ -0,0 +1,355 @@ +// Package staging manages the only host path exposed to a Docker Sandbox. +// +// A staging directory is always a fresh, empty, direct child of a dedicated +// root. Disposal first binds the exact filesystem object, atomically moves it +// to a deterministic quarantine child, and only then removes that private tree. +package staging + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +type Staging struct { + root string +} + +type OwnedDirectory struct { + Path string + Identity string +} + +func Open(root string) (*Staging, error) { + if strings.TrimSpace(root) == "" { + return nil, errors.New("Docker Sandboxes staging root is required") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve Docker Sandboxes staging root: %w", err) + } + absRoot = filepath.Clean(absRoot) + if err := rejectAlternateDataStream(absRoot); err != nil { + return nil, err + } + _, statErr := os.Lstat(absRoot) + rootExisted := statErr == nil + if statErr != nil && !os.IsNotExist(statErr) { + return nil, fmt.Errorf("inspect Docker Sandboxes staging root: %w", statErr) + } + if err := createPathWithoutRedirect(absRoot); err != nil { + return nil, fmt.Errorf("create Docker Sandboxes staging root: %w", err) + } + if !rootExisted { + if err := restrictPlatformPermissions(absRoot); err != nil { + return nil, fmt.Errorf("restrict Docker Sandboxes staging root: %w", err) + } + } + if err := validateDirectory(absRoot, false); err != nil { + return nil, fmt.Errorf("validate Docker Sandboxes staging root: %w", err) + } + return &Staging{root: absRoot}, nil +} + +func (s *Staging) Root() string { + return s.root +} + +// CreateOwned creates a fresh direct child and captures the stable filesystem +// object identity used to distinguish it from a later same-path replacement. +func (s *Staging) CreateOwned(name string) (OwnedDirectory, error) { + if err := validateName(name); err != nil { + return OwnedDirectory{}, err + } + path, err := s.exactPath(name) + if err != nil { + return OwnedDirectory{}, err + } + if err := os.Mkdir(path, 0700); err != nil { + if os.IsExist(err) { + return OwnedDirectory{}, fmt.Errorf("Docker Sandboxes staging directory %q already exists", path) + } + return OwnedDirectory{}, fmt.Errorf("create Docker Sandboxes staging directory %q: %w", path, err) + } + if err := restrictPlatformPermissions(path); err != nil { + _ = os.Remove(path) + return OwnedDirectory{}, fmt.Errorf("restrict Docker Sandboxes staging directory %q: %w", path, err) + } + if err := validateDirectory(path, true); err != nil { + _ = os.Remove(path) + return OwnedDirectory{}, err + } + identity, err := platformDirectoryIdentity(path) + if err != nil { + _ = os.Remove(path) + return OwnedDirectory{}, fmt.Errorf("read Docker Sandboxes staging directory identity %q: %w", path, err) + } + return OwnedDirectory{Path: path, Identity: identity}, nil +} + +func (s *Staging) verifyEmpty(name string) (string, error) { + if err := validateName(name); err != nil { + return "", err + } + path, err := s.exactPath(name) + if err != nil { + return "", err + } + if err := validateDirectory(path, true); err != nil { + return "", err + } + return path, nil +} + +func (s *Staging) VerifyOwnedEmpty(name, identity string) (string, error) { + return s.verifyOwned(name, identity, true) +} + +func (s *Staging) VerifyOwned(name, identity string) (string, error) { + return s.verifyOwned(name, identity, false) +} + +func (s *Staging) verifyOwned(name, identity string, requireEmpty bool) (string, error) { + if strings.TrimSpace(identity) == "" { + return "", fmt.Errorf("Docker Sandboxes staging directory identity is required") + } + if err := validateName(name); err != nil { + return "", err + } + path, err := s.exactPath(name) + if err != nil { + return "", err + } + if err := validateDirectory(path, requireEmpty); err != nil { + return "", err + } + actual, err := platformDirectoryIdentity(path) + if err != nil { + return "", fmt.Errorf("read Docker Sandboxes staging directory identity %q: %w", path, err) + } + if actual != identity { + return "", fmt.Errorf("Docker Sandboxes staging directory %q was replaced by a different filesystem object", path) + } + return path, nil +} + +func (s *Staging) RemoveEmptyOwned(name, identity string) error { + path, err := s.VerifyOwnedEmpty(name, identity) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove exact owned Docker Sandboxes staging directory %q: %w", path, err) + } + return nil +} + +// PurgeOwned removes a non-empty direct workspace only after the sandbox has been +// proven absent. The deterministic quarantine name makes a crash after rename +// recoverable without ever selecting a path by prefix or following a replaced +// top-level object. +func (s *Staging) PurgeOwned(name, identity string) error { + if err := validateName(name); err != nil { + return err + } + if strings.TrimSpace(identity) == "" { + return fmt.Errorf("Docker Sandboxes staging directory identity is required") + } + if err := validateDirectory(s.root, false); err != nil { + return fmt.Errorf("validate Docker Sandboxes staging root before purge: %w", err) + } + original, err := s.exactPath(name) + if err != nil { + return err + } + quarantineName := name + ".deleting" + quarantine, err := s.exactPath(quarantineName) + if err != nil { + return err + } + originalInfo, originalErr := os.Lstat(original) + quarantineInfo, quarantineErr := os.Lstat(quarantine) + if originalErr == nil && quarantineErr == nil { + return fmt.Errorf("Docker Sandboxes staging source and quarantine both exist") + } + if originalErr != nil && !os.IsNotExist(originalErr) { + return fmt.Errorf("inspect exact Docker Sandboxes staging source: %w", originalErr) + } + if quarantineErr != nil && !os.IsNotExist(quarantineErr) { + return fmt.Errorf("inspect exact Docker Sandboxes staging quarantine: %w", quarantineErr) + } + if originalErr == nil { + if !originalInfo.IsDir() { + return fmt.Errorf("Docker Sandboxes staging source is not a real directory") + } + if _, err := s.VerifyOwned(name, identity); err != nil { + return err + } + if err := os.Rename(original, quarantine); err != nil { + return fmt.Errorf("quarantine exact Docker Sandboxes staging source: %w", err) + } + } else if quarantineErr != nil { + return nil + } + if quarantineInfo != nil && !quarantineInfo.IsDir() { + return fmt.Errorf("Docker Sandboxes staging quarantine is not a real directory") + } + actual, err := platformDirectoryIdentity(quarantine) + if err != nil { + return fmt.Errorf("read quarantined Docker Sandboxes staging identity: %w", err) + } + if actual != identity { + return fmt.Errorf("Docker Sandboxes staging quarantine was replaced by a different filesystem object") + } + if err := os.RemoveAll(quarantine); err != nil { + return fmt.Errorf("purge exact quarantined Docker Sandboxes staging tree: %w", err) + } + if _, err := os.Lstat(quarantine); !os.IsNotExist(err) { + return fmt.Errorf("verify exact Docker Sandboxes staging quarantine absence: %w", err) + } + return nil +} + +func (s *Staging) exactPath(name string) (string, error) { + path := filepath.Join(s.root, name) + relative, err := filepath.Rel(s.root, path) + if err != nil || relative != name || filepath.IsAbs(relative) || strings.Contains(relative, string(filepath.Separator)) { + return "", fmt.Errorf("Docker Sandboxes staging path %q escapes its configured root", path) + } + return path, nil +} + +func validateName(name string) error { + if name == "" || name == "." || name == ".." || strings.TrimSpace(name) != name { + return fmt.Errorf("invalid Docker Sandboxes staging name %q", name) + } + for _, value := range name { + if (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') || (value >= '0' && value <= '9') || value == '.' || value == '_' || value == '-' { + continue + } + return fmt.Errorf("invalid Docker Sandboxes staging name %q", name) + } + first := name[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || (first >= '0' && first <= '9')) { + return fmt.Errorf("invalid Docker Sandboxes staging name %q", name) + } + return nil +} + +func validateDirectory(path string, requireEmpty bool) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if err := validateNoRedirectDirectory(path); err != nil { + return err + } + if err := validatePlatformPermissions(path, info); err != nil { + return fmt.Errorf("Docker Sandboxes staging path %q has weak permissions: %w", path, err) + } + evaluated, err := filepath.EvalSymlinks(path) + if err != nil { + return fmt.Errorf("resolve Docker Sandboxes staging path %q: %w", path, err) + } + absEvaluated, err := filepath.Abs(evaluated) + if err != nil { + return fmt.Errorf("resolve canonical Docker Sandboxes staging path %q: %w", path, err) + } + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve Docker Sandboxes staging path %q: %w", path, err) + } + if !samePath(filepath.Clean(absPath), filepath.Clean(absEvaluated)) { + return fmt.Errorf("Docker Sandboxes staging path %q contains a symlink, junction, or reparse redirection", path) + } + if requireEmpty { + entries, err := os.ReadDir(path) + if err != nil { + return fmt.Errorf("read Docker Sandboxes staging path %q: %w", path, err) + } + if len(entries) != 0 { + return fmt.Errorf("Docker Sandboxes staging path %q is not empty", path) + } + } + return nil +} + +func createPathWithoutRedirect(path string) error { + missing := make([]string, 0) + cursor := path + for { + _, err := os.Lstat(cursor) + if err == nil { + if err := validateNoRedirectDirectory(cursor); err != nil { + return err + } + break + } + if !os.IsNotExist(err) { + return err + } + missing = append(missing, cursor) + parent := filepath.Dir(cursor) + if parent == cursor { + return fmt.Errorf("no existing ancestor for staging path") + } + cursor = parent + } + for index := len(missing) - 1; index >= 0; index-- { + candidate := missing[index] + if err := os.Mkdir(candidate, 0700); err != nil { + return err + } + if err := validateNoRedirectDirectory(candidate); err != nil { + return err + } + } + return nil +} + +func validateNoRedirectDirectory(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("Docker Sandboxes staging path %q is not a real directory", path) + } + evaluated, err := filepath.EvalSymlinks(path) + if err != nil { + return fmt.Errorf("resolve Docker Sandboxes staging path %q: %w", path, err) + } + absEvaluated, err := filepath.Abs(evaluated) + if err != nil { + return fmt.Errorf("resolve canonical Docker Sandboxes staging path %q: %w", path, err) + } + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve Docker Sandboxes staging path %q: %w", path, err) + } + if !samePath(filepath.Clean(absPath), filepath.Clean(absEvaluated)) { + return fmt.Errorf("Docker Sandboxes staging path %q contains a symlink, junction, or reparse redirection", path) + } + return nil +} + +func rejectAlternateDataStream(path string) error { + rest := strings.TrimPrefix(path, filepath.VolumeName(path)) + if strings.Contains(rest, ":") { + return fmt.Errorf("Docker Sandboxes staging path %q contains an alternate-data-stream separator", path) + } + return nil +} + +func samePath(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} diff --git a/internal/provider/dockersandboxes/staging/staging_test.go b/internal/provider/dockersandboxes/staging/staging_test.go new file mode 100644 index 0000000..b558215 --- /dev/null +++ b/internal/provider/dockersandboxes/staging/staging_test.go @@ -0,0 +1,250 @@ +package staging + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +func TestCreateAndRemoveExactEmptyStagingDirectory(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + owned, err := staging.CreateOwned("epar-sbx-001") + if err != nil { + t.Fatal(err) + } + path := owned.Path + if got, want := path, filepath.Join(staging.Root(), "epar-sbx-001"); got != want { + t.Fatalf("path = %q, want %q", got, want) + } + if _, err := staging.VerifyOwnedEmpty("epar-sbx-001", owned.Identity); err != nil { + t.Fatal(err) + } + if err := staging.RemoveEmptyOwned("epar-sbx-001", owned.Identity); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("removed staging directory still exists: %v", err) + } + if err := staging.RemoveEmptyOwned("epar-sbx-001", owned.Identity); err != nil { + t.Fatalf("missing exact staging directory should be idempotent: %v", err) + } +} + +func TestRejectsUnsafeNamesAndAlternateDataStreams(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"", ".", "..", "../escape", `..\\escape`, " leading", "trailing ", "name:stream", "name/subdir", "name\\subdir"} { + t.Run(strings.ReplaceAll(name, "/", "_"), func(t *testing.T) { + if _, err := staging.CreateOwned(name); err == nil { + t.Fatalf("Create(%q) succeeded", name) + } + }) + } +} + +func TestRejectsPreexistingOrNonemptyDirectory(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(staging.Root(), "existing") + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + if _, err := staging.CreateOwned("existing"); err == nil { + t.Fatal("pre-existing staging directory accepted") + } + if err := os.WriteFile(filepath.Join(path, "sentinel"), []byte("host data"), 0600); err != nil { + t.Fatal(err) + } + if _, err := staging.verifyEmpty("existing"); err == nil { + t.Fatal("non-empty staging directory verified") + } + if _, err := os.Stat(filepath.Join(path, "sentinel")); err != nil { + t.Fatalf("sentinel was changed: %v", err) + } +} + +func TestRejectsSymlinkOrJunctionRedirection(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0700); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := Open(link); err == nil { + t.Fatal("redirected staging root accepted") + } +} + +func TestOpenDoesNotCreateThroughRedirectedMissingAncestor(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0700); err != nil { + t.Fatal(err) + } + redirect := filepath.Join(root, "redirect") + if err := os.Symlink(target, redirect); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := Open(filepath.Join(redirect, "must-not-exist")); err == nil { + t.Fatal("missing staging root beneath redirect was accepted") + } + if _, err := os.Lstat(filepath.Join(target, "must-not-exist")); !os.IsNotExist(err) { + t.Fatalf("Open created content through redirected ancestor: %v", err) + } +} + +func TestRejectsWeakUnixPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits do not describe Windows ACL strength") + } + root := filepath.Join(t.TempDir(), "staging") + if err := os.Mkdir(root, 0755); err != nil { + t.Fatal(err) + } + if _, err := Open(root); err == nil { + t.Fatal("weak staging root permissions accepted") + } +} + +func TestCreatedStagingDirectoryHasStrongPlatformPermissions(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + owned, err := staging.CreateOwned("private") + if err != nil { + t.Fatal(err) + } + path := owned.Path + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if err := validatePlatformPermissions(path, info); err != nil { + t.Fatalf("created staging permissions are weak: %v", err) + } +} + +func TestConcurrentCreateHasOneOwner(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + const callers = 16 + results := make(chan error, callers) + var wait sync.WaitGroup + wait.Add(callers) + for index := 0; index < callers; index++ { + go func() { + defer wait.Done() + _, err := staging.CreateOwned("one-owner") + results <- err + }() + } + wait.Wait() + close(results) + successes := 0 + for err := range results { + if err == nil { + successes++ + } + } + if successes != 1 { + t.Fatalf("successful concurrent creates = %d, want 1", successes) + } +} + +func TestOwnedIdentityRejectsSamePathReplacement(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + owned, err := staging.CreateOwned("replace-me") + if err != nil { + t.Fatal(err) + } + previous := filepath.Join(staging.Root(), "previous-object") + if err := os.Rename(owned.Path, previous); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(owned.Path, 0700); err != nil { + t.Fatal(err) + } + if err := restrictPlatformPermissions(owned.Path); err != nil { + t.Fatal(err) + } + if _, err := staging.VerifyOwnedEmpty("replace-me", owned.Identity); err == nil || !strings.Contains(err.Error(), "replaced") { + t.Fatalf("replacement identity error = %v", err) + } + if err := staging.RemoveEmptyOwned("replace-me", owned.Identity); err == nil { + t.Fatal("same-path replacement was removed") + } + if _, err := os.Stat(owned.Path); err != nil { + t.Fatalf("same-path replacement was not preserved: %v", err) + } +} + +func TestPurgeOwnedDoesNotFollowNestedSymlink(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + owned, err := staging.CreateOwned("symlink-tree") + if err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.Mkdir(target, 0700); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(target, "preserve") + if err := os.WriteFile(sentinel, []byte("host data"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(owned.Path, "workflow-link")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if err := staging.PurgeOwned("symlink-tree", owned.Identity); err != nil { + t.Fatal(err) + } + if content, err := os.ReadFile(sentinel); err != nil || string(content) != "host data" { + t.Fatalf("purge followed nested symlink: content=%q err=%v", content, err) + } +} + +func TestPurgeOwnedRecoversDeterministicQuarantine(t *testing.T) { + staging, err := Open(filepath.Join(t.TempDir(), "staging")) + if err != nil { + t.Fatal(err) + } + owned, err := staging.CreateOwned("recover") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(owned.Path, "content"), []byte("job output"), 0600); err != nil { + t.Fatal(err) + } + quarantine := filepath.Join(staging.Root(), "recover.deleting") + if err := os.Rename(owned.Path, quarantine); err != nil { + t.Fatal(err) + } + if err := staging.PurgeOwned("recover", owned.Identity); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(quarantine); !os.IsNotExist(err) { + t.Fatalf("recovered quarantine remains: %v", err) + } +} diff --git a/internal/provider/dockersandboxes/validation.go b/internal/provider/dockersandboxes/validation.go new file mode 100644 index 0000000..b334584 --- /dev/null +++ b/internal/provider/dockersandboxes/validation.go @@ -0,0 +1,218 @@ +package dockersandboxes + +import ( + "fmt" + "net" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +var ( + sandboxNamePattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$`) + providerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) + sizePattern = regexp.MustCompile(`^[1-9][0-9]*(?:[kKmMgGtT](?:i?[bB])?)?$`) + templatePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:@+-]{0,511}$`) + templateDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) + profilePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + hostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) + versionOutputPattern = regexp.MustCompile(`^(?:Docker Sandboxes|docker sandboxes|sbx) version:? v?([0-9]+\.[0-9]+\.[0-9]+)(?: [a-f0-9]{40})?\r?\n?$`) +) + +func validateCreateRequest(request provider.CreateRequest) error { + if !sandboxNamePattern.MatchString(request.Name) { + return fmt.Errorf("invalid docker sandbox name") + } + if request.Source != "" { + return fmt.Errorf("docker sandboxes does not accept a legacy source image") + } + if request.CPUs <= 0 || request.CPUs > 256 { + return fmt.Errorf("docker sandbox cpu count must be between 1 and 256") + } + if !sizePattern.MatchString(request.Memory) { + return fmt.Errorf("invalid docker sandbox memory limit") + } + if !templatePattern.MatchString(request.Template) || strings.HasPrefix(request.Template, "-") { + return fmt.Errorf("invalid docker sandbox template") + } + if _, _, err := splitTemplateReference(request.Template); err != nil { + return err + } + if !templateDigestPattern.MatchString(request.TemplateDigest) { + return fmt.Errorf("docker sandbox template digest must be a full lowercase sha256 identity") + } + if err := validateCanonicalStagingPath(request.StagingPath); err != nil { + return err + } + if request.RootDisk != "" && !sizePattern.MatchString(request.RootDisk) { + return fmt.Errorf("invalid docker sandbox root disk size") + } + if request.DockerDisk != "" && !sizePattern.MatchString(request.DockerDisk) { + return fmt.Errorf("invalid docker sandbox docker disk size") + } + return nil +} + +func splitTemplateReference(reference string) (repository, tag string, err error) { + separator := strings.LastIndex(reference, ":") + if separator <= strings.LastIndex(reference, "/") || separator == len(reference)-1 || strings.Contains(reference, "@") { + return "", "", fmt.Errorf("docker sandbox template must be an exact repository:tag reference") + } + repository, tag = reference[:separator], reference[separator+1:] + if repository == "" || tag == "" { + return "", "", fmt.Errorf("docker sandbox template must be an exact repository:tag reference") + } + if !strings.Contains(repository, "/") { + repository = "docker.io/library/" + repository + } else { + first := strings.SplitN(repository, "/", 2)[0] + if first != "localhost" && !strings.ContainsAny(first, ".:") { + repository = "docker.io/" + repository + } + } + return repository, tag, nil +} + +func validateLocalTemplateReference(reference string) error { + if !templatePattern.MatchString(reference) || strings.HasPrefix(reference, "-") { + return fmt.Errorf("docker sandbox template must be an exact repository:tag reference") + } + _, tag, err := splitTemplateReference(reference) + if err != nil || !profilePattern.MatchString(tag) { + return fmt.Errorf("docker sandbox template must be an exact repository:tag reference") + } + return nil +} + +func validateCanonicalStagingPath(path string) error { + if path == "" || strings.ContainsRune(path, 0) || strings.ContainsAny(path, "\r\n") { + return fmt.Errorf("invalid docker sandbox staging path") + } + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("docker sandbox staging path must be an already-validated canonical absolute path") + } + return nil +} + +func validateInstance(instance provider.Instance, requireID bool) error { + if !sandboxNamePattern.MatchString(instance.Name) { + return fmt.Errorf("invalid docker sandbox identity") + } + if requireID && !providerIDPattern.MatchString(instance.ProviderID) { + return fmt.Errorf("docker sandbox stable provider id is required") + } + if instance.ProviderID != "" && !providerIDPattern.MatchString(instance.ProviderID) { + return fmt.Errorf("invalid docker sandbox provider id") + } + return nil +} + +func validateGuestCommand(command []string, opts provider.ExecOptions) error { + if len(command) == 0 { + return fmt.Errorf("docker sandbox guest command is required") + } + for _, arg := range command { + if strings.ContainsRune(arg, 0) { + return fmt.Errorf("docker sandbox guest command contains a null byte") + } + } + if len(opts.Env) != 0 { + return fmt.Errorf("docker sandbox guest environment passthrough is not permitted") + } + if opts.LogPath != "" { + return fmt.Errorf("docker sandbox host log paths are not permitted") + } + return nil +} + +func validateNetworkRule(rule provider.NetworkPolicyRule, forRemoval bool) error { + if forRemoval { + if !providerIDPattern.MatchString(rule.ID) { + return fmt.Errorf("docker sandbox network rule id is required for exact removal") + } + if !providerIDPattern.MatchString(rule.PolicyID) || rule.Scope == "" || rule.AppliesTo == "" || rule.ResourceType == "" || rule.Origin == "" { + return fmt.Errorf("docker sandbox network rule requires its complete stable identity for exact removal") + } + if rule.Decision != provider.NetworkPolicyAllow && rule.Decision != provider.NetworkPolicyDeny { + return fmt.Errorf("invalid docker sandbox network decision") + } + if len(rule.Resources) == 0 { + return fmt.Errorf("docker sandbox network policy requires at least one resource") + } + seen := make(map[string]struct{}, len(rule.Resources)) + for _, resource := range rule.Resources { + if strings.TrimSpace(resource) == "" { + return fmt.Errorf("docker sandbox network policy contains an empty resource") + } + if _, duplicate := seen[resource]; duplicate { + return fmt.Errorf("docker sandbox network policy contains a duplicate resource") + } + seen[resource] = struct{}{} + } + return nil + } + if !forRemoval && rule.ID != "" { + return fmt.Errorf("docker sandbox network rule id must be empty when applying") + } + if !forRemoval && rule.ResourceType != "" && rule.ResourceType != "network" { + return fmt.Errorf("docker sandbox policy mutation supports only network rules") + } + if rule.Decision != provider.NetworkPolicyAllow && rule.Decision != provider.NetworkPolicyDeny { + return fmt.Errorf("invalid docker sandbox network decision") + } + if len(rule.Resources) == 0 { + return fmt.Errorf("docker sandbox network policy requires at least one resource") + } + seen := make(map[string]struct{}, len(rule.Resources)) + for _, resource := range rule.Resources { + if resource != "**" || rule.Decision != provider.NetworkPolicyAllow { + if err := validateNetworkResource(resource); err != nil { + return err + } + } + if _, duplicate := seen[resource]; duplicate { + return fmt.Errorf("docker sandbox network policy contains a duplicate resource") + } + seen[resource] = struct{}{} + } + return nil +} + +func validateNetworkResource(resource string) error { + if resource == "" || len(resource) > 512 || strings.ContainsAny(resource, "\x00\r\n\t ,\\/@?#[]") || strings.HasPrefix(resource, "-") { + return fmt.Errorf("invalid docker sandbox network resource") + } + host := resource + if candidateHost, port, ok := strings.Cut(host, ":"); ok { + if strings.Contains(port, ":") || !validPort(port) { + return fmt.Errorf("invalid docker sandbox network resource") + } + host = candidateHost + } + wildcard := strings.HasPrefix(host, "*.") + if wildcard { + host = strings.TrimPrefix(host, "*.") + } + if host == "" || len(host) > 253 || net.ParseIP(host) != nil || wildcard && !strings.Contains(host, ".") { + return fmt.Errorf("invalid docker sandbox network resource") + } + for _, label := range strings.Split(host, ".") { + if !hostLabelPattern.MatchString(label) { + return fmt.Errorf("invalid docker sandbox network resource") + } + } + return nil +} + +func validPort(value string) bool { + port, err := strconv.Atoi(value) + return err == nil && port >= 1 && port <= 65535 +} + +func isSupportedVersion(output string) bool { + matches := versionOutputPattern.FindStringSubmatch(output) + return len(matches) == 2 && matches[1] == SupportedVersion +} diff --git a/internal/provider/factory.go b/internal/provider/factory.go index 0a9b9d4..a000026 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -2,10 +2,30 @@ package provider import "fmt" -func SupportedTypes() []string { - return []string{"tart", "wsl", "docker-container"} +// Descriptor is the provider-neutral registration record consumed by +// onboarding, configuration, lifecycle, image, and contract tests. A provider +// must not appear in SupportedTypes unless all required contributions exist. +type Descriptor struct { + Type string + DisplayName string + WizardSupported bool + WizardNumber string + WizardLabel string + WizardAliases []string + ConfigurationDecoder bool + ConfigurationDefaults bool + ConfigurationValidator bool + LifecycleSupported bool + StorageSupported bool + ImageMode string } +const ( + ImageModeDocker = "docker-image" + ImageModeNative = "native-image" + ImageModeTemplate = "sandbox-template" +) + func UnsupportedTypeError(providerType string) error { return fmt.Errorf("unsupported provider.type %q", providerType) } diff --git a/internal/provider/layout_test.go b/internal/provider/layout_test.go new file mode 100644 index 0000000..60cf420 --- /dev/null +++ b/internal/provider/layout_test.go @@ -0,0 +1,73 @@ +package provider + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" +) + +func TestDockerSandboxesDomainPackagesStayUnderProviderTree(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not locate provider package") + } + providerDirectory := filepath.Dir(filename) + internalDirectory := filepath.Dir(providerDirectory) + for _, name := range []string{"sandboxcapacity", "sandboxfs", "sandboxledger", "sandboxpolicy", "sandboxpromotion"} { + path := filepath.Join(internalDirectory, name) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("Docker Sandboxes domain package must not live directly under internal: %s", path) + } + } + for _, name := range []string{"capacity", "staging", "policy", "promotion"} { + path := filepath.Join(providerDirectory, "dockersandboxes", name) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("required Docker Sandboxes domain package %s: %v", path, err) + } + if !info.IsDir() { + t.Fatalf("required Docker Sandboxes domain package is not a directory: %s", path) + } + } + if _, err := os.Stat(filepath.Join(providerDirectory, "dockersandboxes", "ledger")); !os.IsNotExist(err) { + t.Fatalf("Docker Sandboxes must use the provider-neutral pool ledger instead of a provider-local ledger") + } +} + +func TestProviderImplementationsDoNotOwnPoolControllers(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not locate provider package") + } + root := filepath.Dir(filename) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imported := range parsed.Imports { + value, err := strconv.Unquote(imported.Path.Value) + if err != nil { + return err + } + if value == "github.com/solutionforest/ephemeral-action-runner/internal/pool" || strings.HasPrefix(value, "github.com/solutionforest/ephemeral-action-runner/internal/pool/") { + t.Errorf("%s imports %q; provider packages implement host integration and must not own a pool controller", path, value) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/provider/legacy_adapter.go b/internal/provider/legacy_adapter.go new file mode 100644 index 0000000..7ffc19d --- /dev/null +++ b/internal/provider/legacy_adapter.go @@ -0,0 +1,161 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +type legacyAdapter struct { + provider Provider + dryRun bool +} + +// AdaptLegacy preserves existing provider behavior behind the new lifecycle +// surface. Legacy runtime readiness is still performed by each provider's +// Start implementation, so VerifyRuntime intentionally has no additional side +// effect. The old one-second address wait is used only when a caller explicitly +// requests Address. +func AdaptLegacy(provider Provider, dryRun ...bool) Lifecycle { + adapter := &legacyAdapter{provider: provider} + if len(dryRun) != 0 { + adapter.dryRun = dryRun[0] + } + return adapter +} + +func (adapter *legacyAdapter) Create(ctx context.Context, request CreateRequest) (Instance, error) { + if adapter == nil || adapter.provider == nil { + return Instance{}, fmt.Errorf("legacy provider is nil") + } + if request.Name == "" { + return Instance{}, fmt.Errorf("instance name is required") + } + if err := adapter.provider.Clone(ctx, request.Source, request.Name); err != nil { + return Instance{}, err + } + readbackContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) + defer cancel() + instances, err := adapter.provider.List(readbackContext) + if err != nil { + return Instance{}, fmt.Errorf("read exact provider identity after create: %w", err) + } + for _, instance := range instances { + if instance.Name != request.Name { + continue + } + if instance.ProviderID == "" { + return Instance{}, fmt.Errorf("provider inventory omitted immutable identity for %q", request.Name) + } + instance.ReceiptVersion = "v1" + instance.Receipt, _ = json.Marshal(map[string]string{"providerId": instance.ProviderID, "source": request.Source}) + return instance, nil + } + if adapter.dryRun { + receipt, _ := json.Marshal(map[string]string{"providerId": "dry-run:" + request.Name, "source": request.Source}) + return Instance{Name: request.Name, ProviderID: "dry-run:" + request.Name, Source: request.Source, ReceiptVersion: "v1", Receipt: receipt}, nil + } + return Instance{}, fmt.Errorf("provider inventory omitted newly created instance %q", request.Name) +} + +func (adapter *legacyAdapter) Start(ctx context.Context, instance Instance, opts StartOptions) (*RunningProcess, error) { + if err := adapter.assertExactIdentity(ctx, instance); err != nil { + return nil, err + } + return adapter.provider.Start(ctx, instance.Name, opts) +} + +func (adapter *legacyAdapter) VerifyRuntime(ctx context.Context, instance Instance) (RuntimeInfo, error) { + if err := adapter.assertExactIdentity(ctx, instance); err != nil { + return RuntimeInfo{}, err + } + if _, err := adapter.provider.Exec(ctx, instance.Name, []string{"sudo", "bash", "/opt/epar/validate-runtime.sh"}, ExecOptions{}); err != nil { + return RuntimeInfo{}, err + } + return RuntimeInfo{Ready: true}, nil +} + +func (adapter *legacyAdapter) Address(ctx context.Context, instance Instance, waitSeconds int) (string, bool, error) { + if err := adapter.assertExactIdentity(ctx, instance); err != nil { + return "", false, err + } + address, err := adapter.provider.IP(ctx, instance.Name, waitSeconds) + if err != nil { + return "", false, err + } + if address == "" { + return "", false, nil + } + return address, true, nil +} + +func (adapter *legacyAdapter) Exec(ctx context.Context, instance Instance, command []string, opts ExecOptions) (ExecResult, error) { + if err := adapter.assertExactIdentity(ctx, instance); err != nil { + return ExecResult{}, err + } + return adapter.provider.Exec(ctx, instance.Name, command, opts) +} + +func (adapter *legacyAdapter) Diagnostics(ctx context.Context, instance Instance) (Diagnostics, error) { + if err := adapter.assertExactIdentity(ctx, instance); err != nil { + return Diagnostics{}, err + } + return Diagnostics{}, nil +} + +func (adapter *legacyAdapter) Stop(ctx context.Context, instance Instance) error { + if err := adapter.assertExactIdentity(ctx, instance); err != nil { + return err + } + return adapter.provider.Stop(ctx, instance.Name) +} + +func (adapter *legacyAdapter) Delete(ctx context.Context, instance Instance) error { + if err := adapter.assertExactIdentity(ctx, instance); err != nil { + return err + } + return adapter.provider.Delete(ctx, instance.Name) +} + +func (adapter *legacyAdapter) Inventory(ctx context.Context) ([]InventoryItem, error) { + identityContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) + defer cancel() + instances, err := adapter.provider.List(identityContext) + if err != nil { + return nil, err + } + items := make([]InventoryItem, 0, len(instances)) + for _, instance := range instances { + items = append(items, InventoryItem{Instance: instance, State: instance.State, Source: instance.Source}) + } + return items, nil +} + +func (adapter *legacyAdapter) assertExactIdentity(ctx context.Context, expected Instance) error { + if adapter == nil || adapter.provider == nil { + return fmt.Errorf("legacy provider is nil") + } + if adapter.dryRun && expected.ProviderID == "dry-run:"+expected.Name { + return nil + } + if expected.Name == "" || expected.ProviderID == "" { + return fmt.Errorf("exact provider name and immutable id are required") + } + identityContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) + defer cancel() + instances, err := adapter.provider.List(identityContext) + if err != nil { + return err + } + for _, actual := range instances { + if actual.Name != expected.Name { + continue + } + if actual.ProviderID == "" || actual.ProviderID != expected.ProviderID { + return fmt.Errorf("provider identity mismatch for %q: expected %q, observed %q", expected.Name, expected.ProviderID, actual.ProviderID) + } + return nil + } + return fmt.Errorf("provider instance %q is missing", expected.Name) +} diff --git a/internal/provider/legacy_adapter_test.go b/internal/provider/legacy_adapter_test.go new file mode 100644 index 0000000..b5bc4b6 --- /dev/null +++ b/internal/provider/legacy_adapter_test.go @@ -0,0 +1,109 @@ +package provider + +import ( + "context" + "testing" +) + +type legacyProviderFake struct { + calls []string + ip string +} + +func (fake *legacyProviderFake) Clone(_ context.Context, source, name string) error { + fake.calls = append(fake.calls, "clone:"+source+":"+name) + return nil +} + +func (fake *legacyProviderFake) Start(_ context.Context, name string, _ StartOptions) (*RunningProcess, error) { + fake.calls = append(fake.calls, "start:"+name) + return &RunningProcess{Name: name}, nil +} + +func (fake *legacyProviderFake) Exec(_ context.Context, name string, command []string, _ ExecOptions) (ExecResult, error) { + fake.calls = append(fake.calls, "exec:"+name) + return ExecResult{Stdout: command[0]}, nil +} + +func (fake *legacyProviderFake) IP(_ context.Context, name string, waitSeconds int) (string, error) { + fake.calls = append(fake.calls, "ip:"+name) + if waitSeconds != 30 { + panic("unexpected address wait") + } + return fake.ip, nil +} + +func (fake *legacyProviderFake) Stop(_ context.Context, name string) error { + fake.calls = append(fake.calls, "stop:"+name) + return nil +} + +func (fake *legacyProviderFake) Delete(_ context.Context, name string) error { + fake.calls = append(fake.calls, "delete:"+name) + return nil +} + +func (fake *legacyProviderFake) List(context.Context) ([]Instance, error) { + fake.calls = append(fake.calls, "list") + return []Instance{{Name: "runner", ProviderID: "fake:runner-id", Source: "image", State: "running"}}, nil +} + +func TestAdaptLegacyMapsLifecycleWithoutAdditionalRuntimeProbe(t *testing.T) { + legacy := &legacyProviderFake{ip: "192.0.2.10"} + lifecycle := AdaptLegacy(legacy) + instance, err := lifecycle.Create(context.Background(), CreateRequest{Name: "runner", Source: "image"}) + if err != nil { + t.Fatal(err) + } + if _, err := lifecycle.Start(context.Background(), instance, StartOptions{}); err != nil { + t.Fatal(err) + } + runtime, err := lifecycle.VerifyRuntime(context.Background(), instance) + if err != nil || !runtime.Ready { + t.Fatalf("runtime = %#v, err = %v", runtime, err) + } + address, available, err := lifecycle.Address(context.Background(), instance, 30) + if err != nil || !available || address != "192.0.2.10" { + t.Fatalf("address = %q, available = %v, err = %v", address, available, err) + } + result, err := lifecycle.Exec(context.Background(), instance, []string{"true"}, ExecOptions{}) + if err != nil || result.Stdout != "true" { + t.Fatalf("result = %#v, err = %v", result, err) + } + if err := lifecycle.Stop(context.Background(), instance); err != nil { + t.Fatal(err) + } + if err := lifecycle.Delete(context.Background(), instance); err != nil { + t.Fatal(err) + } + items, err := lifecycle.Inventory(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Instance.Name != "runner" || items[0].State != "running" { + t.Fatalf("inventory = %#v", items) + } + for _, want := range []string{"clone:image:runner", "start:runner", "ip:runner", "exec:runner", "stop:runner", "delete:runner"} { + if !containsCall(legacy.calls, want) { + t.Fatalf("calls = %#v, missing %q", legacy.calls, want) + } + } +} + +func TestAdaptLegacyAddressCanBeUnavailable(t *testing.T) { + legacy := &legacyProviderFake{} + lifecycle := AdaptLegacy(legacy) + address, available, err := lifecycle.Address(context.Background(), Instance{Name: "runner", ProviderID: "fake:runner-id"}, 30) + if err != nil || available || address != "" { + t.Fatalf("address = %q, available = %v, err = %v", address, available, err) + } +} + +func containsCall(calls []string, want string) bool { + for _, call := range calls { + if call == want { + return true + } + } + return false +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 71b3949..d36e50b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2,15 +2,66 @@ package provider import ( "context" + "encoding/json" "fmt" "io" "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" ) type Instance struct { - Name string - Source string - State string + Name string + ProviderID string + Source string + State string + ReceiptVersion string + Receipt json.RawMessage +} + +// CreateRequest is the provider-neutral input for allocating one isolated +// runtime. Providers must reject fields they cannot honor instead of silently +// weakening the requested isolation or resource constraints. +type CreateRequest struct { + Name string + Source string + Template string + // TemplateDigest is the full sha256 local image/template identity recorded by + // the trusted build and load manifest. Providers must fail closed when they + // cannot bind the configured template reference to this identity. + TemplateDigest string + StagingPath string + CPUs int + Memory string + RootDisk string + DockerDisk string +} + +type RuntimeInfo struct { + Ready bool + Runtime string + Version string +} + +type Diagnostics struct { + Healthy bool + DaemonState string + ChecksPassed int + ChecksWarned int + ChecksFailed int + ChecksSkipped int + OutputLimited bool +} + +type InventoryItem struct { + Instance Instance + State string + Source string + // Workspaces are the exact host paths reported by the provider for this + // instance. They are ownership evidence for crash reconciliation; callers + // must compare canonical paths using host-platform path semantics. + Workspaces []string } type StartOptions struct { @@ -41,6 +92,100 @@ type ExecResult struct { Stderr string } +// Lifecycle is the provider contract used by new orchestration code. Address +// discovery is explicitly optional because delegated runtimes such as Docker +// Sandboxes intentionally expose command execution without a host-routable +// guest address. +type Lifecycle interface { + Create(ctx context.Context, request CreateRequest) (Instance, error) + Start(ctx context.Context, instance Instance, opts StartOptions) (*RunningProcess, error) + VerifyRuntime(ctx context.Context, instance Instance) (RuntimeInfo, error) + Address(ctx context.Context, instance Instance, waitSeconds int) (address string, available bool, err error) + Exec(ctx context.Context, instance Instance, command []string, opts ExecOptions) (ExecResult, error) + Diagnostics(ctx context.Context, instance Instance) (Diagnostics, error) + Stop(ctx context.Context, instance Instance) error + Delete(ctx context.Context, instance Instance) error + Inventory(ctx context.Context) ([]InventoryItem, error) +} + +// ArtifactManager is an optional provider capability for runtimes whose +// reusable artifact is not prepared by the shared OCI image pipeline. +type ArtifactManager interface { + EnsureArtifacts(ctx context.Context, dryRun bool) (handled bool, err error) +} + +// StorageContribution is required for every registered provider. It describes +// the provider's measurable capacity surface and operation expansion before +// the shared pool performs provider side effects. +type StorageContribution interface { + StorageSnapshot(ctx context.Context, request StorageRequest) (StorageSnapshot, error) +} + +type StorageRequest struct { + Operation string + Now time.Time + PeakBytes uint64 + MinimumFreeBytes uint64 +} + +type StorageSnapshot struct { + Surfaces []storage.Surface + Requirements []storage.Requirement + Artifacts []storage.Artifact +} + +// AdmissionVerifier rechecks provider-wide state that can change independently +// of one sandbox. Callers use it before registration and while issuing bounded +// job-admission leases so shared host configuration cannot silently weaken an +// already-created runtime. +type AdmissionVerifier interface { + VerifyAdmission(ctx context.Context) error +} + +// InstanceAdmissionVerifier rechecks mutable provider state attached to one +// exact runtime, including kits, injected authentication, secrets, published +// ports, and management gateways. It is deliberately separate from general +// runtime health because any violation must stop job admission immediately. +type InstanceAdmissionVerifier interface { + VerifyInstanceAdmission(ctx context.Context, instance Instance) error +} + +type NetworkPolicyDecision string + +const ( + NetworkPolicyAllow NetworkPolicyDecision = "allow" + NetworkPolicyDeny NetworkPolicyDecision = "deny" +) + +// NetworkPolicyRule is the attributed effective-policy record returned by a +// policy-capable provider. Read results include every relevant resource type; +// the current mutation methods remain deliberately limited to network rules. +type NetworkPolicyRule struct { + ID string + Name string + PolicyID string + Scope string + AppliesTo string + ResourceType string + Resources []string + Decision NetworkPolicyDecision + Origin string + Status string + Editable bool + Active bool +} + +// PolicyManager is implemented only by providers that can apply and verify +// exact, instance-scoped network rules. Global policy mutation is deliberately +// absent from this contract. +type PolicyManager interface { + ApplyNetworkPolicy(ctx context.Context, instance Instance, rules []NetworkPolicyRule) error + ReadNetworkPolicy(ctx context.Context, instance Instance) ([]NetworkPolicyRule, error) + RemoveNetworkPolicy(ctx context.Context, instance Instance, rules []NetworkPolicyRule) error +} + +// Provider is the legacy EPAR provider contract. New orchestration code should +// consume Lifecycle and wrap existing providers with AdaptLegacy. type Provider interface { Clone(ctx context.Context, source, name string) error Start(ctx context.Context, name string, opts StartOptions) (*RunningProcess, error) diff --git a/internal/provider/registry/contributions_test.go b/internal/provider/registry/contributions_test.go new file mode 100644 index 0000000..f08ecfa --- /dev/null +++ b/internal/provider/registry/contributions_test.go @@ -0,0 +1,43 @@ +package registry + +import ( + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +func TestEveryProviderRegistersRequiredContributions(t *testing.T) { + seen := make(map[string]struct{}) + for _, descriptor := range Descriptors() { + if descriptor.Type == "" { + t.Fatal("provider descriptor type is empty") + } + if _, exists := seen[descriptor.Type]; exists { + t.Fatalf("duplicate provider descriptor %q", descriptor.Type) + } + seen[descriptor.Type] = struct{}{} + if !descriptor.WizardSupported { + t.Errorf("%s has no ./start wizard contribution", descriptor.Type) + } + if descriptor.WizardNumber == "" || descriptor.WizardLabel == "" || len(descriptor.WizardAliases) == 0 { + t.Errorf("%s has an incomplete ./start wizard contribution", descriptor.Type) + } + if !descriptor.ConfigurationDecoder || !descriptor.ConfigurationDefaults || !descriptor.ConfigurationValidator { + t.Errorf("%s has an incomplete configuration contribution", descriptor.Type) + } + if !descriptor.LifecycleSupported { + t.Errorf("%s has no shared lifecycle contribution", descriptor.Type) + } + if !descriptor.StorageSupported { + t.Errorf("%s has no storage contribution", descriptor.Type) + } + switch descriptor.ImageMode { + case provider.ImageModeDocker, provider.ImageModeNative, provider.ImageModeTemplate: + default: + t.Errorf("%s has unsupported image mode %q", descriptor.Type, descriptor.ImageMode) + } + } + if len(seen) != len(SupportedTypes()) { + t.Fatalf("descriptors=%d supported types=%d", len(seen), len(SupportedTypes())) + } +} diff --git a/internal/provider/registry/registry.go b/internal/provider/registry/registry.go new file mode 100644 index 0000000..9d489ca --- /dev/null +++ b/internal/provider/registry/registry.go @@ -0,0 +1,199 @@ +package registry + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockercontainer" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/tart" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/wsl" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +// Runtime is the complete provider wiring needed by the pool manager. +// Legacy is populated only for providers that still use the compatibility +// adapter. +type Runtime struct { + Legacy provider.Provider + Lifecycle provider.Lifecycle + PolicyManager provider.PolicyManager + Storage provider.StorageContribution +} + +type factory func(cfg config.Config, projectRoot string, dryRun bool) Runtime + +type entry struct { + descriptor provider.Descriptor + factory factory +} + +var entries = []entry{ + { + descriptor: provider.Descriptor{Type: "docker-container", DisplayName: "Docker Container", WizardSupported: true, WizardNumber: "1", WizardLabel: "Docker Container — private daemon", WizardAliases: []string{"docker", "docker-container"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker}, + factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { + hostGateway := config.DockerConfigNeedsHostGateway(cfg.Docker) + environment := map[string]string{ + "HTTP_PROXY": cfg.Docker.HTTPProxy, + "HTTPS_PROXY": cfg.Docker.HTTPSProxy, + "NO_PROXY": cfg.Docker.NoProxy, + } + return adaptLegacy(dockercontainer.NewWithOptions("", cfg.Provider.Platform, hostGateway, environment, dryRun), providerStorage(cfg, projectRoot), dryRun) + }, + }, + { + descriptor: provider.Descriptor{Type: "docker-sandboxes", DisplayName: "Docker Sandboxes", WizardSupported: true, WizardNumber: "2", WizardLabel: "Docker Sandboxes — recommended when ready", WizardAliases: []string{"docker-sandboxes", "sandboxes"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeTemplate}, + factory: func(cfg config.Config, projectRoot string, _ bool) Runtime { + sandboxes := dockersandboxes.New("") + return Runtime{Lifecycle: sandboxes, PolicyManager: sandboxes, Storage: providerStorage(cfg, projectRoot)} + }, + }, + { + descriptor: provider.Descriptor{Type: "wsl", DisplayName: "WSL2", WizardSupported: true, WizardNumber: "3", WizardLabel: "WSL2", WizardAliases: []string{"wsl", "wsl2"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker}, + factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { + installRoot := config.ProjectPath(projectRoot, cfg.Provider.InstallRoot) + return adaptLegacy(wsl.New("", installRoot, projectRoot, dryRun), providerStorage(cfg, projectRoot), dryRun) + }, + }, + { + descriptor: provider.Descriptor{Type: "tart", DisplayName: "Tart (experimental)", WizardSupported: true, WizardNumber: "4", WizardLabel: "Tart (experimental)", WizardAliases: []string{"tart"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeNative}, + factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { + return adaptLegacy(tart.New("", dryRun), providerStorage(cfg, projectRoot), dryRun) + }, + }, +} + +func Descriptors() []provider.Descriptor { + result := make([]provider.Descriptor, 0, len(entries)) + for _, registered := range entries { + descriptor := registered.descriptor + descriptor.WizardAliases = append([]string(nil), descriptor.WizardAliases...) + result = append(result, descriptor) + } + return result +} + +func DescriptorFor(providerType string) (provider.Descriptor, bool) { + for _, registered := range entries { + if registered.descriptor.Type == providerType { + descriptor := registered.descriptor + descriptor.WizardAliases = append([]string(nil), descriptor.WizardAliases...) + return descriptor, true + } + } + return provider.Descriptor{}, false +} + +func SupportedTypes() []string { + result := make([]string, 0, len(entries)) + for _, registered := range entries { + result = append(result, registered.descriptor.Type) + } + return result +} + +// New is the single construction point for concrete providers. +func New(cfg config.Config, projectRoot string, dryRun bool) (Runtime, error) { + var registered *entry + for index := range entries { + if entries[index].descriptor.Type == cfg.Provider.Type { + registered = &entries[index] + break + } + } + if registered == nil { + return Runtime{}, provider.UnsupportedTypeError(cfg.Provider.Type) + } + descriptor := registered.descriptor + if !descriptor.WizardSupported || descriptor.WizardNumber == "" || descriptor.WizardLabel == "" || len(descriptor.WizardAliases) == 0 || !descriptor.ConfigurationDecoder || !descriptor.ConfigurationDefaults || !descriptor.ConfigurationValidator || !descriptor.LifecycleSupported || !descriptor.StorageSupported || descriptor.ImageMode == "" { + return Runtime{}, fmt.Errorf("provider %q has an incomplete registry entry", cfg.Provider.Type) + } + runtime := registered.factory(cfg, projectRoot, dryRun) + if runtime.Lifecycle == nil || runtime.Storage == nil { + return Runtime{}, fmt.Errorf("provider %q registry entry did not construct required lifecycle and storage behavior", cfg.Provider.Type) + } + return runtime, nil +} + +func providerStorage(cfg config.Config, projectRoot string) provider.StorageContribution { + roots := []provider.StorageRoot{{ID: cfg.Provider.Type + "-project", Location: projectRoot}} + minimumExpansions := map[string]uint64{} + switch cfg.Provider.Type { + case "docker-container": + roots = append(roots, provider.StorageRoot{ID: "docker-engine-backing", Kind: storage.SurfaceDockerEngine, Location: dockerBackingRoot()}) + case "docker-sandboxes": + rootDisk, rootErr := config.ParseByteSize(cfg.DockerSandboxes.RootDisk) + dockerDisk, dockerErr := config.ParseByteSize(cfg.DockerSandboxes.DockerDisk) + sandboxCreateExpansion := uint64(0) + if rootErr == nil && dockerErr == nil && rootDisk > 0 && dockerDisk > 0 { + sandboxCreateExpansion = uint64(rootDisk) + uint64(dockerDisk) + } + roots = append(roots, + provider.StorageRoot{ + ID: "docker-engine-backing", + Kind: storage.SurfaceDockerEngine, + Location: dockerBackingRoot(), + MinimumExpansions: map[string]uint64{ + "image-pull": 0, + "image-build": 0, + "source-update": 0, + }, + }, + provider.StorageRoot{ + ID: "docker-sandboxes-backing", + Kind: storage.SurfaceSandboxCache, + Location: dockerSandboxesBackingRoot(), + MinimumExpansions: map[string]uint64{"instance-create": sandboxCreateExpansion}, + }, + provider.StorageRoot{ID: "docker-sandboxes-staging", Location: config.ProjectPath(projectRoot, cfg.DockerSandboxes.StagingRoot)}, + ) + case "wsl": + roots = append(roots, + provider.StorageRoot{ID: "wsl-install-root", Location: config.ProjectPath(projectRoot, cfg.Provider.InstallRoot)}, + provider.StorageRoot{ID: "docker-engine-backing", Kind: storage.SurfaceDockerEngine, Location: dockerBackingRoot()}, + ) + case "tart": + roots = append(roots, provider.StorageRoot{ID: "tart-vm-store", Location: tartBackingRoot()}) + } + return provider.NewMultiFilesystemStorageWithMinimumExpansions(cfg.Provider.Type, roots, minimumExpansions) +} + +func dockerBackingRoot() string { + switch runtime.GOOS { + case "windows": + return filepath.Join(os.Getenv("LOCALAPPDATA"), "Docker", "wsl", "disk") + case "darwin": + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data", "vms", "0", "data") + default: + return "/var/lib/docker" + } +} + +func dockerSandboxesBackingRoot() string { + switch runtime.GOOS { + case "windows": + return filepath.Join(os.Getenv("LOCALAPPDATA"), "DockerSandboxes", "sandboxes", "data") + case "darwin": + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data", "docker-sandboxes") + default: + return "/var/lib/docker-sandboxes" + } +} + +func tartBackingRoot() string { + if root := os.Getenv("TART_HOME"); root != "" { + return filepath.Join(root, "vms") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".tart", "vms") +} + +func adaptLegacy(legacy provider.Provider, storageContribution provider.StorageContribution, dryRun bool) Runtime { + return Runtime{Legacy: legacy, Lifecycle: provider.AdaptLegacy(legacy, dryRun), Storage: storageContribution} +} diff --git a/internal/provider/registry/registry_test.go b/internal/provider/registry/registry_test.go new file mode 100644 index 0000000..f960f62 --- /dev/null +++ b/internal/provider/registry/registry_test.go @@ -0,0 +1,218 @@ +package registry + +import ( + "context" + "go/parser" + "go/token" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockercontainer" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/tart" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/wsl" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func TestNewAdaptsEstablishedLegacyProviders(t *testing.T) { + tests := []struct { + providerType string + matches func(any) bool + }{ + {providerType: "tart", matches: func(value any) bool { _, ok := value.(*tart.Provider); return ok }}, + {providerType: "wsl", matches: func(value any) bool { _, ok := value.(*wsl.Provider); return ok }}, + } + for _, test := range tests { + t.Run(test.providerType, func(t *testing.T) { + cfg := config.Default() + cfg.Provider.Type = test.providerType + + providerRuntime, err := New(cfg, t.TempDir(), true) + if err != nil { + t.Fatal(err) + } + if !test.matches(providerRuntime.Legacy) { + t.Fatalf("New() legacy type = %T", providerRuntime.Legacy) + } + if providerRuntime.Lifecycle == nil { + t.Fatal("New() did not adapt the legacy provider to Lifecycle") + } + if providerRuntime.Storage == nil { + t.Fatal("New() did not register provider storage behavior") + } + if providerRuntime.PolicyManager != nil { + t.Fatalf("New() policy manager = %T, want nil", providerRuntime.PolicyManager) + } + }) + } +} + +func TestNewWiresDockerContainerOptions(t *testing.T) { + cfg := config.Default() + cfg.Provider.Type = "docker-container" + cfg.Provider.Platform = "linux/amd64" + cfg.Docker.HTTPProxy = "http://host.docker.internal:3128" + cfg.Docker.HTTPSProxy = "http://host.docker.internal:3128" + cfg.Docker.NoProxy = "localhost,127.0.0.1" + + runtime, err := New(cfg, t.TempDir(), false) + if err != nil { + t.Fatal(err) + } + dockerContainer, ok := runtime.Legacy.(*dockercontainer.Provider) + if !ok { + t.Fatalf("New() legacy type = %T, want Docker Container provider", runtime.Legacy) + } + if runtime.Lifecycle == nil { + t.Fatal("New() did not adapt the legacy provider to Lifecycle") + } + if runtime.Storage == nil { + t.Fatal("New() storage contribution is nil") + } + if !dockerContainer.HostGateway { + t.Fatal("host.docker.internal proxy did not enable host gateway") + } + for key, want := range map[string]string{ + "HTTP_PROXY": cfg.Docker.HTTPProxy, + "HTTPS_PROXY": cfg.Docker.HTTPSProxy, + "NO_PROXY": cfg.Docker.NoProxy, + } { + if got := dockerContainer.Environment[key]; got != want { + t.Errorf("provider environment %s = %q, want %q", key, got, want) + } + } +} + +func TestNewWiresDockerSandboxesCapabilitiesWithoutLegacyAdapter(t *testing.T) { + cfg := config.Default() + cfg.Provider.Type = "docker-sandboxes" + + runtime, err := New(cfg, t.TempDir(), false) + if err != nil { + t.Fatal(err) + } + if runtime.Legacy != nil { + t.Fatalf("New() legacy = %T, want nil", runtime.Legacy) + } + if runtime.Lifecycle == nil { + t.Fatal("New() lifecycle is nil") + } + if runtime.PolicyManager == nil { + t.Fatal("New() policy manager is nil") + } + if runtime.Storage == nil { + t.Fatal("New() storage contribution is nil") + } +} + +func TestDockerSandboxesStorageRoutesOperationsToTheirBackingSurfaces(t *testing.T) { + cfg := config.Default() + cfg.Provider.Type = "docker-sandboxes" + cfg.DockerSandboxes.RootDisk = "30GiB" + cfg.DockerSandboxes.DockerDisk = "100GiB" + contribution := providerStorage(cfg, t.TempDir()) + + create, err := contribution.StorageSnapshot(context.Background(), provider.StorageRequest{ + Operation: "instance-create", + Now: time.Now(), + PeakBytes: 10 << 30, + MinimumFreeBytes: 50 << 30, + }) + if err != nil { + t.Fatal(err) + } + createRequirements := map[string]uint64{} + for _, requirement := range create.Requirements { + createRequirements[requirement.SurfaceID] = requirement.PeakBytes + } + if got, want := createRequirements["docker-sandboxes-backing"], uint64(130<<30); got != want { + t.Fatalf("sandbox backing create expansion = %d, want %d", got, want) + } + if _, found := createRequirements["docker-engine-backing"]; found { + t.Fatalf("sandbox create incorrectly reserved Docker Engine storage: %v", createRequirements) + } + createSurfaces := map[string]storage.SurfaceKind{} + for _, surface := range create.Surfaces { + createSurfaces[surface.ID] = surface.Kind + } + if got, want := createSurfaces["docker-engine-backing"], storage.SurfaceDockerEngine; got != want { + t.Fatalf("Docker Engine surface kind = %q, want %q", got, want) + } + if got, want := createSurfaces["docker-sandboxes-backing"], storage.SurfaceSandboxCache; got != want { + t.Fatalf("Docker Sandboxes surface kind = %q, want %q", got, want) + } + + pull, err := contribution.StorageSnapshot(context.Background(), provider.StorageRequest{ + Operation: "image-pull", + Now: time.Now(), + PeakBytes: 20 << 30, + MinimumFreeBytes: 50 << 30, + }) + if err != nil { + t.Fatal(err) + } + pullRequirements := map[string]uint64{} + for _, requirement := range pull.Requirements { + pullRequirements[requirement.SurfaceID] = requirement.PeakBytes + } + if _, found := pullRequirements["docker-sandboxes-backing"]; found { + t.Fatalf("Docker image pull incorrectly reserved sandbox instance storage: %v", pullRequirements) + } + if got, want := pullRequirements["docker-engine-backing"], uint64(20<<30); got != want { + t.Fatalf("Docker Engine pull expansion = %d, want %d", got, want) + } +} + +func TestNewRejectsUnsupportedProvider(t *testing.T) { + cfg := config.Default() + cfg.Provider.Type = "unknown-provider" + + _, err := New(cfg, t.TempDir(), false) + if err == nil || !strings.Contains(err.Error(), `unsupported provider.type "unknown-provider"`) { + t.Fatalf("New() error = %v", err) + } +} + +func TestPoolImportsOnlyNeutralProviderContracts(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate registry test source") + } + repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..")) + poolFiles, err := filepath.Glob(filepath.Join(repositoryRoot, "internal", "pool", "*.go")) + if err != nil { + t.Fatalf("list pool Go files: %v", err) + } + if len(poolFiles) == 0 { + t.Fatal("no pool Go files found") + } + + concreteProviders := []string{ + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockercontainer", + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes", + "github.com/solutionforest/ephemeral-action-runner/internal/provider/tart", + "github.com/solutionforest/ephemeral-action-runner/internal/provider/wsl", + } + for _, path := range poolFiles { + parsed, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if parseErr != nil { + t.Fatalf("parse imports in %s: %v", path, parseErr) + } + for _, imported := range parsed.Imports { + importPath, unquoteErr := strconv.Unquote(imported.Path.Value) + if unquoteErr != nil { + t.Fatalf("unquote import %s in %s: %v", imported.Path.Value, path, unquoteErr) + } + for _, concrete := range concreteProviders { + if importPath == concrete || strings.HasPrefix(importPath, concrete+"/") { + t.Errorf("%s imports concrete provider %q; pool must use internal/provider contracts", path, importPath) + } + } + } + } +} diff --git a/internal/provider/storage.go b/internal/provider/storage.go new file mode 100644 index 0000000..d4ecf49 --- /dev/null +++ b/internal/provider/storage.go @@ -0,0 +1,141 @@ +package provider + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +type filesystemStorage struct { + providerType string + roots []StorageRoot + minimumExpansions map[string]uint64 +} + +type StorageRoot struct { + ID string + Kind storage.SurfaceKind + Location string + MinimumExpansions map[string]uint64 +} + +// NewFilesystemStorage creates the conservative common contribution used by +// providers whose EPAR-owned staging/install root is on a host filesystem. +// Provider-specific external stores are added to storage status as report-only +// surfaces until they expose an authoritative capacity API. +func NewFilesystemStorage(providerType, root string) StorageContribution { + return NewMultiFilesystemStorage(providerType, []StorageRoot{{ID: providerType + "-host-filesystem", Kind: storage.SurfaceHostFilesystem, Location: root}}) +} + +func NewMultiFilesystemStorage(providerType string, roots []StorageRoot) StorageContribution { + return NewMultiFilesystemStorageWithMinimumExpansions(providerType, roots, nil) +} + +// NewMultiFilesystemStorageWithMinimumExpansions records provider-specific +// lower bounds without leaking provider configuration into the common pool. +func NewMultiFilesystemStorageWithMinimumExpansions(providerType string, roots []StorageRoot, minimumExpansions map[string]uint64) StorageContribution { + expansions := make(map[string]uint64, len(minimumExpansions)) + for operation, bytes := range minimumExpansions { + expansions[operation] = bytes + } + return &filesystemStorage{ + providerType: providerType, + roots: append([]StorageRoot(nil), roots...), + minimumExpansions: expansions, + } +} + +func (contribution *filesystemStorage) StorageSnapshot(_ context.Context, request StorageRequest) (StorageSnapshot, error) { + if contribution == nil || contribution.providerType == "" { + return StorageSnapshot{}, fmt.Errorf("provider storage contribution is incomplete") + } + if len(contribution.roots) == 0 { + return StorageSnapshot{}, fmt.Errorf("provider %s has no required storage roots", contribution.providerType) + } + peakBytes := request.PeakBytes + if minimum := contribution.minimumExpansions[request.Operation]; minimum > peakBytes { + peakBytes = minimum + } + snapshot := StorageSnapshot{} + seen := make(map[string]struct{}, len(contribution.roots)) + for _, specification := range contribution.roots { + if specification.ID == "" || specification.Location == "" { + return StorageSnapshot{}, fmt.Errorf("provider %s has an incomplete storage root", contribution.providerType) + } + if _, duplicate := seen[specification.ID]; duplicate { + return StorageSnapshot{}, fmt.Errorf("provider %s has duplicate storage surface %q", contribution.providerType, specification.ID) + } + seen[specification.ID] = struct{}{} + surfacePeakBytes := peakBytes + requiredForOperation := true + if specification.MinimumExpansions != nil { + minimum, found := specification.MinimumExpansions[request.Operation] + requiredForOperation = found + surfacePeakBytes = request.PeakBytes + if minimum > surfacePeakBytes { + surfacePeakBytes = minimum + } + } + root, err := nearestExistingDirectory(specification.Location) + if err != nil { + return StorageSnapshot{}, fmt.Errorf("resolve %s storage root %s: %w", contribution.providerType, specification.ID, err) + } + capacity, err := storage.ProbeFilesystemCapacity(root, request.Now) + if err != nil { + return StorageSnapshot{}, err + } + kind := specification.Kind + if kind == "" { + kind = storage.SurfaceHostFilesystem + } + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ + ID: specification.ID, + Provider: contribution.providerType, + Kind: kind, + Location: root, + Capacity: capacity, + }) + if !requiredForOperation { + continue + } + snapshot.Requirements = append(snapshot.Requirements, storage.Requirement{ + ID: request.Operation + "-" + specification.ID, + Provider: contribution.providerType, + SurfaceID: specification.ID, + PeakBytes: surfacePeakBytes, + MinimumFreeBytes: request.MinimumFreeBytes, + }) + } + return snapshot, nil +} + +func nearestExistingDirectory(path string) (string, error) { + if path == "" { + path = "." + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + for { + info, statErr := os.Stat(absolute) + if statErr == nil { + if !info.IsDir() { + absolute = filepath.Dir(absolute) + continue + } + return absolute, nil + } + if !os.IsNotExist(statErr) { + return "", statErr + } + parent := filepath.Dir(absolute) + if parent == absolute { + return "", statErr + } + absolute = parent + } +} diff --git a/internal/provider/storage_test.go b/internal/provider/storage_test.go new file mode 100644 index 0000000..1ac70f3 --- /dev/null +++ b/internal/provider/storage_test.go @@ -0,0 +1,89 @@ +package provider + +import ( + "context" + "testing" + "time" +) + +func TestFilesystemStorageAppliesProviderOperationMinimumExpansion(t *testing.T) { + contribution := NewMultiFilesystemStorageWithMinimumExpansions( + "example", + []StorageRoot{{ID: "project", Location: t.TempDir()}}, + map[string]uint64{"instance-create": 42}, + ) + + snapshot, err := contribution.StorageSnapshot(context.Background(), StorageRequest{ + Operation: "instance-create", + Now: time.Now(), + PeakBytes: 10, + MinimumFreeBytes: 20, + }) + if err != nil { + t.Fatal(err) + } + if got, want := len(snapshot.Requirements), 1; got != want { + t.Fatalf("requirement count = %d, want %d", got, want) + } + if got, want := snapshot.Requirements[0].PeakBytes, uint64(42); got != want { + t.Fatalf("peak bytes = %d, want %d", got, want) + } +} + +func TestFilesystemStorageKeepsLargerCommonExpansion(t *testing.T) { + contribution := NewMultiFilesystemStorageWithMinimumExpansions( + "example", + []StorageRoot{{ID: "project", Location: t.TempDir()}}, + map[string]uint64{"instance-create": 42}, + ) + + snapshot, err := contribution.StorageSnapshot(context.Background(), StorageRequest{ + Operation: "instance-create", + Now: time.Now(), + PeakBytes: 84, + }) + if err != nil { + t.Fatal(err) + } + if got, want := snapshot.Requirements[0].PeakBytes, uint64(84); got != want { + t.Fatalf("peak bytes = %d, want %d", got, want) + } +} + +func TestFilesystemStorageRoutesOperationRequirementsToExactSurfaces(t *testing.T) { + root := t.TempDir() + contribution := NewMultiFilesystemStorage( + "example", + []StorageRoot{ + {ID: "project", Location: root}, + {ID: "engine", Location: root, MinimumExpansions: map[string]uint64{"image-pull": 20}}, + {ID: "instance-store", Location: root, MinimumExpansions: map[string]uint64{"instance-create": 42}}, + }, + ) + + snapshot, err := contribution.StorageSnapshot(context.Background(), StorageRequest{ + Operation: "instance-create", + Now: time.Now(), + PeakBytes: 10, + MinimumFreeBytes: 20, + }) + if err != nil { + t.Fatal(err) + } + if got, want := len(snapshot.Requirements), 2; got != want { + t.Fatalf("requirement count = %d, want %d", got, want) + } + if got, want := len(snapshot.Surfaces), 3; got != want { + t.Fatalf("surface count = %d, want %d", got, want) + } + got := map[string]uint64{} + for _, requirement := range snapshot.Requirements { + got[requirement.SurfaceID] = requirement.PeakBytes + } + if got["project"] != 10 || got["instance-store"] != 42 { + t.Fatalf("instance-create requirements = %v, want project=10 and instance-store=42", got) + } + if _, found := got["engine"]; found { + t.Fatalf("instance-create incorrectly required engine capacity: %v", got) + } +} diff --git a/internal/provider/tart/tart.go b/internal/provider/tart/tart.go index f4fe72f..09a3d90 100644 --- a/internal/provider/tart/tart.go +++ b/internal/provider/tart/tart.go @@ -3,9 +3,13 @@ package tart import ( "bytes" "context" + "encoding/json" "fmt" "io" + "os" "os/exec" + "path/filepath" + "runtime" "strings" "github.com/solutionforest/ephemeral-action-runner/internal/provider" @@ -15,6 +19,7 @@ type Provider struct { Binary string DryRun bool runCommand runCommandFunc + identities func() (map[string]string, error) } type runCommandFunc func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) (provider.ExecResult, error) @@ -110,9 +115,61 @@ func (p *Provider) List(ctx context.Context) ([]provider.Instance, error) { State: fields[len(fields)-1], }) } + if p.identities == nil && (runtime.GOOS != "darwin" || p.runCommand != nil) { + return out, nil + } + resolve := p.identities + if resolve == nil { + resolve = readLocalVMIdentities + } + identities, err := resolve() + if err != nil { + return nil, fmt.Errorf("read immutable Tart VM identities: %w", err) + } + for index := range out { + if identity := identities[out[index].Name]; identity != "" { + out[index].ProviderID = "tart-mac:" + strings.ToLower(identity) + } + } return out, nil } +func readLocalVMIdentities() (map[string]string, error) { + home := strings.TrimSpace(os.Getenv("TART_HOME")) + if home == "" { + userHome, err := os.UserHomeDir() + if err != nil { + return nil, err + } + home = filepath.Join(userHome, ".tart") + } + entries, err := os.ReadDir(filepath.Join(home, "vms")) + if os.IsNotExist(err) { + return map[string]string{}, nil + } + if err != nil { + return nil, err + } + result := make(map[string]string) + for _, entry := range entries { + if !entry.IsDir() || strings.ContainsAny(entry.Name(), `/\`) { + continue + } + data, readErr := os.ReadFile(filepath.Join(home, "vms", entry.Name(), "config.json")) + if readErr != nil { + continue + } + var config struct { + MACAddress string `json:"macAddress"` + } + if json.Unmarshal(data, &config) != nil || strings.TrimSpace(config.MACAddress) == "" { + continue + } + result[entry.Name()] = strings.TrimSpace(config.MACAddress) + } + return result, nil +} + func (p *Provider) run(ctx context.Context, stdin io.Reader, args ...string) (provider.ExecResult, error) { return p.runWithLog(ctx, stdin, nil, nil, args...) } diff --git a/internal/provider/tart/tart_test.go b/internal/provider/tart/tart_test.go index 8dcc363..f2ca5c2 100644 --- a/internal/provider/tart/tart_test.go +++ b/internal/provider/tart/tart_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "os" + "path/filepath" "strings" "testing" @@ -103,3 +104,22 @@ func captureStdout(t *testing.T, fn func()) string { } return buf.String() } + +func TestReadLocalVMIdentitiesUsesStableMACAddress(t *testing.T) { + home := t.TempDir() + t.Setenv("TART_HOME", home) + vmDirectory := filepath.Join(home, "vms", "epar-tart-runner") + if err := os.MkdirAll(vmDirectory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vmDirectory, "config.json"), []byte(`{"macAddress":"02:00:00:12:34:56"}`), 0o600); err != nil { + t.Fatal(err) + } + identities, err := readLocalVMIdentities() + if err != nil { + t.Fatal(err) + } + if got := identities["epar-tart-runner"]; got != "02:00:00:12:34:56" { + t.Fatalf("identity = %q", got) + } +} diff --git a/internal/provider/wsl/process_other.go b/internal/provider/wsl/process_other.go new file mode 100644 index 0000000..c73d1d6 --- /dev/null +++ b/internal/provider/wsl/process_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package wsl + +import "os/exec" + +func isolateKeepaliveProcess(*exec.Cmd) {} diff --git a/internal/provider/wsl/process_windows.go b/internal/provider/wsl/process_windows.go new file mode 100644 index 0000000..8b6c360 --- /dev/null +++ b/internal/provider/wsl/process_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package wsl + +import ( + "os/exec" + "syscall" +) + +func isolateKeepaliveProcess(command *exec.Cmd) { + command.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + NoInheritHandles: true, + } +} diff --git a/internal/provider/wsl/process_windows_test.go b/internal/provider/wsl/process_windows_test.go new file mode 100644 index 0000000..a6363a8 --- /dev/null +++ b/internal/provider/wsl/process_windows_test.go @@ -0,0 +1,22 @@ +//go:build windows + +package wsl + +import ( + "os/exec" + "testing" +) + +func TestIsolateKeepaliveProcessPreventsControllerLockInheritance(t *testing.T) { + command := exec.Command("wsl.exe", "--help") + isolateKeepaliveProcess(command) + if command.SysProcAttr == nil { + t.Fatal("SysProcAttr is nil") + } + if !command.SysProcAttr.HideWindow { + t.Fatal("HideWindow is false") + } + if !command.SysProcAttr.NoInheritHandles { + t.Fatal("NoInheritHandles is false") + } +} diff --git a/internal/provider/wsl/wsl.go b/internal/provider/wsl/wsl.go index f4cddd9..fa9353d 100644 --- a/internal/provider/wsl/wsl.go +++ b/internal/provider/wsl/wsl.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "time" "unicode/utf16" @@ -21,6 +22,12 @@ type Provider struct { ProjectRoot string DryRun bool runCommand runCommandFunc + identities func(context.Context) (map[string]wslIdentity, error) +} + +type wslIdentity struct { + ID string + BasePath string } type runCommandFunc func(ctx context.Context, stdin io.Reader, logPath string, stdout, stderr io.Writer, args ...string) (provider.ExecResult, error) @@ -185,7 +192,115 @@ func (p *Provider) List(ctx context.Context) ([]provider.Instance, error) { } return nil, err } - return parseList(result.Stdout), nil + instances := parseList(result.Stdout) + if p.identities == nil && (runtime.GOOS != "windows" || p.runCommand != nil) { + return instances, nil + } + resolve := p.identities + if resolve == nil { + resolve = p.readRegistryIdentities + } + identities, err := resolve(ctx) + if err != nil { + return nil, fmt.Errorf("read immutable WSL distribution identities: %w", err) + } + for index := range instances { + identity, found := identities[strings.ToLower(instances[index].Name)] + if !found || identity.ID == "" { + continue + } + expected, pathErr := p.instanceDir(instances[index].Name) + if pathErr != nil || !sameWindowsPath(identity.BasePath, expected) { + continue + } + instances[index].ProviderID = "wsl:" + strings.ToLower(identity.ID) + } + return instances, nil +} + +func (p *Provider) readRegistryIdentities(ctx context.Context) (map[string]wslIdentity, error) { + command := exec.CommandContext(ctx, "reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss`, "/s") + output, err := command.Output() + if err != nil { + return nil, err + } + return parseRegistryIdentities(cleanWSLOutput(output)) +} + +func parseRegistryIdentities(output string) (map[string]wslIdentity, error) { + result := make(map[string]wslIdentity) + var currentID, distributionName, basePath string + flush := func() { + if currentID != "" && distributionName != "" && basePath != "" { + result[strings.ToLower(distributionName)] = wslIdentity{ID: currentID, BasePath: expandWindowsEnvironment(basePath)} + } + currentID, distributionName, basePath = "", "", "" + } + for _, raw := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") { + line := strings.TrimSpace(raw) + if strings.HasPrefix(strings.ToUpper(line), `HKEY_CURRENT_USER\`) { + flush() + if start := strings.LastIndex(line, `\{`); start >= 0 && strings.HasSuffix(line, "}") { + currentID = strings.Trim(line[start+1:], "{}") + } + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + value := strings.Join(fields[2:], " ") + switch strings.ToLower(fields[0]) { + case "distributionname": + distributionName = value + case "basepath": + basePath = value + } + } + flush() + if len(result) == 0 { + return nil, fmt.Errorf("WSL registry inventory contained no complete distribution identities") + } + return result, nil +} + +func sameWindowsPath(left, right string) bool { + normalize := func(value string) string { + value = strings.TrimPrefix(strings.TrimSpace(value), `\\?\`) + value = filepath.Clean(expandWindowsEnvironment(value)) + return strings.TrimRight(strings.ToLower(value), `\/`) + } + return normalize(left) == normalize(right) +} + +func expandWindowsEnvironment(value string) string { + value = os.ExpandEnv(value) + for { + start := strings.IndexByte(value, '%') + if start < 0 { + return value + } + end := strings.IndexByte(value[start+1:], '%') + if end < 0 { + return value + } + end += start + 1 + key := value[start+1 : end] + replacement := os.Getenv(key) + if replacement == "" { + for _, entry := range os.Environ() { + parts := strings.SplitN(entry, "=", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], key) { + replacement = parts[1] + break + } + } + } + if replacement == "" { + return value + } + value = value[:start] + replacement + value[end+1:] + } } func (p *Provider) Export(ctx context.Context, name, outputPath string) error { @@ -231,6 +346,7 @@ func (p *Provider) startKeepAlive(name string, stdoutSink, stderrSink io.Writer) return nil, nil } cmd := exec.Command(p.Binary, args...) + isolateKeepaliveProcess(cmd) cmd.Stdout = writerOrDiscard(stdoutSink) cmd.Stderr = writerOrDiscard(stderrSink) if err := cmd.Start(); err != nil { diff --git a/internal/provider/wsl/wsl_test.go b/internal/provider/wsl/wsl_test.go index 59dc665..bca0bf4 100644 --- a/internal/provider/wsl/wsl_test.go +++ b/internal/provider/wsl/wsl_test.go @@ -212,3 +212,21 @@ func TestDeleteReturnsUnregisterFailureWhenDistroStillExists(t *testing.T) { t.Fatal("Delete() error = nil, want unregister error") } } + +func TestParseRegistryIdentities(t *testing.T) { + identities, err := parseRegistryIdentities(` +HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\{2A6C842D-6F31-45D8-86A2-66A35D210B42} + DistributionName REG_SZ epar-wsl-runner + BasePath REG_SZ C:\repos\ephemeral-action-runner\work\wsl\epar-wsl-runner +`) + if err != nil { + t.Fatal(err) + } + identity := identities["epar-wsl-runner"] + if identity.ID != "2A6C842D-6F31-45D8-86A2-66A35D210B42" { + t.Fatalf("identity = %#v", identity) + } + if !sameWindowsPath(identity.BasePath, `c:\repos\ephemeral-action-runner\work\wsl\epar-wsl-runner`) { + t.Fatalf("base path did not compare case-insensitively: %#v", identity) + } +} diff --git a/internal/storage/capacity.go b/internal/storage/capacity.go new file mode 100644 index 0000000..d031768 --- /dev/null +++ b/internal/storage/capacity.go @@ -0,0 +1,88 @@ +package storage + +import ( + "errors" + "fmt" + "math" + "sort" + "strings" +) + +// EvaluateCapacity evaluates one requirement against one surface observation. +func EvaluateCapacity(surface Surface, requirement Requirement) (CapacityCheck, error) { + if strings.TrimSpace(surface.ID) == "" { + return CapacityCheck{}, errors.New("storage surface ID is required") + } + if strings.TrimSpace(requirement.ID) == "" { + return CapacityCheck{}, errors.New("storage requirement ID is required") + } + if requirement.SurfaceID != surface.ID { + return CapacityCheck{}, fmt.Errorf("storage requirement %q targets surface %q, not %q", requirement.ID, requirement.SurfaceID, surface.ID) + } + if requirement.MinimumFreeBytes == 0 { + requirement.MinimumFreeBytes = DefaultMinimumFreeBytes + } + if requirement.PeakBytes > math.MaxUint64-requirement.MinimumFreeBytes { + return CapacityCheck{}, fmt.Errorf("storage requirement %q overflows required available bytes", requirement.ID) + } + required := requirement.PeakBytes + requirement.MinimumFreeBytes + check := CapacityCheck{ + Requirement: requirement, + Capacity: surface.Capacity, + RequiredAvailableBytes: required, + } + if !surface.Capacity.Known { + check.Status = CapacityUnknown + check.Reason = "capacity observation is unavailable" + return check, nil + } + if surface.Capacity.TotalBytes > 0 && surface.Capacity.AvailableBytes > surface.Capacity.TotalBytes { + return CapacityCheck{}, fmt.Errorf("storage surface %q reports available bytes greater than total bytes", surface.ID) + } + if surface.Capacity.AvailableBytes < required { + check.Status = CapacityInsufficient + check.DeficitBytes = required - surface.Capacity.AvailableBytes + check.Reason = "available capacity is below peak bytes plus minimum free bytes" + return check, nil + } + check.Status = CapacityReady + check.Reason = "available capacity satisfies peak bytes plus minimum free bytes" + return check, nil +} + +func capacityPreflight(surfaces []Surface, requirements []Requirement) ([]CapacityCheck, error) { + byID := make(map[string]Surface, len(surfaces)) + for _, surface := range surfaces { + if strings.TrimSpace(surface.ID) == "" { + return nil, errors.New("storage surface ID is required") + } + switch surface.Kind { + case SurfaceHostFilesystem, SurfaceDockerEngine, SurfaceSandboxCache, SurfaceExternal: + default: + return nil, fmt.Errorf("storage surface %q has invalid kind %q", surface.ID, surface.Kind) + } + if _, exists := byID[surface.ID]; exists { + return nil, fmt.Errorf("duplicate storage surface ID %q", surface.ID) + } + byID[surface.ID] = surface + } + seenRequirements := make(map[string]struct{}, len(requirements)) + checks := make([]CapacityCheck, 0, len(requirements)) + for _, requirement := range requirements { + if _, exists := seenRequirements[requirement.ID]; exists { + return nil, fmt.Errorf("duplicate storage requirement ID %q", requirement.ID) + } + seenRequirements[requirement.ID] = struct{}{} + surface, exists := byID[requirement.SurfaceID] + if !exists { + return nil, fmt.Errorf("storage requirement %q references unknown surface %q", requirement.ID, requirement.SurfaceID) + } + check, err := EvaluateCapacity(surface, requirement) + if err != nil { + return nil, err + } + checks = append(checks, check) + } + sort.Slice(checks, func(i, j int) bool { return checks[i].Requirement.ID < checks[j].Requirement.ID }) + return checks, nil +} diff --git a/internal/storage/capacity_test.go b/internal/storage/capacity_test.go new file mode 100644 index 0000000..0a8c5e3 --- /dev/null +++ b/internal/storage/capacity_test.go @@ -0,0 +1,68 @@ +package storage + +import ( + "math" + "testing" + "time" +) + +func TestEvaluateCapacity(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + capacity Capacity + requirement Requirement + wantStatus CapacityStatus + wantRequired uint64 + wantDeficit uint64 + }{ + { + name: "unknown fails closed", + capacity: Capacity{}, + requirement: Requirement{ID: "full-build", SurfaceID: "host", PeakBytes: 30 * GiB}, + wantStatus: CapacityUnknown, + wantRequired: 50 * GiB, + }, + { + name: "insufficient includes deficit", + capacity: Capacity{Known: true, AvailableBytes: 49 * GiB, TotalBytes: 100 * GiB, ObservedAt: now}, + requirement: Requirement{ID: "full-build", SurfaceID: "host", PeakBytes: 30 * GiB}, + wantStatus: CapacityInsufficient, + wantRequired: 50 * GiB, + wantDeficit: GiB, + }, + { + name: "ready", + capacity: Capacity{Known: true, AvailableBytes: 50 * GiB, TotalBytes: 100 * GiB, ObservedAt: now}, + requirement: Requirement{ID: "full-build", SurfaceID: "host", PeakBytes: 30 * GiB}, + wantStatus: CapacityReady, + wantRequired: 50 * GiB, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + check, err := EvaluateCapacity(Surface{ID: "host", Kind: SurfaceHostFilesystem, Capacity: test.capacity}, test.requirement) + if err != nil { + t.Fatalf("EvaluateCapacity() error = %v", err) + } + if check.Status != test.wantStatus || check.RequiredAvailableBytes != test.wantRequired || check.DeficitBytes != test.wantDeficit { + t.Fatalf("EvaluateCapacity() = %+v, want status=%s required=%d deficit=%d", check, test.wantStatus, test.wantRequired, test.wantDeficit) + } + }) + } +} + +func TestEvaluateCapacityRejectsOverflowAndInvalidObservation(t *testing.T) { + t.Parallel() + surface := Surface{ID: "host", Capacity: Capacity{Known: true, AvailableBytes: 10, TotalBytes: 5}} + if _, err := EvaluateCapacity(surface, Requirement{ID: "build", SurfaceID: "host", MinimumFreeBytes: 1}); err == nil { + t.Fatal("EvaluateCapacity() accepted available bytes greater than total bytes") + } + surface.Capacity = Capacity{Known: true, AvailableBytes: math.MaxUint64, TotalBytes: math.MaxUint64} + if _, err := EvaluateCapacity(surface, Requirement{ID: "build", SurfaceID: "host", PeakBytes: math.MaxUint64, MinimumFreeBytes: 1}); err == nil { + t.Fatal("EvaluateCapacity() accepted overflowing requirement") + } +} diff --git a/internal/storage/doc.go b/internal/storage/doc.go new file mode 100644 index 0000000..555900f --- /dev/null +++ b/internal/storage/doc.go @@ -0,0 +1,9 @@ +// Package storage defines provider-neutral, fail-closed storage inventory, +// capacity, retention-planning, and exact-execution contracts. +// +// The package does not delete host resources and does not implement Docker, +// Docker Sandboxes, or filesystem cleanup. Adapters inventory exact artifacts, +// Preview deterministically classifies them, and an optional ExactExecutor +// integration can apply only the exact removal entries bound into an approved +// plan hash. +package storage diff --git a/internal/storage/executor.go b/internal/storage/executor.go new file mode 100644 index 0000000..8792acb --- /dev/null +++ b/internal/storage/executor.go @@ -0,0 +1,117 @@ +package storage + +import ( + "context" + "errors" + "fmt" +) + +// Observation is the exact target state returned by an executor. +type Observation struct { + Exists bool `json:"exists"` + Target Target `json:"target"` +} + +// Removal contains exactly one immutable approved target. An implementation of +// RemoveExact must condition removal on Target.Identity and Target.Fingerprint +// and must fail closed when the locator resolves to a different object. +type Removal struct { + ArtifactID string `json:"artifactId"` + Target Target `json:"target"` + SizeBytes uint64 `json:"sizeBytes"` +} + +// ExactExecutor is deliberately unable to receive a prefix, glob, surface, or +// unbounded selector. Implementations must provide conditional exact removal +// and exact post-removal observation. +type ExactExecutor interface { + ObserveExact(context.Context, Target) (Observation, error) + RemoveExact(context.Context, Removal) error +} + +// ExecutionStatus describes an attempted exact removal. +type ExecutionStatus string + +const ( + ExecutionRemoved ExecutionStatus = "removed" + ExecutionDrifted ExecutionStatus = "drifted" + ExecutionFailed ExecutionStatus = "failed" +) + +// ExecutionEntry records one exact target outcome. +type ExecutionEntry struct { + Removal Removal `json:"removal"` + Status ExecutionStatus `json:"status"` + Error string `json:"error,omitempty"` +} + +// ExecutionReport is a partial-completion-safe journal returned in plan order. +type ExecutionReport struct { + PlanHash string `json:"planHash"` + Entries []ExecutionEntry `json:"entries"` + RemovedCount int `json:"removedCount"` + ReclaimedBytes uint64 `json:"reclaimedBytes"` +} + +// Execute applies only ActionRemove entries from a hash-approved plan. It +// re-observes identity before removal and verifies exact absence afterward. +// Execution stops on the first drift or error and returns the partial journal. +func Execute(ctx context.Context, plan Plan, approvedHash string, executor ExactExecutor) (ExecutionReport, error) { + if executor == nil { + return ExecutionReport{}, errors.New("exact storage executor is required") + } + if err := ValidatePlanHash(plan, approvedHash); err != nil { + return ExecutionReport{}, err + } + report := ExecutionReport{PlanHash: plan.Hash} + for _, decision := range plan.Decisions { + if decision.Action != ActionRemove { + continue + } + removal := Removal{ArtifactID: decision.Artifact.ID, Target: decision.Artifact.Target, SizeBytes: decision.Artifact.SizeBytes} + if err := validateExactTarget(removal.Target); err != nil { + return report, fmt.Errorf("planned removal %q is not exact: %w", removal.ArtifactID, err) + } + observation, err := executor.ObserveExact(ctx, removal.Target) + if err != nil { + entry := ExecutionEntry{Removal: removal, Status: ExecutionFailed, Error: err.Error()} + report.Entries = append(report.Entries, entry) + return report, fmt.Errorf("observe exact storage target %q: %w", removal.ArtifactID, err) + } + if !observation.Exists || observation.Target != removal.Target { + entry := ExecutionEntry{Removal: removal, Status: ExecutionDrifted, Error: "exact target identity changed or disappeared"} + report.Entries = append(report.Entries, entry) + return report, fmt.Errorf("exact storage target %q drifted", removal.ArtifactID) + } + if err := executor.RemoveExact(ctx, removal); err != nil { + entry := ExecutionEntry{Removal: removal, Status: ExecutionFailed, Error: err.Error()} + report.Entries = append(report.Entries, entry) + return report, fmt.Errorf("remove exact storage target %q: %w", removal.ArtifactID, err) + } + after, err := executor.ObserveExact(ctx, removal.Target) + if err != nil { + entry := ExecutionEntry{Removal: removal, Status: ExecutionFailed, Error: err.Error()} + report.Entries = append(report.Entries, entry) + return report, fmt.Errorf("verify exact storage target %q absence: %w", removal.ArtifactID, err) + } + if after.Exists { + entry := ExecutionEntry{Removal: removal, Status: ExecutionFailed, Error: "exact target still exists after removal"} + report.Entries = append(report.Entries, entry) + return report, fmt.Errorf("exact storage target %q still exists after removal", removal.ArtifactID) + } + report.Entries = append(report.Entries, ExecutionEntry{Removal: removal, Status: ExecutionRemoved}) + report.RemovedCount++ + report.ReclaimedBytes += removal.SizeBytes + } + return report, nil +} + +func validateExactTarget(target Target) error { + if target.Match != MatchExact { + return fmt.Errorf("target match is %q", target.Match) + } + if target.Kind == "" || target.Locator == "" || target.Identity == "" { + return errors.New("target kind, locator, and identity are required") + } + return nil +} diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go new file mode 100644 index 0000000..4c4396a --- /dev/null +++ b/internal/storage/filesystem.go @@ -0,0 +1,133 @@ +package storage + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// ProbeFilesystemCapacity returns an OS capacity observation for an exact, +// redirect-free existing filesystem path. +func ProbeFilesystemCapacity(path string, now time.Time) (Capacity, error) { + canonical, _, err := inspectFilesystemPath(path) + if err != nil { + return Capacity{}, err + } + available, total, err := platformFilesystemCapacity(canonical) + if err != nil { + return Capacity{}, fmt.Errorf("probe filesystem capacity for %q: %w", canonical, err) + } + return Capacity{Known: true, AvailableBytes: available, TotalBytes: total, ObservedAt: now.UTC()}, nil +} + +// SnapshotFilesystemTarget binds a regular file or real directory to a stable +// object identity and shallow metadata fingerprint. Symlinks, junctions, +// reparse points, special files, and redirected ancestors are rejected. +func SnapshotFilesystemTarget(path string) (Target, error) { + canonical, info, err := inspectFilesystemPath(path) + if err != nil { + return Target{}, err + } + kind := TargetFile + if info.IsDir() { + kind = TargetDirectory + } else if !info.Mode().IsRegular() { + return Target{}, fmt.Errorf("storage path %q is not a regular file or real directory", canonical) + } + identity, err := platformFilesystemIdentity(canonical, info.IsDir()) + if err != nil { + return Target{}, fmt.Errorf("read stable filesystem identity for %q: %w", canonical, err) + } + metadata := struct { + Size int64 `json:"size"` + Mode uint32 `json:"mode"` + ModTime string `json:"modTime"` + }{ + Size: info.Size(), + Mode: uint32(info.Mode()), + ModTime: info.ModTime().UTC().Format(time.RFC3339Nano), + } + encoded, err := json.Marshal(metadata) + if err != nil { + return Target{}, err + } + sum := sha256.Sum256(encoded) + return Target{ + Kind: kind, + Locator: canonical, + Identity: identity, + Fingerprint: "sha256:" + hex.EncodeToString(sum[:]), + Match: MatchExact, + }, nil +} + +func inspectFilesystemPath(path string) (string, os.FileInfo, error) { + if strings.TrimSpace(path) == "" || strings.ContainsRune(path, 0) { + return "", nil, errors.New("storage filesystem path is empty or contains NUL") + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", nil, err + } + absolute = filepath.Clean(absolute) + if runtime.GOOS == "windows" { + rest := strings.TrimPrefix(absolute, filepath.VolumeName(absolute)) + if strings.Contains(rest, ":") { + return "", nil, fmt.Errorf("storage filesystem path %q contains an alternate-data-stream separator", absolute) + } + } + if err := rejectRedirectedAncestors(absolute); err != nil { + return "", nil, err + } + info, err := os.Lstat(absolute) + if err != nil { + return "", nil, err + } + if isFilesystemRedirect(info) { + return "", nil, fmt.Errorf("storage filesystem path %q is a symlink, junction, or reparse point", absolute) + } + evaluated, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", nil, err + } + evaluated, err = filepath.Abs(evaluated) + if err != nil { + return "", nil, err + } + if !sameFilesystemPath(absolute, filepath.Clean(evaluated)) { + return "", nil, fmt.Errorf("storage filesystem path %q contains a symlink, junction, or reparse redirection", absolute) + } + return absolute, info, nil +} + +func rejectRedirectedAncestors(path string) error { + cursor := path + for { + info, err := os.Lstat(cursor) + if err != nil { + return err + } + if isFilesystemRedirect(info) { + return fmt.Errorf("storage filesystem path %q has redirected ancestor %q", path, cursor) + } + parent := filepath.Dir(cursor) + if parent == cursor { + return nil + } + cursor = parent + } +} + +func sameFilesystemPath(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} diff --git a/internal/storage/filesystem_executor.go b/internal/storage/filesystem_executor.go new file mode 100644 index 0000000..85d4f64 --- /dev/null +++ b/internal/storage/filesystem_executor.go @@ -0,0 +1,120 @@ +package storage + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// FilesystemExecutor removes only exact files or directories strictly below +// explicitly approved roots. Every directory descendant is checked for links, +// reparse points, and special files before removal. +type FilesystemExecutor struct { + roots []string +} + +func NewFilesystemExecutor(allowedRoots ...string) (*FilesystemExecutor, error) { + executor := &FilesystemExecutor{} + for _, root := range allowedRoots { + if strings.TrimSpace(root) == "" { + continue + } + target, err := SnapshotFilesystemTarget(root) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, fmt.Errorf("validate storage execution root %q: %w", root, err) + } + if target.Kind != TargetDirectory { + return nil, fmt.Errorf("storage execution root %q is not a directory", root) + } + executor.roots = append(executor.roots, target.Locator) + } + if len(executor.roots) == 0 { + return nil, fmt.Errorf("at least one existing exact storage execution root is required") + } + return executor, nil +} + +func (executor *FilesystemExecutor) ObserveExact(_ context.Context, target Target) (Observation, error) { + observed, err := SnapshotFilesystemTarget(target.Locator) + if os.IsNotExist(err) { + return Observation{Exists: false, Target: target}, nil + } + if err != nil { + return Observation{}, err + } + return Observation{Exists: true, Target: observed}, nil +} + +func (executor *FilesystemExecutor) RemoveExact(ctx context.Context, removal Removal) error { + if err := validateExactTarget(removal.Target); err != nil { + return err + } + if removal.Target.Kind != TargetFile && removal.Target.Kind != TargetDirectory { + return fmt.Errorf("filesystem executor does not support target kind %q", removal.Target.Kind) + } + if !executor.allowed(removal.Target.Locator) { + return fmt.Errorf("storage target %q is not strictly below an approved root", removal.Target.Locator) + } + if err := ctx.Err(); err != nil { + return err + } + observed, err := SnapshotFilesystemTarget(removal.Target.Locator) + if err != nil { + return err + } + if observed != removal.Target { + return fmt.Errorf("storage target identity changed before exact removal") + } + if observed.Kind == TargetFile { + return os.Remove(observed.Locator) + } + if err := validateRemovalTree(ctx, observed.Locator); err != nil { + return err + } + observedAgain, err := SnapshotFilesystemTarget(removal.Target.Locator) + if err != nil { + return err + } + if observedAgain != removal.Target { + return fmt.Errorf("storage directory identity changed during exact removal validation") + } + return os.RemoveAll(observed.Locator) +} + +func (executor *FilesystemExecutor) allowed(path string) bool { + for _, root := range executor.roots { + relative, err := filepath.Rel(root, path) + if err == nil && relative != "." && relative != ".." && !filepath.IsAbs(relative) && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return true + } + } + return false +} + +func validateRemovalTree(ctx context.Context, root string) error { + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + if isFilesystemRedirect(info) { + return fmt.Errorf("refusing to remove redirected storage descendant %q", path) + } + if !info.IsDir() && !info.Mode().IsRegular() { + return fmt.Errorf("refusing to remove special storage descendant %q", path) + } + return nil + }) +} diff --git a/internal/storage/filesystem_executor_test.go b/internal/storage/filesystem_executor_test.go new file mode 100644 index 0000000..77b6227 --- /dev/null +++ b/internal/storage/filesystem_executor_test.go @@ -0,0 +1,92 @@ +package storage + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestFilesystemExecutorRemovesOnlyExactTargetBelowAllowedRoot(t *testing.T) { + root := t.TempDir() + targetPath := filepath.Join(root, "old", "archive.tar") + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(targetPath, []byte("archive"), 0o600); err != nil { + t.Fatal(err) + } + target, err := SnapshotFilesystemTarget(targetPath) + if err != nil { + t.Fatal(err) + } + executor, err := NewFilesystemExecutor(root) + if err != nil { + t.Fatal(err) + } + if err := executor.RemoveExact(context.Background(), Removal{ArtifactID: "archive", Target: target}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(targetPath); !os.IsNotExist(err) { + t.Fatalf("target still exists: %v", err) + } +} + +func TestFilesystemExecutorRejectsAllowedRootAndDrift(t *testing.T) { + root := t.TempDir() + executor, err := NewFilesystemExecutor(root) + if err != nil { + t.Fatal(err) + } + rootTarget, err := SnapshotFilesystemTarget(root) + if err != nil { + t.Fatal(err) + } + if err := executor.RemoveExact(context.Background(), Removal{ArtifactID: "root", Target: rootTarget}); err == nil { + t.Fatal("RemoveExact() removed or accepted the allowed root") + } + + path := filepath.Join(root, "candidate") + if err := os.WriteFile(path, []byte("first"), 0o600); err != nil { + t.Fatal(err) + } + target, err := SnapshotFilesystemTarget(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("replacement"), 0o600); err != nil { + t.Fatal(err) + } + if err := executor.RemoveExact(context.Background(), Removal{ArtifactID: "candidate", Target: target}); err == nil { + t.Fatal("RemoveExact() accepted a drifted target") + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("drifted target was removed: %v", err) + } +} + +func TestFilesystemExecutorRejectsRedirectedDescendant(t *testing.T) { + root := t.TempDir() + targetPath := filepath.Join(root, "revision") + if err := os.Mkdir(targetPath, 0o755); err != nil { + t.Fatal(err) + } + linkPath := filepath.Join(targetPath, "redirect") + if err := os.Symlink(t.TempDir(), linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + target, err := SnapshotFilesystemTarget(targetPath) + if err != nil { + t.Fatal(err) + } + executor, err := NewFilesystemExecutor(root) + if err != nil { + t.Fatal(err) + } + if err := executor.RemoveExact(context.Background(), Removal{ArtifactID: "revision", Target: target}); err == nil { + t.Fatal("RemoveExact() accepted a redirected descendant") + } + if _, err := os.Stat(targetPath); err != nil { + t.Fatalf("target directory was removed: %v", err) + } +} diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go new file mode 100644 index 0000000..e7cc357 --- /dev/null +++ b/internal/storage/filesystem_test.go @@ -0,0 +1,85 @@ +package storage + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestSnapshotFilesystemTargetAndDrift(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "artifact.bin") + if err := os.WriteFile(path, []byte("first"), 0o600); err != nil { + t.Fatal(err) + } + first, err := SnapshotFilesystemTarget(path) + if err != nil { + t.Fatalf("SnapshotFilesystemTarget() error = %v", err) + } + if first.Match != MatchExact || first.Kind != TargetFile || first.Identity == "" || first.Fingerprint == "" { + t.Fatalf("SnapshotFilesystemTarget() = %+v", first) + } + if err := os.WriteFile(path, []byte("different-size"), 0o600); err != nil { + t.Fatal(err) + } + second, err := SnapshotFilesystemTarget(path) + if err != nil { + t.Fatalf("second SnapshotFilesystemTarget() error = %v", err) + } + if first.Identity != second.Identity { + t.Fatalf("same filesystem object identity changed: first=%s second=%s", first.Identity, second.Identity) + } + if first.Fingerprint == second.Fingerprint { + t.Fatal("filesystem metadata drift did not change fingerprint") + } +} + +func TestSnapshotFilesystemTargetRejectsSymlinkOrReparse(t *testing.T) { + t.Parallel() + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.WriteFile(target, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink creation unavailable on %s: %v", runtime.GOOS, err) + } + if _, err := SnapshotFilesystemTarget(link); err == nil { + t.Fatal("SnapshotFilesystemTarget() accepted symlink or reparse point") + } +} + +func TestSnapshotFilesystemTargetRejectsRedirectedAncestor(t *testing.T) { + t.Parallel() + root := t.TempDir() + realDirectory := filepath.Join(root, "real") + if err := os.Mkdir(realDirectory, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(realDirectory, "artifact") + if err := os.WriteFile(path, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "redirect") + if err := os.Symlink(realDirectory, link); err != nil { + t.Skipf("symlink creation unavailable on %s: %v", runtime.GOOS, err) + } + if _, err := SnapshotFilesystemTarget(filepath.Join(link, "artifact")); err == nil { + t.Fatal("SnapshotFilesystemTarget() accepted redirected ancestor") + } +} + +func TestProbeFilesystemCapacity(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + capacity, err := ProbeFilesystemCapacity(t.TempDir(), now) + if err != nil { + t.Fatalf("ProbeFilesystemCapacity() error = %v", err) + } + if !capacity.Known || capacity.TotalBytes == 0 || capacity.AvailableBytes > capacity.TotalBytes || !capacity.ObservedAt.Equal(now) { + t.Fatalf("ProbeFilesystemCapacity() = %+v", capacity) + } +} diff --git a/internal/storage/filesystem_unix.go b/internal/storage/filesystem_unix.go new file mode 100644 index 0000000..78eec89 --- /dev/null +++ b/internal/storage/filesystem_unix.go @@ -0,0 +1,38 @@ +//go:build !windows + +package storage + +import ( + "fmt" + "math" + "os" + "syscall" +) + +func platformFilesystemCapacity(path string) (uint64, uint64, error) { + var stats syscall.Statfs_t + if err := syscall.Statfs(path, &stats); err != nil { + return 0, 0, err + } + blockSize := uint64(stats.Bsize) + available := uint64(stats.Bavail) + total := uint64(stats.Blocks) + if blockSize != 0 && (available > math.MaxUint64/blockSize || total > math.MaxUint64/blockSize) { + return 0, 0, fmt.Errorf("filesystem space result overflow") + } + return available * blockSize, total * blockSize, nil +} + +func platformFilesystemIdentity(path string, _ bool) (string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "", fmt.Errorf("filesystem did not expose a stable object identity") + } + return fmt.Sprintf("unix:%x:%x", uint64(stat.Dev), uint64(stat.Ino)), nil +} + +func isFilesystemRedirect(info os.FileInfo) bool { return info.Mode()&os.ModeSymlink != 0 } diff --git a/internal/storage/filesystem_windows.go b/internal/storage/filesystem_windows.go new file mode 100644 index 0000000..172db15 --- /dev/null +++ b/internal/storage/filesystem_windows.go @@ -0,0 +1,52 @@ +//go:build windows + +package storage + +import ( + "fmt" + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +func platformFilesystemCapacity(path string) (uint64, uint64, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, 0, err + } + var available, total, free uint64 + if err := windows.GetDiskFreeSpaceEx(pointer, &available, &total, &free); err != nil { + return 0, 0, err + } + return available, total, nil +} + +func platformFilesystemIdentity(path string, directory bool) (string, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + flags := uint32(windows.FILE_FLAG_OPEN_REPARSE_POINT) + if directory { + flags |= windows.FILE_FLAG_BACKUP_SEMANTICS + } + handle, err := windows.CreateFile(pointer, 0, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, flags, 0) + if err != nil { + return "", err + } + defer windows.CloseHandle(handle) + var information windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &information); err != nil { + return "", err + } + return fmt.Sprintf("windows:%08x:%08x%08x", information.VolumeSerialNumber, information.FileIndexHigh, information.FileIndexLow), nil +} + +func isFilesystemRedirect(info os.FileInfo) bool { + if info.Mode()&os.ModeSymlink != 0 { + return true + } + data, ok := info.Sys().(*syscall.Win32FileAttributeData) + return ok && data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 +} diff --git a/internal/storage/format.go b/internal/storage/format.go new file mode 100644 index 0000000..b16f249 --- /dev/null +++ b/internal/storage/format.go @@ -0,0 +1,64 @@ +package storage + +import "fmt" + +const ( + KiB uint64 = 1 << 10 + MiB uint64 = 1 << 20 + TiB uint64 = 1 << 40 + PiB uint64 = 1 << 50 + EiB uint64 = 1 << 60 +) + +var byteUnits = []struct { + name string + bytes uint64 +}{ + {name: "EiB", bytes: EiB}, + {name: "PiB", bytes: PiB}, + {name: "TiB", bytes: TiB}, + {name: "GiB", bytes: GiB}, + {name: "MiB", bytes: MiB}, + {name: "KiB", bytes: KiB}, +} + +// FormatBytes renders a byte count in a compact binary unit suitable for +// operator-facing capacity messages. +func FormatBytes(value uint64) string { + for _, unit := range byteUnits { + if value >= unit.bytes { + return fmt.Sprintf("%.2f %s", float64(value)/float64(unit.bytes), unit.name) + } + } + return fmt.Sprintf("%d bytes", value) +} + +// CapacityAdmissionError renders a fail-closed capacity decision in terms an +// operator can act on without translating byte counts or policy arithmetic. +func CapacityAdmissionError(action string, surface Surface, requirement Requirement, check CapacityCheck, cleanupCommand string) error { + available := "unknown" + if surface.Capacity.Known { + available = FormatBytes(surface.Capacity.AvailableBytes) + } + message := fmt.Sprintf( + "not enough disk space to %s.\n\n"+ + " Storage location: %s\n"+ + " Available: %s\n"+ + " Estimated operation growth: %s\n"+ + " Free-space reserve: %s\n"+ + " Required before starting: %s", + action, + surface.Location, + available, + FormatBytes(requirement.PeakBytes), + FormatBytes(requirement.MinimumFreeBytes), + FormatBytes(check.RequiredAvailableBytes), + ) + if surface.Capacity.Known && check.DeficitBytes > 0 { + message += fmt.Sprintf("\n Additional space needed: %s", FormatBytes(check.DeficitBytes)) + } + if cleanupCommand != "" { + message += "\n\nReview reclaimable EPAR storage with:\n " + cleanupCommand + } + return fmt.Errorf("%s", message) +} diff --git a/internal/storage/format_test.go b/internal/storage/format_test.go new file mode 100644 index 0000000..c4c5f9b --- /dev/null +++ b/internal/storage/format_test.go @@ -0,0 +1,59 @@ +package storage + +import ( + "strings" + "testing" +) + +func TestFormatBytes(t *testing.T) { + tests := map[uint64]string{ + 0: "0 bytes", + 512: "512 bytes", + 1536: "1.50 KiB", + 140 * GiB: "140.00 GiB", + 9223372036854775807: "8.00 EiB", + } + for value, want := range tests { + if got := FormatBytes(value); got != want { + t.Errorf("FormatBytes(%d) = %q, want %q", value, got, want) + } + } +} + +func TestCapacityAdmissionError(t *testing.T) { + surface := Surface{ + Location: "sandbox-data", + Capacity: Capacity{Known: true, AvailableBytes: 140 * GiB}, + } + requirement := Requirement{PeakBytes: 130 * GiB, MinimumFreeBytes: 50 * GiB} + check, err := EvaluateCapacity(Surface{ + ID: "sandbox", + Location: surface.Location, + Capacity: surface.Capacity, + }, Requirement{ + ID: "create", + SurfaceID: "sandbox", + PeakBytes: requirement.PeakBytes, + MinimumFreeBytes: requirement.MinimumFreeBytes, + }) + if err != nil { + t.Fatal(err) + } + got := CapacityAdmissionError("initialize the runner", surface, requirement, check, "./start storage prune --provider docker-sandboxes").Error() + for _, want := range []string{ + "not enough disk space to initialize the runner", + "Available: 140.00 GiB", + "Estimated operation growth: 130.00 GiB", + "Free-space reserve: 50.00 GiB", + "Required before starting: 180.00 GiB", + "Additional space needed: 40.00 GiB", + "./start storage prune --provider docker-sandboxes", + } { + if !strings.Contains(got, want) { + t.Errorf("error = %q, want %q", got, want) + } + } + if strings.Contains(got, "150323855360") { + t.Fatalf("error exposes raw byte count: %q", got) + } +} diff --git a/internal/storage/hash.go b/internal/storage/hash.go new file mode 100644 index 0000000..56bedd0 --- /dev/null +++ b/internal/storage/hash.go @@ -0,0 +1,77 @@ +package storage + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const hashPrefix = "sha256:" + +// ComputePlanHash returns the deterministic SHA-256 identity of a plan with its +// Hash field cleared. +func ComputePlanHash(plan Plan) (string, error) { + plan.Hash = "" + encoded, err := json.Marshal(plan) + if err != nil { + return "", fmt.Errorf("encode storage plan: %w", err) + } + sum := sha256.Sum256(encoded) + return hashPrefix + hex.EncodeToString(sum[:]), nil +} + +// ValidatePlanHash verifies both the embedded hash and an independently +// supplied approval hash. +func ValidatePlanHash(plan Plan, approvedHash string) error { + if strings.TrimSpace(approvedHash) == "" { + return errors.New("approved storage plan hash is required") + } + actual, err := ComputePlanHash(plan) + if err != nil { + return err + } + if plan.Hash != actual { + return fmt.Errorf("storage plan content drifted: embedded hash %q does not match %q", plan.Hash, actual) + } + if approvedHash != actual { + return fmt.Errorf("storage plan approval hash %q does not match %q", approvedHash, actual) + } + return nil +} + +// ValidatePlanArtifacts verifies that every planned removal still has the exact +// artifact snapshot supplied by the caller. Missing, added, or changed +// non-removal artifacts do not broaden the approved target set. +func ValidatePlanArtifacts(plan Plan, current []Artifact) error { + byID := make(map[string]Artifact, len(current)) + for _, artifact := range current { + if _, exists := byID[artifact.ID]; exists { + return fmt.Errorf("duplicate current storage artifact ID %q", artifact.ID) + } + byID[artifact.ID] = artifact + } + for _, decision := range plan.Decisions { + if decision.Action != ActionRemove { + continue + } + actual, exists := byID[decision.Artifact.ID] + if !exists { + return fmt.Errorf("planned storage artifact %q is missing", decision.Artifact.ID) + } + expectedJSON, err := json.Marshal(decision.Artifact) + if err != nil { + return err + } + actualJSON, err := json.Marshal(actual) + if err != nil { + return err + } + if string(expectedJSON) != string(actualJSON) { + return fmt.Errorf("planned storage artifact %q drifted", decision.Artifact.ID) + } + } + return nil +} diff --git a/internal/storage/hash_executor_test.go b/internal/storage/hash_executor_test.go new file mode 100644 index 0000000..e840b5a --- /dev/null +++ b/internal/storage/hash_executor_test.go @@ -0,0 +1,169 @@ +package storage + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestPlanHashDetectsContentAndApprovalDrift(t *testing.T) { + t.Parallel() + plan := testPlan(t) + if err := ValidatePlanHash(plan, plan.Hash); err != nil { + t.Fatalf("ValidatePlanHash() error = %v", err) + } + drifted := plan + drifted.Decisions = append([]Decision(nil), plan.Decisions...) + drifted.Decisions[0].Artifact.Provider = "changed-provider" + if err := ValidatePlanHash(drifted, plan.Hash); err == nil || !strings.Contains(err.Error(), "content drifted") { + t.Fatalf("ValidatePlanHash() drift error = %v", err) + } + if err := ValidatePlanHash(plan, "sha256:"+strings.Repeat("0", 64)); err == nil || !strings.Contains(err.Error(), "approval hash") { + t.Fatalf("ValidatePlanHash() approval error = %v", err) + } +} + +func TestValidatePlanArtifactsDetectsRemovalDrift(t *testing.T) { + t.Parallel() + plan := testPlan(t) + current := make([]Artifact, 0, len(plan.Decisions)) + for _, decision := range plan.Decisions { + current = append(current, decision.Artifact) + } + if err := ValidatePlanArtifacts(plan, current); err != nil { + t.Fatalf("ValidatePlanArtifacts() error = %v", err) + } + for index := range current { + if actionFor(plan, current[index].ID) == ActionRemove { + current[index].Target.Identity = "replacement" + break + } + } + if err := ValidatePlanArtifacts(plan, current); err == nil || !strings.Contains(err.Error(), "drifted") { + t.Fatalf("ValidatePlanArtifacts() drift error = %v", err) + } +} + +func TestExecuteOnlyPassesExactPlannedRemovals(t *testing.T) { + t.Parallel() + plan := testPlan(t) + executor := &fakeExactExecutor{existing: make(map[string]Target)} + wantRemove := make(map[string]Target) + for _, decision := range plan.Decisions { + if decision.Action == ActionRemove { + executor.existing[decision.Artifact.Target.Locator] = decision.Artifact.Target + wantRemove[decision.Artifact.ID] = decision.Artifact.Target + } + } + report, err := Execute(context.Background(), plan, plan.Hash, executor) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if report.RemovedCount != len(wantRemove) || len(executor.removals) != len(wantRemove) { + t.Fatalf("Execute() report=%+v removals=%v want=%v", report, executor.removals, wantRemove) + } + for _, removal := range executor.removals { + want, exists := wantRemove[removal.ArtifactID] + if !exists || removal.Target != want || removal.Target.Match != MatchExact { + t.Fatalf("Execute() broadened removal: %+v", removal) + } + } +} + +func TestExecuteStopsOnIdentityDriftWithoutRemoval(t *testing.T) { + t.Parallel() + plan := testPlan(t) + executor := &fakeExactExecutor{existing: make(map[string]Target)} + for _, decision := range plan.Decisions { + if decision.Action == ActionRemove { + replacement := decision.Artifact.Target + replacement.Identity = "replacement" + executor.existing[replacement.Locator] = replacement + break + } + } + report, err := Execute(context.Background(), plan, plan.Hash, executor) + if err == nil || !strings.Contains(err.Error(), "drifted") { + t.Fatalf("Execute() error = %v, want drift", err) + } + if len(executor.removals) != 0 || len(report.Entries) != 1 || report.Entries[0].Status != ExecutionDrifted { + t.Fatalf("Execute() drift report=%+v removals=%v", report, executor.removals) + } +} + +func TestExecuteReturnsPartialJournalOnExactFailure(t *testing.T) { + t.Parallel() + plan := testPlanWithTwoRemovals(t) + executor := &fakeExactExecutor{existing: make(map[string]Target), failAt: 2} + for _, decision := range plan.Decisions { + if decision.Action == ActionRemove { + executor.existing[decision.Artifact.Target.Locator] = decision.Artifact.Target + } + } + report, err := Execute(context.Background(), plan, plan.Hash, executor) + if err == nil || !strings.Contains(err.Error(), "synthetic exact failure") { + t.Fatalf("Execute() error = %v", err) + } + if report.RemovedCount != 1 || len(report.Entries) != 2 || report.Entries[0].Status != ExecutionRemoved || report.Entries[1].Status != ExecutionFailed { + t.Fatalf("Execute() partial report = %+v", report) + } +} + +type fakeExactExecutor struct { + existing map[string]Target + removals []Removal + failAt int +} + +func (executor *fakeExactExecutor) ObserveExact(_ context.Context, target Target) (Observation, error) { + actual, exists := executor.existing[target.Locator] + return Observation{Exists: exists, Target: actual}, nil +} + +func (executor *fakeExactExecutor) RemoveExact(_ context.Context, removal Removal) error { + executor.removals = append(executor.removals, removal) + if executor.failAt > 0 && len(executor.removals) == executor.failAt { + return errors.New("synthetic exact failure") + } + delete(executor.existing, removal.Target.Locator) + return nil +} + +func testPlan(t *testing.T) Plan { + t.Helper() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + plan, err := Preview(PreviewRequest{ + Now: now, + Policy: DefaultPolicy(), + Surfaces: testSurfaces(), + Artifacts: []Artifact{ + testArtifact("remove", ArtifactTemplateArchive, now.Add(-10*24*time.Hour)), + withArtifact(testArtifact("keep", ArtifactTemplateArchive, now.Add(-time.Hour)), func(a *Artifact) {}), + withArtifact(testArtifact("protected", ArtifactTemplateArchive, now.Add(-10*24*time.Hour)), func(a *Artifact) { a.Current = true }), + }, + }) + if err != nil { + t.Fatalf("Preview() error = %v", err) + } + return plan +} + +func testPlanWithTwoRemovals(t *testing.T) Plan { + t.Helper() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + plan, err := Preview(PreviewRequest{ + Now: now, + Policy: DefaultPolicy(), + Surfaces: testSurfaces(), + Artifacts: []Artifact{ + testArtifact("remove-a", ArtifactTemplateArchive, now.Add(-11*24*time.Hour)), + testArtifact("remove-b", ArtifactTemplateArchive, now.Add(-10*24*time.Hour)), + }, + }) + if err != nil { + t.Fatalf("Preview() error = %v", err) + } + return plan +} diff --git a/internal/storage/inventory/collect.go b/internal/storage/inventory/collect.go new file mode 100644 index 0000000..eec9485 --- /dev/null +++ b/internal/storage/inventory/collect.go @@ -0,0 +1,161 @@ +package inventory + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +// Collect inventories only explicitly supplied or project-local EPAR roots. +// Missing optional roots are empty inventory, while unsafe roots are skipped +// with warnings. +func Collect(options Options) (Snapshot, error) { + if err := options.validate(); err != nil { + return Snapshot{}, err + } + now := options.Now.UTC() + projectTarget, err := storage.SnapshotFilesystemTarget(options.ProjectRoot) + if err != nil { + return Snapshot{}, fmt.Errorf("inspect storage inventory project root: %w", err) + } + if projectTarget.Kind != storage.TargetDirectory { + return Snapshot{}, fmt.Errorf("storage inventory project root is not a real directory") + } + projectRoot := projectTarget.Locator + capacity, capacityErr := storage.ProbeFilesystemCapacity(projectRoot, now) + snapshot := Snapshot{ + CollectedAt: now, + ProjectRoot: projectRoot, + ProviderFilter: options.Provider, + Surfaces: []storage.Surface{{ + ID: ProjectSurfaceID, + Kind: storage.SurfaceHostFilesystem, + Location: projectRoot, + Capacity: capacity, + }}, + } + if capacityErr != nil { + snapshot.Surfaces[0].Capacity = storage.Capacity{ObservedAt: now} + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("project filesystem capacity is unknown: %v", capacityErr)) + } + + logsRoot := resolveRoot(projectRoot, options.LogsRoot, filepath.Join("work", "logs")) + nativeRoot := resolveRoot(projectRoot, options.NativeRoot, filepath.Join(".local", "bin")) + templateRoot := resolveRoot(projectRoot, options.TemplateRoot, filepath.Join("work", "template-builds", "docker-sandboxes")) + + if includeProvider(options.Provider, "") { + logsSurfaceID := ensureFilesystemSurface(&snapshot, logsRoot, projectRoot, "", now) + artifacts, warnings := collectLogs(logsRoot, projectRoot) + assignSurface(artifacts, logsSurfaceID) + snapshot.Artifacts = append(snapshot.Artifacts, artifacts...) + snapshot.Warnings = append(snapshot.Warnings, warnings...) + + nativeSurfaceID := ensureFilesystemSurface(&snapshot, nativeRoot, projectRoot, "", now) + artifacts, warnings = collectNative(nativeOptions{ + Root: nativeRoot, + CurrentExecutable: options.CurrentExecutable, + CurrentRevision: options.CurrentRevision, + }) + assignSurface(artifacts, nativeSurfaceID) + snapshot.Artifacts = append(snapshot.Artifacts, artifacts...) + snapshot.Warnings = append(snapshot.Warnings, warnings...) + } + if includeProvider(options.Provider, ProviderDockerSandboxes) { + templateSurfaceID := ensureFilesystemSurface(&snapshot, templateRoot, projectRoot, ProviderDockerSandboxes, now) + artifacts, warnings, err := collectTemplates(templateOptions{ + Root: templateRoot, + Selections: options.ConfiguredTemplates, + Protections: options.TemplateProtections, + }) + if err != nil { + return Snapshot{}, err + } + assignSurface(artifacts, templateSurfaceID) + snapshot.Artifacts = append(snapshot.Artifacts, artifacts...) + snapshot.Warnings = append(snapshot.Warnings, warnings...) + } + artifacts, warnings := collectConfiguredFiles(options.ConfiguredFiles, options.Provider, projectRoot, &snapshot, now) + snapshot.Artifacts = append(snapshot.Artifacts, artifacts...) + snapshot.Warnings = append(snapshot.Warnings, warnings...) + snapshot.normalize() + return snapshot, nil +} + +func assignSurface(artifacts []storage.Artifact, surfaceID string) { + for index := range artifacts { + artifacts[index].SurfaceID = surfaceID + } +} + +func ensureFilesystemSurface(snapshot *Snapshot, root, projectRoot, provider string, now time.Time) string { + absolute, err := filepath.Abs(root) + if err != nil { + absolute = root + } + absolute = filepath.Clean(absolute) + if pathWithin(projectRoot, absolute) { + return ProjectSurfaceID + } + id := stableID("filesystem", absolute) + for index := range snapshot.Surfaces { + if snapshot.Surfaces[index].ID == id { + if snapshot.Surfaces[index].Provider != provider { + snapshot.Surfaces[index].Provider = "" + } + return id + } + } + capacity, probeErr := storage.ProbeFilesystemCapacity(absolute, now) + if probeErr != nil { + capacity = storage.Capacity{ObservedAt: now} + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("filesystem capacity for configured root %q is unknown: %v", absolute, probeErr)) + } + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ + ID: id, + Provider: provider, + Kind: storage.SurfaceHostFilesystem, + Location: absolute, + Capacity: capacity, + }) + return id +} + +func pathWithin(root, candidate string) bool { + relative, err := filepath.Rel(root, candidate) + if err != nil { + return false + } + return relative == "." || (relative != ".." && !filepath.IsAbs(relative) && !strings.HasPrefix(relative, ".."+string(filepath.Separator))) +} + +func resolveRoot(projectRoot, configured, fallback string) string { + if configured == "" { + return filepath.Join(projectRoot, fallback) + } + if filepath.IsAbs(configured) { + return filepath.Clean(configured) + } + return filepath.Join(projectRoot, configured) +} + +func inspectOptionalRoot(path string) (storage.Target, bool, error) { + _, err := os.Lstat(path) + if os.IsNotExist(err) { + return storage.Target{}, false, nil + } + if err != nil { + return storage.Target{}, false, err + } + target, err := storage.SnapshotFilesystemTarget(path) + if err != nil { + return storage.Target{}, false, err + } + if target.Kind != storage.TargetDirectory { + return storage.Target{}, false, fmt.Errorf("%q is not a real directory", path) + } + return target, true, nil +} diff --git a/internal/storage/inventory/collect_test.go b/internal/storage/inventory/collect_test.go new file mode 100644 index 0000000..93d0100 --- /dev/null +++ b/internal/storage/inventory/collect_test.go @@ -0,0 +1,323 @@ +package inventory + +import ( + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func TestCollectDeterministicInventoryAndPreview(t *testing.T) { + t.Parallel() + project := t.TempDir() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + logs := filepath.Join(project, "work", "logs") + mustMkdirAll(t, logs) + mustWriteFile(t, filepath.Join(logs, "epar.log"), []byte("log")) + + nativeRoot := filepath.Join(project, ".local", "bin") + oldKey := repeatedHex("1") + currentKey := repeatedHex("2") + writeNativeRevision(t, nativeRoot, oldKey, now.Add(-16*24*time.Hour), false) + currentExecutable := writeNativeRevision(t, nativeRoot, currentKey, now.Add(-8*24*time.Hour), false) + ambiguousKey := repeatedHex("3") + mustMkdirAll(t, filepath.Join(nativeRoot, ambiguousKey)) + mustWriteFile(t, filepath.Join(nativeRoot, ambiguousKey, "ephemeral-action-runner"), []byte("unlabelled")) + + templateRoot := filepath.Join(project, "work", "template-builds", "docker-sandboxes") + oldTemplate := writeTemplateFixture(t, templateRoot, "old", "act-22.04", "linux/amd64", "old", now.Add(-16*24*time.Hour), nil) + currentTemplate := writeTemplateFixture(t, templateRoot, "current", "act-22.04", "linux/amd64", "current", now.Add(-8*24*time.Hour), nil) + + options := Options{ + ProjectRoot: project, + Now: now, + CurrentExecutable: currentExecutable, + CurrentRevision: "sha256:" + currentKey, + ConfiguredTemplates: []TemplateSelection{{ + Profile: currentTemplate.Profile, + Platform: currentTemplate.Platform, + Tag: currentTemplate.Tag, + TemplateDigest: currentTemplate.TemplateDigest, + ActivatedAt: now.Add(-8 * 24 * time.Hour), + }}, + TemplateProtections: []TemplateProtection{{ + ArchiveSHA256: oldTemplate.ArchiveSHA256, + Kind: storage.ProtectionCertification, + Detail: "release evidence", + }}, + } + first, err := Collect(options) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + second, err := Collect(options) + if err != nil { + t.Fatalf("second Collect() error = %v", err) + } + if first.CollectedAt != second.CollectedAt || first.ProjectRoot != second.ProjectRoot || first.ProviderFilter != second.ProviderFilter || !reflect.DeepEqual(first.Warnings, second.Warnings) { + t.Fatalf("Collect() stable metadata is nondeterministic:\nfirst=%+v\nsecond=%+v", first, second) + } + assertArtifactsEqual(t, first.Artifacts, second.Artifacts) + if len(first.Surfaces) != 1 || first.Surfaces[0].ID != ProjectSurfaceID || !first.Surfaces[0].Capacity.Known { + t.Fatalf("Collect() surfaces = %+v", first.Surfaces) + } + + currentNative := findArtifact(t, first.Artifacts, "native-controller:"+currentKey) + if !currentNative.Current || currentNative.Ownership.Kind != storage.OwnershipExact { + t.Fatalf("current native artifact = %+v", currentNative) + } + oldNative := findArtifact(t, first.Artifacts, "native-controller:"+oldKey) + if oldNative.SupersededAt == nil || !oldNative.SupersededAt.Equal(now.Add(-8*24*time.Hour)) { + t.Fatalf("old native supersededAt = %v", oldNative.SupersededAt) + } + unknownNative := findArtifactByLocatorSuffix(t, first.Artifacts, ambiguousKey) + if unknownNative.Ownership.Kind != storage.OwnershipUnknown { + t.Fatalf("unlabelled native ownership = %s", unknownNative.Ownership.Kind) + } + logArtifact := findArtifactByLocatorSuffix(t, first.Artifacts, "logs") + if !hasProtection(logArtifact, storage.ProtectionOperator) { + t.Fatalf("logs artifact protections = %+v", logArtifact.Protections) + } + currentArchive := findArtifactByArchiveDigest(t, first.Artifacts, currentTemplate.ArchiveSHA256) + if !currentArchive.Current || !hasProtection(currentArchive, storage.ProtectionConfiguration) { + t.Fatalf("current archive = %+v", currentArchive) + } + oldArchive := findArtifactByArchiveDigest(t, first.Artifacts, oldTemplate.ArchiveSHA256) + if !hasProtection(oldArchive, storage.ProtectionCertification) || oldArchive.SupersededAt == nil { + t.Fatalf("old archive = %+v", oldArchive) + } + + plan, err := storage.Preview(first.PreviewRequest(storage.DefaultPolicy(), nil)) + if err != nil { + t.Fatalf("Preview() inventory error = %v", err) + } + if actionForArtifact(plan, oldNative.ID) != storage.ActionRemove { + t.Fatalf("old native action = %s", actionForArtifact(plan, oldNative.ID)) + } + if actionForArtifact(plan, oldArchive.ID) != storage.ActionProtected { + t.Fatalf("certified archive action = %s", actionForArtifact(plan, oldArchive.ID)) + } +} + +func TestCollectProviderFilter(t *testing.T) { + t.Parallel() + project := t.TempDir() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + mustMkdirAll(t, filepath.Join(project, "work", "logs")) + writeNativeRevision(t, filepath.Join(project, ".local", "bin"), repeatedHex("a"), now.Add(-time.Hour), false) + writeTemplateFixture(t, filepath.Join(project, "work", "template-builds", "docker-sandboxes"), "template", "full", "linux/amd64", "current", now.Add(-time.Hour), nil) + + snapshot, err := Collect(Options{ProjectRoot: project, Provider: ProviderDockerSandboxes, Now: now}) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(snapshot.Artifacts) != 3 { + t.Fatalf("provider-filtered artifacts = %+v", snapshot.Artifacts) + } + var providerSpecific int + for _, artifact := range snapshot.Artifacts { + if artifact.Provider == ProviderDockerSandboxes { + providerSpecific++ + if artifact.Kind != storage.ArtifactTemplateArchive { + t.Fatalf("provider-specific artifact = %+v", artifact) + } + } else if artifact.Provider != "" { + t.Fatalf("unrelated provider artifact = %+v", artifact) + } + } + if providerSpecific != 1 { + t.Fatalf("provider-specific artifacts = %d", providerSpecific) + } + if len(snapshot.Surfaces) != 1 { + t.Fatalf("provider-filtered surfaces = %+v", snapshot.Surfaces) + } +} + +func TestCollectAssignsExternalConfiguredRootToSeparateCapacitySurface(t *testing.T) { + t.Parallel() + project := t.TempDir() + externalLogs := t.TempDir() + mustWriteFile(t, filepath.Join(externalLogs, "epar.log"), []byte("log")) + snapshot, err := Collect(Options{ + ProjectRoot: project, + LogsRoot: externalLogs, + Now: time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(snapshot.Surfaces) != 2 { + t.Fatalf("Collect() surfaces = %+v, want project and external", snapshot.Surfaces) + } + logArtifact := findArtifactByLocatorSuffix(t, snapshot.Artifacts, filepath.Base(externalLogs)) + if logArtifact.SurfaceID == ProjectSurfaceID || !hasProtection(logArtifact, storage.ProtectionCustomRoot) { + t.Fatalf("external logs artifact = %+v", logArtifact) + } + var found bool + for _, surface := range snapshot.Surfaces { + if surface.ID == logArtifact.SurfaceID && samePath(surface.Location, externalLogs) && surface.Capacity.Known { + found = true + } + } + if !found { + t.Fatalf("external logs surface %q not found in %+v", logArtifact.SurfaceID, snapshot.Surfaces) + } +} + +func TestCollectConfiguredProviderFilesUsesExactIdentityAndProtection(t *testing.T) { + t.Parallel() + project := t.TempDir() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + imagePath := filepath.Join(project, "work", "images", "runner.tar") + mustMkdirAll(t, filepath.Dir(imagePath)) + mustWriteFile(t, imagePath, []byte("reusable-wsl-image")) + + snapshot, err := Collect(Options{ + ProjectRoot: project, + Provider: "wsl", + Now: now, + ConfiguredFiles: []ConfiguredFile{{ + Provider: "wsl", + Role: "reusable-image", + Path: imagePath, + Kind: storage.ArtifactProviderImage, + Current: true, + ConfiguredAt: now.Add(-time.Hour), + ProtectionKind: storage.ProtectionConfiguration, + ProtectionDetail: "current reusable WSL image", + }}, + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + artifact := findArtifactByLocatorSuffix(t, snapshot.Artifacts, "runner.tar") + if artifact.Provider != "wsl" || artifact.Kind != storage.ArtifactProviderImage || artifact.SurfaceID != ProjectSurfaceID { + t.Fatalf("configured artifact = %+v", artifact) + } + if artifact.Ownership.Kind != storage.OwnershipExact || artifact.Target.Match != storage.MatchExact || artifact.Target.Identity == "" || artifact.Target.Fingerprint == "" { + t.Fatalf("configured artifact identity = %+v", artifact) + } + if artifact.SizeBytes != uint64(len("reusable-wsl-image")) || !artifact.Current || !hasProtection(artifact, storage.ProtectionConfiguration) || !hasProtection(artifact, storage.ProtectionCurrent) { + t.Fatalf("configured artifact protection = %+v", artifact) + } + plan, err := storage.Preview(snapshot.PreviewRequest(storage.DefaultPolicy(), nil)) + if err != nil { + t.Fatalf("Preview() error = %v", err) + } + if actionForArtifact(plan, artifact.ID) != storage.ActionProtected { + t.Fatalf("configured artifact action = %s", actionForArtifact(plan, artifact.ID)) + } +} + +func TestSnapshotPreviewRequestCopiesTopLevelSlices(t *testing.T) { + t.Parallel() + snapshot := Snapshot{ + CollectedAt: time.Now().UTC(), + Surfaces: []storage.Surface{{ID: ProjectSurfaceID}}, + Artifacts: []storage.Artifact{{ID: "artifact"}}, + } + request := snapshot.PreviewRequest(storage.DefaultPolicy(), []storage.Requirement{{ID: "requirement"}}) + request.Surfaces[0].ID = "changed" + request.Artifacts[0].ID = "changed" + if snapshot.Surfaces[0].ID != ProjectSurfaceID || snapshot.Artifacts[0].ID != "artifact" { + t.Fatal("PreviewRequest() aliased snapshot top-level slices") + } +} + +func findArtifact(t *testing.T, artifacts []storage.Artifact, id string) storage.Artifact { + t.Helper() + for _, artifact := range artifacts { + if artifact.ID == id { + return artifact + } + } + t.Fatalf("artifact %q not found in %+v", id, artifacts) + return storage.Artifact{} +} + +func findArtifactByLocatorSuffix(t *testing.T, artifacts []storage.Artifact, suffix string) storage.Artifact { + t.Helper() + for _, artifact := range artifacts { + if filepath.Base(artifact.Target.Locator) == suffix { + return artifact + } + } + t.Fatalf("artifact with locator suffix %q not found", suffix) + return storage.Artifact{} +} + +func findArtifactByKind(t *testing.T, artifacts []storage.Artifact, kind storage.ArtifactKind, provider string) storage.Artifact { + t.Helper() + for _, artifact := range artifacts { + if artifact.Kind == kind && artifact.Provider == provider { + return artifact + } + } + t.Fatalf("artifact kind=%q provider=%q not found", kind, provider) + return storage.Artifact{} +} + +func findArtifactByArchiveDigest(t *testing.T, artifacts []storage.Artifact, digest string) storage.Artifact { + t.Helper() + for _, artifact := range artifacts { + if artifact.Kind == storage.ArtifactTemplateArchive && artifact.Ownership.OwnerID == "template-archive:"+digest { + return artifact + } + } + t.Fatalf("archive artifact %q not found", digest) + return storage.Artifact{} +} + +func actionForArtifact(plan storage.Plan, id string) storage.Action { + for _, decision := range plan.Decisions { + if decision.Artifact.ID == id { + return decision.Action + } + } + return "" +} + +func hasProtection(artifact storage.Artifact, kind storage.ProtectionKind) bool { + for _, protection := range artifact.Protections { + if protection.Kind == kind { + return true + } + } + return false +} + +func repeatedHex(value string) string { + return value + value + value + value + value + value + value + value + + value + value + value + value + value + value + value + value + + value + value + value + value + value + value + value + value + + value + value + value + value + value + value + value + value + + value + value + value + value + value + value + value + value + + value + value + value + value + value + value + value + value + + value + value + value + value + value + value + value + value + + value + value + value + value + value + value + value + value +} + +func mustMkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } +} + +func mustWriteFile(t *testing.T, path string, data []byte) { + t.Helper() + mustMkdirAll(t, filepath.Dir(path)) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func assertArtifactsEqual(t *testing.T, left, right []storage.Artifact) { + t.Helper() + if !reflect.DeepEqual(left, right) { + t.Fatalf("artifacts differ:\nleft=%+v\nright=%+v", left, right) + } +} diff --git a/internal/storage/inventory/configured.go b/internal/storage/inventory/configured.go new file mode 100644 index 0000000..a2b6241 --- /dev/null +++ b/internal/storage/inventory/configured.go @@ -0,0 +1,110 @@ +package inventory + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func collectConfiguredFiles(files []ConfiguredFile, providerFilter, projectRoot string, snapshot *Snapshot, now time.Time) ([]storage.Artifact, []string) { + var artifacts []storage.Artifact + var warnings []string + for _, configured := range files { + if !includeProvider(providerFilter, configured.Provider) { + continue + } + path := resolveRoot(projectRoot, configured.Path, configured.Path) + info, err := os.Stat(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + artifacts = append(artifacts, unknownConfiguredFile(configured, path, err)) + warnings = append(warnings, fmt.Sprintf("configured %s artifact %q was not inventoried safely: %v", configured.Role, path, err)) + continue + } + if !info.Mode().IsRegular() { + err = fmt.Errorf("configured artifact is not a regular file") + artifacts = append(artifacts, unknownConfiguredFile(configured, path, err)) + warnings = append(warnings, fmt.Sprintf("configured %s artifact %q was not inventoried safely: %v", configured.Role, path, err)) + continue + } + target, err := storage.SnapshotFilesystemTarget(path) + if err != nil { + artifacts = append(artifacts, unknownConfiguredFile(configured, path, err)) + warnings = append(warnings, fmt.Sprintf("configured %s artifact %q was not inventoried safely: %v", configured.Role, path, err)) + continue + } + after, err := os.Stat(target.Locator) + if err != nil || !after.Mode().IsRegular() || after.Size() < 0 { + if err == nil { + err = fmt.Errorf("configured artifact changed while being inventoried") + } + artifacts = append(artifacts, unknownConfiguredFile(configured, path, err)) + warnings = append(warnings, fmt.Sprintf("configured %s artifact %q was not inventoried safely: %v", configured.Role, path, err)) + continue + } + confirmed, err := storage.SnapshotFilesystemTarget(target.Locator) + if err != nil || confirmed != target { + if err == nil { + err = fmt.Errorf("configured artifact identity or metadata drifted while being inventoried") + } + artifacts = append(artifacts, unknownConfiguredFile(configured, path, err)) + warnings = append(warnings, fmt.Sprintf("configured %s artifact %q was not inventoried safely: %v", configured.Role, path, err)) + continue + } + kind := configured.Kind + if kind == "" { + kind = storage.ArtifactOther + } + protectionKind := configured.ProtectionKind + if protectionKind == "" { + protectionKind = storage.ProtectionConfiguration + } + protectionDetail := strings.TrimSpace(configured.ProtectionDetail) + if protectionDetail == "" { + protectionDetail = "explicit provider artifact configuration" + } + surfaceID := ensureFilesystemSurface(snapshot, filepath.Dir(target.Locator), projectRoot, configured.Provider, now) + artifact := storage.Artifact{ + ID: stableID("configured-file", configured.Provider+"\x00"+configured.Role+"\x00"+target.Identity), + Provider: configured.Provider, + SurfaceID: surfaceID, + Kind: kind, + RetentionGroup: configured.Provider + ":" + configured.Role, + Target: target, + Ownership: storage.Ownership{ + Kind: storage.OwnershipExact, + OwnerID: stableID("configured-owner", configured.Provider+"\x00"+configured.Role+"\x00"+target.Identity), + Evidence: "exact path persisted in EPAR configuration", + }, + SizeBytes: uint64(after.Size()), + CreatedAt: after.ModTime().UTC(), + LastUsedAt: configured.ConfiguredAt.UTC(), + Current: configured.Current, + Protections: []storage.Protection{{ + Kind: protectionKind, + Detail: protectionDetail, + }}, + } + if artifact.Current { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionCurrent, Detail: "current configured generation"}) + } + artifacts = append(artifacts, artifact) + } + return artifacts, warnings +} + +func unknownConfiguredFile(configured ConfiguredFile, path string, cause error) storage.Artifact { + artifact := unknownEntryArtifact("configured-file", path, configured.Provider, false, configured.Kind, cause) + artifact.RetentionGroup = configured.Provider + ":" + configured.Role + artifact.Protections = append(artifact.Protections, storage.Protection{ + Kind: storage.ProtectionConfiguration, + Detail: "configured provider artifact could not be bound to an exact identity", + }) + return artifact +} diff --git a/internal/storage/inventory/doc.go b/internal/storage/inventory/doc.go new file mode 100644 index 0000000..e2ba2b4 --- /dev/null +++ b/internal/storage/inventory/doc.go @@ -0,0 +1,7 @@ +// Package inventory collects a deterministic, read-only inventory of known +// EPAR filesystem storage roots. +// +// Collection never invokes provider CLIs and never removes or rewrites host +// resources. Exact ownership is claimed only for artifacts that pass their +// complete recognition rules; ambiguous entries remain unknown and report-only. +package inventory diff --git a/internal/storage/inventory/filesystem.go b/internal/storage/inventory/filesystem.go new file mode 100644 index 0000000..e80153b --- /dev/null +++ b/internal/storage/inventory/filesystem.go @@ -0,0 +1,105 @@ +package inventory + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "math" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func hashFile(path string) (string, uint64, error) { + before, err := storage.SnapshotFilesystemTarget(path) + if err != nil { + return "", 0, err + } + if before.Kind != storage.TargetFile { + return "", 0, fmt.Errorf("%q is not a redirect-free regular file", path) + } + file, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer file.Close() + hash := sha256.New() + written, err := io.Copy(hash, file) + if err != nil { + return "", 0, err + } + if written < 0 { + return "", 0, fmt.Errorf("negative byte count while hashing %q", path) + } + after, err := storage.SnapshotFilesystemTarget(path) + if err != nil { + return "", 0, err + } + if after != before { + return "", 0, fmt.Errorf("file identity or metadata drifted while hashing %q", path) + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)), uint64(written), nil +} + +func hashBytes(data []byte) string { + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func directoryBytes(path string) (uint64, error) { + var total uint64 + err := filepath.WalkDir(path, func(current string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := entry.Info() + if err != nil { + return err + } + if isRedirect(info) { + return fmt.Errorf("storage inventory path %q contains a symlink, junction, or reparse point", current) + } + if entry.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("storage inventory path %q contains a non-regular file", current) + } + if info.Size() < 0 || uint64(info.Size()) > math.MaxUint64-total { + return fmt.Errorf("storage inventory byte count overflow at %q", current) + } + total += uint64(info.Size()) + return nil + }) + return total, err +} + +func stableID(prefix, value string) string { + sum := sha256.Sum256([]byte(value)) + return prefix + ":" + hex.EncodeToString(sum[:]) +} + +func samePath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func unknownTarget(path string, directory bool) storage.Target { + kind := storage.TargetFile + if directory { + kind = storage.TargetDirectory + } + absolute, err := filepath.Abs(path) + if err != nil { + absolute = path + } + return storage.Target{Kind: kind, Locator: filepath.Clean(absolute), Match: storage.MatchUnknown} +} diff --git a/internal/storage/inventory/filesystem_unix.go b/internal/storage/inventory/filesystem_unix.go new file mode 100644 index 0000000..c3ec548 --- /dev/null +++ b/internal/storage/inventory/filesystem_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package inventory + +import "os" + +func isRedirect(info os.FileInfo) bool { return info.Mode()&os.ModeSymlink != 0 } diff --git a/internal/storage/inventory/filesystem_windows.go b/internal/storage/inventory/filesystem_windows.go new file mode 100644 index 0000000..f0618e8 --- /dev/null +++ b/internal/storage/inventory/filesystem_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package inventory + +import ( + "os" + "syscall" +) + +func isRedirect(info os.FileInfo) bool { + if info.Mode()&os.ModeSymlink != 0 { + return true + } + data, ok := info.Sys().(*syscall.Win32FileAttributeData) + return ok && data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 +} diff --git a/internal/storage/inventory/logs.go b/internal/storage/inventory/logs.go new file mode 100644 index 0000000..587b1b8 --- /dev/null +++ b/internal/storage/inventory/logs.go @@ -0,0 +1,71 @@ +package inventory + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func collectLogs(root, projectRoot string) ([]storage.Artifact, []string) { + target, exists, err := inspectOptionalRoot(root) + if !exists { + if err == nil { + return nil, nil + } + artifact := unknownRootArtifact("logs", root, "", err) + return []storage.Artifact{artifact}, []string{fmt.Sprintf("logs root was not inventoried safely: %v", err)} + } + info, err := os.Lstat(target.Locator) + if err != nil { + return nil, []string{fmt.Sprintf("logs root metadata is unavailable: %v", err)} + } + size, sizeErr := directoryBytes(target.Locator) + ownership := storage.Ownership{ + Kind: storage.OwnershipExact, + OwnerID: stableID("epar-logs", projectRoot), + Evidence: "explicit EPAR logging root", + } + protections := []storage.Protection{{Kind: storage.ProtectionOperator, Detail: "logging subsystem owns retention"}} + var warnings []string + if sizeErr != nil { + size = 0 + ownership = storage.Ownership{Kind: storage.OwnershipUnknown} + protections = append(protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "unsafe or unreadable descendant"}) + warnings = append(warnings, fmt.Sprintf("logs root size and ownership are uncertain: %v", sizeErr)) + } + defaultRoot := filepath.Join(projectRoot, "work", "logs") + if !samePath(target.Locator, defaultRoot) { + protections = append(protections, storage.Protection{Kind: storage.ProtectionCustomRoot, Detail: "configured logging root"}) + } + return []storage.Artifact{{ + ID: stableID("logs", target.Locator), + SurfaceID: ProjectSurfaceID, + Kind: storage.ArtifactOther, + Target: target, + Ownership: ownership, + SizeBytes: size, + CreatedAt: info.ModTime().UTC(), + Protections: protections, + }}, warnings +} + +func unknownRootArtifact(kind, root, provider string, cause error) storage.Artifact { + detail := "root identity is unsafe or unreadable" + if cause != nil { + detail = cause.Error() + } + return storage.Artifact{ + ID: stableID(kind+"-unknown", root), + Provider: provider, + SurfaceID: ProjectSurfaceID, + Kind: storage.ArtifactOther, + Target: unknownTarget(root, true), + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown}, + Protections: []storage.Protection{{ + Kind: storage.ProtectionUncertain, + Detail: detail, + }}, + } +} diff --git a/internal/storage/inventory/logs_test.go b/internal/storage/inventory/logs_test.go new file mode 100644 index 0000000..fa58e1a --- /dev/null +++ b/internal/storage/inventory/logs_test.go @@ -0,0 +1,40 @@ +package inventory + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func TestCollectLogsProtectsSubsystemOwnedRoot(t *testing.T) { + t.Parallel() + project := t.TempDir() + root := filepath.Join(project, "work", "logs") + mustWriteFile(t, filepath.Join(root, "instances", "runner.log"), []byte("runner")) + artifacts, warnings := collectLogs(root, project) + if len(warnings) != 0 || len(artifacts) != 1 { + t.Fatalf("collectLogs() artifacts=%+v warnings=%v", artifacts, warnings) + } + if artifacts[0].Ownership.Kind != storage.OwnershipExact || artifacts[0].SizeBytes != uint64(len("runner")) || !hasProtection(artifacts[0], storage.ProtectionOperator) { + t.Fatalf("logs artifact = %+v", artifacts[0]) + } +} + +func TestCollectLogsRejectsRedirectedDescendant(t *testing.T) { + t.Parallel() + project := t.TempDir() + root := filepath.Join(project, "work", "logs") + mustMkdirAll(t, root) + outside := t.TempDir() + link := filepath.Join(root, "redirect") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlink creation unavailable on %s: %v", runtime.GOOS, err) + } + artifacts, warnings := collectLogs(root, project) + if len(warnings) != 1 || len(artifacts) != 1 || artifacts[0].Ownership.Kind != storage.OwnershipUnknown || !hasProtection(artifacts[0], storage.ProtectionUncertain) { + t.Fatalf("collectLogs() artifacts=%+v warnings=%v", artifacts, warnings) + } +} diff --git a/internal/storage/inventory/native.go b/internal/storage/inventory/native.go new file mode 100644 index 0000000..ac3ea3b --- /dev/null +++ b/internal/storage/inventory/native.go @@ -0,0 +1,297 @@ +package inventory + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +var ( + cacheKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + leasePattern = regexp.MustCompile(`^lease(?:[.-]).+$`) +) + +type nativeOptions struct { + Root string + CurrentExecutable string + CurrentRevision string +} + +type nativeRevision struct { + artifact storage.Artifact + key string + completed time.Time + binary storage.Target +} + +func collectNative(options nativeOptions) ([]storage.Artifact, []string) { + rootTarget, exists, err := inspectOptionalRoot(options.Root) + if !exists { + if err == nil { + return nil, nil + } + artifact := unknownRootArtifact("native-controller", options.Root, "", err) + return []storage.Artifact{artifact}, []string{fmt.Sprintf("native-controller root was not inventoried safely: %v", err)} + } + entries, err := os.ReadDir(rootTarget.Locator) + if err != nil { + artifact := unknownRootArtifact("native-controller", rootTarget.Locator, "", err) + return []storage.Artifact{artifact}, []string{fmt.Sprintf("native-controller root is unreadable: %v", err)} + } + + currentKey, keyWarning := normalizeCurrentRevision(options.CurrentRevision) + var currentExecutable storage.Target + var warnings []string + if keyWarning != "" { + warnings = append(warnings, keyWarning) + } + if options.CurrentExecutable != "" { + currentExecutable, err = storage.SnapshotFilesystemTarget(options.CurrentExecutable) + if err != nil || currentExecutable.Kind != storage.TargetFile { + warnings = append(warnings, fmt.Sprintf("current native-controller executable is not an exact regular file: %v", err)) + currentExecutable = storage.Target{} + } + } + + var revisions []nativeRevision + var artifacts []storage.Artifact + for _, entry := range entries { + path := filepath.Join(rootTarget.Locator, entry.Name()) + info, infoErr := entry.Info() + if infoErr != nil || isRedirect(info) { + artifact := unknownEntryArtifact("native-controller", path, "", entry.IsDir(), storage.ArtifactOther, infoErr) + artifacts = append(artifacts, artifact) + warnings = append(warnings, fmt.Sprintf("native-controller entry %q is unsafe or unreadable", entry.Name())) + continue + } + if !entry.IsDir() || !cacheKeyPattern.MatchString(entry.Name()) { + artifact := unknownEntryArtifact("native-controller", path, "", entry.IsDir(), storage.ArtifactOther, nil) + artifacts = append(artifacts, artifact) + continue + } + revision, revisionErr := inspectNativeRevision(path, entry.Name()) + if revisionErr != nil { + artifact := unknownEntryArtifact("native-controller-revision", path, "", true, storage.ArtifactNativeControllerRevision, revisionErr) + if currentKey == entry.Name() { + artifact.Current = true + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionCurrent, Detail: "configured current revision has uncertain ownership"}) + } + artifacts = append(artifacts, artifact) + warnings = append(warnings, fmt.Sprintf("native-controller revision %q remains ownership-unknown: %v", entry.Name(), revisionErr)) + continue + } + revisions = append(revisions, revision) + } + + var currentIndexes []int + for index, revision := range revisions { + matchesKey := currentKey != "" && revision.key == currentKey + matchesExecutable := currentExecutable.Identity != "" && revision.binary.Identity == currentExecutable.Identity && revision.binary.Fingerprint == currentExecutable.Fingerprint + if matchesKey || matchesExecutable { + currentIndexes = append(currentIndexes, index) + } + } + if len(currentIndexes) > 1 { + warnings = append(warnings, "native-controller current identity is ambiguous; all recognized revisions are protected") + for index := range revisions { + revisions[index].artifact.Protections = append(revisions[index].artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "ambiguous current revision"}) + } + } else if len(currentIndexes) == 1 { + currentIndex := currentIndexes[0] + current := revisions[currentIndex] + revisions[currentIndex].artifact.Current = true + revisions[currentIndex].artifact.Protections = append(revisions[currentIndex].artifact.Protections, storage.Protection{Kind: storage.ProtectionCurrent, Detail: "explicit current native-controller identity"}) + for index := range revisions { + if index == currentIndex || !revisions[index].completed.Before(current.completed) { + continue + } + supersededAt := current.completed + revisions[index].artifact.SupersededAt = &supersededAt + } + } else if currentKey != "" || currentExecutable.Identity != "" { + warnings = append(warnings, "explicit current native-controller identity did not match a recognized revision") + } + for _, revision := range revisions { + artifacts = append(artifacts, revision.artifact) + } + return artifacts, warnings +} + +func inspectNativeRevision(path, cacheKey string) (nativeRevision, error) { + entries, err := os.ReadDir(path) + if err != nil { + return nativeRevision{}, err + } + var manifestPath, executableName string + var leaseNames []string + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return nativeRevision{}, err + } + if entry.IsDir() || !info.Mode().IsRegular() || isRedirect(info) { + return nativeRevision{}, fmt.Errorf("contains a directory, special file, or redirect at %q", entry.Name()) + } + switch { + case entry.Name() == "controller-cache.manifest": + manifestPath = filepath.Join(path, entry.Name()) + case entry.Name() == "ephemeral-action-runner" || entry.Name() == "ephemeral-action-runner.exe": + if executableName != "" { + return nativeRevision{}, fmt.Errorf("contains multiple controller executables") + } + executableName = entry.Name() + case leasePattern.MatchString(entry.Name()): + leaseNames = append(leaseNames, entry.Name()) + default: + return nativeRevision{}, fmt.Errorf("contains unexpected file %q", entry.Name()) + } + } + if manifestPath == "" || executableName == "" { + return nativeRevision{}, fmt.Errorf("requires one manifest and one controller executable") + } + fields, err := parseManifest(manifestPath) + if err != nil { + return nativeRevision{}, err + } + if fields["schemaVersion"] != "1" || fields["cacheKey"] != cacheKey || fields["executable"] != executableName { + return nativeRevision{}, fmt.Errorf("manifest identity does not match the revision directory") + } + var completed time.Time + if value, exists := fields["completedAtUnix"]; exists { + completedUnix, err := strconv.ParseInt(value, 10, 64) + if err != nil || completedUnix <= 0 { + return nativeRevision{}, fmt.Errorf("manifest completedAtUnix is invalid") + } + completed = time.Unix(completedUnix, 0).UTC() + } else { + completed, err = time.Parse(time.RFC3339Nano, fields["completedAtUtc"]) + if err != nil { + return nativeRevision{}, fmt.Errorf("manifest completedAtUtc is invalid") + } + completed = completed.UTC() + } + target, err := storage.SnapshotFilesystemTarget(path) + if err != nil { + return nativeRevision{}, err + } + binary, err := storage.SnapshotFilesystemTarget(filepath.Join(path, executableName)) + if err != nil { + return nativeRevision{}, err + } + manifestSHA, _, err := hashFile(manifestPath) + if err != nil { + return nativeRevision{}, err + } + size, err := directoryBytes(path) + if err != nil { + return nativeRevision{}, err + } + artifact := storage.Artifact{ + ID: "native-controller:" + cacheKey, + SurfaceID: ProjectSurfaceID, + Kind: storage.ArtifactNativeControllerRevision, + RetentionGroup: "native-controller", + Target: target, + Ownership: storage.Ownership{ + Kind: storage.OwnershipExact, + OwnerID: "native-controller:" + cacheKey, + Evidence: "controller-cache.manifest@" + manifestSHA, + }, + SizeBytes: size, + CreatedAt: completed, + } + if len(leaseNames) > 0 { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionLease, Detail: "revision contains one or more unexpired-status-unknown leases"}) + } + return nativeRevision{artifact: artifact, key: cacheKey, completed: completed, binary: binary}, nil +} + +func parseManifest(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + fields := make(map[string]string, 4) + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024), 16*1024) + for scanner.Scan() { + line := scanner.Text() + key, value, ok := strings.Cut(line, "=") + if !ok || key == "" || value == "" { + return nil, fmt.Errorf("manifest contains an invalid field") + } + if _, exists := fields[key]; exists { + return nil, fmt.Errorf("manifest contains duplicate field %q", key) + } + fields[key] = value + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(fields) != 4 { + return nil, fmt.Errorf("manifest must contain exactly four fields") + } + for _, key := range []string{"schemaVersion", "cacheKey", "executable"} { + if _, exists := fields[key]; !exists { + return nil, fmt.Errorf("manifest is missing %q", key) + } + } + _, hasUnix := fields["completedAtUnix"] + _, hasUTC := fields["completedAtUtc"] + if hasUnix == hasUTC { + return nil, fmt.Errorf("manifest must contain exactly one completion timestamp") + } + return fields, nil +} + +func normalizeCurrentRevision(value string) (string, string) { + if value == "" { + return "", "" + } + value = strings.TrimPrefix(value, "dirty:") + value = strings.TrimPrefix(value, "sha256:") + if !cacheKeyPattern.MatchString(value) { + return "", fmt.Sprintf("current native-controller revision %q is not an exact 64-character SHA-256 identity", value) + } + return value, "" +} + +func unknownEntryArtifact(prefix, path, provider string, directory bool, kind storage.ArtifactKind, cause error) storage.Artifact { + target, err := storage.SnapshotFilesystemTarget(path) + if err != nil { + target = unknownTarget(path, directory) + } + var size uint64 + if target.Match == storage.MatchExact { + if directory { + size, _ = directoryBytes(path) + } else if info, statErr := os.Lstat(path); statErr == nil && info.Size() >= 0 { + size = uint64(info.Size()) + } + } + detail := "entry does not satisfy an exact EPAR ownership schema" + if cause != nil { + detail = cause.Error() + } + return storage.Artifact{ + ID: stableID(prefix+"-unknown", path), + Provider: provider, + SurfaceID: ProjectSurfaceID, + Kind: kind, + Target: target, + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown}, + SizeBytes: size, + Protections: []storage.Protection{{ + Kind: storage.ProtectionUncertain, + Detail: detail, + }}, + } +} diff --git a/internal/storage/inventory/native_test.go b/internal/storage/inventory/native_test.go new file mode 100644 index 0000000..064ccc3 --- /dev/null +++ b/internal/storage/inventory/native_test.go @@ -0,0 +1,135 @@ +package inventory + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func TestCollectNativeRecognitionLeaseAndMalformedManifest(t *testing.T) { + t.Parallel() + root := t.TempDir() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + validKey := repeatedHex("4") + writeNativeRevision(t, root, validKey, now.Add(-10*24*time.Hour), true) + badKey := repeatedHex("5") + badDirectory := filepath.Join(root, badKey) + mustMkdirAll(t, badDirectory) + mustWriteFile(t, filepath.Join(badDirectory, "ephemeral-action-runner"), []byte("binary")) + mustWriteFile(t, filepath.Join(badDirectory, "controller-cache.manifest"), []byte("schemaVersion=1\ncacheKey="+badKey+"\ncacheKey="+badKey+"\nexecutable=ephemeral-action-runner\ncompletedAtUnix=1\n")) + + artifacts, warnings := collectNative(nativeOptions{Root: root}) + if len(artifacts) != 2 || len(warnings) != 1 { + t.Fatalf("collectNative() artifacts=%+v warnings=%v", artifacts, warnings) + } + valid := findArtifact(t, artifacts, "native-controller:"+validKey) + if valid.Ownership.Kind != storage.OwnershipExact || !hasProtection(valid, storage.ProtectionLease) { + t.Fatalf("valid leased revision = %+v", valid) + } + bad := findArtifactByLocatorSuffix(t, artifacts, badKey) + if bad.Ownership.Kind != storage.OwnershipUnknown || !hasProtection(bad, storage.ProtectionUncertain) { + t.Fatalf("malformed revision = %+v", bad) + } +} + +func TestCollectNativeCurrentExecutableIdentity(t *testing.T) { + t.Parallel() + root := t.TempDir() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + oldKey := repeatedHex("6") + currentKey := repeatedHex("7") + writeNativeRevision(t, root, oldKey, now.Add(-20*24*time.Hour), false) + currentExecutable := writeNativeRevision(t, root, currentKey, now.Add(-10*24*time.Hour), false) + artifacts, warnings := collectNative(nativeOptions{Root: root, CurrentExecutable: currentExecutable}) + if len(warnings) != 0 { + t.Fatalf("collectNative() warnings = %v", warnings) + } + current := findArtifact(t, artifacts, "native-controller:"+currentKey) + old := findArtifact(t, artifacts, "native-controller:"+oldKey) + if !current.Current || old.SupersededAt == nil || !old.SupersededAt.Equal(now.Add(-10*24*time.Hour)) { + t.Fatalf("current=%+v old=%+v", current, old) + } +} + +func TestCollectNativeRejectsSymlinkedRevision(t *testing.T) { + t.Parallel() + root := t.TempDir() + outside := t.TempDir() + link := filepath.Join(root, repeatedHex("8")) + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlink creation unavailable on %s: %v", runtime.GOOS, err) + } + artifacts, warnings := collectNative(nativeOptions{Root: root}) + if len(artifacts) != 1 || len(warnings) != 1 || artifacts[0].Ownership.Kind != storage.OwnershipUnknown || artifacts[0].Target.Match != storage.MatchUnknown { + t.Fatalf("collectNative() symlink artifacts=%+v warnings=%v", artifacts, warnings) + } +} + +func TestParseManifestRejectsUnknownAndMissingFields(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "manifest") + for name, content := range map[string]string{ + "unknown": "schemaVersion=1\ncacheKey=" + repeatedHex("9") + "\nexecutable=ephemeral-action-runner\ncompletedAtUnix=1\nother=value\n", + "missing": "schemaVersion=1\ncacheKey=" + repeatedHex("9") + "\nexecutable=ephemeral-action-runner\n", + } { + t.Run(name, func(t *testing.T) { + mustWriteFile(t, path, []byte(content)) + if _, err := parseManifest(path); err == nil { + t.Fatalf("parseManifest() accepted %s fields", name) + } + }) + } +} + +func TestInspectNativeRevisionAcceptsWindowsUTCManifest(t *testing.T) { + t.Parallel() + root := t.TempDir() + key := repeatedHex("b") + directory := filepath.Join(root, key) + mustMkdirAll(t, directory) + mustWriteFile(t, filepath.Join(directory, "ephemeral-action-runner.exe"), []byte("binary")) + completed := time.Date(2026, 7, 27, 12, 0, 0, 123, time.UTC) + manifest := fmt.Sprintf("schemaVersion=1\ncacheKey=%s\nexecutable=ephemeral-action-runner.exe\ncompletedAtUtc=%s\n", key, completed.Format(time.RFC3339Nano)) + mustWriteFile(t, filepath.Join(directory, "controller-cache.manifest"), []byte(manifest)) + revision, err := inspectNativeRevision(directory, key) + if err != nil { + t.Fatalf("inspectNativeRevision() error = %v", err) + } + if !revision.completed.Equal(completed) || revision.artifact.Ownership.Kind != storage.OwnershipExact { + t.Fatalf("inspectNativeRevision() = %+v", revision) + } +} + +func TestNormalizeCurrentRevisionAcceptsInjectedSourceRevisionForms(t *testing.T) { + t.Parallel() + key := repeatedHex("c") + for _, value := range []string{key, "sha256:" + key, "dirty:sha256:" + key} { + got, warning := normalizeCurrentRevision(value) + if got != key || warning != "" { + t.Fatalf("normalizeCurrentRevision(%q) = %q, %q", value, got, warning) + } + } +} + +func writeNativeRevision(t *testing.T, root, key string, completed time.Time, lease bool) string { + t.Helper() + directory := filepath.Join(root, key) + mustMkdirAll(t, directory) + executable := "ephemeral-action-runner" + if runtime.GOOS == "windows" { + executable += ".exe" + } + binary := filepath.Join(directory, executable) + mustWriteFile(t, binary, []byte("binary:"+key)) + manifest := fmt.Sprintf("schemaVersion=1\ncacheKey=%s\nexecutable=%s\ncompletedAtUnix=%d\n", key, executable, completed.Unix()) + mustWriteFile(t, filepath.Join(directory, "controller-cache.manifest"), []byte(manifest)) + if lease { + mustWriteFile(t, filepath.Join(directory, "lease.123.abcdef"), []byte("schemaVersion=1\n")) + } + return binary +} diff --git a/internal/storage/inventory/template.go b/internal/storage/inventory/template.go new file mode 100644 index 0000000..5b8474a --- /dev/null +++ b/internal/storage/inventory/template.go @@ -0,0 +1,375 @@ +package inventory + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +const maximumTemplateMetadataBytes = 4 << 20 + +var ( + templateDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + templateCacheIDPattern = regexp.MustCompile(`^[0-9a-f]{12}$`) + templateTagPattern = regexp.MustCompile(`^(?:docker\.io/library/)?epar-docker-sandboxes-[a-z0-9._-]+:[a-z0-9._-]+$`) + templateProfilePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + templateArchivePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.tar$`) +) + +type templateOptions struct { + Root string + Selections []TemplateSelection + Protections []TemplateProtection +} + +type templateMetadata struct { + SchemaVersion int `json:"schemaVersion"` + Profile string `json:"profile"` + Platform string `json:"platform"` + Template struct { + Tag string `json:"tag"` + Digest string `json:"digest"` + TemplateDigest string `json:"templateDigest"` + CacheID string `json:"cacheID"` + Archive string `json:"archive"` + ArchiveSHA256 string `json:"archiveSha256"` + ArchiveBytes uint64 `json:"archiveBytes"` + } `json:"template"` + Compatibility struct { + SupportedSbxVersions []string `json:"supportedSbxVersions"` + Candidate string `json:"candidate"` + DockerDaemonOwner string `json:"dockerDaemonOwner"` + ExpectedDockerDaemonCount int `json:"expectedDockerDaemonCount"` + } `json:"compatibility"` +} + +type templateRecord struct { + artifact storage.Artifact + metadata templateMetadata + metadataSHA256 string +} + +func collectTemplates(options templateOptions) ([]storage.Artifact, []string, error) { + if err := validateTemplateInputs(options); err != nil { + return nil, nil, err + } + rootTarget, exists, err := inspectOptionalRoot(options.Root) + if !exists { + if err == nil { + return nil, nil, nil + } + artifact := unknownRootArtifact("template-archive", options.Root, ProviderDockerSandboxes, err) + return []storage.Artifact{artifact}, []string{fmt.Sprintf("Docker Sandboxes template root was not inventoried safely: %v", err)}, nil + } + entries, err := os.ReadDir(rootTarget.Locator) + if err != nil { + artifact := unknownRootArtifact("template-archive", rootTarget.Locator, ProviderDockerSandboxes, err) + return []storage.Artifact{artifact}, []string{fmt.Sprintf("Docker Sandboxes template root is unreadable: %v", err)}, nil + } + protections := make(map[string][]storage.Protection) + for _, protection := range options.Protections { + protections[protection.ArchiveSHA256] = append(protections[protection.ArchiveSHA256], storage.Protection{Kind: protection.Kind, Detail: protection.Detail}) + } + var records []templateRecord + var artifacts []storage.Artifact + var warnings []string + for _, entry := range entries { + path := filepath.Join(rootTarget.Locator, entry.Name()) + info, infoErr := entry.Info() + if infoErr != nil || isRedirect(info) || !entry.IsDir() { + artifact := unknownEntryArtifact("template-archive", path, ProviderDockerSandboxes, entry.IsDir(), storage.ArtifactOther, infoErr) + artifacts = append(artifacts, artifact) + warnings = append(warnings, fmt.Sprintf("Docker Sandboxes template entry %q is not a safe artifact directory", entry.Name())) + continue + } + record, unknown, inspectErr := inspectTemplateDirectory(path) + if inspectErr != nil { + artifacts = append(artifacts, unknown) + warnings = append(warnings, fmt.Sprintf("Docker Sandboxes template directory %q remains ownership-unknown: %v", entry.Name(), inspectErr)) + continue + } + record.artifact.Protections = append(record.artifact.Protections, protections[record.metadata.Template.ArchiveSHA256]...) + records = append(records, record) + } + applyTemplateSelections(records, options.Selections, &warnings) + for index := range records { + sortTemplateProtections(records[index].artifact.Protections) + record := records[index] + artifacts = append(artifacts, record.artifact) + } + return artifacts, warnings, nil +} + +func inspectTemplateDirectory(path string) (templateRecord, storage.Artifact, error) { + metadataPath := filepath.Join(path, "template-metadata.json") + metadataTarget, err := storage.SnapshotFilesystemTarget(metadataPath) + if err != nil || metadataTarget.Kind != storage.TargetFile { + cause := err + if cause == nil { + cause = fmt.Errorf("template metadata is not a regular file") + } + unknown := unknownEntryArtifact("template-directory", path, ProviderDockerSandboxes, true, storage.ArtifactOther, cause) + return templateRecord{}, unknown, fmt.Errorf("template metadata is missing or unsafe: %w", cause) + } + data, err := readBoundedFile(metadataTarget.Locator, maximumTemplateMetadataBytes) + if err != nil { + unknown := unknownEntryArtifact("template-directory", path, ProviderDockerSandboxes, true, storage.ArtifactOther, err) + return templateRecord{}, unknown, err + } + if err := validateUniqueJSON(data); err != nil { + unknown := unknownEntryArtifact("template-directory", path, ProviderDockerSandboxes, true, storage.ArtifactOther, err) + return templateRecord{}, unknown, fmt.Errorf("invalid template metadata JSON: %w", err) + } + var metadata templateMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + unknown := unknownEntryArtifact("template-directory", path, ProviderDockerSandboxes, true, storage.ArtifactOther, err) + return templateRecord{}, unknown, fmt.Errorf("decode template metadata: %w", err) + } + if err := validateTemplateMetadata(metadata); err != nil { + unknown := unknownEntryArtifact("template-directory", path, ProviderDockerSandboxes, true, storage.ArtifactOther, err) + return templateRecord{}, unknown, err + } + metadataSHA256, _, err := hashFile(metadataTarget.Locator) + if err != nil { + unknown := unknownEntryArtifact("template-directory", path, ProviderDockerSandboxes, true, storage.ArtifactOther, err) + return templateRecord{}, unknown, err + } + if metadataSHA256 != hashBytes(data) { + err := fmt.Errorf("template metadata drifted while being inspected") + unknown := unknownEntryArtifact("template-directory", path, ProviderDockerSandboxes, true, storage.ArtifactOther, err) + return templateRecord{}, unknown, err + } + archivePath := filepath.Join(path, metadata.Template.Archive) + archiveTarget, targetErr := storage.SnapshotFilesystemTarget(archivePath) + if targetErr != nil || archiveTarget.Kind != storage.TargetFile { + cause := targetErr + if cause == nil { + cause = fmt.Errorf("template archive is not a regular file") + } + unknown := unknownEntryArtifact("template-archive", archivePath, ProviderDockerSandboxes, false, storage.ArtifactTemplateArchive, cause) + return templateRecord{}, unknown, fmt.Errorf("template archive is missing or unsafe: %w", cause) + } + actualSHA256, actualBytes, hashErr := hashFile(archiveTarget.Locator) + info, statErr := os.Lstat(archiveTarget.Locator) + if hashErr != nil || statErr != nil || actualSHA256 != metadata.Template.ArchiveSHA256 || actualBytes != metadata.Template.ArchiveBytes { + cause := fmt.Errorf("archive integrity mismatch: expected sha256=%s bytes=%d, got sha256=%s bytes=%d", metadata.Template.ArchiveSHA256, metadata.Template.ArchiveBytes, actualSHA256, actualBytes) + if hashErr != nil { + cause = hashErr + } else if statErr != nil { + cause = statErr + } + unknown := unknownEntryArtifact("template-archive", archiveTarget.Locator, ProviderDockerSandboxes, false, storage.ArtifactTemplateArchive, cause) + unknown.SizeBytes = actualBytes + return templateRecord{}, unknown, cause + } + artifact := storage.Artifact{ + ID: stableID("template-archive", archiveTarget.Locator+"@"+actualSHA256), + Provider: ProviderDockerSandboxes, + SurfaceID: ProjectSurfaceID, + Kind: storage.ArtifactTemplateArchive, + RetentionGroup: metadata.Profile + "/" + metadata.Platform, + Target: archiveTarget, + Ownership: storage.Ownership{ + Kind: storage.OwnershipExact, + OwnerID: "template-archive:" + actualSHA256, + Evidence: "template-metadata.json@" + metadataSHA256, + }, + SizeBytes: actualBytes, + CreatedAt: info.ModTime().UTC(), + } + return templateRecord{artifact: artifact, metadata: metadata, metadataSHA256: metadataSHA256}, storage.Artifact{}, nil +} + +func validateTemplateMetadata(metadata templateMetadata) error { + if metadata.SchemaVersion != 2 { + return fmt.Errorf("template metadata schemaVersion must be 2") + } + if !templateProfilePattern.MatchString(metadata.Profile) { + return fmt.Errorf("template metadata profile is invalid") + } + if metadata.Platform != "linux/amd64" && metadata.Platform != "linux/arm64" { + return fmt.Errorf("template metadata platform is invalid") + } + if !templateTagPattern.MatchString(metadata.Template.Tag) || !templateDigestPattern.MatchString(metadata.Template.Digest) || !templateDigestPattern.MatchString(metadata.Template.TemplateDigest) || !templateCacheIDPattern.MatchString(metadata.Template.CacheID) { + return fmt.Errorf("template metadata contains an invalid tag, digest, template identity, or cache ID") + } + if metadata.Template.CacheID != strings.TrimPrefix(metadata.Template.TemplateDigest, "sha256:")[:12] { + return fmt.Errorf("template metadata cache ID does not match template identity") + } + if !templateArchivePattern.MatchString(metadata.Template.Archive) || filepath.Base(metadata.Template.Archive) != metadata.Template.Archive { + return fmt.Errorf("template metadata archive is not an exact basename") + } + if !templateDigestPattern.MatchString(metadata.Template.ArchiveSHA256) || metadata.Template.ArchiveBytes == 0 { + return fmt.Errorf("template metadata archive digest or size is invalid") + } + if len(metadata.Compatibility.SupportedSbxVersions) != 1 || metadata.Compatibility.SupportedSbxVersions[0] != "0.35.0" || metadata.Compatibility.Candidate != "A" || metadata.Compatibility.DockerDaemonOwner != "docker-sandboxes-runtime" || metadata.Compatibility.ExpectedDockerDaemonCount != 1 { + return fmt.Errorf("template metadata compatibility is not Candidate A for sbx v0.35.0") + } + return nil +} + +func validateTemplateInputs(options templateOptions) error { + seenGroups := make(map[string]struct{}) + for _, selection := range options.Selections { + if (selection.Profile != "" && !templateProfilePattern.MatchString(selection.Profile)) || (selection.Platform != "" && selection.Platform != "linux/amd64" && selection.Platform != "linux/arm64") || !templateTagPattern.MatchString(selection.Tag) || !templateDigestPattern.MatchString(selection.TemplateDigest) { + return fmt.Errorf("configured template selection has an invalid exact identity") + } + if selection.MetadataSHA256 != "" && !templateDigestPattern.MatchString(selection.MetadataSHA256) { + return fmt.Errorf("configured template selection metadata digest is invalid") + } + group := normalizedTemplateTag(selection.Tag) + "@" + selection.TemplateDigest + if _, exists := seenGroups[group]; exists { + return fmt.Errorf("duplicate configured template selection exists for %q", group) + } + seenGroups[group] = struct{}{} + } + for _, protection := range options.Protections { + if !templateDigestPattern.MatchString(protection.ArchiveSHA256) || protection.Kind == "" { + return fmt.Errorf("template protection has an invalid archive digest or kind") + } + } + return nil +} + +func applyTemplateSelections(records []templateRecord, selections []TemplateSelection, warnings *[]string) { + for _, selection := range selections { + var matches []int + for index := range records { + record := records[index] + if (selection.Profile == "" || record.metadata.Profile == selection.Profile) && (selection.Platform == "" || record.metadata.Platform == selection.Platform) && normalizedTemplateTag(record.metadata.Template.Tag) == normalizedTemplateTag(selection.Tag) && record.metadata.Template.TemplateDigest == selection.TemplateDigest && (selection.MetadataSHA256 == "" || record.metadataSHA256 == selection.MetadataSHA256) { + matches = append(matches, index) + } + } + selectionIdentity := selection.Tag + "@" + selection.TemplateDigest + if len(matches) == 0 { + *warnings = append(*warnings, fmt.Sprintf("configured Docker Sandboxes template %q did not match a verified archive", selectionIdentity)) + continue + } + if len(matches) > 1 { + *warnings = append(*warnings, fmt.Sprintf("configured Docker Sandboxes template %q matched multiple archives; all are protected", selectionIdentity)) + group := records[matches[0]].artifact.RetentionGroup + for index := range records { + if records[index].artifact.RetentionGroup == group { + records[index].artifact.Protections = append(records[index].artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "ambiguous configured template archive"}) + } + } + continue + } + currentIndex := matches[0] + group := records[currentIndex].artifact.RetentionGroup + records[currentIndex].artifact.Current = true + records[currentIndex].artifact.Protections = append(records[currentIndex].artifact.Protections, storage.Protection{Kind: storage.ProtectionConfiguration, Detail: "configured Docker Sandboxes template"}) + if selection.ActivatedAt.IsZero() { + continue + } + activatedAt := selection.ActivatedAt.UTC() + for index := range records { + if index == currentIndex || records[index].artifact.RetentionGroup != group || !records[index].artifact.CreatedAt.Before(activatedAt) { + continue + } + supersededAt := activatedAt + records[index].artifact.SupersededAt = &supersededAt + } + } +} + +func normalizedTemplateTag(value string) string { + return strings.TrimPrefix(value, "docker.io/library/") +} + +func readBoundedFile(path string, maximum int64) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maximum+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > maximum { + return nil, fmt.Errorf("file exceeds %d bytes", maximum) + } + return data, nil +} + +func validateUniqueJSON(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + var readValue func() error + readValue = func() error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + keys := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("JSON object key is not a string") + } + if _, exists := keys[key]; exists { + return fmt.Errorf("JSON object contains duplicate key %q", key) + } + keys[key] = struct{}{} + if err := readValue(); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil || end != json.Delim('}') { + return fmt.Errorf("JSON object is not terminated") + } + case '[': + for decoder.More() { + if err := readValue(); err != nil { + return err + } + } + end, err := decoder.Token() + if err != nil || end != json.Delim(']') { + return fmt.Errorf("JSON array is not terminated") + } + default: + return fmt.Errorf("unexpected JSON delimiter %q", delimiter) + } + return nil + } + if err := readValue(); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("JSON contains trailing content") + } + return err + } + return nil +} + +func sortTemplateProtections(protections []storage.Protection) { + sort.Slice(protections, func(i, j int) bool { + if protections[i].Kind == protections[j].Kind { + return protections[i].Detail < protections[j].Detail + } + return protections[i].Kind < protections[j].Kind + }) +} diff --git a/internal/storage/inventory/template_test.go b/internal/storage/inventory/template_test.go new file mode 100644 index 0000000..57ccbb9 --- /dev/null +++ b/internal/storage/inventory/template_test.go @@ -0,0 +1,216 @@ +package inventory + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +type templateFixture struct { + Profile string + Platform string + Tag string + TemplateDigest string + ArchiveSHA256 string + MetadataSHA256 string + Directory string + Archive string +} + +func TestCollectTemplatesStrictIntegrityValidation(t *testing.T) { + t.Parallel() + root := t.TempDir() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + valid := writeTemplateFixture(t, root, "valid", "act-22.04", "linux/amd64", "valid", now.Add(-10*24*time.Hour), nil) + writeTemplateFixture(t, root, "bad-hash", "act-22.04", "linux/amd64", "bad-hash", now.Add(-10*24*time.Hour), func(metadata map[string]any) { + template := metadata["template"].(map[string]any) + template["archiveSha256"] = "sha256:" + strings.Repeat("0", 64) + }) + duplicateDirectory := filepath.Join(root, "duplicate-json") + mustMkdirAll(t, duplicateDirectory) + mustWriteFile(t, filepath.Join(duplicateDirectory, "template-metadata.json"), []byte(`{"schemaVersion":2,"schemaVersion":2}`)) + + artifacts, warnings, err := collectTemplates(templateOptions{Root: root}) + if err != nil { + t.Fatalf("collectTemplates() error = %v", err) + } + if len(artifacts) != 3 || len(warnings) != 2 { + t.Fatalf("collectTemplates() artifacts=%+v warnings=%v", artifacts, warnings) + } + exact := findArtifactByArchiveDigest(t, artifacts, valid.ArchiveSHA256) + if exact.Ownership.Kind != storage.OwnershipExact || exact.SizeBytes == 0 { + t.Fatalf("valid archive = %+v", exact) + } + unknownCount := 0 + for _, artifact := range artifacts { + if artifact.Ownership.Kind == storage.OwnershipUnknown { + unknownCount++ + } + } + if unknownCount != 2 { + t.Fatalf("ownership-unknown artifacts = %d, want 2", unknownCount) + } +} + +func TestCollectTemplatesConfiguredIdentityAndActivation(t *testing.T) { + t.Parallel() + root := t.TempDir() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + old := writeTemplateFixture(t, root, "old", "full", "linux/amd64", "old", now.Add(-20*24*time.Hour), nil) + current := writeTemplateFixture(t, root, "current", "full", "linux/amd64", "current", now.Add(-10*24*time.Hour), nil) + artifacts, warnings, err := collectTemplates(templateOptions{ + Root: root, + Selections: []TemplateSelection{{ + Platform: current.Platform, + Tag: current.Tag, + TemplateDigest: current.TemplateDigest, + MetadataSHA256: current.MetadataSHA256, + ActivatedAt: now.Add(-9 * 24 * time.Hour), + }}, + Protections: []TemplateProtection{{ + ArchiveSHA256: old.ArchiveSHA256, + Kind: storage.ProtectionPromotion, + Detail: "promoted bytes", + }}, + }) + if err != nil || len(warnings) != 0 { + t.Fatalf("collectTemplates() error=%v warnings=%v", err, warnings) + } + currentArtifact := findArtifactByArchiveDigest(t, artifacts, current.ArchiveSHA256) + oldArtifact := findArtifactByArchiveDigest(t, artifacts, old.ArchiveSHA256) + if !currentArtifact.Current || !hasProtection(currentArtifact, storage.ProtectionConfiguration) { + t.Fatalf("current artifact = %+v", currentArtifact) + } + if oldArtifact.SupersededAt == nil || !oldArtifact.SupersededAt.Equal(now.Add(-9*24*time.Hour)) || !hasProtection(oldArtifact, storage.ProtectionPromotion) { + t.Fatalf("old artifact = %+v", oldArtifact) + } +} + +func TestCollectTemplatesRejectsSymlinkArchive(t *testing.T) { + t.Parallel() + root := t.TempDir() + directory := filepath.Join(root, "symlink") + mustMkdirAll(t, directory) + outside := filepath.Join(t.TempDir(), "archive.tar") + mustWriteFile(t, outside, []byte("archive")) + link := filepath.Join(directory, "archive.tar") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlink creation unavailable on %s: %v", runtime.GOOS, err) + } + digest := sha256.Sum256([]byte("archive")) + metadata := validTemplateMetadata("act-22.04", "linux/amd64", "symlink", "archive.tar", "sha256:"+hex.EncodeToString(digest[:]), uint64(len("archive"))) + encoded, _ := json.Marshal(metadata) + mustWriteFile(t, filepath.Join(directory, "template-metadata.json"), encoded) + + artifacts, warnings, err := collectTemplates(templateOptions{Root: root}) + if err != nil { + t.Fatalf("collectTemplates() error = %v", err) + } + if len(artifacts) != 1 || len(warnings) != 1 || artifacts[0].Ownership.Kind != storage.OwnershipUnknown { + t.Fatalf("symlink archive artifacts=%+v warnings=%v", artifacts, warnings) + } +} + +func TestValidateTemplateInputsRejectsAmbiguity(t *testing.T) { + t.Parallel() + selection := TemplateSelection{ + Profile: "full", + Platform: "linux/amd64", + Tag: "epar-docker-sandboxes-full:test-amd64", + TemplateDigest: "sha256:" + strings.Repeat("a", 64), + } + if err := validateTemplateInputs(templateOptions{Selections: []TemplateSelection{selection, selection}}); err == nil { + t.Fatal("validateTemplateInputs() accepted duplicate configured group") + } + if err := validateTemplateInputs(templateOptions{Protections: []TemplateProtection{{ArchiveSHA256: "sha256:short", Kind: storage.ProtectionCertification}}}); err == nil { + t.Fatal("validateTemplateInputs() accepted invalid protected digest") + } +} + +func TestTemplateSelectionAcceptsCanonicalDockerHubName(t *testing.T) { + selection := TemplateSelection{ + Platform: "linux/amd64", + Tag: "docker.io/library/epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64", + TemplateDigest: "sha256:" + strings.Repeat("a", 64), + } + if err := validateTemplateInputs(templateOptions{Selections: []TemplateSelection{selection}}); err != nil { + t.Fatalf("canonical Docker Hub template name rejected: %v", err) + } + if got, want := normalizedTemplateTag(selection.Tag), "epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64"; got != want { + t.Fatalf("normalized tag = %q, want %q", got, want) + } +} + +func writeTemplateFixture(t *testing.T, root, name, profile, platform, suffix string, created time.Time, mutate func(map[string]any)) templateFixture { + t.Helper() + directory := filepath.Join(root, name) + mustMkdirAll(t, directory) + archiveName := "epar-docker-sandboxes-" + profile + ".tar" + archive := filepath.Join(directory, archiveName) + archiveBytes := []byte("archive:" + name + ":" + suffix) + mustWriteFile(t, archive, archiveBytes) + if err := os.Chtimes(archive, created, created); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(archiveBytes) + archiveSHA := "sha256:" + hex.EncodeToString(sum[:]) + metadata := validTemplateMetadata(profile, platform, suffix, archiveName, archiveSHA, uint64(len(archiveBytes))) + if mutate != nil { + mutate(metadata) + } + encoded, err := json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + metadataPath := filepath.Join(directory, "template-metadata.json") + mustWriteFile(t, metadataPath, encoded) + metadataSum := sha256.Sum256(encoded) + template := metadata["template"].(map[string]any) + return templateFixture{ + Profile: profile, + Platform: platform, + Tag: template["tag"].(string), + TemplateDigest: template["templateDigest"].(string), + ArchiveSHA256: archiveSHA, + MetadataSHA256: "sha256:" + hex.EncodeToString(metadataSum[:]), + Directory: directory, + Archive: archive, + } +} + +func validTemplateMetadata(profile, platform, suffix, archive string, archiveSHA string, archiveBytes uint64) map[string]any { + templateDigest := "sha256:" + strings.Repeat(digestCharacter(suffix), 64) + return map[string]any{ + "schemaVersion": 2, + "profile": profile, + "platform": platform, + "template": map[string]any{ + "tag": "epar-docker-sandboxes-" + profile + ":" + suffix + "-amd64", + "digest": templateDigest, + "templateDigest": templateDigest, + "cacheID": strings.TrimPrefix(templateDigest, "sha256:")[:12], + "archive": archive, + "archiveSha256": archiveSHA, + "archiveBytes": archiveBytes, + }, + "compatibility": map[string]any{ + "supportedSbxVersions": []string{"0.35.0"}, + "candidate": "A", + "dockerDaemonOwner": "docker-sandboxes-runtime", + "expectedDockerDaemonCount": 1, + }, + } +} + +func digestCharacter(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:])[:1] +} diff --git a/internal/storage/inventory/types.go b/internal/storage/inventory/types.go new file mode 100644 index 0000000..d8e0433 --- /dev/null +++ b/internal/storage/inventory/types.go @@ -0,0 +1,111 @@ +package inventory + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +const ( + ProviderDockerSandboxes = "docker-sandboxes" + ProjectSurfaceID = "project-filesystem" +) + +// TemplateSelection is an explicit configured template identity. Profile and +// Platform may be omitted when the caller has only the configured tag and full +// template digest. ActivatedAt is optional; without it, non-current archives +// remain retention-uncertain. +type TemplateSelection struct { + Profile string `json:"profile"` + Platform string `json:"platform"` + Tag string `json:"tag"` + TemplateDigest string `json:"templateDigest"` + MetadataSHA256 string `json:"metadataSha256,omitempty"` + ActivatedAt time.Time `json:"activatedAt,omitempty"` +} + +// TemplateProtection preserves an archive by its full content digest. +type TemplateProtection struct { + ArchiveSHA256 string `json:"archiveSha256"` + Kind storage.ProtectionKind `json:"kind"` + Detail string `json:"detail,omitempty"` +} + +// ConfiguredFile identifies one provider artifact by its explicit persisted +// configuration path. Configured files are inventoried with exact filesystem +// identity, but remain protected from automatic cleanup. +type ConfiguredFile struct { + Provider string `json:"provider"` + Role string `json:"role"` + Path string `json:"path"` + Kind storage.ArtifactKind `json:"kind"` + Current bool `json:"current,omitempty"` + ConfiguredAt time.Time `json:"configuredAt,omitempty"` + ProtectionKind storage.ProtectionKind `json:"protectionKind,omitempty"` + ProtectionDetail string `json:"protectionDetail,omitempty"` +} + +// Options supplies all paths and active identities explicitly. Empty storage +// roots use their project-local defaults. +type Options struct { + ProjectRoot string `json:"projectRoot"` + Provider string `json:"provider,omitempty"` + Now time.Time `json:"now"` + LogsRoot string `json:"logsRoot,omitempty"` + NativeRoot string `json:"nativeRoot,omitempty"` + TemplateRoot string `json:"templateRoot,omitempty"` + CurrentExecutable string `json:"currentExecutable,omitempty"` + CurrentRevision string `json:"currentRevision,omitempty"` + ConfiguredTemplates []TemplateSelection `json:"configuredTemplates,omitempty"` + TemplateProtections []TemplateProtection `json:"templateProtections,omitempty"` + ConfiguredFiles []ConfiguredFile `json:"configuredFiles,omitempty"` +} + +// Snapshot is a deterministic storage-core input plus fail-closed collection +// warnings. +type Snapshot struct { + CollectedAt time.Time `json:"collectedAt"` + ProjectRoot string `json:"projectRoot"` + ProviderFilter string `json:"providerFilter,omitempty"` + Surfaces []storage.Surface `json:"surfaces"` + Artifacts []storage.Artifact `json:"artifacts,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// PreviewRequest converts an inventory into a storage plan request without +// changing any inventory classification. +func (snapshot Snapshot) PreviewRequest(policy storage.Policy, requirements []storage.Requirement) storage.PreviewRequest { + return storage.PreviewRequest{ + Now: snapshot.CollectedAt, + Policy: policy, + Surfaces: append([]storage.Surface(nil), snapshot.Surfaces...), + Requirements: append([]storage.Requirement(nil), requirements...), + Artifacts: append([]storage.Artifact(nil), snapshot.Artifacts...), + } +} + +func (snapshot *Snapshot) normalize() { + sort.Slice(snapshot.Surfaces, func(i, j int) bool { return snapshot.Surfaces[i].ID < snapshot.Surfaces[j].ID }) + sort.Slice(snapshot.Artifacts, func(i, j int) bool { return snapshot.Artifacts[i].ID < snapshot.Artifacts[j].ID }) + sort.Strings(snapshot.Warnings) +} + +func (options Options) validate() error { + if strings.TrimSpace(options.ProjectRoot) == "" { + return fmt.Errorf("storage inventory project root is required") + } + if options.Now.IsZero() { + return fmt.Errorf("storage inventory time is required") + } + if strings.TrimSpace(options.Provider) != options.Provider { + return fmt.Errorf("storage inventory provider filter has surrounding whitespace") + } + return nil +} + +func includeProvider(filter, provider string) bool { + return provider == "" || filter == "" || filter == provider +} diff --git a/internal/storage/planner.go b/internal/storage/planner.go new file mode 100644 index 0000000..9963219 --- /dev/null +++ b/internal/storage/planner.go @@ -0,0 +1,321 @@ +package storage + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" +) + +const planSchemaVersion = 1 + +var automaticKinds = map[ArtifactKind]bool{ + ArtifactNativeControllerRevision: true, + ArtifactTemplateArchive: true, +} + +// Preview deterministically classifies a complete storage snapshot. It never +// mutates the snapshot or any host resource. +func Preview(request PreviewRequest) (Plan, error) { + now := request.Now.UTC() + if request.Now.IsZero() { + return Plan{}, errors.New("storage preview time is required") + } + policy, err := normalizePolicy(request.Policy) + if err != nil { + return Plan{}, err + } + surfaces := append([]Surface(nil), request.Surfaces...) + for index := range surfaces { + surfaces[index].Capacity.ObservedAt = surfaces[index].Capacity.ObservedAt.UTC() + } + sort.Slice(surfaces, func(i, j int) bool { return surfaces[i].ID < surfaces[j].ID }) + checks, err := capacityPreflight(surfaces, request.Requirements) + if err != nil { + return Plan{}, err + } + artifacts := append([]Artifact(nil), request.Artifacts...) + if err := normalizeAndValidateArtifacts(artifacts, surfaces); err != nil { + return Plan{}, err + } + sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].ID < artifacts[j].ID }) + + decisions := make([]Decision, len(artifacts)) + candidates := make(map[string]time.Time) + for index, artifact := range artifacts { + decision, candidateAt := classifyArtifact(now, policy, artifact) + decisions[index] = decision + if candidateAt != nil { + candidates[artifact.ID] = *candidateAt + } + } + applyGenerationCount(policy, decisions, candidates) + applyCacheBudgets(policy, decisions, candidates) + + plan := Plan{ + SchemaVersion: planSchemaVersion, + CreatedAt: now, + Policy: policy, + Surfaces: surfaces, + CapacityChecks: checks, + Decisions: decisions, + } + for _, check := range checks { + if check.Status != CapacityReady { + plan.Warnings = append(plan.Warnings, fmt.Sprintf("capacity %s: requirement=%s surface=%s reason=%s", check.Status, check.Requirement.ID, check.Requirement.SurfaceID, check.Reason)) + } + } + for _, decision := range decisions { + if decision.Action == ActionRemove { + plan.RemovalCount++ + plan.ReclaimableBytes += decision.Artifact.SizeBytes + } + } + plan.Hash, err = ComputePlanHash(plan) + if err != nil { + return Plan{}, err + } + return plan, nil +} + +func normalizePolicy(policy Policy) (Policy, error) { + if policy.GracePeriod == 0 && policy.KeepPrevious == 0 && len(policy.Budgets) == 0 { + policy = DefaultPolicy() + } + if policy.GracePeriod <= 0 { + return Policy{}, errors.New("storage retention grace period must be positive") + } + if policy.KeepPrevious < 0 { + return Policy{}, errors.New("storage keepPrevious must not be negative") + } + policy.Budgets = append([]Budget(nil), policy.Budgets...) + sort.Slice(policy.Budgets, func(i, j int) bool { return policy.Budgets[i].Kind < policy.Budgets[j].Kind }) + seen := make(map[ArtifactKind]struct{}, len(policy.Budgets)) + for _, budget := range policy.Budgets { + if budget.Kind != ArtifactBuildKitCache && budget.Kind != ArtifactGoCache { + return Policy{}, fmt.Errorf("storage budget for unsupported automatic artifact kind %q", budget.Kind) + } + if budget.MaxBytes == 0 { + return Policy{}, fmt.Errorf("storage budget for %q must be positive", budget.Kind) + } + if _, exists := seen[budget.Kind]; exists { + return Policy{}, fmt.Errorf("duplicate storage budget for %q", budget.Kind) + } + seen[budget.Kind] = struct{}{} + } + return policy, nil +} + +func normalizeAndValidateArtifacts(artifacts []Artifact, surfaces []Surface) error { + surfaceIDs := make(map[string]struct{}, len(surfaces)) + for _, surface := range surfaces { + surfaceIDs[surface.ID] = struct{}{} + } + ids := make(map[string]struct{}, len(artifacts)) + for index := range artifacts { + artifact := &artifacts[index] + artifact.CreatedAt = artifact.CreatedAt.UTC() + artifact.LastUsedAt = artifact.LastUsedAt.UTC() + if artifact.SupersededAt != nil { + supersededAt := artifact.SupersededAt.UTC() + artifact.SupersededAt = &supersededAt + } + if artifact.Lease != nil { + lease := *artifact.Lease + lease.ExpiresAt = lease.ExpiresAt.UTC() + artifact.Lease = &lease + } + artifact.Protections = append([]Protection(nil), artifact.Protections...) + if strings.TrimSpace(artifact.ID) == "" { + return errors.New("storage artifact ID is required") + } + if _, exists := ids[artifact.ID]; exists { + return fmt.Errorf("duplicate storage artifact ID %q", artifact.ID) + } + ids[artifact.ID] = struct{}{} + if _, exists := surfaceIDs[artifact.SurfaceID]; !exists { + return fmt.Errorf("storage artifact %q references unknown surface %q", artifact.ID, artifact.SurfaceID) + } + if artifact.Kind == "" { + return fmt.Errorf("storage artifact %q kind is required", artifact.ID) + } + if artifact.Target.Kind == "" || strings.TrimSpace(artifact.Target.Locator) == "" { + return fmt.Errorf("storage artifact %q target kind and locator are required", artifact.ID) + } + if artifact.Target.Match == MatchExact && strings.TrimSpace(artifact.Target.Identity) == "" { + return fmt.Errorf("storage artifact %q exact target identity is required", artifact.ID) + } + if artifact.Target.Match != MatchExact && artifact.Target.Match != MatchPrefix && artifact.Target.Match != MatchUnknown { + return fmt.Errorf("storage artifact %q has invalid target match %q", artifact.ID, artifact.Target.Match) + } + if artifact.Ownership.Kind != OwnershipExact && artifact.Ownership.Kind != OwnershipShared && artifact.Ownership.Kind != OwnershipUnknown { + return fmt.Errorf("storage artifact %q has invalid ownership %q", artifact.ID, artifact.Ownership.Kind) + } + if artifact.Ownership.Kind == OwnershipExact && (strings.TrimSpace(artifact.Ownership.OwnerID) == "" || strings.TrimSpace(artifact.Ownership.Evidence) == "") { + return fmt.Errorf("storage artifact %q exact ownership requires owner ID and evidence", artifact.ID) + } + sort.Slice(artifact.Protections, func(i, j int) bool { + if artifact.Protections[i].Kind == artifact.Protections[j].Kind { + return artifact.Protections[i].Detail < artifact.Protections[j].Detail + } + return artifact.Protections[i].Kind < artifact.Protections[j].Kind + }) + } + return nil +} + +func classifyArtifact(now time.Time, policy Policy, artifact Artifact) (Decision, *time.Time) { + decision := Decision{Artifact: artifact} + add := func(action Action, reasons ...string) (Decision, *time.Time) { + decision.Action = action + decision.Reasons = append(decision.Reasons, reasons...) + return decision, nil + } + if artifact.Current { + return add(ActionProtected, "current") + } + if artifact.Active { + return add(ActionProtected, "active") + } + if len(artifact.Protections) > 0 { + for _, protection := range artifact.Protections { + decision.Reasons = append(decision.Reasons, "protected:"+string(protection.Kind)) + } + decision.Action = ActionProtected + return decision, nil + } + if artifact.Lease != nil { + if strings.TrimSpace(artifact.Lease.ID) == "" || artifact.Lease.ExpiresAt.IsZero() { + return add(ActionProtected, "lease-uncertain") + } + if !artifact.Lease.ExpiresAt.UTC().Before(now) { + return add(ActionProtected, "lease-active") + } + } + if artifact.Ownership.Kind != OwnershipExact { + return add(ActionReportOnly, "ownership-"+string(artifact.Ownership.Kind)) + } + if artifact.Target.Match != MatchExact { + return add(ActionReportOnly, "target-"+string(artifact.Target.Match)) + } + if !automaticKinds[artifact.Kind] { + return add(ActionReportOnly, "explicit-cleanup-only") + } + if !automaticTargetKind(artifact.Kind, artifact.Target.Kind) { + return add(ActionReportOnly, "target-kind-not-automatic") + } + + var candidateAt time.Time + switch artifact.Kind { + case ArtifactNativeControllerRevision, ArtifactTemplateArchive: + if artifact.RetentionGroup == "" || artifact.SupersededAt == nil || artifact.SupersededAt.IsZero() { + return add(ActionReportOnly, "superseded-state-uncertain") + } + candidateAt = artifact.SupersededAt.UTC() + default: + return add(ActionReportOnly, "explicit-cleanup-only") + } + if candidateAt.After(now) { + return add(ActionReportOnly, "retention-clock-skew") + } + if now.Sub(candidateAt) < policy.GracePeriod { + return add(ActionKeep, "within-grace-period") + } + decision.Action = ActionKeep + decision.Reasons = []string{"eligible"} + return decision, &candidateAt +} + +func automaticTargetKind(artifactKind ArtifactKind, targetKind TargetKind) bool { + switch artifactKind { + case ArtifactNativeControllerRevision: + return targetKind == TargetDirectory || targetKind == TargetFile + case ArtifactTemplateArchive: + return targetKind == TargetFile + default: + return false + } +} + +func applyGenerationCount(policy Policy, decisions []Decision, candidates map[string]time.Time) { + groups := make(map[string][]int) + for index, decision := range decisions { + if _, candidate := candidates[decision.Artifact.ID]; !candidate { + continue + } + switch decision.Artifact.Kind { + case ArtifactNativeControllerRevision, ArtifactTemplateArchive: + key := string(decision.Artifact.Kind) + "\x00" + decision.Artifact.RetentionGroup + groups[key] = append(groups[key], index) + } + } + for _, indexes := range groups { + sort.Slice(indexes, func(i, j int) bool { + left, right := decisions[indexes[i]].Artifact, decisions[indexes[j]].Artifact + leftAt, rightAt := candidates[left.ID], candidates[right.ID] + if leftAt.Equal(rightAt) { + return left.ID < right.ID + } + return leftAt.After(rightAt) + }) + for position, index := range indexes { + if position < policy.KeepPrevious { + decisions[index].Action = ActionKeep + decisions[index].Reasons = []string{"keep-previous"} + continue + } + decisions[index].Action = ActionRemove + decisions[index].Reasons = []string{"superseded", "grace-period-expired"} + } + } +} + +func applyCacheBudgets(policy Policy, decisions []Decision, candidates map[string]time.Time) { + budgets := make(map[ArtifactKind]uint64, len(policy.Budgets)) + for _, budget := range policy.Budgets { + budgets[budget.Kind] = budget.MaxBytes + } + for kind, maxBytes := range budgets { + var total uint64 + var eligible []int + overflow := false + for index, decision := range decisions { + if decision.Artifact.Kind != kind { + continue + } + next := total + decision.Artifact.SizeBytes + if next < total { + overflow = true + break + } + total = next + if _, candidate := candidates[decision.Artifact.ID]; candidate { + eligible = append(eligible, index) + } + } + if overflow || total <= maxBytes { + continue + } + sort.Slice(eligible, func(i, j int) bool { + left, right := decisions[eligible[i]].Artifact, decisions[eligible[j]].Artifact + leftAt, rightAt := candidates[left.ID], candidates[right.ID] + if leftAt.Equal(rightAt) { + return left.ID < right.ID + } + return leftAt.Before(rightAt) + }) + for _, index := range eligible { + if total <= maxBytes { + break + } + decisions[index].Action = ActionRemove + decisions[index].Reasons = []string{"aggregate-budget-exceeded", "grace-period-expired"} + if decisions[index].Artifact.SizeBytes > total { + total = 0 + } else { + total -= decisions[index].Artifact.SizeBytes + } + } + } +} diff --git a/internal/storage/planner_test.go b/internal/storage/planner_test.go new file mode 100644 index 0000000..162b809 --- /dev/null +++ b/internal/storage/planner_test.go @@ -0,0 +1,235 @@ +package storage + +import ( + "encoding/json" + "math/rand" + "strings" + "testing" + "time" +) + +func TestPreviewConservativeClassification(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + expired := now.Add(-DefaultGracePeriod - time.Hour) + withinGrace := now.Add(-DefaultGracePeriod + time.Hour) + activeLease := &Lease{ID: "lease-active", ExpiresAt: now.Add(time.Hour)} + expiredLease := &Lease{ID: "lease-expired", ExpiresAt: now.Add(-time.Hour)} + artifacts := []Artifact{ + testArtifact("expired", ArtifactNativeControllerRevision, expired), + testArtifact("archive-expired", ArtifactTemplateArchive, expired), + withArtifact(testArtifact("within-grace", ArtifactNativeControllerRevision, withinGrace), func(a *Artifact) {}), + withArtifact(testArtifact("current", ArtifactNativeControllerRevision, expired), func(a *Artifact) { a.Current = true }), + withArtifact(testArtifact("active", ArtifactNativeControllerRevision, expired), func(a *Artifact) { a.Active = true }), + withArtifact(testArtifact("active-lease", ArtifactNativeControllerRevision, expired), func(a *Artifact) { a.Lease = activeLease }), + withArtifact(testArtifact("expired-lease", ArtifactNativeControllerRevision, expired), func(a *Artifact) { a.Lease = expiredLease }), + withArtifact(testArtifact("uncertain-lease", ArtifactNativeControllerRevision, expired), func(a *Artifact) { a.Lease = &Lease{} }), + withArtifact(testArtifact("shared", ArtifactNativeControllerRevision, expired), func(a *Artifact) { a.Ownership = Ownership{Kind: OwnershipShared} }), + withArtifact(testArtifact("unknown-owner", ArtifactNativeControllerRevision, expired), func(a *Artifact) { a.Ownership = Ownership{Kind: OwnershipUnknown} }), + withArtifact(testArtifact("prefix", ArtifactNativeControllerRevision, expired), func(a *Artifact) { a.Target.Match = MatchPrefix }), + withArtifact(testArtifact("protected", ArtifactTemplateArchive, expired), func(a *Artifact) { + a.Protections = []Protection{{Kind: ProtectionCertification, Detail: "release evidence"}} + }), + withArtifact(testArtifact("docker-image", ArtifactDockerImage, expired), func(a *Artifact) {}), + withArtifact(testArtifact("archive-wrong-target", ArtifactTemplateArchive, expired), func(a *Artifact) { a.Target.Kind = TargetDockerVolume }), + withArtifact(testArtifact("no-superseded-state", ArtifactTemplateArchive, expired), func(a *Artifact) { a.SupersededAt = nil }), + withArtifact(testArtifact("clock-skew", ArtifactTemplateArchive, now.Add(time.Hour)), func(a *Artifact) {}), + } + plan, err := Preview(PreviewRequest{ + Now: now, + Policy: DefaultPolicy(), + Surfaces: []Surface{{ID: "host", Kind: SurfaceHostFilesystem, Capacity: Capacity{Known: true, AvailableBytes: 100 * GiB, TotalBytes: 200 * GiB}}}, + Artifacts: artifacts, + }) + if err != nil { + t.Fatalf("Preview() error = %v", err) + } + want := map[string]Action{ + "expired": ActionRemove, + "archive-expired": ActionRemove, + "within-grace": ActionKeep, + "current": ActionProtected, + "active": ActionProtected, + "active-lease": ActionProtected, + "expired-lease": ActionRemove, + "uncertain-lease": ActionProtected, + "shared": ActionReportOnly, + "unknown-owner": ActionReportOnly, + "prefix": ActionReportOnly, + "protected": ActionProtected, + "docker-image": ActionReportOnly, + "archive-wrong-target": ActionReportOnly, + "no-superseded-state": ActionReportOnly, + "clock-skew": ActionReportOnly, + } + for _, decision := range plan.Decisions { + if decision.Action != want[decision.Artifact.ID] { + t.Errorf("artifact %q action = %q reasons=%v, want %q", decision.Artifact.ID, decision.Action, decision.Reasons, want[decision.Artifact.ID]) + } + delete(want, decision.Artifact.ID) + } + if len(want) != 0 { + t.Fatalf("Preview() omitted decisions: %v", want) + } + if plan.RemovalCount != 3 { + t.Fatalf("Preview() removal count = %d, want 3", plan.RemovalCount) + } +} + +func TestPreviewKeepPrevious(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + policy := DefaultPolicy() + policy.KeepPrevious = 1 + artifacts := []Artifact{ + testArtifact("oldest", ArtifactNativeControllerRevision, now.Add(-10*24*time.Hour)), + testArtifact("newest", ArtifactNativeControllerRevision, now.Add(-8*24*time.Hour)), + } + plan, err := Preview(PreviewRequest{Now: now, Policy: policy, Surfaces: testSurfaces(), Artifacts: artifacts}) + if err != nil { + t.Fatalf("Preview() error = %v", err) + } + if actionFor(plan, "newest") != ActionKeep || actionFor(plan, "oldest") != ActionRemove { + t.Fatalf("keepPrevious classifications = newest:%s oldest:%s", actionFor(plan, "newest"), actionFor(plan, "oldest")) + } +} + +func TestPreviewDedicatedCachesRemainSelfManaged(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + policy := DefaultPolicy() + policy.Budgets = []Budget{{Kind: ArtifactGoCache, MaxBytes: 10 * GiB}, {Kind: ArtifactBuildKitCache, MaxBytes: 64 * GiB}} + makeCache := func(id string, kind ArtifactKind, size uint64, age time.Duration) Artifact { + artifact := testArtifact(id, kind, now.Add(-age)) + artifact.SupersededAt = nil + artifact.LastUsedAt = now.Add(-age) + artifact.SizeBytes = size + if kind == ArtifactBuildKitCache { + artifact.Target.Kind = TargetBuildKitRecord + } else { + artifact.Target.Kind = TargetDirectory + } + return artifact + } + artifacts := []Artifact{ + makeCache("go-old", ArtifactGoCache, 4*GiB, 10*24*time.Hour), + makeCache("go-new", ArtifactGoCache, 4*GiB, 8*24*time.Hour), + makeCache("go-within-grace", ArtifactGoCache, 4*GiB, 2*24*time.Hour), + makeCache("buildkit-under-budget", ArtifactBuildKitCache, 60*GiB, 10*24*time.Hour), + } + plan, err := Preview(PreviewRequest{Now: now, Policy: policy, Surfaces: testSurfaces(), Artifacts: artifacts}) + if err != nil { + t.Fatalf("Preview() error = %v", err) + } + for _, id := range []string{"go-old", "go-new", "go-within-grace", "buildkit-under-budget"} { + if actionFor(plan, id) != ActionReportOnly { + t.Fatalf("%s action = %s, want report-only", id, actionFor(plan, id)) + } + } +} + +func TestPreviewIsDeterministicAcrossInputOrder(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.FixedZone("offset", 8*60*60)) + artifacts := []Artifact{ + testArtifact("c", ArtifactTemplateArchive, now.Add(-10*24*time.Hour)), + testArtifact("a", ArtifactNativeControllerRevision, now.Add(-10*24*time.Hour)), + testArtifact("b", ArtifactDockerImage, now.Add(-10*24*time.Hour)), + } + request := PreviewRequest{ + Now: now, + Policy: DefaultPolicy(), + Surfaces: []Surface{{ID: "z", Provider: "docker-sandboxes", Kind: SurfaceDockerEngine}, {ID: "host", Kind: SurfaceHostFilesystem}}, + Requirements: []Requirement{ + {ID: "z-check", Provider: "docker-sandboxes", SurfaceID: "z", PeakBytes: GiB}, + {ID: "a-check", SurfaceID: "host", PeakBytes: GiB}, + }, + Artifacts: artifacts, + } + first, err := Preview(request) + if err != nil { + t.Fatalf("first Preview() error = %v", err) + } + rand.New(rand.NewSource(42)).Shuffle(len(request.Artifacts), func(i, j int) { + request.Artifacts[i], request.Artifacts[j] = request.Artifacts[j], request.Artifacts[i] + }) + request.Surfaces[0], request.Surfaces[1] = request.Surfaces[1], request.Surfaces[0] + request.Requirements[0], request.Requirements[1] = request.Requirements[1], request.Requirements[0] + second, err := Preview(request) + if err != nil { + t.Fatalf("second Preview() error = %v", err) + } + firstJSON, _ := json.Marshal(first) + secondJSON, _ := json.Marshal(second) + if string(firstJSON) != string(secondJSON) { + t.Fatalf("Preview() is nondeterministic:\n%s\n%s", firstJSON, secondJSON) + } +} + +func TestPreviewDoesNotMutateArtifactProtectionOrder(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + artifact := testArtifact("protected", ArtifactTemplateArchive, now.Add(-10*24*time.Hour)) + artifact.Protections = []Protection{{Kind: ProtectionOperator, Detail: "z"}, {Kind: ProtectionCertification, Detail: "a"}} + original := append([]Protection(nil), artifact.Protections...) + if _, err := Preview(PreviewRequest{Now: now, Policy: DefaultPolicy(), Surfaces: testSurfaces(), Artifacts: []Artifact{artifact}}); err != nil { + t.Fatalf("Preview() error = %v", err) + } + if artifact.Protections[0] != original[0] || artifact.Protections[1] != original[1] { + t.Fatalf("Preview() mutated caller protections: got=%v want=%v", artifact.Protections, original) + } +} + +func TestPreviewCapacityWarningsAndValidation(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + plan, err := Preview(PreviewRequest{ + Now: now, + Policy: DefaultPolicy(), + Surfaces: []Surface{{ID: "host", Kind: SurfaceHostFilesystem}}, + Requirements: []Requirement{{ID: "build", SurfaceID: "host", PeakBytes: 30 * GiB}}, + }) + if err != nil { + t.Fatalf("Preview() error = %v", err) + } + if len(plan.Warnings) != 1 || !strings.Contains(plan.Warnings[0], "capacity unknown") { + t.Fatalf("Preview() warnings = %v, want unknown capacity", plan.Warnings) + } + if _, err := Preview(PreviewRequest{Now: now, Policy: DefaultPolicy(), Surfaces: testSurfaces(), Artifacts: []Artifact{testArtifact("duplicate", ArtifactTemplateArchive, now.Add(-10*24*time.Hour)), testArtifact("duplicate", ArtifactTemplateArchive, now.Add(-9*24*time.Hour))}}); err == nil { + t.Fatal("Preview() accepted duplicate artifact IDs") + } +} + +func testArtifact(id string, kind ArtifactKind, lifecycleAt time.Time) Artifact { + at := lifecycleAt.UTC() + return Artifact{ + ID: id, + Provider: "test-provider", + SurfaceID: "host", + Kind: kind, + RetentionGroup: "default", + Target: Target{Kind: TargetFile, Locator: "/exact/" + id, Identity: "identity:" + id, Fingerprint: "fingerprint:" + id, Match: MatchExact}, + Ownership: Ownership{Kind: OwnershipExact, OwnerID: "installation:test", Evidence: "signed-manifest"}, + SizeBytes: GiB, + CreatedAt: at.Add(-time.Hour), + SupersededAt: &at, + } +} + +func withArtifact(artifact Artifact, mutate func(*Artifact)) Artifact { + mutate(&artifact) + return artifact +} + +func testSurfaces() []Surface { + return []Surface{{ID: "host", Kind: SurfaceHostFilesystem, Capacity: Capacity{Known: true, AvailableBytes: 100 * GiB, TotalBytes: 200 * GiB}}} +} + +func actionFor(plan Plan, id string) Action { + for _, decision := range plan.Decisions { + if decision.Artifact.ID == id { + return decision.Action + } + } + return "" +} diff --git a/internal/storage/types.go b/internal/storage/types.go new file mode 100644 index 0000000..dea04e1 --- /dev/null +++ b/internal/storage/types.go @@ -0,0 +1,252 @@ +package storage + +import "time" + +const ( + GiB uint64 = 1 << 30 + + DefaultMinimumFreeBytes = 20 * GiB + DefaultGracePeriod = 168 * time.Hour + DefaultKeepPrevious = 0 + DefaultBuildKitMaxBytes = 64 * GiB + DefaultGoCacheMaxBytes = 10 * GiB +) + +// SurfaceKind identifies a capacity and reclaim domain. Reclaim on one surface +// must not be reported as free space on another surface. +type SurfaceKind string + +const ( + SurfaceHostFilesystem SurfaceKind = "host-filesystem" + SurfaceDockerEngine SurfaceKind = "docker-engine" + SurfaceSandboxCache SurfaceKind = "sandbox-template-cache" + SurfaceExternal SurfaceKind = "external" +) + +// Capacity is an observation for one storage surface. Unknown capacity is a +// first-class fail-closed state, not zero available bytes. +type Capacity struct { + Known bool `json:"known"` + AvailableBytes uint64 `json:"availableBytes,omitempty"` + TotalBytes uint64 `json:"totalBytes,omitempty"` + ObservedAt time.Time `json:"observedAt,omitempty"` +} + +// Surface identifies one capacity and reclaim domain. +type Surface struct { + ID string `json:"id"` + Provider string `json:"provider,omitempty"` + Kind SurfaceKind `json:"kind"` + Location string `json:"location,omitempty"` + Capacity Capacity `json:"capacity"` +} + +// Requirement is the peak additional capacity needed by an operation on one +// surface. MinimumFreeBytes defaults to DefaultMinimumFreeBytes when zero. +type Requirement struct { + ID string `json:"id"` + Provider string `json:"provider,omitempty"` + SurfaceID string `json:"surfaceId"` + PeakBytes uint64 `json:"peakBytes"` + MinimumFreeBytes uint64 `json:"minimumFreeBytes"` +} + +// CapacityStatus is the result of a capacity preflight. +type CapacityStatus string + +const ( + CapacityReady CapacityStatus = "ready" + CapacityInsufficient CapacityStatus = "insufficient" + CapacityUnknown CapacityStatus = "unknown" +) + +// CapacityCheck binds a requirement to the exact capacity observation used to +// evaluate it. +type CapacityCheck struct { + Requirement Requirement `json:"requirement"` + Capacity Capacity `json:"capacity"` + Status CapacityStatus `json:"status"` + RequiredAvailableBytes uint64 `json:"requiredAvailableBytes"` + DeficitBytes uint64 `json:"deficitBytes,omitempty"` + Reason string `json:"reason"` +} + +// ArtifactKind selects the retention policy applicable to an artifact. +type ArtifactKind string + +const ( + ArtifactNativeControllerRevision ArtifactKind = "native-controller-revision" + ArtifactTemplateArchive ArtifactKind = "template-archive" + ArtifactDockerImage ArtifactKind = "docker-image" + ArtifactSandboxTemplate ArtifactKind = "sandbox-template" + ArtifactDockerVolume ArtifactKind = "docker-volume" + ArtifactBuildKitCache ArtifactKind = "buildkit-cache" + ArtifactGoCache ArtifactKind = "go-cache" + ArtifactProviderImage ArtifactKind = "provider-image" + ArtifactProviderCache ArtifactKind = "provider-cache" + ArtifactOther ArtifactKind = "other" +) + +// TargetKind describes the one exact object an executor may receive. +type TargetKind string + +const ( + TargetFile TargetKind = "file" + TargetDirectory TargetKind = "directory" + TargetDockerImageTag TargetKind = "docker-image-tag" + TargetDockerVolume TargetKind = "docker-volume" + TargetBuildKitRecord TargetKind = "buildkit-record" + TargetSandboxTemplate TargetKind = "sandbox-template" + TargetExternal TargetKind = "external" +) + +// MatchKind records whether a locator identifies one object or a broad set. +type MatchKind string + +const ( + MatchExact MatchKind = "exact" + MatchPrefix MatchKind = "prefix" + MatchUnknown MatchKind = "unknown" +) + +// Target binds a locator to an immutable object identity. Fingerprint may bind +// additional mutable metadata, such as a filesystem object's size and mtime. +type Target struct { + Kind TargetKind `json:"kind"` + Locator string `json:"locator"` + Identity string `json:"identity,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Match MatchKind `json:"match"` +} + +// OwnershipKind expresses how confidently an artifact belongs exclusively to +// this EPAR installation. +type OwnershipKind string + +const ( + OwnershipExact OwnershipKind = "exact" + OwnershipShared OwnershipKind = "shared" + OwnershipUnknown OwnershipKind = "unknown" +) + +// Ownership carries the exact owner identity and its evidence. Names or +// prefixes alone are not exact ownership evidence. +type Ownership struct { + Kind OwnershipKind `json:"kind"` + OwnerID string `json:"ownerId,omitempty"` + Evidence string `json:"evidence,omitempty"` +} + +// ProtectionKind identifies a reason an artifact must not be selected. +type ProtectionKind string + +const ( + ProtectionActive ProtectionKind = "active" + ProtectionCurrent ProtectionKind = "current" + ProtectionLease ProtectionKind = "lease" + ProtectionConfiguration ProtectionKind = "configuration" + ProtectionLock ProtectionKind = "source-lock" + ProtectionPromotion ProtectionKind = "promotion" + ProtectionCertification ProtectionKind = "certification" + ProtectionCustomRoot ProtectionKind = "custom-root" + ProtectionOperator ProtectionKind = "operator" + ProtectionUncertain ProtectionKind = "uncertain" +) + +// Protection is adapter-supplied evidence that an artifact must be retained. +type Protection struct { + Kind ProtectionKind `json:"kind"` + Detail string `json:"detail,omitempty"` +} + +// Lease protects an artifact through ExpiresAt. A missing ID or expiry is +// treated as uncertain and remains protected. +type Lease struct { + ID string `json:"id"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// Artifact is an evidence-bearing snapshot of one storage object. +type Artifact struct { + ID string `json:"id"` + Provider string `json:"provider,omitempty"` + SurfaceID string `json:"surfaceId"` + Kind ArtifactKind `json:"kind"` + RetentionGroup string `json:"retentionGroup,omitempty"` + Target Target `json:"target"` + Ownership Ownership `json:"ownership"` + SizeBytes uint64 `json:"sizeBytes"` + CreatedAt time.Time `json:"createdAt,omitempty"` + LastUsedAt time.Time `json:"lastUsedAt,omitempty"` + SupersededAt *time.Time `json:"supersededAt,omitempty"` + Current bool `json:"current,omitempty"` + Active bool `json:"active,omitempty"` + Lease *Lease `json:"lease,omitempty"` + Protections []Protection `json:"protections,omitempty"` +} + +// Budget bounds one automatically managed artifact kind. +type Budget struct { + Kind ArtifactKind `json:"kind"` + MaxBytes uint64 `json:"maxBytes"` +} + +// Policy is the complete deterministic retention policy embedded into a plan. +type Policy struct { + GracePeriod time.Duration `json:"gracePeriod"` + KeepPrevious int `json:"keepPrevious"` + Budgets []Budget `json:"budgets"` +} + +// DefaultPolicy returns the approved conservative automatic defaults. +func DefaultPolicy() Policy { + return Policy{ + GracePeriod: DefaultGracePeriod, + KeepPrevious: DefaultKeepPrevious, + Budgets: []Budget{ + {Kind: ArtifactBuildKitCache, MaxBytes: DefaultBuildKitMaxBytes}, + {Kind: ArtifactGoCache, MaxBytes: DefaultGoCacheMaxBytes}, + }, + } +} + +// Action is the deterministic classification for one artifact. +type Action string + +const ( + ActionKeep Action = "keep" + ActionProtected Action = "protected" + ActionReportOnly Action = "report-only" + ActionRemove Action = "remove" +) + +// Decision is one artifact's retention outcome and evidence. +type Decision struct { + Artifact Artifact `json:"artifact"` + Action Action `json:"action"` + Reasons []string `json:"reasons"` +} + +// PreviewRequest supplies a complete storage snapshot and an explicit clock. +type PreviewRequest struct { + Now time.Time `json:"now"` + Policy Policy `json:"policy"` + Surfaces []Surface `json:"surfaces"` + Requirements []Requirement `json:"requirements,omitempty"` + Artifacts []Artifact `json:"artifacts,omitempty"` +} + +// Plan is an immutable, deterministic preview. Hash is the SHA-256 of the plan +// with the Hash field empty. +type Plan struct { + SchemaVersion int `json:"schemaVersion"` + CreatedAt time.Time `json:"createdAt"` + Policy Policy `json:"policy"` + Surfaces []Surface `json:"surfaces"` + CapacityChecks []CapacityCheck `json:"capacityChecks,omitempty"` + Decisions []Decision `json:"decisions,omitempty"` + RemovalCount int `json:"removalCount"` + ReclaimableBytes uint64 `json:"reclaimableBytes"` + Warnings []string `json:"warnings,omitempty"` + Hash string `json:"hash"` +} diff --git a/scripts/build-native-controller.ps1 b/scripts/build-native-controller.ps1 new file mode 100644 index 0000000..0f4b39d --- /dev/null +++ b/scripts/build-native-controller.ps1 @@ -0,0 +1,509 @@ +[CmdletBinding()] +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $EparArgs +) + +$ErrorActionPreference = 'Stop' +$RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$GoImage = if ($env:GO_DOCKER_IMAGE) { $env:GO_DOCKER_IMAGE } else { 'golang:1.25' } +$DevImage = if ($env:EPAR_DEV_IMAGE) { $env:EPAR_DEV_IMAGE } else { 'epar-dev-toolchain' } +$repoRootPath = [System.IO.Path]::GetFullPath($RepoRoot) +$projectHasher = [System.Security.Cryptography.SHA256]::Create() +try { + $projectMaterial = [System.Text.Encoding]::UTF8.GetBytes($repoRootPath.ToLowerInvariant()) + $ProjectID = ([BitConverter]::ToString($projectHasher.ComputeHash($projectMaterial))).Replace('-', '').ToLowerInvariant().Substring(0, 12) +} finally { + $projectHasher.Dispose() +} +$GomodVolume = if ($env:EPAR_GOMOD_VOLUME) { $env:EPAR_GOMOD_VOLUME } else { "epar-$ProjectID-gomod" } +$GocacheVolume = if ($env:EPAR_GOCACHE_VOLUME) { $env:EPAR_GOCACHE_VOLUME } else { "epar-$ProjectID-gocache" } +$ManageGoCache = -not $env:EPAR_GOMOD_VOLUME -and -not $env:EPAR_GOCACHE_VOLUME +$GoCacheLimitBytes = [uint64](10GB) +if ($env:EPAR_GO_CACHE_LIMIT_BYTES) { + $parsedGoCacheLimit = [uint64] 0 + if (-not [uint64]::TryParse($env:EPAR_GO_CACHE_LIMIT_BYTES, [ref] $parsedGoCacheLimit) -or $parsedGoCacheLimit -eq 0) { + Write-Error 'EPAR_GO_CACHE_LIMIT_BYTES must be a positive integer byte count.' + exit 1 + } + $GoCacheLimitBytes = $parsedGoCacheLimit +} +$BootstrapMinimumFreeBytes = [uint64](20GB) +if ($env:EPAR_BOOTSTRAP_MIN_FREE_BYTES) { + $parsedBootstrapMinimum = [uint64] 0 + if (-not [uint64]::TryParse($env:EPAR_BOOTSTRAP_MIN_FREE_BYTES, [ref] $parsedBootstrapMinimum) -or $parsedBootstrapMinimum -eq 0) { + Write-Error 'EPAR_BOOTSTRAP_MIN_FREE_BYTES must be a positive integer byte count.' + exit 1 + } + $BootstrapMinimumFreeBytes = $parsedBootstrapMinimum +} + +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Error 'docker command not found. Install Docker Desktop or another working Docker host.' + exit 1 +} + +try { + $repoVolumeRoot = [System.IO.Path]::GetPathRoot($repoRootPath) + $repoDrive = [System.IO.DriveInfo]::new($repoVolumeRoot) + $bootstrapAvailableBytes = [uint64] $repoDrive.AvailableFreeSpace +} catch { + Write-Error "cannot measure bootstrap storage for ${RepoRoot}: $($_.Exception.Message)" + exit 1 +} +if ($bootstrapAvailableBytes -lt $BootstrapMinimumFreeBytes) { + Write-Error ("insufficient bootstrap storage on {0}: available={1} required-reserve={2}. Free space or run `ephemeral-action-runner storage status` from an existing controller before retrying." -f $repoVolumeRoot, $bootstrapAvailableBytes, $BootstrapMinimumFreeBytes) + exit 1 +} + +function Get-EparGoCacheVolumeIdentity { + param([Parameter(Mandatory = $true)][string] $Name) + $inspectionJSON = @((docker volume inspect $Name 2>$null)) + if ($LASTEXITCODE -ne 0) { + return [pscustomobject]@{ Exists = $false; Identity = '' } + } + try { + $records = @(($inspectionJSON -join [Environment]::NewLine) | ConvertFrom-Json) + } catch { + throw "Docker returned invalid inspection JSON for Go cache volume ${Name}: $($_.Exception.Message)" + } + if ($records.Count -ne 1 -or $null -eq $records[0].Labels) { + throw "Docker returned an incomplete inspection record for Go cache volume $Name" + } + $labels = $records[0].Labels + $identity = '{0}|{1}|{2}|{3}' -f $labels.'io.solutionforest.epar.project', $labels.'io.solutionforest.epar.cache', $labels.'io.solutionforest.epar.schema', $labels.'io.solutionforest.epar.root' + return [pscustomobject]@{ Exists = $true; Identity = $identity } +} + +function Initialize-EparGoCacheVolume { + param( + [Parameter(Mandatory = $true)][string] $Name, + [Parameter(Mandatory = $true)][string] $Role + ) + $expected = "$ProjectID|$Role|1|$repoRootPath" + $inspection = Get-EparGoCacheVolumeIdentity -Name $Name + if ($inspection.Exists) { + if ($inspection.Identity -cne $expected) { + throw "refusing Go cache volume ${Name}: EPAR ownership labels do not match this project" + } + return + } + docker volume create ` + --label "io.solutionforest.epar.project=$ProjectID" ` + --label "io.solutionforest.epar.cache=$Role" ` + --label 'io.solutionforest.epar.schema=1' ` + --label "io.solutionforest.epar.root=$repoRootPath" ` + $Name | Out-Null + if ($LASTEXITCODE -ne 0) { throw "failed to create exact EPAR Go cache volume $Name" } + $inspection = Get-EparGoCacheVolumeIdentity -Name $Name + if (-not $inspection.Exists -or $inspection.Identity -cne $expected) { + throw "refusing Go cache volume ${Name}: post-create ownership labels do not match this project" + } +} + +function Invoke-EparGoCacheLimit { + $active = @(@( + docker ps -q --filter "volume=$GomodVolume" + docker ps -q --filter "volume=$GocacheVolume" + ) | Where-Object { $_ } | Sort-Object -Unique) + if (@($active).Count -gt 0) { + Write-Warning 'EPAR Go cache limit check skipped because an exact cache volume is active.' + return + } + $gcName = "epar-$ProjectID-go-cache-gc" + $existingGC = @((docker ps -aq --filter "name=^/$gcName$") | Where-Object { $_ }) + if (@($existingGC).Count -gt 0) { + Write-Warning "EPAR Go cache limit check skipped because $gcName already exists." + return + } + $usageLines = @(docker run --rm ` + --name $gcName ` + -v "${GomodVolume}:/go/pkg/mod" ` + -v "${GocacheVolume}:/root/.cache/go-build" ` + $DevImage ` + du -sk /go/pkg/mod /root/.cache/go-build) + if ($LASTEXITCODE -ne 0) { throw 'failed to measure the exact EPAR Go cache volumes' } + [uint64] $usedKiB = 0 + foreach ($line in $usageLines) { + if ($line -notmatch '^\s*([0-9]+)\s+') { + throw "Docker returned an invalid Go cache usage line: $line" + } + [uint64] $entryKiB = 0 + if (-not [uint64]::TryParse($Matches[1], [ref] $entryKiB) -or $usedKiB -gt ([uint64]::MaxValue - $entryKiB)) { + throw 'Go cache usage exceeds the supported range' + } + $usedKiB += $entryKiB + } + if ($usageLines.Count -ne 2 -or $usedKiB -gt ([uint64]::MaxValue / 1024)) { + throw 'Docker returned an incomplete or overflowing Go cache measurement' + } + [uint64] $usedBytes = $usedKiB * 1024 + if ($usedBytes -gt $GoCacheLimitBytes) { + docker run --rm ` + --name $gcName ` + -v "${GomodVolume}:/go/pkg/mod" ` + -v "${GocacheVolume}:/root/.cache/go-build" ` + $DevImage ` + go clean -cache -modcache + if ($LASTEXITCODE -ne 0) { throw 'failed to clear the exact EPAR Go cache volumes' } + } +} + +function Get-EparNativeSourceHash { + param( + [Parameter(Mandatory = $true)][string] $DevImageID, + [Parameter(Mandatory = $true)][string] $GitCommit, + [Parameter(Mandatory = $true)][string] $SourceState + ) + $sourceFiles = @( + Get-ChildItem -LiteralPath (Join-Path $RepoRoot 'cmd'), (Join-Path $RepoRoot 'internal') -Filter '*.go' -File -Recurse + Get-ChildItem -LiteralPath (Join-Path $RepoRoot 'scripts\docker') -File -Recurse + Get-Item -LiteralPath (Join-Path $RepoRoot 'go.mod'), (Join-Path $RepoRoot 'go.sum') + Get-Item -LiteralPath $MyInvocation.ScriptName + ) | Sort-Object FullName + $material = [System.Text.StringBuilder]::new() + [void] $material.AppendLine('windows/amd64') + [void] $material.AppendLine($DevImageID) + [void] $material.AppendLine($GitCommit) + [void] $material.AppendLine($SourceState) + foreach ($file in $sourceFiles) { + $relative = $file.FullName.Substring($RepoRoot.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/') + $digest = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + [void] $material.AppendLine($relative) + [void] $material.AppendLine($digest) + } + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($material.ToString()) + return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Get-EparDirectoryBytes { + param([Parameter(Mandatory = $true)][string] $Path) + $measurement = Get-ChildItem -LiteralPath $Path -File -Force -Recurse -ErrorAction Stop | Measure-Object -Property Length -Sum + if ($null -eq $measurement.Sum) { return [int64] 0 } + return [int64] $measurement.Sum +} + +function Test-EparNativeControllerLeaseActive { + param([Parameter(Mandatory = $true)][string] $Directory) + $now = [DateTime]::UtcNow + foreach ($lease in @(Get-ChildItem -LiteralPath $Directory -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^lease(?:-|\.)' })) { + $fields = @{} + try { + foreach ($line in @(Get-Content -LiteralPath $lease.FullName -ErrorAction Stop)) { + $separator = $line.IndexOf('=') + if ($separator -gt 0) { + $fields[$line.Substring(0, $separator)] = $line.Substring($separator + 1) + } + } + } catch { + return $true + } + $startedAt = [DateTime]::MinValue + if (-not [DateTime]::TryParse($fields.startedAtUtc, [ref] $startedAt)) { + return $true + } + $startedAt = $startedAt.ToUniversalTime() + if ($fields.host -ne [Environment]::MachineName) { + if (($now - $startedAt) -lt [TimeSpan]::FromDays(30)) { return $true } + continue + } + $leasePID = 0 + if (-not [int]::TryParse($fields.pid, [ref] $leasePID) -or $leasePID -le 0) { + return $true + } + try { + $process = Get-Process -Id $leasePID -ErrorAction Stop + if ($fields.processStartUtc) { + $expectedStart = [DateTime]::MinValue + if (-not [DateTime]::TryParse($fields.processStartUtc, [ref] $expectedStart)) { + return $true + } + if ($process.StartTime.ToUniversalTime() -ne $expectedStart.ToUniversalTime()) { + continue + } + } + return $true + } catch { + continue + } + } + return $false +} + +function Test-EparNativeControllerBuildLeaseValid { + param([Parameter(Mandatory = $true)][System.IO.FileInfo] $Lease) + if (($Lease.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 -or $Lease.Name -notmatch '^lease-build-([1-9][0-9]*)-[0-9a-f]{32}\.txt$') { + return $false + } + $namePID = $Matches[1] + $fields = @{} + try { + foreach ($line in @(Get-Content -LiteralPath $Lease.FullName -ErrorAction Stop)) { + $separator = $line.IndexOf('=') + if ($separator -le 0) { return $false } + $key = $line.Substring(0, $separator) + if ($fields.ContainsKey($key)) { return $false } + $fields[$key] = $line.Substring($separator + 1) + } + } catch { + return $false + } + if ($fields.Count -ne 5 -or $fields.schemaVersion -ne '1' -or -not $fields.host) { return $false } + $leasePID = 0 + if (-not [int]::TryParse($fields.pid, [ref] $leasePID) -or $leasePID -le 0 -or $leasePID.ToString() -ne $namePID) { return $false } + $processStartUtc = [DateTime]::MinValue + if (-not [DateTime]::TryParse($fields.processStartUtc, [ref] $processStartUtc)) { return $false } + $startedAtUtc = [DateTime]::MinValue + return [DateTime]::TryParse($fields.startedAtUtc, [ref] $startedAtUtc) +} + +function Invoke-EparNativeControllerCacheRetention { + param( + [Parameter(Mandatory = $true)][string] $CacheRoot, + [Parameter(Mandatory = $true)][ValidatePattern('^[0-9a-f]{64}$')][string] $CurrentCacheKey, + [ValidateRange(0, 50)][int] $KeepPrevious = 5, + [ValidateRange(1, [long]::MaxValue)][long] $MaxBytes = 268435456, + [TimeSpan] $GracePeriod = ([TimeSpan]::FromDays(7)), + [TimeSpan] $AbandonedBuildGracePeriod = ([TimeSpan]::FromHours(24)) + ) + if (-not (Test-Path -LiteralPath $CacheRoot -PathType Container)) { return } + $resolvedRoot = [System.IO.Path]::GetFullPath($CacheRoot).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $expectedCurrent = [System.IO.Path]::GetFullPath((Join-Path $resolvedRoot $CurrentCacheKey)) + $now = [DateTime]::UtcNow + + foreach ($directory in @(Get-ChildItem -LiteralPath $resolvedRoot -Directory -Force | Where-Object { $_.Name -match '^\.build[-.][0-9A-Za-z]+$' })) { + if (($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue } + $buildLeases = @(Get-ChildItem -LiteralPath $directory.FullName -File -Force -ErrorAction SilentlyContinue | Where-Object { Test-EparNativeControllerBuildLeaseValid -Lease $_ }) + if ($buildLeases.Count -ne 1) { continue } + if (Test-EparNativeControllerLeaseActive -Directory $directory.FullName) { continue } + if (($now - $directory.LastWriteTimeUtc) -lt $AbandonedBuildGracePeriod) { continue } + $candidate = [System.IO.Path]::GetFullPath($directory.FullName) + if ([System.IO.Path]::GetDirectoryName($candidate) -ne $resolvedRoot) { continue } + Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction Stop + } + + $entries = @() + foreach ($directory in @(Get-ChildItem -LiteralPath $resolvedRoot -Directory -Force | Where-Object { $_.Name -match '^[0-9a-f]{64}$' })) { + if (($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue } + $candidate = [System.IO.Path]::GetFullPath($directory.FullName) + if ($candidate -eq $expectedCurrent) { continue } + if ([System.IO.Path]::GetDirectoryName($candidate) -ne $resolvedRoot) { continue } + if (Test-EparNativeControllerLeaseActive -Directory $candidate) { continue } + + $files = @(Get-ChildItem -LiteralPath $candidate -File -Force -ErrorAction Stop) + $directories = @(Get-ChildItem -LiteralPath $candidate -Directory -Force -ErrorAction Stop) + if ($directories.Count -ne 0) { continue } + if (@($files | Where-Object { ($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 }).Count -ne 0) { continue } + $manifest = $files | Where-Object { $_.Name -eq 'controller-cache.manifest' } | Select-Object -First 1 + if ($null -eq $manifest) { continue } + $manifestLines = @(Get-Content -LiteralPath $manifest.FullName -ErrorAction Stop) + if (-not $manifestLines.Contains('schemaVersion=1') -or -not $manifestLines.Contains("cacheKey=$($directory.Name)")) { continue } + $executableLines = @($manifestLines | Where-Object { $_ -match '^executable=' }) + if ($executableLines.Count -ne 1) { continue } + $executable = $executableLines[0].Substring('executable='.Length) + if ($executable -notin @('ephemeral-action-runner', 'ephemeral-action-runner.exe')) { continue } + if (-not (Test-Path -LiteralPath (Join-Path $candidate $executable) -PathType Leaf)) { continue } + $unexpected = @($files | Where-Object { $_.Name -notin @($executable, 'controller-cache.manifest') -and $_.Name -notmatch '^lease(?:-|\.)' }) + if ($unexpected.Count -ne 0) { continue } + $entries += [pscustomobject]@{ + Path = $candidate + Name = $directory.Name + LastWriteTimeUtc = $directory.LastWriteTimeUtc + Bytes = Get-EparDirectoryBytes -Path $candidate + } + } + + $retainedCount = 0 + $retainedBytes = if (Test-Path -LiteralPath $expectedCurrent -PathType Container) { Get-EparDirectoryBytes -Path $expectedCurrent } else { [int64] 0 } + foreach ($entry in @($entries | Sort-Object -Property @{ Expression = 'LastWriteTimeUtc'; Descending = $true }, @{ Expression = 'Name'; Descending = $false })) { + $withinGrace = ($now - $entry.LastWriteTimeUtc) -lt $GracePeriod + $withinCount = $retainedCount -lt $KeepPrevious + $withinBudget = $retainedBytes -le ($MaxBytes - $entry.Bytes) + if ($withinGrace -or ($withinCount -and $withinBudget)) { + $retainedCount++ + $retainedBytes += $entry.Bytes + continue + } + $candidate = [System.IO.Path]::GetFullPath($entry.Path) + if ([System.IO.Path]::GetDirectoryName($candidate) -ne $resolvedRoot -or [System.IO.Path]::GetFileName($candidate) -notmatch '^[0-9a-f]{64}$') { + throw "refusing native-controller cache retention outside the exact cache root: $candidate" + } + Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction Stop + } +} + +function Test-EparBenignDockerDesktopPrefaceDiagnostic { + param([Parameter(Mandatory = $true)][string] $Transcript) + $normalized = ($Transcript -replace '\s+', ' ').Trim() + return $normalized -match '^(?:docker\s*:\s*)?\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} http2: server: error reading preface from client //\./pipe/(?:dockerDesktopLinuxEngine|docker_engine): file has already been closed(?: At .* FullyQualifiedErrorId\s*:\s*NativeCommandError)?$' +} + +function Test-EparRetryableDockerContextMetadataDiagnostic { + param([Parameter(Mandatory = $true)][string] $Transcript) + $normalized = ($Transcript -replace '\s+', ' ').Trim() + return $normalized -match '^(?:docker(?:\.exe|\.cmd)?\s*:\s*)?ERROR: failed to build: failed to read metadata: open [^:*?"<>|\r\n]+:\\[^:*?"<>|\r\n]*\\\.docker\\contexts\\meta\\[0-9a-f]{64}\\meta\.json: The process cannot access the file because it is being used by another process\.(?: At .* FullyQualifiedErrorId\s*:\s*NativeCommandError)?$' +} + +function Invoke-EparDockerBuild { + $stderrPath = [System.IO.Path]::GetTempFileName() + $previousErrorActionPreference = $ErrorActionPreference + try { + # Windows PowerShell otherwise promotes native stderr to terminating + # ErrorRecord objects under Stop, before the Docker exit code and the + # complete transcript can be classified below. + $ErrorActionPreference = 'Continue' + $maximumAttempts = 5 + for ($attempt = 1; $attempt -le $maximumAttempts; $attempt++) { + docker build --quiet --build-arg "GO_IMAGE=$GoImage" -t $DevImage -f (Join-Path $RepoRoot 'scripts\docker\dev.Dockerfile') (Join-Path $RepoRoot 'scripts\docker') 2> $stderrPath | Out-Null + $exitCode = $LASTEXITCODE + $stderrTranscript = if (Test-Path -LiteralPath $stderrPath) { Get-Content -Raw -LiteralPath $stderrPath } else { '' } + if ($exitCode -ne 0 -and $attempt -lt $maximumAttempts -and (Test-EparRetryableDockerContextMetadataDiagnostic -Transcript $stderrTranscript)) { + $delayMilliseconds = 250 * [math]::Pow(2, $attempt - 1) + Write-Warning "Docker context metadata is temporarily busy; retrying toolchain build in $delayMilliseconds ms (attempt $($attempt + 1) of $maximumAttempts)." + Start-Sleep -Milliseconds $delayMilliseconds + continue + } + if ($stderrTranscript -and -not ($exitCode -eq 0 -and (Test-EparBenignDockerDesktopPrefaceDiagnostic -Transcript $stderrTranscript))) { + [Console]::Error.Write($stderrTranscript) + } + return $exitCode + } + throw 'unreachable Docker build retry state' + } finally { + $ErrorActionPreference = $previousErrorActionPreference + Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue + } +} + +$buildExit = Invoke-EparDockerBuild +if ($buildExit -ne 0) { exit $buildExit } +$DevImageID = ((docker image inspect --format '{{.Id}}' $DevImage) -join '').Trim() +if ($LASTEXITCODE -ne 0 -or $DevImageID -notmatch '^sha256:[0-9a-f]{64}$') { + Write-Error "could not resolve the immutable Docker toolchain image ID for $DevImage" + exit 1 +} +if ($ManageGoCache) { + Initialize-EparGoCacheVolume -Name $GomodVolume -Role 'gomod' + Initialize-EparGoCacheVolume -Name $GocacheVolume -Role 'gobuild' + Invoke-EparGoCacheLimit +} + +$gitCommit = 'unknown' +$sourceState = 'unknown' +if (Get-Command git -ErrorAction SilentlyContinue) { + $gitCommitOutput = ((& git -C $RepoRoot rev-parse --verify HEAD 2>$null) -join '').Trim() + if ($LASTEXITCODE -eq 0 -and $gitCommitOutput -match '^[0-9a-f]{40}$') { + $gitCommit = $gitCommitOutput + $gitStatus = @(& git -C $RepoRoot status --porcelain=v1 --untracked-files=all 2>$null) + if ($LASTEXITCODE -eq 0) { + $sourceState = if ($gitStatus.Count -eq 0) { 'clean' } else { 'dirty' } + } + } +} + +$cacheKey = Get-EparNativeSourceHash -DevImageID $DevImageID -GitCommit $gitCommit -SourceState $sourceState +$controllerSourceRevision = if ($sourceState -eq 'clean') { "sha256:$cacheKey" } elseif ($sourceState -eq 'dirty') { "dirty:sha256:$cacheKey" } else { 'unknown' } +$cacheRoot = Join-Path $RepoRoot '.local\bin' +$cacheDirectory = Join-Path $cacheRoot $cacheKey +$binary = Join-Path $cacheDirectory 'ephemeral-action-runner.exe' +if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { + New-Item -ItemType Directory -Force -Path $cacheRoot | Out-Null + $temporaryDirectory = Join-Path $cacheRoot ('.build-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null + $buildLeasePath = Join-Path $temporaryDirectory ("lease-build-{0}-{1}.txt" -f $PID, [guid]::NewGuid().ToString('N')) + $buildProcessStartUtc = (Get-Process -Id $PID).StartTime.ToUniversalTime().ToString('o') + [System.IO.File]::WriteAllLines($buildLeasePath, @( + 'schemaVersion=1', + "host=$([Environment]::MachineName)", + "pid=$PID", + "processStartUtc=$buildProcessStartUtc", + "startedAtUtc=$([DateTime]::UtcNow.ToString('o'))" + ), [System.Text.UTF8Encoding]::new($false)) + try { + docker run --rm ` + -e CGO_ENABLED=0 ` + -e GOOS=windows ` + -e GOARCH=amd64 ` + -v "${RepoRoot}:/src:ro" ` + -v "${temporaryDirectory}:/out" ` + -v "${GomodVolume}:/go/pkg/mod" ` + -v "${GocacheVolume}:/root/.cache/go-build" ` + -w /src ` + $DevImage ` + go build -trimpath -ldflags "-X main.sourceRevision=$controllerSourceRevision" -o /out/ephemeral-action-runner.exe ./cmd/ephemeral-action-runner + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Test-Path -LiteralPath (Join-Path $temporaryDirectory 'ephemeral-action-runner.exe') -PathType Leaf)) { + Write-Error 'native EPAR build completed without producing the expected Windows binary' + exit 1 + } + if (-not (Test-Path -LiteralPath $cacheDirectory)) { + Remove-Item -LiteralPath $buildLeasePath -Force + Move-Item -LiteralPath $temporaryDirectory -Destination $cacheDirectory + $temporaryDirectory = $null + } + } finally { + if ($temporaryDirectory -and (Test-Path -LiteralPath $temporaryDirectory)) { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } +} +if ($ManageGoCache) { + $configuredGoCacheLimit = ((& $binary storage effective-go-cache-limit --project-root $RepoRoot) -join '').Trim() + $parsedConfiguredGoCacheLimit = [uint64] 0 + if ($LASTEXITCODE -ne 0 -or -not [uint64]::TryParse($configuredGoCacheLimit, [ref] $parsedConfiguredGoCacheLimit) -or $parsedConfiguredGoCacheLimit -eq 0) { + throw 'EPAR returned an invalid configured Go cache limit' + } + $GoCacheLimitBytes = $parsedConfiguredGoCacheLimit + Invoke-EparGoCacheLimit +} + +$manifestPath = Join-Path $cacheDirectory 'controller-cache.manifest' +$manifestTemporaryPath = Join-Path $cacheDirectory ('.manifest-' + [guid]::NewGuid().ToString('N')) +[System.IO.File]::WriteAllLines($manifestTemporaryPath, @( + 'schemaVersion=1', + "cacheKey=$cacheKey", + 'executable=ephemeral-action-runner.exe', + "completedAtUtc=$([DateTime]::UtcNow.ToString('o'))" +), [System.Text.UTF8Encoding]::new($false)) +Move-Item -LiteralPath $manifestTemporaryPath -Destination $manifestPath -Force + +$leasePath = Join-Path $cacheDirectory ("lease-{0}-{1}.txt" -f $PID, [guid]::NewGuid().ToString('N')) +$processStartUtc = (Get-Process -Id $PID).StartTime.ToUniversalTime().ToString('o') +[System.IO.File]::WriteAllLines($leasePath, @( + 'schemaVersion=1', + "host=$([Environment]::MachineName)", + "pid=$PID", + "processStartUtc=$processStartUtc", + "startedAtUtc=$([DateTime]::UtcNow.ToString('o'))" +), [System.Text.UTF8Encoding]::new($false)) +try { + Invoke-EparNativeControllerCacheRetention -CacheRoot $cacheRoot -CurrentCacheKey $cacheKey +} catch { + Write-Warning "Native-controller cache retention skipped after an error: $($_.Exception.Message)" +} + +$previousNative = $env:EPAR_NATIVE_CONTROLLER +$previousControllerOS = $env:EPAR_CONTROLLER_HOST_OS +$previousHostName = $env:EPAR_HOST_NAME +$previousHints = $env:DOCKER_CLI_HINTS +try { + $env:EPAR_NATIVE_CONTROLLER = '1' + $env:EPAR_CONTROLLER_HOST_OS = 'windows' + if (-not $env:EPAR_HOST_NAME) { + $env:EPAR_HOST_NAME = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { [System.Net.Dns]::GetHostName() } + } + if (-not $env:DOCKER_CLI_HINTS) { $env:DOCKER_CLI_HINTS = 'false' } + & $binary @EparArgs + exit $LASTEXITCODE +} finally { + Remove-Item -LiteralPath $leasePath -Force -ErrorAction SilentlyContinue + if ($null -eq $previousNative) { Remove-Item Env:EPAR_NATIVE_CONTROLLER -ErrorAction SilentlyContinue } else { $env:EPAR_NATIVE_CONTROLLER = $previousNative } + if ($null -eq $previousControllerOS) { Remove-Item Env:EPAR_CONTROLLER_HOST_OS -ErrorAction SilentlyContinue } else { $env:EPAR_CONTROLLER_HOST_OS = $previousControllerOS } + if ($null -eq $previousHostName) { Remove-Item Env:EPAR_HOST_NAME -ErrorAction SilentlyContinue } else { $env:EPAR_HOST_NAME = $previousHostName } + if ($null -eq $previousHints) { Remove-Item Env:DOCKER_CLI_HINTS -ErrorAction SilentlyContinue } else { $env:DOCKER_CLI_HINTS = $previousHints } +} diff --git a/scripts/build-native-controller.sh b/scripts/build-native-controller.sh new file mode 100644 index 0000000..0e3ddc6 --- /dev/null +++ b/scripts/build-native-controller.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +go_image="${GO_DOCKER_IMAGE:-golang:1.25}" +dev_image="${EPAR_DEV_IMAGE:-epar-dev-toolchain}" +native_cache_keep_previous=5 +native_cache_max_bytes=$((256 * 1024 * 1024)) +native_cache_grace_seconds=$((7 * 24 * 60 * 60)) +abandoned_build_grace_seconds=$((24 * 60 * 60)) +bootstrap_minimum_free_bytes="${EPAR_BOOTSTRAP_MIN_FREE_BYTES:-$((20 * 1024 * 1024 * 1024))}" +go_cache_limit_bytes="${EPAR_GO_CACHE_LIMIT_BYTES:-$((10 * 1024 * 1024 * 1024))}" + +command -v docker >/dev/null 2>&1 || { echo "docker command not found. Install Docker Desktop, Docker Engine, or a compatible Docker host." >&2; exit 1; } +command -v shasum >/dev/null 2>&1 || { echo "shasum is required to build the native EPAR cache key." >&2; exit 1; } +[[ "$bootstrap_minimum_free_bytes" =~ ^[1-9][0-9]*$ ]] || { echo "EPAR_BOOTSTRAP_MIN_FREE_BYTES must be a positive integer byte count." >&2; exit 1; } +[[ "$go_cache_limit_bytes" =~ ^[1-9][0-9]*$ ]] || { echo "EPAR_GO_CACHE_LIMIT_BYTES must be a positive integer byte count." >&2; exit 1; } +project_id="$(printf '%s' "$repo_root" | shasum -a 256 | awk '{print substr($1,1,12)}')" +gomod_volume="${EPAR_GOMOD_VOLUME:-epar-${project_id}-gomod}" +gocache_volume="${EPAR_GOCACHE_VOLUME:-epar-${project_id}-gocache}" +manage_go_cache=0 +if [[ -z "${EPAR_GOMOD_VOLUME:-}" && -z "${EPAR_GOCACHE_VOLUME:-}" ]]; then manage_go_cache=1; fi +bootstrap_available_kib="$(df -Pk "$repo_root" | awk 'NR == 2 { print $4 }')" +[[ "$bootstrap_available_kib" =~ ^[0-9]+$ ]] || { echo "cannot measure bootstrap storage for ${repo_root}" >&2; exit 1; } +bootstrap_available_bytes=$((bootstrap_available_kib * 1024)) +if ((bootstrap_available_bytes < bootstrap_minimum_free_bytes)); then + echo "insufficient bootstrap storage for ${repo_root}: available=${bootstrap_available_bytes} required-reserve=${bootstrap_minimum_free_bytes}. Free space or run 'ephemeral-action-runner storage status' from an existing controller before retrying." >&2 + exit 1 +fi + +epar_ensure_go_cache_volume() { + local volume="$1" + local role="$2" + local expected="${project_id}|${role}|1|${repo_root}" + local actual="" + if actual="$(docker volume inspect --format '{{ index .Labels "io.solutionforest.epar.project" }}|{{ index .Labels "io.solutionforest.epar.cache" }}|{{ index .Labels "io.solutionforest.epar.schema" }}|{{ index .Labels "io.solutionforest.epar.root" }}' "$volume" 2>/dev/null)"; then + [[ "$actual" == "$expected" ]] || { echo "refusing Go cache volume ${volume}: EPAR ownership labels do not match this project" >&2; return 1; } + return 0 + fi + docker volume create \ + --label "io.solutionforest.epar.project=${project_id}" \ + --label "io.solutionforest.epar.cache=${role}" \ + --label 'io.solutionforest.epar.schema=1' \ + --label "io.solutionforest.epar.root=${repo_root}" \ + "$volume" >/dev/null + actual="$(docker volume inspect --format '{{ index .Labels "io.solutionforest.epar.project" }}|{{ index .Labels "io.solutionforest.epar.cache" }}|{{ index .Labels "io.solutionforest.epar.schema" }}|{{ index .Labels "io.solutionforest.epar.root" }}' "$volume")" + [[ "$actual" == "$expected" ]] || { echo "refusing Go cache volume ${volume}: post-create ownership labels do not match this project" >&2; return 1; } +} + +epar_enforce_go_cache_limit() { + local active gc_name + active="$( + { + docker ps -q --filter "volume=${gomod_volume}" + docker ps -q --filter "volume=${gocache_volume}" + } | sort -u | sed '/^$/d' + )" + if [[ -n "$active" ]]; then + echo "warning: EPAR Go cache limit check skipped because an exact cache volume is active" >&2 + return 0 + fi + gc_name="epar-${project_id}-go-cache-gc" + if [[ -n "$(docker ps -aq --filter "name=^/${gc_name}$")" ]]; then + echo "warning: EPAR Go cache limit check skipped because ${gc_name} already exists" >&2 + return 0 + fi + docker run --rm \ + --name "$gc_name" \ + -e "EPAR_GO_CACHE_LIMIT_BYTES=${go_cache_limit_bytes}" \ + -v "${gomod_volume}:/go/pkg/mod" \ + -v "${gocache_volume}:/root/.cache/go-build" \ + "$dev_image" \ + sh -ceu 'mod_kib="$(du -sk /go/pkg/mod | awk "{print \$1}")"; build_kib="$(du -sk /root/.cache/go-build | awk "{print \$1}")"; used_bytes="$(((mod_kib + build_kib) * 1024))"; if [ "$used_bytes" -gt "$EPAR_GO_CACHE_LIMIT_BYTES" ]; then go clean -cache -modcache; fi' +} + +epar_directory_mtime() { + stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" +} + +epar_directory_bytes() { + du -sk "$1" | awk '{print $1 * 1024}' +} + +epar_native_controller_lease_active() { + local directory="$1" + local lease lease_host lease_pid lease_started now host + now="$(date +%s)" + host="$(hostname 2>/dev/null || true)" + for lease in "${directory}"/lease-* "${directory}"/lease.*; do + [[ -f "$lease" ]] || continue + lease_host="$(sed -n 's/^host=//p' "$lease" | head -n 1)" + lease_pid="$(sed -n 's/^pid=//p' "$lease" | head -n 1)" + lease_started="$(sed -n 's/^startedAtUnix=//p' "$lease" | head -n 1)" + [[ "$lease_started" =~ ^[0-9]+$ ]] || return 0 + if [[ "$lease_host" != "$host" ]]; then + ((now - lease_started >= 30 * 24 * 60 * 60)) || return 0 + continue + fi + [[ "$lease_pid" =~ ^[1-9][0-9]*$ ]] || return 0 + kill -0 "$lease_pid" 2>/dev/null && return 0 + done + return 1 +} + +epar_native_controller_build_lease_valid() { + local lease="$1" + local name="${lease##*/}" + [[ -f "$lease" && ! -L "$lease" ]] || return 1 + [[ "$name" =~ ^lease-build-([1-9][0-9]*)\.[0-9A-Za-z]{6}$ ]] || return 1 + local name_pid="${BASH_REMATCH[1]}" + [[ "$(wc -l <"$lease" | tr -d ' ')" == "4" ]] || return 1 + [[ "$(grep -c '^schemaVersion=' "$lease")" == "1" ]] || return 1 + grep -Fqx 'schemaVersion=1' "$lease" || return 1 + [[ "$(grep -c '^host=.' "$lease")" == "1" ]] || return 1 + [[ "$(grep -c '^pid=[1-9][0-9]*$' "$lease")" == "1" ]] || return 1 + [[ "$(grep -c '^startedAtUnix=[0-9][0-9]*$' "$lease")" == "1" ]] || return 1 + [[ "$(sed -n 's/^pid=//p' "$lease")" == "$name_pid" ]] || return 1 +} + +epar_prune_native_controller_cache() { + local cache_root="$1" + local current_cache_key="$2" + local now path name mtime bytes manifest unexpected lease valid_build_leases executable + local retained_count=0 + local retained_bytes=0 + local within_grace=0 + [[ "$current_cache_key" =~ ^[0-9a-f]{64}$ ]] || return 1 + [[ -d "$cache_root" ]] || return 0 + now="$(date +%s)" + + for path in "${cache_root}"/.build-* "${cache_root}"/.build.*; do + [[ -d "$path" ]] || continue + [[ ! -L "$path" ]] || continue + name="${path##*/}" + [[ "$name" =~ ^\.build[-.][0-9A-Za-z]+$ ]] || continue + valid_build_leases=0 + for lease in "${path}"/lease-build-*; do + epar_native_controller_build_lease_valid "$lease" || continue + valid_build_leases=$((valid_build_leases + 1)) + done + ((valid_build_leases == 1)) || continue + epar_native_controller_lease_active "$path" && continue + mtime="$(epar_directory_mtime "$path")" || continue + if ((now - mtime >= abandoned_build_grace_seconds)); then + rm -rf -- "${cache_root:?}/${name}" + fi + done + + retention_inventory_file="$(mktemp "${cache_root}/.retention.XXXXXX")" + for path in "${cache_root}"/*; do + [[ -d "$path" ]] || continue + [[ ! -L "$path" ]] || continue + name="${path##*/}" + [[ "$name" =~ ^[0-9a-f]{64}$ ]] || continue + [[ "$name" != "$current_cache_key" ]] || continue + epar_native_controller_lease_active "$path" && continue + manifest="${path}/controller-cache.manifest" + [[ -f "$manifest" && ! -L "$manifest" ]] || continue + grep -Fqx 'schemaVersion=1' "$manifest" || continue + grep -Fqx "cacheKey=${name}" "$manifest" || continue + [[ "$(grep -c '^executable=' "$manifest")" == "1" ]] || continue + executable="$(sed -n 's/^executable=//p' "$manifest")" + [[ "$executable" == ephemeral-action-runner || "$executable" == ephemeral-action-runner.exe ]] || continue + [[ -f "${path}/${executable}" && ! -L "${path}/${executable}" ]] || continue + unexpected="$(find "$path" -mindepth 1 -maxdepth 1 ! \( -type f \( -name "$executable" -o -name controller-cache.manifest -o -name 'lease-*' -o -name 'lease.*' \) \) -print -quit)" + [[ -z "$unexpected" ]] || continue + mtime="$(epar_directory_mtime "$path")" || continue + bytes="$(epar_directory_bytes "$path")" || continue + printf '%s:%s:%s\n' "$mtime" "$bytes" "$name" >>"$retention_inventory_file" + done + + if [[ -d "${cache_root}/${current_cache_key}" ]]; then + retained_bytes="$(epar_directory_bytes "${cache_root}/${current_cache_key}")" + fi + while IFS=: read -r mtime bytes name; do + [[ "$mtime" =~ ^[0-9]+$ && "$bytes" =~ ^[0-9]+$ && "$name" =~ ^[0-9a-f]{64}$ ]] || continue + within_grace=0 + ((now - mtime < native_cache_grace_seconds)) && within_grace=1 + if ((within_grace == 1 || (retained_count < native_cache_keep_previous && retained_bytes + bytes <= native_cache_max_bytes))); then + retained_count=$((retained_count + 1)) + retained_bytes=$((retained_bytes + bytes)) + continue + fi + path="${cache_root}/${name}" + [[ "${path%/*}" == "$cache_root" && "${path##*/}" =~ ^[0-9a-f]{64}$ ]] || return 1 + rm -rf -- "$path" + done < <(sort -t: -k1,1nr -k3,3 "$retention_inventory_file") + rm -f -- "$retention_inventory_file" + retention_inventory_file="" +} + +case "$(uname -s)/$(uname -m)" in + Darwin/arm64) goos=darwin; goarch=arm64 ;; + Linux/x86_64|Linux/amd64) goos=linux; goarch=amd64 ;; + Linux/aarch64|Linux/arm64) goos=linux; goarch=arm64 ;; + *) echo "unsupported native EPAR controller platform: $(uname -s)/$(uname -m)" >&2; exit 1 ;; +esac + +docker build --quiet \ + --build-arg "GO_IMAGE=${go_image}" \ + -t "$dev_image" \ + -f "${repo_root}/scripts/docker/dev.Dockerfile" \ + "${repo_root}/scripts/docker" >/dev/null +dev_image_id="$(docker image inspect --format '{{.Id}}' "$dev_image")" +[[ "$dev_image_id" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "could not resolve the immutable Docker toolchain image ID for ${dev_image}" >&2; exit 1; } +if ((manage_go_cache == 1)); then + epar_ensure_go_cache_volume "$gomod_volume" gomod + epar_ensure_go_cache_volume "$gocache_volume" gobuild + epar_enforce_go_cache_limit +fi + +git_commit=unknown +source_state=unknown +if command -v git >/dev/null 2>&1; then + git_commit_candidate="$(git -C "$repo_root" rev-parse --verify HEAD 2>/dev/null || true)" + if [[ "$git_commit_candidate" =~ ^[0-9a-f]{40}$ ]]; then + git_commit="$git_commit_candidate" + if git -C "$repo_root" status --porcelain=v1 --untracked-files=all >/dev/null 2>&1; then + if [[ -z "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=all)" ]]; then source_state=clean; else source_state=dirty; fi + fi + fi +fi + +source_manifest="$( + printf '%s\n%s\n%s\n%s\n' "${goos}/${goarch}" "$dev_image_id" "$git_commit" "$source_state" + { + find "${repo_root}/cmd" "${repo_root}/internal" -type f -name '*.go' -print + find "${repo_root}/scripts/docker" -type f -print + printf '%s\n' "${repo_root}/go.mod" "${repo_root}/go.sum" "${repo_root}/scripts/build-native-controller.sh" + } | LC_ALL=C sort | while IFS= read -r file; do + printf '%s\n' "${file#"${repo_root}/"}" + shasum -a 256 "$file" | awk '{print $1}' + done +)" +cache_key="$(printf '%s' "$source_manifest" | shasum -a 256 | awk '{print $1}')" +case "$source_state" in + clean) controller_source_revision="sha256:${cache_key}" ;; + dirty) controller_source_revision="dirty:sha256:${cache_key}" ;; + *) controller_source_revision=unknown ;; +esac +cache_root="${repo_root}/.local/bin" +cache_directory="${cache_root}/${cache_key}" +binary="${cache_directory}/ephemeral-action-runner" + +temporary_directory="" +lease_file="" +build_lease_file="" +retention_inventory_file="" +manifest_temporary="" +cleanup_build() { + if [[ -n "$temporary_directory" && -d "$temporary_directory" ]]; then rm -rf -- "$temporary_directory"; fi + if [[ -n "$lease_file" && -f "$lease_file" ]]; then rm -f -- "$lease_file"; fi + if [[ -n "$build_lease_file" && -f "$build_lease_file" ]]; then rm -f -- "$build_lease_file"; fi + if [[ -n "$retention_inventory_file" && -f "$retention_inventory_file" ]]; then rm -f -- "$retention_inventory_file"; fi + if [[ -n "$manifest_temporary" && -f "$manifest_temporary" ]]; then rm -f -- "$manifest_temporary"; fi +} +trap cleanup_build EXIT INT TERM + +if [[ ! -x "$binary" ]]; then + mkdir -p "$cache_root" + temporary_directory="$(mktemp -d "${cache_root}/.build.XXXXXX")" + build_lease_file="$(mktemp "${temporary_directory}/lease-build-$$.XXXXXX")" + printf '%s\n' \ + 'schemaVersion=1' \ + "host=$(hostname 2>/dev/null || true)" \ + "pid=$$" \ + "startedAtUnix=$(date +%s)" >"$build_lease_file" + docker run --rm \ + -e CGO_ENABLED=0 \ + -e "GOOS=${goos}" \ + -e "GOARCH=${goarch}" \ + -v "${repo_root}:/src:ro" \ + -v "${temporary_directory}:/out" \ + -v "${gomod_volume}:/go/pkg/mod" \ + -v "${gocache_volume}:/root/.cache/go-build" \ + -w /src \ + "$dev_image" \ + go build -trimpath -ldflags "-X main.sourceRevision=${controller_source_revision}" -o /out/ephemeral-action-runner ./cmd/ephemeral-action-runner + [[ -f "${temporary_directory}/ephemeral-action-runner" ]] || { echo "native EPAR build did not produce the expected binary" >&2; exit 1; } + chmod 0755 "${temporary_directory}/ephemeral-action-runner" + if [[ ! -e "$cache_directory" ]]; then + rm -f -- "$build_lease_file" + build_lease_file="" + mv -- "$temporary_directory" "$cache_directory" + temporary_directory="" + fi +fi +if ((manage_go_cache == 1)); then + go_cache_limit_bytes="$("$binary" storage effective-go-cache-limit --project-root "$repo_root")" + [[ "$go_cache_limit_bytes" =~ ^[1-9][0-9]*$ ]] || { echo "EPAR returned an invalid configured Go cache limit" >&2; exit 1; } + epar_enforce_go_cache_limit +fi + +manifest_temporary="$(mktemp "${cache_directory}/.manifest.XXXXXX")" +printf '%s\n' \ + 'schemaVersion=1' \ + "cacheKey=${cache_key}" \ + 'executable=ephemeral-action-runner' \ + "completedAtUnix=$(date +%s)" >"$manifest_temporary" +mv -f -- "$manifest_temporary" "${cache_directory}/controller-cache.manifest" +manifest_temporary="" + +lease_file="$(mktemp "${cache_directory}/lease.$$.XXXXXX")" +printf '%s\n' \ + 'schemaVersion=1' \ + "host=$(hostname 2>/dev/null || true)" \ + "pid=$$" \ + "startedAtUnix=$(date +%s)" >"$lease_file" +if ! epar_prune_native_controller_cache "$cache_root" "$cache_key"; then + echo "warning: native-controller cache retention skipped after an error" >&2 +fi + +export EPAR_NATIVE_CONTROLLER=1 +export EPAR_CONTROLLER_HOST_OS="$goos" +export DOCKER_CLI_HINTS="${DOCKER_CLI_HINTS:-false}" +export EPAR_HOST_NAME="${EPAR_HOST_NAME:-$(hostname 2>/dev/null || true)}" +"$binary" "$@" +status=$? +cleanup_build +trap - EXIT INT TERM +exit "$status" diff --git a/scripts/docker-sandboxes/build-template.ps1 b/scripts/docker-sandboxes/build-template.ps1 new file mode 100644 index 0000000..a27aba4 --- /dev/null +++ b/scripts/docker-sandboxes/build-template.ps1 @@ -0,0 +1,411 @@ +[CmdletBinding()] +param( + [ValidateSet('act-22.04', 'full')] + [string]$Profile = 'act-22.04', + [ValidateSet('linux/amd64', 'linux/arm64')] + [string]$Platform = 'linux/amd64', + [string]$OutputDirectory, + [switch]$Execute, + [switch]$ReplaceArtifacts +) + +$ErrorActionPreference = 'Stop' +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $scriptDirectory '..\..')) +$templateDirectory = Join-Path $repositoryRoot 'templates\docker-sandboxes' +$lockPath = Join-Path $templateDirectory 'sources.lock.json' +$storageStateDirectory = Join-Path $repositoryRoot '.local\state\storage' +$ownerIDPath = Join-Path $storageStateDirectory 'owner-id' +$builderMetadataPath = Join-Path $storageStateDirectory 'buildx-builder.json' +$buildKitConfigPath = Join-Path $storageStateDirectory 'buildkitd.toml' +$lock = Get-Content -Raw -LiteralPath $lockPath | ConvertFrom-Json +$profileLock = $lock.profiles.PSObject.Properties[$Profile].Value +if ($null -eq $profileLock) { + throw "Profile $Profile is not present in $lockPath" +} +$platformLock = $lock.platforms.PSObject.Properties[$Platform].Value +if ($null -eq $platformLock) { + throw "Platform $Platform is not present in $lockPath" +} +$profilePlatformLock = $profileLock.platforms.PSObject.Properties[$Platform].Value +if ($null -eq $profilePlatformLock) { + throw "Profile $Profile does not define platform $Platform in $lockPath" +} +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $outputName = if ($Platform -eq 'linux/amd64') { $Profile } else { "{0}-{1}" -f $Profile, $platformLock.architecture } + $OutputDirectory = Join-Path $repositoryRoot ("work\template-builds\docker-sandboxes\{0}" -f $outputName) +} +$OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) + +function Get-Sha256Text { + param([Parameter(Mandatory = $true)][string]$Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text) + return 'sha256:' + ([System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()) + } + finally { + $sha.Dispose() + } +} + +function Get-TemplateContextDigest { + param([Parameter(Mandatory = $true)][string]$Directory) + $material = [System.Text.StringBuilder]::new() + $files = @(Get-ChildItem -LiteralPath $Directory -File -Recurse | Sort-Object FullName) + foreach ($file in $files) { + $relative = $file.FullName.Substring($Directory.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/') + $digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant() + [void]$material.AppendLine($relative) + [void]$material.AppendLine($digest) + } + return Get-Sha256Text -Text $material.ToString() +} + +function Test-RemoteIndex { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Reference, + [Parameter(Mandatory = $true)][string]$ExpectedIndexDigest, + [Parameter(Mandatory = $true)][string]$ExpectedManifestDigest, + [Parameter(Mandatory = $true)][string]$Platform + ) + Write-Host "Verifying $Name index and $Platform manifest without pulling layers..." + $rawLines = @(& docker buildx imagetools inspect --raw $Reference) + if ($LASTEXITCODE -ne 0) { + throw "docker buildx imagetools inspect failed for $Reference" + } + # OCI manifests are UTF-8 JSON with LF separators. Reconstructing them with + # the host newline would turn LF into CRLF on Windows and change the digest. + $raw = [string]::Join("`n", $rawLines) + $actualIndexDigest = Get-Sha256Text -Text $raw + if ($actualIndexDigest -ne $ExpectedIndexDigest) { + throw "$Name index digest mismatch: expected $ExpectedIndexDigest, got $actualIndexDigest" + } + $index = $raw | ConvertFrom-Json + $platformParts = $Platform -split '/', 2 + $matching = @($index.manifests | Where-Object { $_.platform.os -eq $platformParts[0] -and $_.platform.architecture -eq $platformParts[1] }) + if ($matching.Count -ne 1) { + throw "$Name must contain exactly one $Platform manifest; found $($matching.Count)" + } + if ($matching[0].digest -ne $ExpectedManifestDigest) { + throw "$Name $Platform manifest mismatch: expected $ExpectedManifestDigest, got $($matching[0].digest)" + } +} + +function Write-Utf8Json { + param( + [Parameter(Mandatory = $true)]$Value, + [Parameter(Mandatory = $true)][string]$Path + ) + $json = $Value | ConvertTo-Json -Depth 100 + [System.IO.File]::WriteAllText($Path, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) +} + +$minimumFreeBytes = [uint64](50GB) +$estimatedExpansionBytes = if ($Profile -eq 'full') { [uint64](30GB) } else { [uint64](10GB) } +$requiredAvailableBytes = $minimumFreeBytes + $estimatedExpansionBytes + +$buildPlan = [ordered]@{ + schemaVersion = 1 + execute = [bool]$Execute + profile = $Profile + status = $profilePlatformLock.validationStatus + platform = $Platform + source = $profileLock.immutableReference + sourceIndexDigest = $profileLock.indexDigest + sourceManifestDigest = $profilePlatformLock.manifestDigest + templateTag = $profilePlatformLock.templateTag + sbxCompatibility = '0.35.0 only' + outputDirectory = $OutputDirectory + storage = [ordered]@{ + surface = [System.IO.Path]::GetPathRoot($OutputDirectory) + estimatedExpansionBytes = $estimatedExpansionBytes + minimumFreeBytes = $minimumFreeBytes + requiredAvailableBytes = $requiredAvailableBytes + cleanupPreview = 'ephemeral-action-runner storage prune --provider docker-sandboxes' + } + operations = @( + "verify pinned OCI indexes and $Platform manifests", + "build one $Platform image and load it once into the local Docker image store", + 'save one docker-archive for operator-controlled sbx template load', + 'generate SPDX SBOM, max-mode provenance JSON, software inventory, helper hashes, and compatibility metadata' + ) +} +$buildPlan | ConvertTo-Json -Depth 10 + +if (-not $Execute) { + Write-Host 'Plan only. Re-run with -Execute to acquire image layers and mutate the local Docker image store. A cold full-profile acquisition may take up to 60 minutes.' + exit 0 +} + +if ($lock.schemaVersion -ne 2 -or $lock.defaultPlatform -ne 'linux/amd64' -or -not @($lock.supportedPlatforms).Contains($Platform)) { + throw 'Unsupported source lock schema or platform' +} +if ($profilePlatformLock.templateTag -notmatch '^epar-docker-sandboxes-[a-z0-9._-]+:[a-z0-9._-]+$') { + throw "Template tag does not satisfy the epar-docker-sandboxes-* naming contract: $($profilePlatformLock.templateTag)" +} + +& docker buildx version *> $null +if ($LASTEXITCODE -ne 0) { + throw 'Docker Buildx is required' +} +& docker scout version *> $null +if ($LASTEXITCODE -ne 0) { + throw 'Docker Scout is required to produce the SPDX SBOM' +} +& docker version --format '{{.Server.Version}}' *> $null +if ($LASTEXITCODE -ne 0) { + throw 'A reachable Docker daemon is required for an executed build' +} +$dockerServerPlatformRaw = ((& docker info --format '{{.OSType}}/{{.Architecture}}') -join '').Trim().ToLowerInvariant() +if ($LASTEXITCODE -ne 0) { + throw 'Could not determine the Docker server platform for an executed build' +} +$dockerServerPlatform = switch -Regex ($dockerServerPlatformRaw) { + '^linux/(amd64|x86_64)$' { 'linux/amd64'; break } + '^linux/(arm64|aarch64)$' { 'linux/arm64'; break } + default { throw "Unsupported Docker server platform for a Docker Sandboxes template build: $dockerServerPlatformRaw" } +} +if ($dockerServerPlatform -ne $Platform) { + throw "Cross-architecture Docker Sandboxes template builds are unsupported because pinned build stages require BUILDPLATFORM=$Platform; Docker reports $dockerServerPlatform. Run this executed build on a native $Platform Docker server." +} +$outputRoot = [System.IO.Path]::GetPathRoot($OutputDirectory) +if ([string]::IsNullOrWhiteSpace($outputRoot)) { + throw "Could not determine the storage surface for $OutputDirectory. Run 'ephemeral-action-runner storage status --provider docker-sandboxes' for a capacity report." +} +$drive = [System.IO.DriveInfo]::new($outputRoot) +if (-not $drive.IsReady) { + throw "Storage surface $outputRoot cannot be measured. Run 'ephemeral-action-runner storage status --provider docker-sandboxes' for a capacity report." +} +$availableBytes = [uint64]$drive.AvailableFreeSpace +if ($availableBytes -lt $requiredAvailableBytes) { + throw ("Insufficient storage on {0}: available={1} bytes, estimated expansion={2} bytes, minimum free reserve={3} bytes, required={4} bytes. Preview exact cleanup with 'ephemeral-action-runner storage prune --provider docker-sandboxes'." -f $outputRoot, $availableBytes, $estimatedExpansionBytes, $minimumFreeBytes, $requiredAvailableBytes) +} +Write-Host ("Storage preflight passed for {0}: available={1:N1} GiB, estimated expansion={2:N1} GiB, reserve={3:N1} GiB." -f $outputRoot, ($availableBytes / 1GB), ($estimatedExpansionBytes / 1GB), ($minimumFreeBytes / 1GB)) + +if (-not (Test-Path -LiteralPath $storageStateDirectory)) { + New-Item -ItemType Directory -Path $storageStateDirectory | Out-Null +} +if (Test-Path -LiteralPath $ownerIDPath) { + $ownerID = (Get-Content -Raw -LiteralPath $ownerIDPath).Trim() +} +else { + $ownerID = [guid]::NewGuid().ToString('N') + [System.IO.File]::WriteAllText($ownerIDPath, $ownerID + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) +} +if ($ownerID -notmatch '^[0-9a-f]{32}$') { + throw "Invalid EPAR storage owner identity in $ownerIDPath" +} +$builderName = 'epar-' + $ownerID.Substring(0, 12) +$buildKitConfig = @" +[worker.oci] + gc = true + + [[worker.oci.gcpolicy]] + keepBytes = 68719476736 + keepDuration = 604800 + + [[worker.oci.gcpolicy]] + all = true + keepBytes = 68719476736 +"@ +[System.IO.File]::WriteAllText($buildKitConfigPath, $buildKitConfig, [System.Text.UTF8Encoding]::new($false)) + +$builderExists = $false +$previousErrorActionPreference = $ErrorActionPreference +try { + $ErrorActionPreference = 'Continue' + & docker buildx inspect $builderName *> $null + $builderExists = $LASTEXITCODE -eq 0 +} +finally { + $ErrorActionPreference = $previousErrorActionPreference +} +if ($builderExists) { + if (-not (Test-Path -LiteralPath $builderMetadataPath)) { + throw "Buildx builder $builderName already exists without EPAR ownership metadata; refusing to use or modify it." + } + $builderMetadata = Get-Content -Raw -LiteralPath $builderMetadataPath | ConvertFrom-Json + if ($builderMetadata.ownerID -ne $ownerID -or $builderMetadata.builderName -ne $builderName -or $builderMetadata.driver -ne 'docker-container') { + throw "Buildx builder ownership metadata does not match $builderName; refusing to use or modify it." + } +} +else { + $createdBuilderName = ((& docker buildx create --name $builderName --driver docker-container --buildkitd-config $buildKitConfigPath) -join '').Trim() + if ($LASTEXITCODE -ne 0 -or $createdBuilderName -ne $builderName) { + throw "Could not create the dedicated EPAR Buildx builder $builderName" + } + Write-Utf8Json -Value ([ordered]@{ schemaVersion = 1; ownerID = $ownerID; builderName = $builderName; driver = 'docker-container'; cacheLimitBytes = [uint64](64GB) }) -Path $builderMetadataPath +} + +Test-RemoteIndex -Name 'Dockerfile frontend' -Reference $lock.dockerfileFrontend.inspectionReference -ExpectedIndexDigest $lock.dockerfileFrontend.indexDigest -ExpectedManifestDigest $platformLock.dockerfileFrontendManifestDigest -Platform $Platform +Test-RemoteIndex -Name 'SBOM generator' -Reference $lock.sbomGenerator.inspectionReference -ExpectedIndexDigest $lock.sbomGenerator.indexDigest -ExpectedManifestDigest $platformLock.sbomGeneratorManifestDigest -Platform $Platform +Test-RemoteIndex -Name 'Go hook-launcher builder' -Reference $lock.goBuilder.inspectionReference -ExpectedIndexDigest $lock.goBuilder.indexDigest -ExpectedManifestDigest $platformLock.goBuilderManifestDigest -Platform $Platform +Test-RemoteIndex -Name "Catthehacker $Profile source" -Reference $profileLock.inspectionReference -ExpectedIndexDigest $profileLock.indexDigest -ExpectedManifestDigest $profilePlatformLock.manifestDigest -Platform $Platform + +if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory | Out-Null +} +$archiveName = if ($Platform -eq 'linux/amd64') { "epar-docker-sandboxes-{0}.tar" -f $Profile } else { "epar-docker-sandboxes-{0}-{1}.tar" -f $Profile, $platformLock.architecture } +$artifactPaths = [ordered]@{ + buildMetadata = Join-Path $OutputDirectory 'build-metadata.json' + provenance = Join-Path $OutputDirectory 'provenance.json' + sbom = Join-Path $OutputDirectory 'sbom.spdx.json' + inventory = Join-Path $OutputDirectory 'software-inventory.txt' + helpers = Join-Path $OutputDirectory 'helpers.sha256' + compatibility = Join-Path $OutputDirectory 'compatibility.json' + templateMetadata = Join-Path $OutputDirectory 'template-metadata.json' + archive = Join-Path $OutputDirectory $archiveName +} +$existingArtifacts = @($artifactPaths.Values | Where-Object { Test-Path -LiteralPath $_ }) +if ($existingArtifacts.Count -gt 0 -and -not $ReplaceArtifacts) { + throw "Refusing to overwrite existing artifacts. Use a new output directory or pass -ReplaceArtifacts: $($existingArtifacts -join ', ')" +} +$localImageExists = $false +$previousErrorActionPreference = $ErrorActionPreference +try { + $ErrorActionPreference = 'Continue' + & docker image inspect $profilePlatformLock.templateTag *> $null + $imageInspectExitCode = $LASTEXITCODE +} +finally { + $ErrorActionPreference = $previousErrorActionPreference +} +if ($imageInspectExitCode -eq 0) { + $localImageExists = $true +} +if ($localImageExists -and -not $ReplaceArtifacts) { + throw "Local image tag already exists: $($profilePlatformLock.templateTag). Pass -ReplaceArtifacts to replace it intentionally." +} + +$helperManifestPath = Join-Path $templateDirectory 'helpers.sha256' +$compatibilityInputPath = Join-Path (Join-Path $templateDirectory 'profiles') $profilePlatformLock.compatibilityFile +$helperManifestBytes = [System.IO.File]::ReadAllBytes($helperManifestPath) +$compatibilityInputBytes = [System.IO.File]::ReadAllBytes($compatibilityInputPath) +$templateContextDigest = Get-TemplateContextDigest -Directory $templateDirectory + +Write-Host '[1/7] Building the pinned template with plain progress. Cold acquisition may take up to 60 minutes.' +$previousMetadataProvenance = $env:BUILDX_METADATA_PROVENANCE +$env:BUILDX_METADATA_PROVENANCE = 'max' +try { + & docker buildx build --builder $builderName --platform $Platform --pull --progress plain --load --provenance mode=max --sbom ("generator={0}" -f $platformLock.sbomGeneratorReference) --metadata-file $artifactPaths.buildMetadata --tag $profilePlatformLock.templateTag --build-arg ("TEMPLATE_PLATFORM={0}" -f $Platform) --build-arg ("SOURCE_IMAGE={0}" -f $profileLock.immutableReference) --build-arg ("GO_BUILDER_IMAGE={0}" -f $platformLock.goBuilderReference) --build-arg ("HOOK_LAUNCHER_SHA256={0}" -f $lock.hookLauncher.sha256) --build-arg ("SOURCE_PROFILE={0}" -f $Profile) --build-arg ("SOURCE_INDEX_DIGEST={0}" -f $profileLock.indexDigest) --build-arg ("SOURCE_MANIFEST_DIGEST={0}" -f $profilePlatformLock.manifestDigest) --build-arg ("SOURCE_REVISION={0}" -f $profileLock.sourceRevision) --build-arg ("TEMPLATE_VERSION={0}" -f (($profilePlatformLock.templateTag -split ':', 2)[1])) --build-arg ("COMPATIBILITY_FILE={0}" -f $profilePlatformLock.compatibilityFile) --build-arg ("ACTIONS_RUNNER_URL={0}" -f $platformLock.actionsRunner.url) --build-arg ("ACTIONS_RUNNER_SHA256=sha256:{0}" -f $platformLock.actionsRunner.sha256) --build-arg ("TINI_URL={0}" -f $platformLock.tini.url) --build-arg ("TINI_SHA256=sha256:{0}" -f $platformLock.tini.sha256) --file (Join-Path $templateDirectory 'Dockerfile') $templateDirectory + if ($LASTEXITCODE -ne 0) { + throw 'docker buildx build failed' + } +} +finally { + $env:BUILDX_METADATA_PROVENANCE = $previousMetadataProvenance +} +if ((Get-TemplateContextDigest -Directory $templateDirectory) -ne $templateContextDigest) { + throw 'Template source lock or build context changed during the build; refusing mixed-source artifacts' +} + +Write-Host '[2/7] Extracting max-mode provenance from Buildx metadata.' +$buildMetadata = Get-Content -Raw -LiteralPath $artifactPaths.buildMetadata | ConvertFrom-Json +$imageDigest = $buildMetadata.'containerimage.digest' +$provenance = $buildMetadata.'buildx.build.provenance' +if ($imageDigest -notmatch '^sha256:[0-9a-f]{64}$' -or $null -eq $provenance) { + throw 'Buildx metadata omitted the immutable image digest or max-mode provenance' +} +$templateDigest = ((& docker image inspect --format '{{.Id}}' $profilePlatformLock.templateTag) -join '').Trim() +if ($LASTEXITCODE -ne 0 -or $templateDigest -notmatch '^sha256:[0-9a-f]{64}$') { + throw 'Docker image inspection omitted the full local template identity' +} +if ($imageDigest -ne $templateDigest) { + throw "Buildx image digest $imageDigest does not match the full local image identity $templateDigest" +} +Write-Utf8Json -Value $provenance -Path $artifactPaths.provenance + +Write-Host '[3/7] Generating SPDX SBOM from the local image without a registry fallback.' +& docker scout sbom --format spdx --output $artifactPaths.sbom ("local://{0}" -f $profilePlatformLock.templateTag) +if ($LASTEXITCODE -ne 0) { + throw 'docker scout sbom failed' +} + +Write-Host '[4/7] Collecting deterministic software inventory without starting the template entrypoint.' +$inventoryLines = @(& docker run --rm --pull never --platform $Platform --entrypoint /opt/epar/collect-software-inventory.sh $profilePlatformLock.templateTag) +if ($LASTEXITCODE -ne 0) { + throw 'software inventory collection failed' +} +[System.IO.File]::WriteAllText($artifactPaths.inventory, [string]::Join([Environment]::NewLine, $inventoryLines) + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) + +Write-Host '[5/7] Saving one Docker archive for an explicit sbx template load.' +& docker image save --output $artifactPaths.archive $profilePlatformLock.templateTag +if ($LASTEXITCODE -ne 0) { + throw 'docker image save failed' +} + +Write-Host '[6/7] Copying helper hashes and sbx v0.35.0-only compatibility metadata.' +[System.IO.File]::WriteAllBytes($artifactPaths.helpers, $helperManifestBytes) +[System.IO.File]::WriteAllBytes($artifactPaths.compatibility, $compatibilityInputBytes) + +Write-Host '[7/7] Hashing artifacts and writing immutable template metadata.' +$dockerVersion = ((& docker version --format '{{.Client.Version}}') -join '').Trim() +$buildxVersion = ((& docker buildx version) -join ' ').Trim() +$scoutVersionLine = ((& docker scout version | Select-String -Pattern '^version:' | Select-Object -First 1) -as [string]).Trim() +$templateMetadata = [ordered]@{ + schemaVersion = 2 + profile = $Profile + validationStatus = $profilePlatformLock.validationStatus + platform = $Platform + template = [ordered]@{ + tag = $profilePlatformLock.templateTag + digest = $imageDigest + templateDigest = $templateDigest + cacheID = $templateDigest.Substring(7, 12) + archive = [System.IO.Path]::GetFileName($artifactPaths.archive) + archiveSha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.archive).Hash.ToLowerInvariant() + archiveBytes = (Get-Item -LiteralPath $artifactPaths.archive).Length + } + source = [ordered]@{ + reference = $profileLock.immutableReference + indexDigest = $profileLock.indexDigest + manifestDigest = $profilePlatformLock.manifestDigest + revision = $profileLock.sourceRevision + } + inputs = [ordered]@{ + actionsRunnerVersion = $lock.actionsRunner.version + actionsRunnerUrl = $platformLock.actionsRunner.url + actionsRunnerSha256 = $platformLock.actionsRunner.sha256 + tiniVersion = $lock.tini.version + tiniUrl = $platformLock.tini.url + tiniSha256 = $platformLock.tini.sha256 + dockerfileFrontend = $lock.dockerfileFrontend.reference + dockerfileFrontendManifestDigest = $platformLock.dockerfileFrontendManifestDigest + goBuilderVersion = $lock.goBuilder.version + goBuilder = $platformLock.goBuilderReference + goBuilderIndexDigest = $lock.goBuilder.indexDigest + goBuilderManifestDigest = $platformLock.goBuilderManifestDigest + hookLauncherSha256 = $lock.hookLauncher.sha256 + sbomGenerator = $platformLock.sbomGeneratorReference + sourceLockSha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $lockPath).Hash.ToLowerInvariant() + templateContextDigest = $templateContextDigest + } + compatibility = [ordered]@{ + supportedSbxVersions = @('0.35.0') + candidate = 'A' + dockerDaemonOwner = 'docker-sandboxes-runtime' + expectedDockerDaemonCount = 1 + } + artifacts = [ordered]@{ + buildMetadata = [ordered]@{ path = 'build-metadata.json'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.buildMetadata).Hash.ToLowerInvariant() } + sbom = [ordered]@{ path = 'sbom.spdx.json'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.sbom).Hash.ToLowerInvariant() } + provenance = [ordered]@{ path = 'provenance.json'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.provenance).Hash.ToLowerInvariant() } + softwareInventory = [ordered]@{ path = 'software-inventory.txt'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.inventory).Hash.ToLowerInvariant() } + helperHashes = [ordered]@{ path = 'helpers.sha256'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.helpers).Hash.ToLowerInvariant() } + compatibility = [ordered]@{ path = 'compatibility.json'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.compatibility).Hash.ToLowerInvariant() } + } + hostTools = [ordered]@{ + dockerClient = $dockerVersion + buildx = $buildxVersion + scout = $scoutVersionLine + } +} +Write-Utf8Json -Value $templateMetadata -Path $artifactPaths.templateMetadata +$templateMetadataDigest = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.templateMetadata).Hash.ToLowerInvariant() +Write-Host "Template artifacts are ready in $OutputDirectory" +Write-Host "Immutable template identity: $($profilePlatformLock.templateTag)@$imageDigest" +Write-Host "EPAR dockerSandboxes.templateDigest (verified local template ID): $templateDigest" +Write-Host "Operator trust anchor for load-template.ps1 -ExpectedMetadataSha256: $templateMetadataDigest" +Write-Host 'No sbx command was invoked. Record the metadata digest outside the artifact directory, review the evidence, then use load-template.ps1 -Execute for one explicit template load.' diff --git a/scripts/docker-sandboxes/load-template.ps1 b/scripts/docker-sandboxes/load-template.ps1 new file mode 100644 index 0000000..03cb17e --- /dev/null +++ b/scripts/docker-sandboxes/load-template.ps1 @@ -0,0 +1,272 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ArtifactDirectory, + [Parameter(Mandatory = $true)] + [ValidatePattern('^sha256:[0-9a-f]{64}$')] + [string]$ExpectedMetadataSha256, + [switch]$Execute +) + +$ErrorActionPreference = 'Stop' +$ArtifactDirectory = [System.IO.Path]::GetFullPath($ArtifactDirectory) +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $scriptDirectory '..\..')) +$templateDirectory = Join-Path $repositoryRoot 'templates\docker-sandboxes' +$lockPath = Join-Path $templateDirectory 'sources.lock.json' + +function Get-Sha256File { + param([Parameter(Mandatory = $true)][string]$Path) + return 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Get-Sha256Text { + param([Parameter(Mandatory = $true)][string]$Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text) + return 'sha256:' + ([System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()) + } + finally { + $sha.Dispose() + } +} + +function Get-TemplateContextDigest { + param([Parameter(Mandatory = $true)][string]$Directory) + $material = [System.Text.StringBuilder]::new() + $files = @(Get-ChildItem -LiteralPath $Directory -File -Recurse | Sort-Object FullName) + foreach ($file in $files) { + $relative = $file.FullName.Substring($Directory.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/') + $digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant() + [void]$material.AppendLine($relative) + [void]$material.AppendLine($digest) + } + return Get-Sha256Text -Text $material.ToString() +} + +function Assert-ExactArtifact { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$ExpectedFileName, + [Parameter(Mandatory = $true)]$Record + ) + if ($Record.path -ne $ExpectedFileName -or $Record.sha256 -notmatch '^sha256:[0-9a-f]{64}$') { + throw "Artifact metadata for $Name must name exactly $ExpectedFileName and contain a full lowercase SHA-256 digest" + } + if ([System.IO.Path]::IsPathRooted($Record.path) -or [System.IO.Path]::GetFileName($Record.path) -ne $Record.path) { + throw "Artifact metadata for $Name contains an unsafe path" + } + $path = Join-Path $ArtifactDirectory $Record.path + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required evidence artifact is missing: $path" + } + $actual = Get-Sha256File -Path $path + if ($actual -ne $Record.sha256) { + throw "Evidence artifact checksum mismatch for ${Name}: expected $($Record.sha256), got $actual" + } + return $path +} + +$metadataPath = Join-Path $ArtifactDirectory 'template-metadata.json' +if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { + throw "Template metadata is missing: $metadataPath" +} +$actualMetadataSha256 = Get-Sha256File -Path $metadataPath +if ($actualMetadataSha256 -ne $ExpectedMetadataSha256) { + throw "Template metadata trust-anchor mismatch: operator expected $ExpectedMetadataSha256, got $actualMetadataSha256" +} +$metadata = Get-Content -Raw -LiteralPath $metadataPath | ConvertFrom-Json +if ($metadata.schemaVersion -ne 2 -or @($metadata.compatibility.supportedSbxVersions).Count -ne 1 -or $metadata.compatibility.supportedSbxVersions[0] -ne '0.35.0') { + throw 'Artifact compatibility metadata must use schema 2 and allow exactly sbx v0.35.0' +} +if ($metadata.platform -ne 'linux/amd64' -and $metadata.platform -ne 'linux/arm64') { + throw "Artifact metadata contains an unsupported platform: $($metadata.platform)" +} +if ($metadata.template.tag -notmatch '^epar-docker-sandboxes-[a-z0-9._-]+:[a-z0-9._-]+$' -or $metadata.template.digest -notmatch '^sha256:[0-9a-f]{64}$' -or $metadata.template.templateDigest -notmatch '^sha256:[0-9a-f]{64}$' -or $metadata.template.cacheID -notmatch '^[0-9a-f]{12}$') { + throw 'Artifact metadata contains an invalid template tag, OCI digest, full local image identity, or cache ID' +} +if ($metadata.template.cacheID -ne $metadata.template.templateDigest.Substring(7, 12)) { + throw 'Artifact metadata cache ID does not match the first 12 hexadecimal characters of the full local image identity' +} +if ($metadata.compatibility.candidate -ne 'A' -or $metadata.compatibility.dockerDaemonOwner -ne 'docker-sandboxes-runtime' -or $metadata.compatibility.expectedDockerDaemonCount -ne 1) { + throw 'Artifact compatibility metadata is not Candidate A' +} + +$lock = Get-Content -Raw -LiteralPath $lockPath | ConvertFrom-Json +if ($lock.schemaVersion -ne 2 -or -not @($lock.supportedPlatforms).Contains($metadata.platform)) { + throw 'Repository source lock does not support the artifact platform' +} +$profileLock = $lock.profiles.PSObject.Properties[$metadata.profile].Value +$platformLock = $lock.platforms.PSObject.Properties[$metadata.platform].Value +$profilePlatformLock = if ($null -ne $profileLock) { $profileLock.platforms.PSObject.Properties[$metadata.platform].Value } else { $null } +if ($null -eq $profileLock -or $null -eq $platformLock -or $null -eq $profilePlatformLock) { + throw 'Artifact profile and platform are not present in the repository source lock' +} +$sourceLockSha256 = Get-Sha256File -Path $lockPath +$templateContextDigest = Get-TemplateContextDigest -Directory $templateDirectory +if ($metadata.inputs.sourceLockSha256 -ne $sourceLockSha256 -or $metadata.inputs.templateContextDigest -ne $templateContextDigest) { + throw 'Artifact metadata is not anchored to the current repository source lock and complete template build context' +} +if ($metadata.template.tag -ne $profilePlatformLock.templateTag -or $metadata.source.reference -ne $profileLock.immutableReference -or $metadata.source.indexDigest -ne $profileLock.indexDigest -or $metadata.source.manifestDigest -ne $profilePlatformLock.manifestDigest -or $metadata.source.revision -ne $profileLock.sourceRevision) { + throw 'Artifact template or source identity differs from the authoritative repository lock' +} +if ($metadata.inputs.actionsRunnerVersion -ne $lock.actionsRunner.version -or $metadata.inputs.actionsRunnerUrl -ne $platformLock.actionsRunner.url -or $metadata.inputs.actionsRunnerSha256 -ne $platformLock.actionsRunner.sha256 -or $metadata.inputs.tiniVersion -ne $lock.tini.version -or $metadata.inputs.tiniUrl -ne $platformLock.tini.url -or $metadata.inputs.tiniSha256 -ne $platformLock.tini.sha256 -or $metadata.inputs.dockerfileFrontend -ne $lock.dockerfileFrontend.reference -or $metadata.inputs.dockerfileFrontendManifestDigest -ne $platformLock.dockerfileFrontendManifestDigest -or $metadata.inputs.sbomGenerator -ne $platformLock.sbomGeneratorReference) { + throw 'Artifact build inputs differ from the authoritative repository lock' +} + +$expectedArtifacts = [ordered]@{ + buildMetadata = 'build-metadata.json' + sbom = 'sbom.spdx.json' + provenance = 'provenance.json' + softwareInventory = 'software-inventory.txt' + helperHashes = 'helpers.sha256' + compatibility = 'compatibility.json' +} +$actualArtifactNames = @($metadata.artifacts.PSObject.Properties.Name | Sort-Object) +$expectedArtifactNames = @($expectedArtifacts.Keys | Sort-Object) +if (Compare-Object -ReferenceObject $expectedArtifactNames -DifferenceObject $actualArtifactNames) { + throw 'Template metadata must enumerate exactly every required evidence artifact' +} +$verifiedArtifacts = @{} +foreach ($name in $expectedArtifacts.Keys) { + $verifiedArtifacts[$name] = Assert-ExactArtifact -Name $name -ExpectedFileName $expectedArtifacts[$name] -Record $metadata.artifacts.$name +} + +$archiveName = $metadata.template.archive +if ([System.IO.Path]::IsPathRooted($archiveName) -or [System.IO.Path]::GetFileName($archiveName) -ne $archiveName -or $metadata.template.archiveSha256 -notmatch '^sha256:[0-9a-f]{64}$' -or $metadata.template.archiveBytes -le 0) { + throw 'Artifact metadata contains an unsafe archive record' +} +$archivePath = Join-Path $ArtifactDirectory $archiveName +if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { + throw "Template archive is missing: $archivePath" +} +$actualArchiveHash = Get-Sha256File -Path $archivePath +$actualArchiveBytes = (Get-Item -LiteralPath $archivePath).Length +if ($actualArchiveHash -ne $metadata.template.archiveSha256 -or $actualArchiveBytes -ne $metadata.template.archiveBytes) { + throw "Template archive evidence mismatch: expected $($metadata.template.archiveSha256) and $($metadata.template.archiveBytes) bytes, got $actualArchiveHash and $actualArchiveBytes bytes" +} + +$buildMetadata = Get-Content -Raw -LiteralPath $verifiedArtifacts.buildMetadata | ConvertFrom-Json +if ($buildMetadata.'containerimage.digest' -ne $metadata.template.digest -or $metadata.template.digest -ne $metadata.template.templateDigest -or [string]::IsNullOrWhiteSpace($buildMetadata.'buildx.build.ref') -or $null -eq $buildMetadata.'buildx.build.provenance') { + throw 'Buildx metadata does not bind the recorded OCI digest, full local image identity, build reference, and provenance' +} +$provenance = Get-Content -Raw -LiteralPath $verifiedArtifacts.provenance | ConvertFrom-Json +if ($null -eq $provenance.buildType -or $null -eq $provenance.invocation -or $null -eq $provenance.metadata) { + throw 'Provenance artifact does not contain the required max-mode predicate fields' +} +$sbom = Get-Content -Raw -LiteralPath $verifiedArtifacts.sbom | ConvertFrom-Json +if ($sbom.SPDXID -ne 'SPDXRef-DOCUMENT' -or $sbom.spdxVersion -notmatch '^SPDX-2\.' -or $null -eq $sbom.packages) { + throw 'SBOM artifact is not a valid SPDX JSON document' +} +if ((Get-Item -LiteralPath $verifiedArtifacts.softwareInventory).Length -le 0) { + throw 'Software inventory evidence is empty' +} +$repositoryHelpers = Join-Path $templateDirectory 'helpers.sha256' +$repositoryCompatibility = Join-Path (Join-Path $templateDirectory 'profiles') $profilePlatformLock.compatibilityFile +if ((Get-Sha256File -Path $verifiedArtifacts.helperHashes) -ne (Get-Sha256File -Path $repositoryHelpers) -or (Get-Sha256File -Path $verifiedArtifacts.compatibility) -ne (Get-Sha256File -Path $repositoryCompatibility)) { + throw 'Copied helper or compatibility evidence differs from the repository-anchored source' +} + +$localTemplateDigest = ((& docker image inspect --format '{{.Id}}' $metadata.template.tag) -join '').Trim() +if ($LASTEXITCODE -ne 0 -or $localTemplateDigest -ne $metadata.template.templateDigest) { + throw "Local Docker image identity does not match the anchored full template identity $($metadata.template.templateDigest)" +} + +Write-Host "Verified operator-anchored metadata: $actualMetadataSha256" +Write-Host "Verified archive: $archivePath" +Write-Host "Full local Docker image identity: $($metadata.template.tag)@$($metadata.template.templateDigest)" +Write-Host "Expected sbx v0.35.0 cache ID: $($metadata.template.cacheID)" +if (-not $Execute) { + Write-Host 'Plan only. All evidence was verified without invoking sbx. Re-run with -Execute and the same expected metadata digest to invoke sbx template load at most once.' + exit 0 +} + +$runtimeArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +$hostArchitecture = switch ($runtimeArchitecture) { + 'x64' { 'amd64' } + 'arm64' { 'arm64' } + default { $runtimeArchitecture } +} +if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + $hostOS = 'windows' +} +elseif ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) { + $hostOS = 'linux' +} +elseif ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) { + $hostOS = 'darwin' +} +else { + throw 'Docker Sandboxes does not support this controller host operating system' +} +$expectedPlatform = switch ($hostArchitecture) { + 'amd64' { 'linux/amd64' } + 'arm64' { 'linux/arm64' } + default { throw "Docker Sandboxes has no EPAR template for controller architecture $hostArchitecture on $hostOS" } +} +if ($metadata.platform -ne $expectedPlatform) { + throw "Template platform $($metadata.platform) cannot be loaded for Docker Sandboxes on $hostOS/$hostArchitecture; expected $expectedPlatform" +} + +$sbxVersionOutput = ((& sbx version) -join [Environment]::NewLine).Trim() +if ($LASTEXITCODE -ne 0 -or $sbxVersionOutput -notmatch '^(?:Docker Sandboxes|docker sandboxes|sbx) version:? v?0\.35\.0(?: [a-f0-9]{40})?$') { + throw "Exactly sbx v0.35.0 is required; version output was: $sbxVersionOutput" +} + +function Get-TemplateInventory { + $text = ((& sbx template ls --json) -join [Environment]::NewLine).Trim() + if ($LASTEXITCODE -ne 0) { + throw 'sbx template inventory readback failed' + } + try { + $inventory = $text | ConvertFrom-Json + } + catch { + throw 'sbx template inventory returned invalid JSON' + } + if ($null -eq $inventory.images) { + throw 'sbx template inventory omitted the images array' + } + return $inventory +} + +$separator = $metadata.template.tag.LastIndexOf(':') +$expectedRepository = $metadata.template.tag.Substring(0, $separator) +$expectedTag = $metadata.template.tag.Substring($separator + 1) +$firstRepositoryComponent = ($expectedRepository -split '/', 2)[0] +if (-not $expectedRepository.Contains('/')) { + $expectedRepository = "docker.io/library/$expectedRepository" +} +elseif ($firstRepositoryComponent -ne 'localhost' -and $firstRepositoryComponent -notmatch '[\.:]') { + $expectedRepository = "docker.io/$expectedRepository" +} +$templateInventory = Get-TemplateInventory +$matchingTemplates = @($templateInventory.images | Where-Object { $_.repository -eq $expectedRepository -and $_.tag -eq $expectedTag }) +if ($matchingTemplates.Count -gt 1) { + throw "Expected at most one loaded template named $($metadata.template.tag); found $($matchingTemplates.Count)" +} +if ($matchingTemplates.Count -eq 1 -and $matchingTemplates[0].id -ne $metadata.template.cacheID) { + throw "Loaded template cache ID mismatch: expected $($metadata.template.cacheID), got $($matchingTemplates[0].id)" +} +if ($matchingTemplates.Count -eq 1) { + Write-Host "Template is already loaded with the exact expected cache ID: $($metadata.template.cacheID)" + exit 0 +} + +Write-Host 'Loading the verified prewarmed template archive into Docker Sandboxes once...' +& sbx template load $archivePath +if ($LASTEXITCODE -ne 0) { + throw 'sbx template load failed' +} +$templateInventory = Get-TemplateInventory +$matchingTemplates = @($templateInventory.images | Where-Object { $_.repository -eq $expectedRepository -and $_.tag -eq $expectedTag }) +if ($matchingTemplates.Count -ne 1) { + throw "Expected exactly one loaded template named $($metadata.template.tag); found $($matchingTemplates.Count)" +} +if ($matchingTemplates[0].id -ne $metadata.template.cacheID) { + throw "Loaded template cache ID mismatch: expected $($metadata.template.cacheID), got $($matchingTemplates[0].id)" +} +Write-Host "Template load readback completed with cache ID $($metadata.template.cacheID)." +Write-Host 'The 12-hex cache ID is the complete identity exposed by sbx v0.35.0; it is not a full digest. Full identity is anchored independently by the operator-verified metadata and local Docker image readback.' +Write-Host 'The script does not preload /var/lib/docker and will not invoke sbx template load again.' diff --git a/scripts/docker-sandboxes/validate-assets.ps1 b/scripts/docker-sandboxes/validate-assets.ps1 new file mode 100644 index 0000000..6e78cee --- /dev/null +++ b/scripts/docker-sandboxes/validate-assets.ps1 @@ -0,0 +1,296 @@ +[CmdletBinding()] +param( + [ValidateSet('linux/amd64', 'linux/arm64')] + [string]$Platform = 'linux/amd64', + [switch]$VerifyRemote, + [switch]$DockerfileCheck +) + +$ErrorActionPreference = 'Stop' +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $scriptDirectory '..\..')) +$templateDirectory = Join-Path $repositoryRoot 'templates\docker-sandboxes' +$lockPath = Join-Path $templateDirectory 'sources.lock.json' +$lock = Get-Content -Raw -LiteralPath $lockPath | ConvertFrom-Json + +function Assert-Equal { + param([string]$Name, $Actual, $Expected) + if ($Actual -ne $Expected) { + throw "$Name mismatch: expected $Expected, got $Actual" + } +} + +function Get-Sha256Text { + param([string]$Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text) + return 'sha256:' + ([System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()) + } + finally { + $sha.Dispose() + } +} + +function Test-RemoteIndex { + param([string]$Name, [string]$Reference, [string]$ExpectedIndexDigest, [string]$ExpectedManifestDigest, [string]$Platform) + $rawLines = @(& docker buildx imagetools inspect --raw $Reference) + if ($LASTEXITCODE -ne 0) { + throw "Remote inspection failed for $Reference" + } + # OCI manifests are UTF-8 JSON with LF separators. Reconstructing them with + # the host newline would turn LF into CRLF on Windows and change the digest. + $raw = [string]::Join("`n", $rawLines) + Assert-Equal "$Name index" (Get-Sha256Text $raw) $ExpectedIndexDigest + $index = $raw | ConvertFrom-Json + $platformParts = $Platform -split '/', 2 + $matching = @($index.manifests | Where-Object { $_.platform.os -eq $platformParts[0] -and $_.platform.architecture -eq $platformParts[1] }) + Assert-Equal "$Name $Platform manifest count" $matching.Count 1 + Assert-Equal "$Name $Platform manifest" $matching[0].digest $ExpectedManifestDigest +} + +Write-Host '[1/6] Checking pinned constants and exact plural naming.' +Assert-Equal 'source lock schema' $lock.schemaVersion 2 +Assert-Equal 'default platform' $lock.defaultPlatform 'linux/amd64' +Assert-Equal 'supported platform count' @($lock.supportedPlatforms).Count 2 +Assert-Equal 'first supported platform' $lock.supportedPlatforms[0] 'linux/amd64' +Assert-Equal 'second supported platform' $lock.supportedPlatforms[1] 'linux/arm64' +Assert-Equal 'Dockerfile frontend index' $lock.dockerfileFrontend.indexDigest 'sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e' +Assert-Equal 'SBOM generator index' $lock.sbomGenerator.indexDigest 'sha256:79e7b013cbec16bbb436f312819a49a4a57752b2270c1a9332ae1a10fcc82a68' +Assert-Equal 'Go builder version' $lock.goBuilder.version '1.25.12' +Assert-Equal 'Go builder index' $lock.goBuilder.indexDigest 'sha256:9006890ecba0a168034d99516084099ae3114d9f2b7d6572c77f2dde57ebc980' +Assert-Equal 'hook launcher source checksum' $lock.hookLauncher.sha256 '7fe07f10f484fa6888481a4165e81570187c0aeff422738d3ea5add6b95dd9b7' +Assert-Equal 'Actions runner version' $lock.actionsRunner.version '2.332.0' +Assert-Equal 'Tini version' $lock.tini.version '0.19.0' +$expectedPlatforms = [ordered]@{ + 'linux/amd64' = [ordered]@{ + architecture = 'amd64' + frontendManifest = 'sha256:b5f3b260a9678e1d83d2fce86eeddf79420b79147eaba2a25986f47133d73720' + goBuilderManifest = 'sha256:12e171e33ce7ade87ac8ab2bbe65cea9371527285bdab43ca02780a9e6ac60e5' + sbomManifest = 'sha256:13864237fb990943433f89d698590aad1de38d4a7e13d38e7b12f2488c1952e7' + runnerUrl = 'https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-x64-2.332.0.tar.gz' + runnerSha256 = 'f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a' + tiniUrl = 'https://github.com/krallin/tini/releases/download/v0.19.0/tini-amd64' + tiniSha256 = '93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c' + } + 'linux/arm64' = [ordered]@{ + architecture = 'arm64' + frontendManifest = 'sha256:c8678869a83fab70232869ba24acc1c0be661f4d65135c0eeacb6a8e78420fdd' + goBuilderManifest = 'sha256:afe53a4752b49f57ddebc97501a99394e2f7715236b4241efa830d54efb44434' + sbomManifest = 'sha256:860305b3d1667c35142f11f6e9485e322c1c6173702a0831dc68739a34847f2d' + runnerUrl = 'https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-arm64-2.332.0.tar.gz' + runnerSha256 = 'b72f0599cdbd99dd9513ab64fcb59e424fc7359c93b849e8f5efdd5a72f743a6' + tiniUrl = 'https://github.com/krallin/tini/releases/download/v0.19.0/tini-arm64' + tiniSha256 = '07952557df20bfd2a95f9bef198b445e006171969499a1d361bd9e6f8e5e0e81' + } +} +foreach ($platformName in $expectedPlatforms.Keys) { + $platformRecord = $lock.platforms.PSObject.Properties[$platformName].Value + $expectedPlatform = $expectedPlatforms[$platformName] + Assert-Equal "$platformName architecture" $platformRecord.architecture $expectedPlatform.architecture + Assert-Equal "$platformName Dockerfile frontend manifest" $platformRecord.dockerfileFrontendManifestDigest $expectedPlatform.frontendManifest + Assert-Equal "$platformName Go builder manifest" $platformRecord.goBuilderManifestDigest $expectedPlatform.goBuilderManifest + Assert-Equal "$platformName Go builder reference" $platformRecord.goBuilderReference ("docker.io/library/golang@{0}" -f $expectedPlatform.goBuilderManifest) + Assert-Equal "$platformName SBOM generator manifest" $platformRecord.sbomGeneratorManifestDigest $expectedPlatform.sbomManifest + Assert-Equal "$platformName SBOM generator reference" $platformRecord.sbomGeneratorReference ("docker.io/docker/buildkit-syft-scanner@{0}" -f $expectedPlatform.sbomManifest) + Assert-Equal "$platformName Actions runner URL" $platformRecord.actionsRunner.url $expectedPlatform.runnerUrl + Assert-Equal "$platformName Actions runner checksum" $platformRecord.actionsRunner.sha256 $expectedPlatform.runnerSha256 + Assert-Equal "$platformName Tini URL" $platformRecord.tini.url $expectedPlatform.tiniUrl + Assert-Equal "$platformName Tini checksum" $platformRecord.tini.sha256 $expectedPlatform.tiniSha256 +} +$expectedProfiles = [ordered]@{ + 'act-22.04' = [ordered]@{ + observedTag = 'ghcr.io/catthehacker/ubuntu:act-22.04' + index = 'sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac' + legacyManifest = 'sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8ef546ecb44ca256655' + legacyTag = 'epar-docker-sandboxes-catthehacker-act-22.04:20260723-r3-amd64' + platforms = [ordered]@{ + 'linux/amd64' = [ordered]@{ manifest = 'sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8ef546ecb44ca256655'; status = 'planned'; tag = 'epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64'; compatibilityFile = 'act-22.04.amd64.compatibility.json' } + 'linux/arm64' = [ordered]@{ manifest = 'sha256:72b9ec71ee5972e02df5053f0000d34dbd2a3d0165b912bf25bbeabd72fba160'; status = 'unvalidated'; tag = 'epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-arm64'; compatibilityFile = 'act-22.04.arm64.compatibility.json' } + } + } + 'full' = [ordered]@{ + observedTag = 'ghcr.io/catthehacker/ubuntu:full-latest' + index = 'sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c' + legacyManifest = 'sha256:58314fa8cbf0f0e5384a37b3444811033320038816ef7c16f30b3e841ed65e51' + legacyTag = 'epar-docker-sandboxes-catthehacker-full:20260723-r1-amd64' + platforms = [ordered]@{ + 'linux/amd64' = [ordered]@{ manifest = 'sha256:58314fa8cbf0f0e5384a37b3444811033320038816ef7c16f30b3e841ed65e51'; status = 'planned'; tag = 'epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64'; compatibilityFile = 'full.amd64.compatibility.json' } + 'linux/arm64' = [ordered]@{ manifest = 'sha256:245c8981fbf4ac268db015463c6c446b9411481f7e0001537128dc384d46dd0c'; status = 'unvalidated'; tag = 'epar-docker-sandboxes-catthehacker-full:20260723-r2-arm64'; compatibilityFile = 'full.arm64.compatibility.json' } + } + } +} +foreach ($profileName in $expectedProfiles.Keys) { + $profile = $lock.profiles.PSObject.Properties[$profileName].Value + $expectedProfile = $expectedProfiles[$profileName] + Assert-Equal "$profileName observed source channel" $profile.observedTagReference $expectedProfile.observedTag + Assert-Equal "$profileName index" $profile.indexDigest $expectedProfile.index + foreach ($forbiddenActiveProperty in @('amd64ManifestDigest', 'status', 'templateTag')) { + if ($profile.PSObject.Properties.Name -contains $forbiddenActiveProperty) { + throw "$profileName must not expose historical $forbiddenActiveProperty as an active profile property" + } + } + $supersededRecord = $lock.supersededRecords.'linux/amd64'.PSObject.Properties[$profileName].Value + Assert-Equal "$profileName superseded record authority" $supersededRecord.authoritative $false + Assert-Equal "$profileName superseded amd64 manifest" $supersededRecord.manifestDigest $expectedProfile.legacyManifest + Assert-Equal "$profileName superseded template tag" $supersededRecord.templateTag $expectedProfile.legacyTag + Assert-Equal "$profileName superseded reason" $supersededRecord.reason 'Predates current Candidate A helper and architecture changes' + if ($supersededRecord.PSObject.Properties.Name -contains 'validationStatus') { + throw "$profileName superseded record must not carry a current validation status" + } + $expectedReferenceSuffix = '@' + $expectedProfile.index + if (-not $profile.immutableReference.EndsWith($expectedReferenceSuffix, [System.StringComparison]::Ordinal)) { + throw "$profileName immutable reference is not pinned to its index digest" + } + foreach ($platformName in $expectedPlatforms.Keys) { + $profilePlatform = $profile.platforms.PSObject.Properties[$platformName].Value + $expectedProfilePlatform = $expectedProfile.platforms[$platformName] + Assert-Equal "$profileName $platformName manifest" $profilePlatform.manifestDigest $expectedProfilePlatform.manifest + Assert-Equal "$profileName $platformName status" $profilePlatform.validationStatus $expectedProfilePlatform.status + Assert-Equal "$profileName $platformName template tag" $profilePlatform.templateTag $expectedProfilePlatform.tag + Assert-Equal "$profileName $platformName compatibility file" $profilePlatform.compatibilityFile $expectedProfilePlatform.compatibilityFile + if ($profilePlatform.templateTag -notmatch '^epar-docker-sandboxes-') { + throw "$profileName $platformName template tag violates the plural naming contract" + } + } +} + +Write-Host '[2/6] Verifying deterministic guest-helper hashes.' +$launcherPath = Join-Path (Join-Path $templateDirectory 'hook-launcher') 'main.go' +$launcherHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $launcherPath).Hash.ToLowerInvariant() +Assert-Equal 'hook launcher source' $launcherHash $lock.hookLauncher.sha256 +$hashManifestPath = Join-Path $templateDirectory 'helpers.sha256' +$manifestEntries = Get-Content -LiteralPath $hashManifestPath +$guestFiles = @(Get-ChildItem -LiteralPath (Join-Path $templateDirectory 'guest') -Filter '*.sh' -File | Sort-Object Name) +Assert-Equal 'helper manifest entry count' $manifestEntries.Count $guestFiles.Count +foreach ($line in $manifestEntries) { + if ($line -notmatch '^([0-9a-f]{64}) \./([a-z0-9.-]+\.sh)$') { + throw "Invalid helper hash entry: $line" + } + $helperPath = Join-Path (Join-Path $templateDirectory 'guest') $Matches[2] + if (-not (Test-Path -LiteralPath $helperPath -PathType Leaf)) { + throw "Helper hash references missing file: $helperPath" + } + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $helperPath).Hash.ToLowerInvariant() + Assert-Equal "helper $($Matches[2])" $actualHash $Matches[1] +} + +Write-Host '[3/6] Checking Dockerfile and entrypoint invariants.' +$dockerfilePath = Join-Path $templateDirectory 'Dockerfile' +$dockerfile = Get-Content -Raw -LiteralPath $dockerfilePath +$dockerignore = Get-Content -Raw -LiteralPath (Join-Path $templateDirectory '.dockerignore') +foreach ($required in @( + '# syntax=docker/dockerfile:1.7.1@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e', + 'FROM --platform=$BUILDPLATFORM ${GO_BUILDER_IMAGE} AS hook-builder', + 'FROM --platform=${TEMPLATE_PLATFORM} ${SOURCE_IMAGE}', + 'COPY --from=hook-builder --chmod=0555 /out/epar-hook-bash /opt/epar/hook-bin/bash', + 'com.docker.sandboxes.start-docker=true', + 'USER agent', + 'ENTRYPOINT ["/usr/local/bin/tini", "-g", "--", "/opt/epar/template-entrypoint.sh"]', + 'sha256:f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a', + 'sha256:93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c' +)) { + if (-not $dockerfile.Contains($required)) { + throw "Dockerfile is missing required invariant: $required" + } +} +if ($dockerfile -match '(?im)apt-get\s+update|(?im)\blatest\b|(?im)COPY\s+.*var/lib/docker|(?im)--privileged|(?im)--secret') { + throw 'Dockerfile contains an unpinned, privileged, secret, or /var/lib/docker preload pattern' +} +foreach ($requiredContextEntry in @('!Dockerfile', '!helpers.sha256', '!guest/*.sh', '!hook-launcher/*.go', '!profiles/*.compatibility.json')) { + if (-not ($dockerignore -split "`r?`n").Contains($requiredContextEntry)) { + throw ".dockerignore is missing deterministic context entry: $requiredContextEntry" + } +} +$guestText = ($guestFiles | ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName }) -join "`n" +if ($guestText -match '(?im)apt-get\s+update|(?im)(^|[;&|]\s*)dockerd(?:\s|$)|(?im)-----BEGIN .*PRIVATE KEY-----|(?im)AKIA[0-9A-Z]{16}') { + throw 'Guest helpers contain a boot-time package update, dockerd start, or credential pattern' +} +$configureRunner = Get-Content -Raw -LiteralPath (Join-Path (Join-Path $templateDirectory 'guest') 'configure-runner.sh') +if ($configureRunner -match '(?m)(^|\s)--replace(\s|$)') { + throw 'configure-runner.sh must not allow runner replacement' +} +$runnerDiagnostics = Get-Content -Raw -LiteralPath (Join-Path (Join-Path $templateDirectory 'guest') 'collect-runner-diagnostics.sh') +if ($runnerDiagnostics -match '(?im)\btail\b|_diag|(?:^|,)cmd=') { + throw 'collect-runner-diagnostics.sh must not emit command lines or runner/job log content' +} +if ($guestText -match '(?im)\btail\s+(?:-[^\s]+\s+)*["'']?\$\{?(?:log_file|runner_log|job_log)') { + throw 'Guest helpers must not copy runner or job log content into controller-visible output' +} + +Write-Host '[4/6] Parsing compatibility metadata and enforcing sbx v0.35.0 only.' +foreach ($profileName in $expectedProfiles.Keys) { + $profile = $lock.profiles.PSObject.Properties[$profileName].Value + foreach ($platformName in $expectedPlatforms.Keys) { + $profilePlatform = $profile.platforms.PSObject.Properties[$platformName].Value + $compatibilityPath = Join-Path (Join-Path $templateDirectory 'profiles') $profilePlatform.compatibilityFile + $compatibility = Get-Content -Raw -LiteralPath $compatibilityPath | ConvertFrom-Json + Assert-Equal "$profileName $platformName compatibility schema" $compatibility.schemaVersion 1 + Assert-Equal "$profileName $platformName compatibility candidate" $compatibility.candidate 'A' + Assert-Equal "$profileName $platformName compatibility profile" $compatibility.profile $profileName + Assert-Equal "$profileName $platformName compatibility status" $compatibility.validationStatus $profilePlatform.validationStatus + Assert-Equal "$profileName $platformName compatibility platform" $compatibility.platform $platformName + Assert-Equal "$profileName $platformName source reference" $compatibility.source.reference $profile.immutableReference + Assert-Equal "$profileName $platformName source index" $compatibility.source.indexDigest $profile.indexDigest + Assert-Equal "$profileName $platformName source manifest" $compatibility.source.manifestDigest $profilePlatform.manifestDigest + Assert-Equal "$profileName $platformName supported sbx count" @($compatibility.supportedSbxVersions).Count 1 + Assert-Equal "$profileName $platformName supported sbx version" $compatibility.supportedSbxVersions[0] '0.35.0' + Assert-Equal "$profileName $platformName daemon count" $compatibility.docker.expectedDaemonCount 1 + Assert-Equal "$profileName $platformName daemon owner" $compatibility.docker.daemonOwner 'docker-sandboxes-runtime' + Assert-Equal "$profileName $platformName /var/lib/docker preload" $compatibility.docker.imagePreloadsVarLibDocker $false + } +} + +Write-Host '[5/6] Parsing PowerShell and Bash sources.' +$parseErrors = @() +Get-ChildItem -LiteralPath $scriptDirectory -Filter '*.ps1' -File | ForEach-Object { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errors) + $parseErrors += @($errors) +} +if ($parseErrors.Count -gt 0) { + throw "PowerShell parse errors: $($parseErrors | ForEach-Object Message | Sort-Object -Unique -join '; ')" +} +$gitBashPath = 'C:\Program Files\Git\bin\bash.exe' +if (Test-Path -LiteralPath $gitBashPath -PathType Leaf) { + $bashPath = $gitBashPath +} +else { + $bashCommand = Get-Command bash -ErrorAction SilentlyContinue + $bashPath = if ($null -eq $bashCommand) { $null } else { $bashCommand.Source } +} +if ($null -eq $bashPath) { + throw 'bash is required to syntax-check guest helpers' +} +foreach ($guestFile in $guestFiles) { + & $bashPath -n $guestFile.FullName + if ($LASTEXITCODE -ne 0) { + throw "bash -n failed for $($guestFile.FullName)" + } +} + +Write-Host '[6/6] Running optional remote and Dockerfile frontend checks.' +if ($VerifyRemote) { + $platformLock = $lock.platforms.PSObject.Properties[$Platform].Value + Test-RemoteIndex 'Dockerfile frontend' $lock.dockerfileFrontend.inspectionReference $lock.dockerfileFrontend.indexDigest $platformLock.dockerfileFrontendManifestDigest $Platform + Test-RemoteIndex 'SBOM generator' $lock.sbomGenerator.inspectionReference $lock.sbomGenerator.indexDigest $platformLock.sbomGeneratorManifestDigest $Platform + Test-RemoteIndex 'Go hook-launcher builder' $lock.goBuilder.inspectionReference $lock.goBuilder.indexDigest $platformLock.goBuilderManifestDigest $Platform + foreach ($profileName in $expectedProfiles.Keys) { + $profile = $lock.profiles.PSObject.Properties[$profileName].Value + $profilePlatform = $profile.platforms.PSObject.Properties[$Platform].Value + Test-RemoteIndex "Catthehacker $profileName" $profile.inspectionReference $profile.indexDigest $profilePlatform.manifestDigest $Platform + } +} +if ($DockerfileCheck) { + $platformLock = $lock.platforms.PSObject.Properties[$Platform].Value + foreach ($profileName in $expectedProfiles.Keys) { + $profile = $lock.profiles.PSObject.Properties[$profileName].Value + $profilePlatform = $profile.platforms.PSObject.Properties[$Platform].Value + & docker buildx build --call check --platform $Platform --build-arg ("TEMPLATE_PLATFORM={0}" -f $Platform) --build-arg ("SOURCE_IMAGE={0}" -f $profile.immutableReference) --build-arg ("GO_BUILDER_IMAGE={0}" -f $platformLock.goBuilderReference) --build-arg ("HOOK_LAUNCHER_SHA256={0}" -f $lock.hookLauncher.sha256) --build-arg ("SOURCE_PROFILE={0}" -f $profileName) --build-arg ("SOURCE_INDEX_DIGEST={0}" -f $profile.indexDigest) --build-arg ("SOURCE_MANIFEST_DIGEST={0}" -f $profilePlatform.manifestDigest) --build-arg ("SOURCE_REVISION={0}" -f $profile.sourceRevision) --build-arg ("TEMPLATE_VERSION={0}" -f (($profilePlatform.templateTag -split ':', 2)[1])) --build-arg ("COMPATIBILITY_FILE={0}" -f $profilePlatform.compatibilityFile) --build-arg ("ACTIONS_RUNNER_URL={0}" -f $platformLock.actionsRunner.url) --build-arg ("ACTIONS_RUNNER_SHA256=sha256:{0}" -f $platformLock.actionsRunner.sha256) --build-arg ("TINI_URL={0}" -f $platformLock.tini.url) --build-arg ("TINI_SHA256=sha256:{0}" -f $platformLock.tini.sha256) --file $dockerfilePath $templateDirectory + if ($LASTEXITCODE -ne 0) { + throw "Dockerfile frontend check failed for $profileName" + } + } +} +Write-Host 'Docker Sandboxes Candidate A template assets passed validation.' diff --git a/scripts/guest/ubuntu/check-host-trust-generation.sh b/scripts/guest/ubuntu/check-host-trust-generation.sh index 6e822b8..5262b9b 100644 --- a/scripts/guest/ubuntu/check-host-trust-generation.sh +++ b/scripts/guest/ubuntu/check-host-trust-generation.sh @@ -1,8 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -marker="${EPAR_HOST_TRUST_MARKER:-/opt/epar/host-trust-generation.json}" -lease="${EPAR_HOST_TRUST_LEASE:-/run/epar/host-trust-lease.json}" +if [[ "$#" -eq 0 ]]; then + marker="/opt/epar/host-trust-generation.json" + lease="/run/epar/host-trust-lease.json" +elif [[ "$#" -eq 2 ]]; then + marker="$1" + lease="$2" +else + echo "EPAR host-trust gate: invalid invocation" >&2 + exit 1 +fi if [[ ! -s "${marker}" ]]; then echo "EPAR host-trust gate: image generation marker is missing" >&2 @@ -12,12 +20,12 @@ if [[ ! -s "${lease}" ]]; then echo "EPAR host-trust gate: controller lease is missing" >&2 exit 1 fi -if ! command -v python3 >/dev/null 2>&1; then +if [[ ! -x /usr/bin/python3 ]]; then echo "EPAR host-trust gate: python3 is required" >&2 exit 1 fi -python3 - "${marker}" "${lease}" <<'PY' +/usr/bin/env -i PATH=/usr/bin:/bin LANG=C.UTF-8 /usr/bin/python3 -I -S - "${marker}" "${lease}" <<'PY' import datetime import json import sys @@ -44,8 +52,10 @@ for key in ("generation", "hostOS", "mode", "scopes"): f"(image={marker.get(key)!r}, lease={lease.get(key)!r})" ) -if marker.get("mode") != "overlay" or not marker.get("generation"): +if marker.get("mode") not in ("overlay", "disabled") or not marker.get("generation"): raise SystemExit("EPAR host-trust gate: invalid image trust policy") +if marker.get("mode") == "disabled" and marker.get("scopes") != []: + raise SystemExit("EPAR host-trust gate: disabled trust mode must not carry scopes") expires = lease.get("expiresAt") if not isinstance(expires, str) or not expires: diff --git a/scripts/run-with-docker.ps1 b/scripts/run-with-docker.ps1 index c8583e8..1d0865c 100644 --- a/scripts/run-with-docker.ps1 +++ b/scripts/run-with-docker.ps1 @@ -3,22 +3,36 @@ param( [string[]] $EparArgs ) -# Runs EPAR from source with no local Go install: a containerized Go -# toolchain compiles and executes the source with `go run`, the same as the -# documented source-first path (docs/usage.md) - just inside a container -# instead of on the host. No binary is built or left on disk. +# Runs EPAR from source with no local Go install. By default, a containerized +# Go toolchain builds a CGO-disabled native controller under .local/bin and the +# wrapper executes that binary on the host. Set +# EPAR_LEGACY_CONTROLLER_IN_DOCKER=1 only for compatible legacy providers. # -# Docker is still required (both for this wrapper and for EPAR's own -# Docker Container provider, reached here via the mounted host socket). +# Docker is required for the build toolchain and for the Docker Container +# provider. Docker Sandboxes also requires its separately installed sbx CLI. # # Usage: scripts\run-with-docker.ps1 [epar-args...] $ErrorActionPreference = "Stop" +$OriginalInvocationExists = Test-Path Env:EPAR_INVOCATION +$OriginalInvocation = $env:EPAR_INVOCATION +$OwnInvocationMarker = [string]::IsNullOrWhiteSpace($env:EPAR_INVOCATION) +if (-not $env:EPAR_INVOCATION) { + $env:EPAR_INVOCATION = "run-with-docker-powershell" +} + +if ($env:EPAR_LEGACY_CONTROLLER_IN_DOCKER -ne '1') { + try { + & (Join-Path $PSScriptRoot 'build-native-controller.ps1') @EparArgs + $nativeExitCode = $LASTEXITCODE + } finally { + if ($OwnInvocationMarker -and $OriginalInvocationExists) { $env:EPAR_INVOCATION = $OriginalInvocation } elseif ($OwnInvocationMarker) { Remove-Item Env:EPAR_INVOCATION -ErrorAction SilentlyContinue } + } + exit $nativeExitCode +} $Image = if ($env:GO_DOCKER_IMAGE) { $env:GO_DOCKER_IMAGE } else { "golang:1.25" } $DevImage = if ($env:EPAR_DEV_IMAGE) { $env:EPAR_DEV_IMAGE } else { "epar-dev-toolchain" } -$GomodVolume = if ($env:EPAR_GOMOD_VOLUME) { $env:EPAR_GOMOD_VOLUME } else { "epar-gomod" } -$GocacheVolume = if ($env:EPAR_GOCACHE_VOLUME) { $env:EPAR_GOCACHE_VOLUME } else { "epar-gocache" } $DockerSock = if ($env:EPAR_DOCKER_SOCK) { $env:EPAR_DOCKER_SOCK } else { "/var/run/docker.sock" } $OriginalDockerCliHintsExists = Test-Path Env:DOCKER_CLI_HINTS $OriginalDockerCliHints = $env:DOCKER_CLI_HINTS @@ -37,6 +51,8 @@ if (-not $HostName) { } $DockerEnvFlags = @() $DockerEnvFlags += @("-e", "DOCKER_CLI_HINTS=$DockerCliHints") +$DockerEnvFlags += @("-e", "EPAR_CONTROLLER_IN_DOCKER=1") +$DockerEnvFlags += @("-e", "EPAR_INVOCATION=$($env:EPAR_INVOCATION)") if ($env:EPAR_CONFIG) { $DockerEnvFlags += @("-e", "EPAR_CONFIG=$($env:EPAR_CONFIG)") } @@ -50,13 +66,20 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { } function Test-EparBenignDockerDesktopPrefaceDiagnostic { - param([Parameter(Mandatory = $true)][string] $Line) - return $Line -match '^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} http2: server: error reading preface from client //\./pipe/(?:dockerDesktopLinuxEngine|docker_engine): file has already been closed$' + param([Parameter(Mandatory = $true)][string] $Transcript) + $normalized = ($Transcript -replace '\s+', ' ').Trim() + return $normalized -match '^(?:docker\s*:\s*)?\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} http2: server: error reading preface from client //\./pipe/(?:dockerDesktopLinuxEngine|docker_engine): file has already been closed(?: At .* FullyQualifiedErrorId\s*:\s*NativeCommandError)?$' } function Invoke-EparBootstrapDockerBuild { $stderrPath = [System.IO.Path]::GetTempFileName() + $previousErrorActionPreference = $ErrorActionPreference try { + # Windows PowerShell converts native stderr into ErrorRecord objects + # when the preference is Stop. Keep the native transcript as bytes so + # the one known successful Docker Desktop diagnostic can be classified + # without terminating this wrapper or losing real failure output. + $ErrorActionPreference = 'Continue' docker build --quiet ` --build-arg "GO_IMAGE=$Image" ` -t $DevImage ` @@ -64,15 +87,14 @@ function Invoke-EparBootstrapDockerBuild { (Join-Path $RepoRoot "scripts\docker") 2> $stderrPath | Out-Null $buildExitCode = $LASTEXITCODE if (Test-Path -LiteralPath $stderrPath) { - foreach ($line in Get-Content -LiteralPath $stderrPath) { - if ($buildExitCode -eq 0 -and (Test-EparBenignDockerDesktopPrefaceDiagnostic -Line ([string]$line))) { - continue - } - [Console]::Error.WriteLine([string]$line) + $stderrTranscript = Get-Content -Raw -LiteralPath $stderrPath + if ($stderrTranscript -and -not ($buildExitCode -eq 0 -and (Test-EparBenignDockerDesktopPrefaceDiagnostic -Transcript $stderrTranscript))) { + [Console]::Error.Write($stderrTranscript) } } return $buildExitCode } finally { + $ErrorActionPreference = $previousErrorActionPreference Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue } } @@ -87,6 +109,9 @@ if ($EparCommand -eq "init" -or $ImplicitInit) { $DockerEnvFlags += @("-e", "EPAR_CONTROLLER_HOST_OS=$(Get-EparHostTrustHostOS)") } $DockerRunFlags = @("--rm", "-i") +$GoCacheFlags = @() +if ($env:EPAR_GOMOD_VOLUME) { $GoCacheFlags += @("-v", "$($env:EPAR_GOMOD_VOLUME):/go/pkg/mod") } +if ($env:EPAR_GOCACHE_VOLUME) { $GoCacheFlags += @("-v", "$($env:EPAR_GOCACHE_VOLUME):/root/.cache/go-build") } try { if (-not [Console]::IsInputRedirected) { $DockerRunFlags += "-t" @@ -106,9 +131,8 @@ try { $InitArgs = @(Get-EparHostTrustInitArguments -Arguments $EparArgs) docker run @DockerRunFlags ` @DockerEnvFlags ` + @GoCacheFlags ` -v "${RepoRoot}:/app" -w /app ` - -v "${GomodVolume}:/go/pkg/mod" ` - -v "${GocacheVolume}:/root/.cache/go-build" ` -v "${DockerSock}:/var/run/docker.sock" ` $DevImage ` go run ./cmd/ephemeral-action-runner @InitArgs @@ -129,9 +153,8 @@ try { docker run @DockerRunFlags ` @DockerEnvFlags ` @HostTrustFlags ` + @GoCacheFlags ` -v "${RepoRoot}:/app" -w /app ` - -v "${GomodVolume}:/go/pkg/mod" ` - -v "${GocacheVolume}:/root/.cache/go-build" ` -v "${DockerSock}:/var/run/docker.sock" ` $DevImage ` go run ./cmd/ephemeral-action-runner @EparArgs @@ -149,6 +172,7 @@ try { } else { Remove-Item Env:DOCKER_CLI_HINTS -ErrorAction SilentlyContinue } + if ($OwnInvocationMarker -and $OriginalInvocationExists) { $env:EPAR_INVOCATION = $OriginalInvocation } elseif ($OwnInvocationMarker) { Remove-Item Env:EPAR_INVOCATION -ErrorAction SilentlyContinue } } exit $ExitCode diff --git a/scripts/run-with-docker.sh b/scripts/run-with-docker.sh index bed2c37..55deedd 100755 --- a/scripts/run-with-docker.sh +++ b/scripts/run-with-docker.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +export EPAR_INVOCATION="${EPAR_INVOCATION:-run-with-docker}" + case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" @@ -10,20 +12,24 @@ case "$(uname -s)" in ;; esac -# Runs EPAR from source with no local Go install: a containerized Go -# toolchain compiles and executes the source with `go run`, the same as the -# documented source-first path (docs/usage.md) — just inside a container -# instead of on the host. No binary is built or left on disk. +if [[ "${EPAR_LEGACY_CONTROLLER_IN_DOCKER:-0}" != "1" ]]; then + exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/build-native-controller.sh" "$@" +fi + +# Runs EPAR from source with no local Go install. By default, a containerized +# Go toolchain builds a CGO-disabled native controller under .local/bin and the +# wrapper executes that binary on the host. Set +# EPAR_LEGACY_CONTROLLER_IN_DOCKER=1 only for compatible legacy providers. # -# Docker is still required (both for this wrapper and for EPAR's own -# Docker Container provider, reached here via the mounted host socket). +# Docker is required for the build toolchain and for the Docker Container +# provider. Docker Sandboxes also requires its separately installed sbx CLI. # # Usage: scripts/run-with-docker.sh [epar-args...] image="${GO_DOCKER_IMAGE:-golang:1.25}" dev_image="${EPAR_DEV_IMAGE:-epar-dev-toolchain}" -gomod_volume="${EPAR_GOMOD_VOLUME:-epar-gomod}" -gocache_volume="${EPAR_GOCACHE_VOLUME:-epar-gocache}" +gomod_volume="${EPAR_GOMOD_VOLUME:-}" +gocache_volume="${EPAR_GOCACHE_VOLUME:-}" docker_sock="${EPAR_DOCKER_SOCK:-/var/run/docker.sock}" export DOCKER_CLI_HINTS="${DOCKER_CLI_HINTS:-false}" host_name="${EPAR_HOST_NAME:-}" @@ -31,6 +37,8 @@ if [[ -z "${host_name}" ]]; then host_name="$(hostname 2>/dev/null || true)" fi docker_env_flags=(-e "DOCKER_CLI_HINTS=${DOCKER_CLI_HINTS}") +docker_env_flags+=(-e "EPAR_CONTROLLER_IN_DOCKER=1") +docker_env_flags+=(-e "EPAR_INVOCATION=${EPAR_INVOCATION}") if [[ -n "${EPAR_CONFIG:-}" ]]; then docker_env_flags+=(-e "EPAR_CONFIG=${EPAR_CONFIG}"); fi if [[ -n "${host_name}" ]]; then docker_env_flags+=(-e "EPAR_HOST_NAME=${host_name}") @@ -68,6 +76,9 @@ if [[ -t 0 ]]; then fi host_trust_docker_flags=() +go_cache_docker_flags=() +if [[ -n "$gomod_volume" ]]; then go_cache_docker_flags+=(-v "${gomod_volume}:/go/pkg/mod"); fi +if [[ -n "$gocache_volume" ]]; then go_cache_docker_flags+=(-v "${gocache_volume}:/root/.cache/go-build"); fi run_controller() { local -a docker_args=(run) docker_args+=("${tty_flags[@]}" "${docker_env_flags[@]}") @@ -76,12 +87,10 @@ run_controller() { fi docker_args+=( -v "${repo_root}:/app" -w /app - -v "${gomod_volume}:/go/pkg/mod" - -v "${gocache_volume}:/root/.cache/go-build" -v "${docker_sock}:/var/run/docker.sock" - "$dev_image" - go run ./cmd/ephemeral-action-runner "$@" ) + docker_args+=("${go_cache_docker_flags[@]}") + docker_args+=("$dev_image" go run ./cmd/ephemeral-action-runner "$@") docker "${docker_args[@]}" } diff --git a/scripts/sync-wiki.sh b/scripts/sync-wiki.sh index b49b5e6..fb1a16c 100755 --- a/scripts/sync-wiki.sh +++ b/scripts/sync-wiki.sh @@ -52,6 +52,8 @@ rewrite_links() { "image-build.md" => "Image-Build", "docs/design.md" => "Design", "design.md" => "Design", + "docs/development-principles.md" => "Development-Principles", + "development-principles.md" => "Development-Principles", "docs/operations.md" => "Operations", "operations.md" => "Operations", "docs/security.md" => "Security", @@ -67,6 +69,9 @@ rewrite_links() { "docs/providers/docker-container.md" => "Docker-Container-Provider", "providers/docker-container.md" => "Docker-Container-Provider", "docker-container.md" => "Docker-Container-Provider", + "docs/providers/docker-sandboxes.md" => "Docker-Sandboxes-Provider", + "providers/docker-sandboxes.md" => "Docker-Sandboxes-Provider", + "docker-sandboxes.md" => "Docker-Sandboxes-Provider", "docs/providers/adding-provider.md" => "Adding-A-Provider", "providers/adding-provider.md" => "Adding-A-Provider", "adding-provider.md" => "Adding-A-Provider", @@ -106,12 +111,14 @@ copy_page "docs/usage.md" "Usage" copy_page "docs/github-app.md" "GitHub-App-Setup" copy_page "docs/image-build.md" "Image-Build" copy_page "docs/design.md" "Design" +copy_page "docs/development-principles.md" "Development-Principles" copy_page "docs/operations.md" "Operations" copy_page "docs/security.md" "Security" copy_page "docs/background.md" "Background" copy_page "docs/providers/tart.md" "Tart-Provider" copy_page "docs/providers/wsl.md" "WSL-Provider" copy_page "docs/providers/docker-container.md" "Docker-Container-Provider" +copy_page "docs/providers/docker-sandboxes.md" "Docker-Sandboxes-Provider" copy_page "docs/providers/adding-provider.md" "Adding-A-Provider" copy_page "docs/advanced/docker-registry-mirrors.md" "Docker-Registry-Mirrors" copy_page "docs/advanced/macos-startup.md" "macOS-Startup" @@ -131,6 +138,7 @@ cat > "$out_dir/_Sidebar.md" <<'SIDEBAR' ## Providers - [Docker Container](Docker-Container-Provider) +- [Docker Sandboxes](Docker-Sandboxes-Provider) - [Tart](Tart-Provider) - [WSL](WSL-Provider) - [Adding A Provider](Adding-A-Provider) @@ -140,6 +148,7 @@ cat > "$out_dir/_Sidebar.md" <<'SIDEBAR' - [Operations](Operations) - [Security](Security) - [Design](Design) +- [Development Principles](Development-Principles) - [Background](Background) - [Docker Registry Mirrors](Docker-Registry-Mirrors) - [macOS Startup](macOS-Startup) diff --git a/scripts/test/docker-sandboxes-plan-smoke.ps1 b/scripts/test/docker-sandboxes-plan-smoke.ps1 new file mode 100644 index 0000000..4657820 --- /dev/null +++ b/scripts/test/docker-sandboxes-plan-smoke.ps1 @@ -0,0 +1,152 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\..')) +$templateDirectory = Join-Path $repositoryRoot 'templates\docker-sandboxes' +$lockPath = Join-Path $templateDirectory 'sources.lock.json' +$powerShell = (Get-Process -Id $PID).Path +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('epar-docker-sandboxes-plan-' + [guid]::NewGuid().ToString('N')) + +function Get-Sha256File { + param([Parameter(Mandatory = $true)][string]$Path) + return 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Get-Sha256Text { + param([Parameter(Mandatory = $true)][string]$Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + return 'sha256:' + ([System.BitConverter]::ToString($sha.ComputeHash([System.Text.UTF8Encoding]::new($false).GetBytes($Text))).Replace('-', '').ToLowerInvariant()) + } + finally { + $sha.Dispose() + } +} + +function Get-TemplateContextDigest { + $material = [System.Text.StringBuilder]::new() + foreach ($file in @(Get-ChildItem -LiteralPath $templateDirectory -File -Recurse | Sort-Object FullName)) { + $relative = $file.FullName.Substring($templateDirectory.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/') + [void]$material.AppendLine($relative) + [void]$material.AppendLine((Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant()) + } + return Get-Sha256Text -Text $material.ToString() +} + +function Write-Utf8 { + param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Text) + [System.IO.File]::WriteAllText($Path, $Text, [System.Text.UTF8Encoding]::new($false)) +} + +function Write-Json { + param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)]$Value) + Write-Utf8 -Path $Path -Text (($Value | ConvertTo-Json -Depth 100) + [Environment]::NewLine) +} + +try { + $artifactDirectory = Join-Path $temporaryRoot 'artifacts' + $fakeBin = Join-Path $temporaryRoot 'bin' + New-Item -ItemType Directory -Path $artifactDirectory, $fakeBin | Out-Null + + $buildPlan = @(& $powerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'scripts\docker-sandboxes\build-template.ps1') -Profile act-22.04 -Platform linux/amd64) + if ($LASTEXITCODE -ne 0 -or ($buildPlan -join "`n") -notmatch '"execute"\s*:\s*false' -or ($buildPlan -join "`n") -notmatch 'Plan only') { + throw 'build-template.ps1 plan-only smoke test failed' + } + + $lock = Get-Content -Raw -LiteralPath $lockPath | ConvertFrom-Json + $profile = $lock.profiles.'act-22.04' + $platform = $lock.platforms.'linux/amd64' + $profilePlatform = $profile.platforms.'linux/amd64' + $fullIdentity = 'sha256:' + ('a' * 64) + $paths = [ordered]@{ + buildMetadata = Join-Path $artifactDirectory 'build-metadata.json' + sbom = Join-Path $artifactDirectory 'sbom.spdx.json' + provenance = Join-Path $artifactDirectory 'provenance.json' + softwareInventory = Join-Path $artifactDirectory 'software-inventory.txt' + helperHashes = Join-Path $artifactDirectory 'helpers.sha256' + compatibility = Join-Path $artifactDirectory 'compatibility.json' + archive = Join-Path $artifactDirectory 'template.tar' + metadata = Join-Path $artifactDirectory 'template-metadata.json' + } + Write-Json -Path $paths.buildMetadata -Value ([ordered]@{ + 'containerimage.digest' = $fullIdentity + 'buildx.build.ref' = 'test-builder/test-builder/test-build' + 'buildx.build.provenance' = [ordered]@{ buildType = 'https://mobyproject.org/buildkit@v1'; invocation = @{}; metadata = @{} } + }) + Write-Json -Path $paths.sbom -Value ([ordered]@{ spdxVersion = 'SPDX-2.3'; SPDXID = 'SPDXRef-DOCUMENT'; packages = @() }) + Write-Json -Path $paths.provenance -Value ([ordered]@{ buildType = 'https://mobyproject.org/buildkit@v1'; invocation = @{}; metadata = @{} }) + Write-Utf8 -Path $paths.softwareInventory -Text "test-package 1.0`n" + [System.IO.File]::Copy((Join-Path $templateDirectory 'helpers.sha256'), $paths.helperHashes) + [System.IO.File]::Copy((Join-Path (Join-Path $templateDirectory 'profiles') $profilePlatform.compatibilityFile), $paths.compatibility) + Write-Utf8 -Path $paths.archive -Text 'archive' + + $artifactRecords = [ordered]@{} + foreach ($name in @('buildMetadata', 'sbom', 'provenance', 'softwareInventory', 'helperHashes', 'compatibility')) { + $artifactRecords[$name] = [ordered]@{ path = [System.IO.Path]::GetFileName($paths[$name]); sha256 = Get-Sha256File -Path $paths[$name] } + } + $metadata = [ordered]@{ + schemaVersion = 2 + profile = 'act-22.04' + validationStatus = $profilePlatform.validationStatus + platform = 'linux/amd64' + template = [ordered]@{ + tag = $profilePlatform.templateTag + digest = $fullIdentity + templateDigest = $fullIdentity + cacheID = 'aaaaaaaaaaaa' + archive = 'template.tar' + archiveSha256 = Get-Sha256File -Path $paths.archive + archiveBytes = (Get-Item -LiteralPath $paths.archive).Length + } + source = [ordered]@{ reference = $profile.immutableReference; indexDigest = $profile.indexDigest; manifestDigest = $profilePlatform.manifestDigest; revision = $profile.sourceRevision } + inputs = [ordered]@{ + actionsRunnerVersion = $lock.actionsRunner.version + actionsRunnerUrl = $platform.actionsRunner.url + actionsRunnerSha256 = $platform.actionsRunner.sha256 + tiniVersion = $lock.tini.version + tiniUrl = $platform.tini.url + tiniSha256 = $platform.tini.sha256 + dockerfileFrontend = $lock.dockerfileFrontend.reference + dockerfileFrontendManifestDigest = $platform.dockerfileFrontendManifestDigest + sbomGenerator = $platform.sbomGeneratorReference + sourceLockSha256 = Get-Sha256File -Path $lockPath + templateContextDigest = Get-TemplateContextDigest + } + compatibility = [ordered]@{ supportedSbxVersions = @('0.35.0'); candidate = 'A'; dockerDaemonOwner = 'docker-sandboxes-runtime'; expectedDockerDaemonCount = 1 } + artifacts = $artifactRecords + } + Write-Json -Path $paths.metadata -Value $metadata + $expectedMetadataSha256 = Get-Sha256File -Path $paths.metadata + + $sbxMarker = Join-Path $temporaryRoot 'sbx-invoked' + if ($IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop') { + Write-Utf8 -Path (Join-Path $fakeBin 'docker.cmd') -Text "@echo $fullIdentity`r`n" + Write-Utf8 -Path (Join-Path $fakeBin 'sbx.cmd') -Text "@echo invoked>`"$sbxMarker`"`r`n@exit /b 1`r`n" + } + else { + $dockerPath = Join-Path $fakeBin 'docker' + $sbxPath = Join-Path $fakeBin 'sbx' + Write-Utf8 -Path $dockerPath -Text "#!/usr/bin/env sh`nprintf '%s\n' '$fullIdentity'`n" + Write-Utf8 -Path $sbxPath -Text "#!/usr/bin/env sh`nprintf invoked > '$sbxMarker'`nexit 1`n" + & chmod 0755 $dockerPath $sbxPath + if ($LASTEXITCODE -ne 0) { throw 'could not make fake plan-only commands executable' } + } + $previousPath = $env:PATH + try { + $env:PATH = $fakeBin + [System.IO.Path]::PathSeparator + $env:PATH + $loadPlan = @(& $powerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'scripts\docker-sandboxes\load-template.ps1') -ArtifactDirectory $artifactDirectory -ExpectedMetadataSha256 $expectedMetadataSha256) + } + finally { + $env:PATH = $previousPath + } + if ($LASTEXITCODE -ne 0 -or ($loadPlan -join "`n") -notmatch 'All evidence was verified without invoking sbx' -or (Test-Path -LiteralPath $sbxMarker)) { + throw 'load-template.ps1 plan-only smoke test failed or invoked sbx' + } + Write-Host 'Docker Sandboxes build/load plan-only smoke tests passed.' +} +finally { + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +} diff --git a/scripts/test/native-controller-cache-retention.sh b/scripts/test/native-controller-cache-retention.sh new file mode 100644 index 0000000..c939f21 --- /dev/null +++ b/scripts/test/native-controller-cache-retention.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +builder="${source_root}/scripts/build-native-controller.sh" +eval "$(sed -n '/^epar_directory_mtime()/,/^case "$(uname -s)\/$(uname -m)"/p' "$builder" | sed '$d')" + +native_cache_keep_previous=2 +native_cache_max_bytes=$((1024 * 1024)) +native_cache_grace_seconds=0 +abandoned_build_grace_seconds=0 +retention_inventory_file="" + +temporary="$(mktemp -d)" +cleanup() { + rm -rf -- "$temporary" +} +trap cleanup EXIT INT TERM + +repeat_character() { + printf '%64s' '' | tr ' ' "$1" +} + +write_manifested_revision() { + local root="$1" + local key="$2" + local directory="${root}/${key}" + mkdir -p "$directory" + printf '%s' "$key" >"${directory}/ephemeral-action-runner" + printf '%s\n' \ + 'schemaVersion=1' \ + "cacheKey=${key}" \ + 'executable=ephemeral-action-runner' >"${directory}/controller-cache.manifest" +} + +cache_root="${temporary}/bin" +mkdir -p "$cache_root" +keys=() +for character in 0 1 2 3 4 5 6; do + keys+=("$(repeat_character "$character")") +done +for index in 0 1 2 3 4; do + write_manifested_revision "$cache_root" "${keys[$index]}" + touch -t "20200101010${index}" "${cache_root}/${keys[$index]}" +done + +printf '%s\n' \ + 'schemaVersion=1' \ + "host=$(hostname 2>/dev/null || true)" \ + "pid=$$" \ + 'startedAtUnix=1' >"${cache_root}/${keys[4]}/lease-active" + +mkdir -p "${cache_root}/${keys[5]}" +printf '%s' "${keys[5]}" >"${cache_root}/${keys[5]}/ephemeral-action-runner" +mkdir -p "${cache_root}/${keys[6]}" +printf '%s' "${keys[6]}" >"${cache_root}/${keys[6]}/ephemeral-action-runner" +printf '%s\n' \ + 'schemaVersion=1' \ + "cacheKey=${keys[5]}" \ + 'executable=ephemeral-action-runner' >"${cache_root}/${keys[6]}/controller-cache.manifest" + +stale_build="${cache_root}/.build-stale" +mkdir -p "$stale_build" +printf '%s\n' \ + 'schemaVersion=1' \ + "host=$(hostname 2>/dev/null || true)" \ + 'pid=2147483646' \ + 'startedAtUnix=1' >"${stale_build}/lease-build-2147483646.ABC123" +touch -t 202001010100 "$stale_build" + +active_build="${cache_root}/.build-active" +mkdir -p "$active_build" +printf '%s\n' \ + 'schemaVersion=1' \ + "host=$(hostname 2>/dev/null || true)" \ + "pid=$$" \ + 'startedAtUnix=1' >"${active_build}/lease-build-$$.DEF456" +touch -t 202001010100 "$active_build" + +unmarked_build="${cache_root}/.build-unmarked" +mkdir -p "$unmarked_build" +touch -t 202001010100 "$unmarked_build" + +malformed_build="${cache_root}/.build-malformed" +mkdir -p "$malformed_build" +printf '%s\n' \ + "host=$(hostname 2>/dev/null || true)" \ + 'pid=2147483646' \ + 'startedAtUnix=1' >"${malformed_build}/lease-build-2147483646.GHI789" +touch -t 202001010100 "$malformed_build" + +foreign_build="${cache_root}/.build-foreign" +mkdir -p "$foreign_build" +printf '%s\n' \ + 'schemaVersion=1' \ + "host=foreign-$(hostname 2>/dev/null || true)" \ + 'pid=2147483646' \ + "startedAtUnix=$(date +%s)" >"${foreign_build}/lease-build-2147483646.JKL012" +touch -t 202001010100 "$foreign_build" + +symlink_key="$(repeat_character 8)" +symlink_created=0 +if ln -s "${cache_root}/${keys[0]}" "${cache_root}/${symlink_key}" 2>/dev/null && [[ -L "${cache_root}/${symlink_key}" ]]; then + symlink_created=1 +fi + +epar_prune_native_controller_cache "$cache_root" "${keys[0]}" + +expected=("${keys[0]}" "${keys[2]}" "${keys[3]}" "${keys[4]}" "${keys[5]}" "${keys[6]}") +for key in "${expected[@]}"; do + [[ -d "${cache_root}/${key}" ]] || { echo "retention removed protected revision ${key}" >&2; exit 1; } +done +[[ ! -e "${cache_root}/${keys[1]}" ]] || { echo "retention kept the expired excess revision" >&2; exit 1; } +[[ ! -e "$stale_build" ]] || { echo "retention kept an inactive leased build" >&2; exit 1; } +[[ -d "$active_build" ]] || { echo "retention removed an active build" >&2; exit 1; } +[[ -d "$unmarked_build" ]] || { echo "retention removed a markerless build" >&2; exit 1; } +[[ -d "$malformed_build" ]] || { echo "retention treated a malformed build lease as ownership evidence" >&2; exit 1; } +[[ -d "$foreign_build" ]] || { echo "retention removed a recent foreign-host build lease" >&2; exit 1; } +if ((symlink_created == 1)); then + [[ -L "${cache_root}/${symlink_key}" ]] || { echo "retention followed or removed a cache symlink" >&2; exit 1; } +fi + +policy_root="${temporary}/policy" +mkdir -p "$policy_root" +policy_current="$(repeat_character a)" +policy_expired="$(repeat_character b)" +policy_grace="$(repeat_character c)" +write_manifested_revision "$policy_root" "$policy_current" +write_manifested_revision "$policy_root" "$policy_expired" +write_manifested_revision "$policy_root" "$policy_grace" +printf '%1024s' '' >"${policy_root}/${policy_expired}/ephemeral-action-runner" +printf '%1024s' '' >"${policy_root}/${policy_grace}/ephemeral-action-runner" +touch -t 202001010100 "${policy_root}/${policy_expired}" +native_cache_keep_previous=5 +native_cache_max_bytes=512 +native_cache_grace_seconds=$((7 * 24 * 60 * 60)) +epar_prune_native_controller_cache "$policy_root" "$policy_current" +[[ ! -e "${policy_root}/${policy_expired}" ]] || { echo "retention kept an expired revision beyond the byte budget" >&2; exit 1; } +[[ -d "${policy_root}/${policy_grace}" ]] || { echo "retention removed a grace-protected revision beyond the byte budget" >&2; exit 1; } + +echo "Unix native-controller cache retention contract passed" diff --git a/scripts/test/no-go-first-run-smoke.ps1 b/scripts/test/no-go-first-run-smoke.ps1 deleted file mode 100644 index bd347eb..0000000 --- a/scripts/test/no-go-first-run-smoke.ps1 +++ /dev/null @@ -1,102 +0,0 @@ -[CmdletBinding()] -param( - [string] $ProjectRoot = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) -) - -$ErrorActionPreference = 'Stop' -$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ('epar-no-go-first-run-' + [guid]::NewGuid().ToString('N')) -$oldPath = $env:PATH -$oldLocalAppData = $env:LOCALAPPDATA -$oldFakeProject = $env:FAKE_PROJECT -$oldFakeDockerLog = $env:FAKE_DOCKER_LOG -$oldFakeInitFail = $env:FAKE_INIT_FAIL -$oldFakeBuildDiagnostic = $env:FAKE_BUILD_DIAGNOSTIC -$oldFakeBuildFail = $env:FAKE_BUILD_FAIL -try { - $hostPowerShell = (Get-Process -Id $PID).Path - $project = Join-Path $temporary 'project' - $hostTrust = Join-Path $project 'scripts\host-trust' - $dockerScripts = Join-Path $project 'scripts\docker' - $fakeBin = Join-Path $temporary 'bin' - New-Item -ItemType Directory -Force -Path $hostTrust, $dockerScripts, $fakeBin | Out-Null - Copy-Item (Join-Path $ProjectRoot 'scripts\run-with-docker.ps1') (Join-Path $project 'scripts\run-with-docker.ps1') - Copy-Item (Join-Path $ProjectRoot 'scripts\host-trust\wrapper-lib.ps1') (Join-Path $hostTrust 'wrapper-lib.ps1') - Copy-Item (Join-Path $ProjectRoot 'scripts\host-trust\host-trust-feed.ps1') (Join-Path $hostTrust 'host-trust-feed.ps1') - [System.IO.File]::WriteAllText((Join-Path $dockerScripts 'dev.Dockerfile'), '', [System.Text.UTF8Encoding]::new($false)) - - $fakeDocker = @' -$ErrorActionPreference = 'Stop' -$line = 'CALL' + (($args | ForEach-Object { ' <' + $_ + '>' }) -join '') -Add-Content -LiteralPath $env:FAKE_DOCKER_LOG -Value $line -Encoding utf8 -if ($args.Count -gt 0 -and $args[0] -eq 'build') { - if ($env:FAKE_BUILD_DIAGNOSTIC -eq '1') { - [Console]::Error.WriteLine('2026/07/22 02:36:46 http2: server: error reading preface from client //./pipe/dockerDesktopLinuxEngine: file has already been closed') - } - if ($env:FAKE_BUILD_FAIL -eq '1') { - [Console]::Error.WriteLine('fake Docker build failure') - exit 24 - } - exit 0 -} -if ($args -contains 'init') { - if ($env:FAKE_INIT_FAIL -eq '1') { exit 23 } - $local = Join-Path $env:FAKE_PROJECT '.local' - New-Item -ItemType Directory -Force -Path $local | Out-Null - $config = "provider:`n type: docker-container`nrunner:`n ephemeral: true`nimage:`n hostTrustMode: overlay`n hostTrustScopes: [system, user]`n" - [System.IO.File]::WriteAllText((Join-Path $local 'config.yml'), $config, [System.Text.UTF8Encoding]::new($false)) -} -exit 0 -'@ - [System.IO.File]::WriteAllText((Join-Path $fakeBin 'fake-docker.ps1'), $fakeDocker, [System.Text.UTF8Encoding]::new($false)) - $cmd = "@echo off`r`npwsh.exe -NoLogo -NoProfile -File `"%~dp0fake-docker.ps1`" %*`r`nexit /b %ERRORLEVEL%`r`n" - [System.IO.File]::WriteAllText((Join-Path $fakeBin 'docker.cmd'), $cmd, [System.Text.ASCIIEncoding]::new()) - - $env:PATH = $fakeBin + [System.IO.Path]::PathSeparator + $oldPath - $env:LOCALAPPDATA = Join-Path $temporary 'cache' - $env:FAKE_PROJECT = $project - $env:FAKE_DOCKER_LOG = Join-Path $temporary 'docker.log' - Remove-Item Env:FAKE_INIT_FAIL -ErrorAction SilentlyContinue - $env:FAKE_BUILD_DIAGNOSTIC = '1' - Remove-Item Env:FAKE_BUILD_FAIL -ErrorAction SilentlyContinue - - $firstRunOutput = @(& $hostPowerShell -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $project 'scripts\run-with-docker.ps1') start *>&1) - if ($LASTEXITCODE -ne 0) { throw "first-run wrapper exited $LASTEXITCODE" } - if (($firstRunOutput | Out-String) -match 'http2: server: error reading preface') { throw "successful bootstrap leaked the benign Docker Desktop diagnostic: $($firstRunOutput -join [Environment]::NewLine)" } - $calls = @(Get-Content -LiteralPath $env:FAKE_DOCKER_LOG | Where-Object { $_ -like '* *' }) - if ($calls.Count -ne 2) { throw "expected two controller runs, got $($calls.Count)" } - if ($calls[0] -notlike '* *' -or $calls[0] -notlike '* *' -or $calls[0] -notlike '* *') { - throw "implicit init did not receive the real Windows host/deferred-init contract: $($calls[0])" - } - if ($calls[1] -notlike '* *' -or $calls[1] -notlike '*:/run/epar-host-trust:ro>*' -or $calls[1] -notlike '* *') { - throw "second start did not receive the read-only host-trust feed: $($calls[1])" - } - - $env:FAKE_BUILD_FAIL = '1' - $failedBuildOutput = @(& $hostPowerShell -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $project 'scripts\run-with-docker.ps1') start *>&1) - if ($LASTEXITCODE -ne 24) { throw "failing bootstrap build exited $LASTEXITCODE, want 24" } - $failedBuildText = $failedBuildOutput | Out-String - if ($failedBuildText -notmatch 'http2: server: error reading preface' -or $failedBuildText -notmatch 'fake Docker build failure') { - throw "failing bootstrap build did not preserve complete Docker stderr: $failedBuildText" - } - Remove-Item Env:FAKE_BUILD_FAIL -ErrorAction SilentlyContinue - - Remove-Item -LiteralPath (Join-Path $project '.local') -Recurse -Force - Remove-Item -LiteralPath $env:LOCALAPPDATA -Recurse -Force -ErrorAction SilentlyContinue - [System.IO.File]::WriteAllText($env:FAKE_DOCKER_LOG, '', [System.Text.UTF8Encoding]::new($false)) - $env:FAKE_INIT_FAIL = '1' - & $hostPowerShell -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $project 'scripts\run-with-docker.ps1') start - if ($LASTEXITCODE -ne 23) { throw "failing implicit init exited $LASTEXITCODE, want 23" } - - Write-Output 'Windows no-Go first-run start lifecycle smoke passed' - exit 0 -} -finally { - $env:PATH = $oldPath - if ($null -eq $oldLocalAppData) { Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue } else { $env:LOCALAPPDATA = $oldLocalAppData } - if ($null -eq $oldFakeProject) { Remove-Item Env:FAKE_PROJECT -ErrorAction SilentlyContinue } else { $env:FAKE_PROJECT = $oldFakeProject } - if ($null -eq $oldFakeDockerLog) { Remove-Item Env:FAKE_DOCKER_LOG -ErrorAction SilentlyContinue } else { $env:FAKE_DOCKER_LOG = $oldFakeDockerLog } - if ($null -eq $oldFakeInitFail) { Remove-Item Env:FAKE_INIT_FAIL -ErrorAction SilentlyContinue } else { $env:FAKE_INIT_FAIL = $oldFakeInitFail } - if ($null -eq $oldFakeBuildDiagnostic) { Remove-Item Env:FAKE_BUILD_DIAGNOSTIC -ErrorAction SilentlyContinue } else { $env:FAKE_BUILD_DIAGNOSTIC = $oldFakeBuildDiagnostic } - if ($null -eq $oldFakeBuildFail) { Remove-Item Env:FAKE_BUILD_FAIL -ErrorAction SilentlyContinue } else { $env:FAKE_BUILD_FAIL = $oldFakeBuildFail } - Remove-Item -LiteralPath $temporary -Recurse -Force -ErrorAction SilentlyContinue -} diff --git a/scripts/test/no-go-first-run-smoke.sh b/scripts/test/no-go-first-run-smoke.sh index 2e73a7f..4e820db 100644 --- a/scripts/test/no-go-first-run-smoke.sh +++ b/scripts/test/no-go-first-run-smoke.sh @@ -63,6 +63,7 @@ if [[ "$host_os" == Linux ]]; then fi export FAKE_PROJECT="$project" export FAKE_DOCKER_LOG="$temporary/docker.log" +export EPAR_LEGACY_CONTROLLER_IN_DOCKER=1 (cd "$project" && scripts/run-with-docker.sh start) [[ "$(grep -c ' ' "$FAKE_DOCKER_LOG")" == 2 ]] diff --git a/scripts/test/start-command-forwarding.sh b/scripts/test/start-command-forwarding.sh new file mode 100644 index 0000000..a98d097 --- /dev/null +++ b/scripts/test/start-command-forwarding.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +test_root="$(mktemp -d)" +trap 'rm -rf "$test_root"' EXIT + +cat >"$test_root/uname" <<'SCRIPT' +#!/usr/bin/env bash +printf 'Linux\n' +SCRIPT +cat >"$test_root/go" <<'SCRIPT' +#!/usr/bin/env bash +if [[ "${1:-}" == "version" ]]; then + exit 0 +fi +printf '%s\n' "$@" >"$EPAR_START_FORWARD_LOG" +SCRIPT +chmod +x "$test_root/uname" "$test_root/go" + +export PATH="$test_root:$PATH" +export EPAR_GO_BIN=go +export EPAR_USE_DOCKER_RUN=0 +export EPAR_START_FORWARD_LOG="$test_root/arguments" + +"$repo_root/start" storage prune --provider docker-sandboxes +actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" +expected="run ./cmd/ephemeral-action-runner storage prune --provider docker-sandboxes " +if [[ "$actual" != "$expected" ]]; then + echo "explicit command forwarding mismatch: got '$actual', want '$expected'" >&2 + exit 1 +fi + +"$repo_root/start" version +actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" +expected="run ./cmd/ephemeral-action-runner version " +if [[ "$actual" != "$expected" ]]; then + echo "version forwarding mismatch: got '$actual', want '$expected'" >&2 + exit 1 +fi + +printf 'start command-forwarding smoke passed\n' diff --git a/scripts/test/windows-native-controller-contract.ps1 b/scripts/test/windows-native-controller-contract.ps1 new file mode 100644 index 0000000..835014e --- /dev/null +++ b/scripts/test/windows-native-controller-contract.ps1 @@ -0,0 +1,332 @@ +[CmdletBinding()] +param( + [string] $ProjectRoot +) + +$ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($ProjectRoot)) { + $scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path + $ProjectRoot = Split-Path -Parent (Split-Path -Parent $scriptDirectory) +} +$ProjectRoot = [System.IO.Path]::GetFullPath($ProjectRoot) +$wrapperPath = Join-Path $ProjectRoot 'scripts\run-with-docker.ps1' +$builderPath = Join-Path $ProjectRoot 'scripts\build-native-controller.ps1' +$startPath = Join-Path $ProjectRoot 'start.ps1' +$wrapperSource = Get-Content -Raw -LiteralPath $wrapperPath +$builderSource = Get-Content -Raw -LiteralPath $builderPath +$startSource = Get-Content -Raw -LiteralPath $startPath + +$tokens = $null +$parseErrors = $null +$wrapperAst = [System.Management.Automation.Language.Parser]::ParseFile($wrapperPath, [ref]$tokens, [ref]$parseErrors) +if (@($parseErrors).Count -ne 0) { + throw "run-with-docker.ps1 failed to parse: $(@($parseErrors).Message -join '; ')" +} +$startTokens = $null +$startParseErrors = $null +[System.Management.Automation.Language.Parser]::ParseFile($startPath, [ref]$startTokens, [ref]$startParseErrors) | Out-Null +if (@($startParseErrors).Count -ne 0) { + throw "start.ps1 failed to parse: $(@($startParseErrors).Message -join '; ')" +} + +$classifier = $wrapperAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-EparBenignDockerDesktopPrefaceDiagnostic' +}, $true) +if ($null -eq $classifier) { + throw 'Docker Desktop transcript classifier is missing' +} +Invoke-Expression $classifier.Extent.Text +$benign = '2026/07/22 02:36:46 http2: server: error reading preface from client //./pipe/dockerDesktopLinuxEngine: file has already been closed' +if (-not (Test-EparBenignDockerDesktopPrefaceDiagnostic -Transcript $benign)) { + throw 'known successful Docker Desktop preface diagnostic was not classified as benign' +} +if (Test-EparBenignDockerDesktopPrefaceDiagnostic -Transcript ($benign + [Environment]::NewLine + 'real build failure')) { + throw 'mixed Docker stderr was incorrectly discarded as benign' +} + +$builderTokens = $null +$builderParseErrors = $null +$builderAst = [System.Management.Automation.Language.Parser]::ParseFile($builderPath, [ref]$builderTokens, [ref]$builderParseErrors) +if (@($builderParseErrors).Count -ne 0) { + throw "build-native-controller.ps1 failed to parse: $(@($builderParseErrors).Message -join '; ')" +} +foreach ($functionName in @('Get-EparDirectoryBytes', 'Test-EparNativeControllerLeaseActive', 'Test-EparNativeControllerBuildLeaseValid', 'Invoke-EparNativeControllerCacheRetention', 'Get-EparGoCacheVolumeIdentity', 'Invoke-EparGoCacheLimit')) { + $function = $builderAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName + }, $true) + if ($null -eq $function) { + throw "native controller cache retention function is missing: $functionName" + } + Invoke-Expression $function.Extent.Text +} +$GomodVolume = 'contract-gomod' +$GocacheVolume = 'contract-gocache' +$ProjectID = 'contract' +$GoCacheLimitBytes = [uint64](10GB) +$DevImage = 'contract-dev-image' +$dockerCalls = [System.Collections.Generic.List[string]]::new() +function docker { + $dockerCalls.Add(($args -join ' ')) + if ($args -contains 'du') { + Write-Output "1`t/go/pkg/mod" + Write-Output "1`t/root/.cache/go-build" + } + $global:LASTEXITCODE = 0 +} +try { + Invoke-EparGoCacheLimit + if (@($dockerCalls | Where-Object { $_ -like 'run *' }).Count -ne 1) { + throw "empty Docker queries should run one exact Go cache GC probe; calls=$($dockerCalls -join '; ')" + } +} finally { + Remove-Item Function:\docker -ErrorAction SilentlyContinue +} +$retentionRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('epar-native-cache-retention-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $retentionRoot | Out-Null +try { + $keys = @('0', '1', '2', '3', '4', '5', '6') | ForEach-Object { [string]::new([char]$_, 64) } + foreach ($key in $keys[0..4]) { + $directory = Join-Path $retentionRoot $key + New-Item -ItemType Directory -Path $directory | Out-Null + [System.IO.File]::WriteAllText((Join-Path $directory 'ephemeral-action-runner.exe'), $key) + [System.IO.File]::WriteAllLines((Join-Path $directory 'controller-cache.manifest'), @( + 'schemaVersion=1', + "cacheKey=$key", + 'executable=ephemeral-action-runner.exe' + )) + } + $unmanifestedDirectory = Join-Path $retentionRoot $keys[5] + New-Item -ItemType Directory -Path $unmanifestedDirectory | Out-Null + [System.IO.File]::WriteAllText((Join-Path $unmanifestedDirectory 'ephemeral-action-runner.exe'), $keys[5]) + $mismatchedDirectory = Join-Path $retentionRoot $keys[6] + New-Item -ItemType Directory -Path $mismatchedDirectory | Out-Null + [System.IO.File]::WriteAllText((Join-Path $mismatchedDirectory 'ephemeral-action-runner.exe'), $keys[6]) + [System.IO.File]::WriteAllLines((Join-Path $mismatchedDirectory 'controller-cache.manifest'), @( + 'schemaVersion=1', + "cacheKey=$($keys[5])", + 'executable=ephemeral-action-runner.exe' + )) + $activeKey = $keys[4] + $activeDirectory = Join-Path $retentionRoot $activeKey + $process = Get-Process -Id $PID + [System.IO.File]::WriteAllLines((Join-Path $activeDirectory "lease-$PID-contract.txt"), @( + 'schemaVersion=1', + "host=$([Environment]::MachineName)", + "pid=$PID", + "processStartUtc=$($process.StartTime.ToUniversalTime().ToString('o'))", + "startedAtUtc=$([DateTime]::UtcNow.AddDays(-10).ToString('o'))" + )) + $oldest = [DateTime]::UtcNow.AddDays(-10) + for ($index = 0; $index -lt 5; $index++) { + (Get-Item -LiteralPath (Join-Path $retentionRoot $keys[$index])).LastWriteTimeUtc = $oldest.AddMinutes($index) + } + $staleBuild = Join-Path $retentionRoot '.build-stale' + New-Item -ItemType Directory -Path $staleBuild | Out-Null + [System.IO.File]::WriteAllLines((Join-Path $staleBuild 'lease-build-2147483646-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.txt'), @( + 'schemaVersion=1', + "host=$([Environment]::MachineName)", + 'pid=2147483646', + "processStartUtc=$($oldest.ToString('o'))", + "startedAtUtc=$($oldest.ToString('o'))" + )) + (Get-Item -LiteralPath $staleBuild).LastWriteTimeUtc = $oldest + $activeBuild = Join-Path $retentionRoot '.build-active' + New-Item -ItemType Directory -Path $activeBuild | Out-Null + [System.IO.File]::WriteAllLines((Join-Path $activeBuild ("lease-build-{0}-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.txt" -f $PID)), @( + 'schemaVersion=1', + "host=$([Environment]::MachineName)", + "pid=$PID", + "processStartUtc=$($process.StartTime.ToUniversalTime().ToString('o'))", + "startedAtUtc=$($oldest.ToString('o'))" + )) + (Get-Item -LiteralPath $activeBuild).LastWriteTimeUtc = $oldest + $unmarkedBuild = Join-Path $retentionRoot '.build-unmarked' + New-Item -ItemType Directory -Path $unmarkedBuild | Out-Null + (Get-Item -LiteralPath $unmarkedBuild).LastWriteTimeUtc = $oldest + $malformedBuild = Join-Path $retentionRoot '.build-malformed' + New-Item -ItemType Directory -Path $malformedBuild | Out-Null + [System.IO.File]::WriteAllLines((Join-Path $malformedBuild 'lease-build-2147483646-cccccccccccccccccccccccccccccccc.txt'), @( + "host=$([Environment]::MachineName)", + 'pid=2147483646', + "processStartUtc=$($oldest.ToString('o'))", + "startedAtUtc=$($oldest.ToString('o'))" + )) + (Get-Item -LiteralPath $malformedBuild).LastWriteTimeUtc = $oldest + $foreignBuild = Join-Path $retentionRoot '.build-foreign' + New-Item -ItemType Directory -Path $foreignBuild | Out-Null + [System.IO.File]::WriteAllLines((Join-Path $foreignBuild 'lease-build-2147483646-dddddddddddddddddddddddddddddddd.txt'), @( + 'schemaVersion=1', + "host=foreign-$([Environment]::MachineName)", + 'pid=2147483646', + "processStartUtc=$($oldest.ToString('o'))", + "startedAtUtc=$([DateTime]::UtcNow.ToString('o'))" + )) + (Get-Item -LiteralPath $foreignBuild).LastWriteTimeUtc = $oldest + + Invoke-EparNativeControllerCacheRetention -CacheRoot $retentionRoot -CurrentCacheKey $keys[0] -KeepPrevious 2 -MaxBytes 1MB -GracePeriod ([TimeSpan]::Zero) -AbandonedBuildGracePeriod ([TimeSpan]::Zero) + + $remaining = @(Get-ChildItem -LiteralPath $retentionRoot -Directory | Where-Object { $_.Name -match '^[0-9a-f]{64}$' } | Select-Object -ExpandProperty Name | Sort-Object) + $expected = @($keys[0], $keys[2], $keys[3], $keys[4], $keys[5], $keys[6] | Sort-Object) + if (Compare-Object -ReferenceObject $expected -DifferenceObject $remaining) { + throw "native controller retention kept $($remaining -join ', '), want $($expected -join ', ')" + } + if (Test-Path -LiteralPath $staleBuild) { + throw 'native controller retention did not remove an abandoned build directory' + } + if (-not (Test-Path -LiteralPath $activeBuild)) { + throw 'native controller retention removed an active build directory' + } + if (-not (Test-Path -LiteralPath $unmarkedBuild)) { + throw 'native controller retention removed a markerless build directory without positive ownership evidence' + } + if (-not (Test-Path -LiteralPath $malformedBuild)) { + throw 'native controller retention treated a malformed build lease as ownership evidence' + } + if (-not (Test-Path -LiteralPath $foreignBuild)) { + throw 'native controller retention removed a recent foreign-host build lease' + } + + $policyRoot = Join-Path $retentionRoot 'policy-contract' + New-Item -ItemType Directory -Path $policyRoot | Out-Null + $policyKeys = @('a', 'b', 'c') | ForEach-Object { [string]::new([char]$_, 64) } + foreach ($key in $policyKeys) { + $directory = Join-Path $policyRoot $key + New-Item -ItemType Directory -Path $directory | Out-Null + [System.IO.File]::WriteAllText((Join-Path $directory 'ephemeral-action-runner.exe'), $(if ($key -eq $policyKeys[0]) { 'x' } else { [string]::new('x', 1024) })) + [System.IO.File]::WriteAllLines((Join-Path $directory 'controller-cache.manifest'), @( + 'schemaVersion=1', + "cacheKey=$key", + 'executable=ephemeral-action-runner.exe' + )) + } + (Get-Item -LiteralPath (Join-Path $policyRoot $policyKeys[1])).LastWriteTimeUtc = $oldest + Invoke-EparNativeControllerCacheRetention -CacheRoot $policyRoot -CurrentCacheKey $policyKeys[0] -KeepPrevious 5 -MaxBytes 512 -GracePeriod ([TimeSpan]::FromDays(7)) -AbandonedBuildGracePeriod ([TimeSpan]::Zero) + if (Test-Path -LiteralPath (Join-Path $policyRoot $policyKeys[1])) { + throw 'native controller retention kept an expired revision beyond the byte budget' + } + if (-not (Test-Path -LiteralPath (Join-Path $policyRoot $policyKeys[2]))) { + throw 'native controller retention removed a grace-protected revision beyond the byte budget' + } +} finally { + Remove-Item -LiteralPath $retentionRoot -Recurse -Force -ErrorAction SilentlyContinue +} +$retryClassifier = $builderAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-EparRetryableDockerContextMetadataDiagnostic' +}, $true) +if ($null -eq $retryClassifier) { + throw 'Docker context metadata retry classifier is missing' +} +Invoke-Expression $retryClassifier.Extent.Text +$buildInvoker = $builderAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Invoke-EparDockerBuild' +}, $true) +if ($null -eq $buildInvoker) { + throw 'Docker build retry function is missing' +} +Invoke-Expression $buildInvoker.Extent.Text +$retryable = 'ERROR: failed to build: failed to read metadata: open C:\Users\runner\.docker\contexts\meta\fe9c6bd7a66301f49ca9b6a70b217107cd1284598bfc254700c989b916da791e\meta.json: The process cannot access the file because it is being used by another process.' +if (-not (Test-EparRetryableDockerContextMetadataDiagnostic -Transcript $retryable)) { + throw 'known transient Docker context metadata sharing violation was not classified as retryable' +} +$wrappedRetryable = "docker.exe : $retryable`r`nAt line:1 char:1`r`n+ docker build`r`n+ ~~~~~~~~~~~~`r`n + CategoryInfo : NotSpecified: (ERROR: failed to build:String) [], RemoteException`r`n + FullyQualifiedErrorId : NativeCommandError" +if (-not (Test-EparRetryableDockerContextMetadataDiagnostic -Transcript $wrappedRetryable)) { + throw 'Windows PowerShell NativeCommandError-wrapped Docker context metadata sharing violation was not classified as retryable' +} +$nativeCaptureDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ('epar-docker-context-retry-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $nativeCaptureDirectory | Out-Null +try { + $fakeDocker = Join-Path $nativeCaptureDirectory 'docker.exe' + Copy-Item -LiteralPath $env:ComSpec -Destination $fakeDocker + $emitScript = Join-Path $nativeCaptureDirectory 'emit.cmd' + Set-Content -LiteralPath $emitScript -Encoding ASCII -Value @( + '@echo off' + "echo $retryable 1>&2" + 'exit /b 1' + ) + $capturedStderrPath = Join-Path $nativeCaptureDirectory 'stderr.txt' + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $fakeDocker /d /c $emitScript 2> $capturedStderrPath + $fakeDockerExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($fakeDockerExitCode -ne 1) { + throw "native stderr test shim exited $fakeDockerExitCode, want 1" + } + $capturedStderr = Get-Content -Raw -LiteralPath $capturedStderrPath + if (-not (Test-EparRetryableDockerContextMetadataDiagnostic -Transcript $capturedStderr)) { + throw "Windows PowerShell native stderr capture was not classified as retryable: $capturedStderr" + } +} finally { + Remove-Item -LiteralPath $nativeCaptureDirectory -Recurse -Force -ErrorAction SilentlyContinue +} +$retryHarnessDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ('epar-docker-context-retry-loop-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $retryHarnessDirectory | Out-Null +$previousPath = $env:PATH +try { + Set-Content -LiteralPath (Join-Path $retryHarnessDirectory 'retry-count.txt') -Encoding ASCII -Value '0' + Set-Content -LiteralPath (Join-Path $retryHarnessDirectory 'docker.cmd') -Encoding ASCII -Value @( + '@echo off' + 'set /p retry_count=<"%~dp0retry-count.txt"' + 'set /a retry_count=retry_count+1' + '> "%~dp0retry-count.txt" echo %retry_count%' + 'if %retry_count% LSS 3 (' + " echo $retryable 1>&2" + ' exit /b 1' + ')' + 'exit /b 0' + ) + $env:PATH = $retryHarnessDirectory + [System.IO.Path]::PathSeparator + $previousPath + $GoImage = 'contract-test-go-image' + $DevImage = 'contract-test-dev-image' + $RepoRoot = $ProjectRoot + $retryResult = Invoke-EparDockerBuild + $retryCount = [int] ((Get-Content -Raw -LiteralPath (Join-Path $retryHarnessDirectory 'retry-count.txt')).Trim()) + if ($retryResult -ne 0 -or $retryCount -ne 3) { + throw "Docker context retry loop result=$retryResult attempts=$retryCount, want success after 3 attempts" + } +} finally { + $env:PATH = $previousPath + Remove-Item -LiteralPath $retryHarnessDirectory -Recurse -Force -ErrorAction SilentlyContinue +} +if (Test-EparRetryableDockerContextMetadataDiagnostic -Transcript ($retryable + [Environment]::NewLine + 'real build failure')) { + throw 'mixed Docker context metadata stderr was incorrectly classified as retryable' +} +if (Test-EparRetryableDockerContextMetadataDiagnostic -Transcript ($wrappedRetryable + [Environment]::NewLine + 'real build failure')) { + throw 'mixed Windows PowerShell-wrapped Docker context metadata stderr was incorrectly classified as retryable' +} +if (Test-EparRetryableDockerContextMetadataDiagnostic -Transcript ($retryable -replace '\\contexts\\meta\\', '\buildx\')) { + throw 'unrelated Docker metadata sharing violation was incorrectly classified as retryable' +} + +$nativeBranch = $wrapperSource.IndexOf("if (`$env:EPAR_LEGACY_CONTROLLER_IN_DOCKER -ne '1')", [System.StringComparison]::Ordinal) +$legacyImage = $wrapperSource.IndexOf('$Image =', [System.StringComparison]::Ordinal) +if ($nativeBranch -lt 0 -or $legacyImage -lt 0 -or $nativeBranch -gt $legacyImage) { + throw 'native-controller dispatch must occur before the explicit legacy controller-in-Docker path' +} +foreach ($required in @("Join-Path `$PSScriptRoot 'build-native-controller.ps1'", 'exit $nativeExitCode')) { + if (-not $wrapperSource.Contains($required)) { + throw "native no-Go wrapper contract is missing: $required" + } +} +foreach ($required in @('$env:EPAR_INVOCATION = "start"', 'if ($ControllerArgs.Count -eq 0 -or $ControllerArgs[0].StartsWith("-"))', '$ControllerArgs = @("start") + $ControllerArgs', 'scripts\run-with-docker.ps1") @ControllerArgs', 'run ./cmd/ephemeral-action-runner @ControllerArgs')) { + if (-not $startSource.Contains($required)) { + throw "start command-forwarding contract is missing: $required" + } +} +if ($startSource.Contains('@StartArgs')) { + throw 'start command-forwarding contract still forces the start command' +} +foreach ($required in @('CGO_ENABLED=0', 'GOOS=windows', 'GOARCH=amd64', '.local\bin', 'dirty:sha256:', 'EPAR_NATIVE_CONTROLLER', 'Get-EparNativeSourceHash', 'Invoke-EparNativeControllerCacheRetention', 'Test-EparNativeControllerBuildLeaseValid', 'controller-cache.manifest', 'lease-build-', '[System.IO.FileAttributes]::ReparsePoint', '$maximumAttempts = 5', 'Start-Sleep -Milliseconds')) { + if (-not $builderSource.Contains($required)) { + throw "native controller build contract is missing: $required" + } +} + +Write-Output 'Windows no-Go native-controller source and transcript smoke passed' diff --git a/start b/start index 2e8a9eb..d2eb1f6 100755 --- a/start +++ b/start @@ -1,14 +1,15 @@ #!/usr/bin/env bash set -euo pipefail -# One-command start: ./start or ./start --config .local/config.yml --instances 2 +# EPAR entry point: ./start, ./start --config .local/config.yml, or +# ./start storage status. # -# Uses local Go if present. Otherwise runs EPAR from source inside a -# containerized Go toolchain via `go run` (no local Go install needed, and -# no binary is built or left on disk). The containerized fallback requires Docker. -# See docs/advanced/no-go-install.md. +# Uses local Go if present. Otherwise a containerized Go toolchain builds a +# CGO-disabled native host controller cached under .local/bin, then runs it on +# the host. The no-Go fallback requires Docker. See docs/advanced/no-go-install.md. script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd -P)" +export EPAR_INVOCATION=start case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) ps_script="$script_dir/start.ps1" @@ -24,6 +25,11 @@ EPAR_HOST_TRUST_HELPER="${script_dir}/scripts/host-trust/host-trust-feed.sh" GO_BIN="${EPAR_GO_BIN:-go}" USE_DOCKER_RUN="${EPAR_USE_DOCKER_RUN:-auto}" +epar_args=("$@") +if ((${#epar_args[@]} == 0)) || [[ "${epar_args[0]}" == -* ]]; then + epar_args=(start "${epar_args[@]}") +fi +controller_command="${epar_args[0]}" # A present-but-broken `go` (wrong architecture, ancient pre-modules install # left over on PATH, etc.) must not look "usable" just because it exists. @@ -32,8 +38,8 @@ go_usable() { } if [[ "${USE_DOCKER_RUN}" == "1" ]] || { [[ "${USE_DOCKER_RUN}" == "auto" ]] && ! go_usable "${GO_BIN}"; }; then - echo "Go not found or not runnable (or EPAR_USE_DOCKER_RUN=1); running with a containerized Go toolchain instead..." >&2 - exec "${script_dir}/scripts/run-with-docker.sh" start "$@" + echo "Go not found or not runnable (or EPAR_USE_DOCKER_RUN=1); building and running a cached native controller with the Docker toolchain..." >&2 + exec "${script_dir}/scripts/run-with-docker.sh" "${epar_args[@]}" fi if ! go_usable "${GO_BIN}"; then @@ -43,14 +49,17 @@ if ! go_usable "${GO_BIN}"; then exit 1 fi -epar_host_trust_prepare "${script_dir}" start "$@" +epar_host_trust_prepare "${script_dir}" "${controller_command}" "${epar_args[@]}" trap epar_host_trust_cleanup EXIT INT TERM if [[ -n "${EPAR_HOST_TRUST_FEED_DIR}" ]]; then export EPAR_CONTROLLER_HOST_OS="$(epar_host_trust_host_os)" export EPAR_HOST_TRUST_FEED="${EPAR_HOST_TRUST_FEED_DIR}/current.json" fi -"${GO_BIN}" run ./cmd/ephemeral-action-runner start "$@" -status=$? +status=0 +"${GO_BIN}" run ./cmd/ephemeral-action-runner "${epar_args[@]}" || status=$? +if [[ "$status" == "0" && "$controller_command" == "init" ]]; then + epar_host_trust_post_init "${script_dir}" || status=$? +fi epar_host_trust_cleanup trap - EXIT INT TERM exit "$status" diff --git a/start.ps1 b/start.ps1 index 5dbd9f0..8a07994 100644 --- a/start.ps1 +++ b/start.ps1 @@ -3,24 +3,32 @@ param( [string[]] $EparArgs ) -# One-command start: .\start.ps1 or .\start.ps1 --config .local\config.yml --instances 2 +# EPAR entry point: .\start.ps1, .\start.ps1 --config .local\config.yml, or +# .\start.ps1 storage status. # -# Uses local Go if present and actually runnable. Otherwise runs EPAR from -# source inside a containerized Go toolchain via `go run` (no local Go -# install needed, and no binary is built or left on disk). The containerized -# fallback requires Docker. See docs/advanced/no-go-install.md. +# Uses local Go if present and actually runnable. Otherwise a containerized Go +# toolchain builds a CGO-disabled native host controller cached under +# .local/bin, then runs it on the host. The no-Go fallback requires Docker. +# See docs/advanced/no-go-install.md. $ErrorActionPreference = "Stop" $Root = Split-Path -Parent $MyInvocation.MyCommand.Path Set-Location -LiteralPath $Root . (Join-Path $Root "scripts\host-trust\wrapper-lib.ps1") +$OriginalInvocationExists = Test-Path Env:EPAR_INVOCATION +$OriginalInvocation = $env:EPAR_INVOCATION +$env:EPAR_INVOCATION = "start" $GoBin = if ($env:EPAR_GO_BIN) { $env:EPAR_GO_BIN } else { "go" } $UseDockerRun = if ($env:EPAR_USE_DOCKER_RUN) { $env:EPAR_USE_DOCKER_RUN } else { "auto" } -$StartArgs = @("start") -if ($null -ne $EparArgs -and $EparArgs.Count -gt 0) { - $StartArgs += $EparArgs +[string[]] $ControllerArgs = @() +if ($null -ne $EparArgs) { + $ControllerArgs = [string[]] @($EparArgs) } +if ($ControllerArgs.Count -eq 0 -or $ControllerArgs[0].StartsWith("-")) { + $ControllerArgs = @("start") + $ControllerArgs +} +$ControllerCommand = [string] $ControllerArgs[0] function Test-GoUsable { param([string]$GoBin) @@ -36,17 +44,23 @@ function Test-GoUsable { $goUsable = Test-GoUsable -GoBin $GoBin if ($UseDockerRun -eq "1" -or ($UseDockerRun -eq "auto" -and -not $goUsable)) { - Write-Warning "Go not found or not runnable (or EPAR_USE_DOCKER_RUN=1); running with a containerized Go toolchain instead..." - & (Join-Path $Root "scripts\run-with-docker.ps1") @StartArgs - exit $LASTEXITCODE + Write-Warning "Go not found or not runnable (or EPAR_USE_DOCKER_RUN=1); building and running a cached native controller with the Docker toolchain..." + try { + & (Join-Path $Root "scripts\run-with-docker.ps1") @ControllerArgs + $exitCode = $LASTEXITCODE + } finally { + if ($OriginalInvocationExists) { $env:EPAR_INVOCATION = $OriginalInvocation } else { Remove-Item Env:EPAR_INVOCATION -ErrorAction SilentlyContinue } + } + exit $exitCode } if (-not $goUsable) { + if ($OriginalInvocationExists) { $env:EPAR_INVOCATION = $OriginalInvocation } else { Remove-Item Env:EPAR_INVOCATION -ErrorAction SilentlyContinue } Write-Error "Go not found or not runnable: $GoBin`nInstall Go, set EPAR_GO_BIN, or set EPAR_USE_DOCKER_RUN=1 to run with a containerized Go toolchain instead.`nSee docs/advanced/no-go-install.md." exit 1 } -$bridge = Start-EparHostTrustBridge -ProjectRoot $Root -Command "start" -Arguments $EparArgs +$bridge = Start-EparHostTrustBridge -ProjectRoot $Root -Command $ControllerCommand -Arguments $ControllerArgs $previousHostOS = $env:EPAR_CONTROLLER_HOST_OS $previousFeed = $env:EPAR_HOST_TRUST_FEED try { @@ -54,11 +68,15 @@ try { $env:EPAR_CONTROLLER_HOST_OS = Get-EparHostTrustHostOS $env:EPAR_HOST_TRUST_FEED = Join-Path $bridge.FeedDir "current.json" } - & $GoBin run ./cmd/ephemeral-action-runner @StartArgs + & $GoBin run ./cmd/ephemeral-action-runner @ControllerArgs $exitCode = $LASTEXITCODE + if ($exitCode -eq 0 -and $ControllerCommand -eq "init") { + Complete-EparHostTrustInit -ProjectRoot $Root -Bridge $bridge + } } finally { Stop-EparHostTrustBridge -Bridge $bridge if ($null -eq $previousHostOS) { Remove-Item Env:EPAR_CONTROLLER_HOST_OS -ErrorAction SilentlyContinue } else { $env:EPAR_CONTROLLER_HOST_OS = $previousHostOS } if ($null -eq $previousFeed) { Remove-Item Env:EPAR_HOST_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_HOST_TRUST_FEED = $previousFeed } + if ($OriginalInvocationExists) { $env:EPAR_INVOCATION = $OriginalInvocation } else { Remove-Item Env:EPAR_INVOCATION -ErrorAction SilentlyContinue } } exit $exitCode diff --git a/templates/docker-sandboxes/.dockerignore b/templates/docker-sandboxes/.dockerignore new file mode 100644 index 0000000..680099d --- /dev/null +++ b/templates/docker-sandboxes/.dockerignore @@ -0,0 +1,9 @@ +* +!Dockerfile +!helpers.sha256 +!guest/ +!guest/*.sh +!hook-launcher/ +!hook-launcher/*.go +!profiles/ +!profiles/*.compatibility.json diff --git a/templates/docker-sandboxes/Dockerfile b/templates/docker-sandboxes/Dockerfile new file mode 100644 index 0000000..cafece3 --- /dev/null +++ b/templates/docker-sandboxes/Dockerfile @@ -0,0 +1,85 @@ +# syntax=docker/dockerfile:1.7.1@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +ARG TEMPLATE_PLATFORM=linux/amd64 +ARG SOURCE_IMAGE=ghcr.io/catthehacker/ubuntu@sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac +ARG GO_BUILDER_IMAGE=docker.io/library/golang@sha256:9006890ecba0a168034d99516084099ae3114d9f2b7d6572c77f2dde57ebc980 + +FROM --platform=$BUILDPLATFORM ${GO_BUILDER_IMAGE} AS hook-builder +ARG TARGETOS +ARG TARGETARCH +ARG HOOK_LAUNCHER_SHA256=7fe07f10f484fa6888481a4165e81570187c0aeff422738d3ea5add6b95dd9b7 +WORKDIR /src +COPY hook-launcher/main.go /src/main.go +RUN echo "${HOOK_LAUNCHER_SHA256} /src/main.go" | sha256sum --check - \ + && install -d -m 0755 /out \ + && CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" GOTOOLCHAIN=local go build -trimpath -buildvcs=false -ldflags='-s -w -buildid=' -o /out/epar-hook-bash /src/main.go + +FROM --platform=${TEMPLATE_PLATFORM} ${SOURCE_IMAGE} + +ARG TEMPLATE_PLATFORM +ARG SOURCE_IMAGE +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH +ARG SOURCE_PROFILE=act-22.04 +ARG SOURCE_INDEX_DIGEST=sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac +ARG SOURCE_MANIFEST_DIGEST=sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8ef546ecb44ca256655 +ARG SOURCE_REVISION=e2f8efe464c82732f78e967ee709c00b6af53643 +ARG TEMPLATE_VERSION=20260723-r4-amd64 +ARG COMPATIBILITY_FILE=act-22.04.amd64.compatibility.json +ARG ACTIONS_RUNNER_URL=https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-x64-2.332.0.tar.gz +ARG ACTIONS_RUNNER_SHA256=sha256:f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a +ARG TINI_URL=https://github.com/krallin/tini/releases/download/v0.19.0/tini-amd64 +ARG TINI_SHA256=sha256:93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c + +USER root +SHELL ["/bin/bash", "-euo", "pipefail", "-c"] + +COPY guest/ /opt/epar/ +COPY helpers.sha256 /opt/epar/helpers.sha256 +COPY profiles/${COMPATIBILITY_FILE} /opt/epar/template-compatibility.json +COPY --from=hook-builder --chmod=0555 /out/epar-hook-bash /opt/epar/hook-bin/bash + +ADD --chmod=0755 --checksum=${TINI_SHA256} ${TINI_URL} /usr/local/bin/tini +ADD --checksum=${ACTIONS_RUNNER_SHA256} ${ACTIONS_RUNNER_URL} /tmp/actions-runner.tar.gz + +RUN [[ "${TARGETPLATFORM}" == "${TEMPLATE_PLATFORM}" ]] \ + && [[ "${TARGETOS}" == "linux" ]] \ + && [[ "${TARGETARCH}" == "amd64" || "${TARGETARCH}" == "arm64" ]] \ + && cd /opt/epar \ + && sha256sum --check helpers.sha256 \ + && chmod 0555 ./*.sh \ + && /opt/epar/prepare-template.sh \ + && rm -rf /opt/actions-runner \ + && install -d -m 0755 -o agent -g agent /opt/actions-runner /var/log/actions-runner \ + && tar -xzf /tmp/actions-runner.tar.gz -C /opt/actions-runner \ + && rm -f /tmp/actions-runner.tar.gz \ + && chown -R agent:agent /opt/actions-runner /var/log/actions-runner \ + && sudo -u agent -H /opt/actions-runner/bin/Runner.Listener --version | grep -Fx '2.332.0' \ + && /usr/local/bin/tini --version 2>&1 | grep -F 'version 0.19.0' + +ENV HOME=/home/agent \ + USER=agent \ + LOGNAME=agent \ + EPAR_ACTIONS_RUNNER_DIR=/opt/actions-runner \ + EPAR_RUNNER_WORK_DIR=/opt/actions-runner \ + EPAR_TEMPLATE_SBX_VERSION=0.35.0 \ + EPAR_TEMPLATE_PLATFORM=${TEMPLATE_PLATFORM} \ + DEBIAN_FRONTEND=dialog + +LABEL com.docker.sandboxes.start-docker=true \ + io.solutionforest.epar.template.candidate="A" \ + io.solutionforest.epar.template.profile="${SOURCE_PROFILE}" \ + io.solutionforest.epar.template.platform="${TEMPLATE_PLATFORM}" \ + io.solutionforest.epar.template.sbx-version="0.35.0" \ + io.solutionforest.epar.template.runner-version="2.332.0" \ + io.solutionforest.epar.template.source-index-digest="${SOURCE_INDEX_DIGEST}" \ + io.solutionforest.epar.template.source-manifest-digest="${SOURCE_MANIFEST_DIGEST}" \ + org.opencontainers.image.base.name="${SOURCE_IMAGE}" \ + org.opencontainers.image.revision="${SOURCE_REVISION}" \ + org.opencontainers.image.version="${TEMPLATE_VERSION}" + +WORKDIR /home/agent +USER agent +ENTRYPOINT ["/usr/local/bin/tini", "-g", "--", "/opt/epar/template-entrypoint.sh"] +CMD ["sleep", "infinity"] diff --git a/templates/docker-sandboxes/guest/check-host-trust-generation.sh b/templates/docker-sandboxes/guest/check-host-trust-generation.sh new file mode 100644 index 0000000..5262b9b --- /dev/null +++ b/templates/docker-sandboxes/guest/check-host-trust-generation.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -eq 0 ]]; then + marker="/opt/epar/host-trust-generation.json" + lease="/run/epar/host-trust-lease.json" +elif [[ "$#" -eq 2 ]]; then + marker="$1" + lease="$2" +else + echo "EPAR host-trust gate: invalid invocation" >&2 + exit 1 +fi + +if [[ ! -s "${marker}" ]]; then + echo "EPAR host-trust gate: image generation marker is missing" >&2 + exit 1 +fi +if [[ ! -s "${lease}" ]]; then + echo "EPAR host-trust gate: controller lease is missing" >&2 + exit 1 +fi +if [[ ! -x /usr/bin/python3 ]]; then + echo "EPAR host-trust gate: python3 is required" >&2 + exit 1 +fi + +/usr/bin/env -i PATH=/usr/bin:/bin LANG=C.UTF-8 /usr/bin/python3 -I -S - "${marker}" "${lease}" <<'PY' +import datetime +import json +import sys + +marker_path, lease_path = sys.argv[1:] + +def read_json(path, label): + try: + with open(path, "r", encoding="utf-8") as handle: + value = json.load(handle) + except Exception as exc: + raise SystemExit(f"EPAR host-trust gate: invalid {label}: {exc}") + if not isinstance(value, dict): + raise SystemExit(f"EPAR host-trust gate: {label} must be a JSON object") + return value + +marker = read_json(marker_path, "image marker") +lease = read_json(lease_path, "controller lease") + +for key in ("generation", "hostOS", "mode", "scopes"): + if marker.get(key) != lease.get(key): + raise SystemExit( + f"EPAR host-trust gate: {key} mismatch " + f"(image={marker.get(key)!r}, lease={lease.get(key)!r})" + ) + +if marker.get("mode") not in ("overlay", "disabled") or not marker.get("generation"): + raise SystemExit("EPAR host-trust gate: invalid image trust policy") +if marker.get("mode") == "disabled" and marker.get("scopes") != []: + raise SystemExit("EPAR host-trust gate: disabled trust mode must not carry scopes") + +expires = lease.get("expiresAt") +if not isinstance(expires, str) or not expires: + raise SystemExit("EPAR host-trust gate: lease expiry is missing") +try: + expires_at = datetime.datetime.fromisoformat(expires.replace("Z", "+00:00")) +except ValueError as exc: + raise SystemExit(f"EPAR host-trust gate: invalid lease expiry: {exc}") +if expires_at.tzinfo is None: + raise SystemExit("EPAR host-trust gate: lease expiry must include a timezone") +now = datetime.datetime.now(datetime.timezone.utc) +if expires_at <= now: + raise SystemExit( + "EPAR host-trust gate: lease expired at " + + expires_at.astimezone(datetime.timezone.utc).isoformat() + ) + +print("EPAR host-trust gate: generation and lease are current") +PY diff --git a/templates/docker-sandboxes/guest/check-runner.sh b/templates/docker-sandboxes/guest/check-runner.sh new file mode 100644 index 0000000..2b3c272 --- /dev/null +++ b/templates/docker-sandboxes/guest/check-runner.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +runner_dir="${EPAR_RUNNER_WORK_DIR:-/opt/actions-runner}" +pid_file="${EPAR_RUNNER_PID_FILE:-/var/run/actions-runner.pid}" +pid_start_file="${EPAR_RUNNER_PID_START_FILE:-${pid_file}.start}" +pid="$(cat "${pid_file}" 2>/dev/null || true)" +stored_start="$(cat "${pid_start_file}" 2>/dev/null || true)" + +if [[ ! "${pid}" =~ ^[1-9][0-9]*$ ]] || ! kill -0 "${pid}" >/dev/null 2>&1; then + echo "actions-runner process is not running" >&2 + exit 1 +fi +state="$(ps -p "${pid}" -o stat= 2>/dev/null | tr -d '[:space:]')" +if [[ -z "${state}" || "${state}" == Z* ]]; then + echo "actions-runner process ${pid} has invalid state ${state:-}" >&2 + exit 1 +fi +if [[ "$(readlink -f "/proc/${pid}/cwd" 2>/dev/null || true)" != "$(readlink -f "${runner_dir}")" ]]; then + echo "actions-runner process ${pid} has an unexpected working directory" >&2 + exit 1 +fi +stat_line="$(cat "/proc/${pid}/stat")" +stat_fields="${stat_line##*) }" +read -r -a fields <<<"${stat_fields}" +current_start="${fields[19]:-}" +if [[ ! "${stored_start}" =~ ^[0-9]+$ || "${current_start}" != "${stored_start}" ]]; then + echo "actions-runner process ${pid} does not match its stored start marker" >&2 + exit 1 +fi diff --git a/templates/docker-sandboxes/guest/collect-runner-diagnostics.sh b/templates/docker-sandboxes/guest/collect-runner-diagnostics.sh new file mode 100644 index 0000000..63190cd --- /dev/null +++ b/templates/docker-sandboxes/guest/collect-runner-diagnostics.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -u + +pid_file="${EPAR_RUNNER_PID_FILE:-/var/run/actions-runner.pid}" + +echo "=== EPAR Docker Sandboxes runner diagnostics ===" +pid="$(cat "${pid_file}" 2>/dev/null || true)" +if [[ ! "${pid}" =~ ^[1-9][0-9]*$ ]]; then + pid="" +fi +echo "runner_pid=${pid:-}" +if [[ -n "${pid}" ]]; then + ps -p "${pid}" -o pid=,ppid=,stat=,etime= 2>/dev/null || echo "runner_process=" +fi +echo "dockerd_processes=$(pgrep -x dockerd 2>/dev/null | wc -l | tr -d '[:space:]')" +docker info --format 'docker_server={{.ServerVersion}} driver={{.Driver}}' 2>/dev/null || echo "docker_server= driver=" +echo "=== end diagnostics ===" diff --git a/templates/docker-sandboxes/guest/collect-software-inventory.sh b/templates/docker-sandboxes/guest/collect-software-inventory.sh new file mode 100644 index 0000000..16cc496 --- /dev/null +++ b/templates/docker-sandboxes/guest/collect-software-inventory.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +printf 'schemaVersion\t1\n' +printf 'platform\t%s\n' "${EPAR_TEMPLATE_PLATFORM:?EPAR_TEMPLATE_PLATFORM is required}" +printf 'templateSbxVersion\t%s\n' "${EPAR_TEMPLATE_SBX_VERSION:-unknown}" +printf '\n[os-release]\n' +LC_ALL=C sort /etc/os-release +printf '\n[dpkg]\n' +dpkg-query --show --showformat='${binary:Package}\t${Version}\t${Architecture}\n' | LC_ALL=C sort +printf '\n[tools]\n' +for tool_name in bash docker dockerd git go java node npm python3; do + tool_path="$(command -v "${tool_name}" 2>/dev/null || true)" + if [[ -n "${tool_path}" ]]; then + tool_version="$("${tool_path}" --version 2>&1 | head -n 1 || true)" + printf '%s\t%s\t%s\n' "${tool_name}" "${tool_path}" "${tool_version}" + else + printf '%s\t\t\n' "${tool_name}" + fi +done +printf 'actions-runner\t/opt/actions-runner/bin/Runner.Listener\t%s\n' "$(/opt/actions-runner/bin/Runner.Listener --version)" +printf 'tini\t/usr/local/bin/tini\t%s\n' "$(/usr/local/bin/tini --version 2>&1)" diff --git a/templates/docker-sandboxes/guest/configure-runner.sh b/templates/docker-sandboxes/guest/configure-runner.sh new file mode 100644 index 0000000..61d3506 --- /dev/null +++ b/templates/docker-sandboxes/guest/configure-runner.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +: "${RUNNER_URL:?RUNNER_URL is required}" +: "${RUNNER_NAME:?RUNNER_NAME is required}" +: "${RUNNER_LABELS:?RUNNER_LABELS is required}" +RUNNER_EPHEMERAL="${RUNNER_EPHEMERAL:-true}" +RUNNER_GROUP="${RUNNER_GROUP:-}" +RUNNER_NO_DEFAULT_LABELS="${RUNNER_NO_DEFAULT_LABELS:-false}" +runner_dir="${EPAR_ACTIONS_RUNNER_DIR:-/opt/actions-runner}" + +if ! IFS= read -r runner_token || [[ -z "${runner_token}" ]]; then + echo "RUNNER_TOKEN must be provided as one nonempty line on stdin" >&2 + exit 1 +fi +if IFS= read -r extra_line; then + echo "RUNNER_TOKEN input must contain exactly one line" >&2 + exit 1 +fi + +cd "${runner_dir}" +if [[ -f .runner ]]; then + echo "refusing to configure a Docker Sandboxes template that already contains runner registration state" >&2 + exit 1 +fi + +args=( + --url "${RUNNER_URL}" + --token "${runner_token}" + --name "${RUNNER_NAME}" + --labels "${RUNNER_LABELS}" + --work _work + --unattended +) +if [[ "${RUNNER_EPHEMERAL}" == "true" ]]; then + args+=(--ephemeral) +fi +if [[ -n "${RUNNER_GROUP}" ]]; then + args+=(--runnergroup "${RUNNER_GROUP}") +fi +if [[ "${RUNNER_NO_DEFAULT_LABELS}" == "true" ]]; then + args+=(--no-default-labels) +fi + +sudo -u agent -H ./config.sh "${args[@]}" +unset runner_token args diff --git a/templates/docker-sandboxes/guest/prepare-template.sh b/templates/docker-sandboxes/guest/prepare-template.sh new file mode 100644 index 0000000..995a03b --- /dev/null +++ b/templates/docker-sandboxes/guest/prepare-template.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(id -u)" != "0" ]]; then + echo "prepare-template.sh must run as root" >&2 + exit 1 +fi + +for command_name in bash cut docker dockerd dpkg-query find getent grep groupadd groupmod head install nohup pgrep ps readlink seq sha256sum sort sudo tar tr useradd usermod wc; do + command -v "${command_name}" >/dev/null 2>&1 || { + echo "pinned source image is missing required command: ${command_name}" >&2 + exit 1 + } +done + +agent_uid="$(id -u agent 2>/dev/null || true)" +uid_1000_user="$(getent passwd 1000 | cut -d: -f1 || true)" + +if [[ -n "${agent_uid}" && "${agent_uid}" != "1000" ]]; then + echo "pinned source image already has an agent user with unexpected UID ${agent_uid}" >&2 + exit 1 +fi + +if [[ -z "${agent_uid}" ]]; then + if [[ -n "${uid_1000_user}" && "${uid_1000_user}" != "ubuntu" && "${uid_1000_user}" != "packer" ]]; then + echo "pinned source image assigns UID 1000 to unexpected user ${uid_1000_user}" >&2 + exit 1 + fi + + gid_1000_group="$(getent group 1000 | cut -d: -f1 || true)" + if [[ -n "${gid_1000_group}" && "${gid_1000_group}" != "ubuntu" && "${gid_1000_group}" != "packer" && "${gid_1000_group}" != "agent" ]]; then + echo "pinned source image assigns GID 1000 to unexpected group ${gid_1000_group}" >&2 + exit 1 + fi + if [[ "${gid_1000_group}" == "ubuntu" || "${gid_1000_group}" == "packer" ]]; then + groupmod --new-name agent "${gid_1000_group}" + elif [[ -z "${gid_1000_group}" ]]; then + groupadd --gid 1000 agent + fi + + if [[ "${uid_1000_user}" == "ubuntu" || "${uid_1000_user}" == "packer" ]]; then + usermod --login agent "${uid_1000_user}" + usermod --home /home/agent --move-home agent + else + useradd --create-home --uid 1000 --gid 1000 --shell /bin/bash agent + fi +fi + +if [[ "$(id -u agent)" != "1000" || "$(id -g agent)" != "1000" ]]; then + echo "agent identity must resolve to UID/GID 1000" >&2 + exit 1 +fi + +getent group docker >/dev/null 2>&1 || groupadd docker +getent group sudo >/dev/null 2>&1 || { + echo "pinned source image is missing the sudo group" >&2 + exit 1 +} +usermod --append --groups docker,sudo agent + +install -d -m 0755 -o agent -g agent /home/agent /home/agent/.docker /home/agent/.docker/sandbox /home/agent/.docker/sandbox/locks +install -d -m 0755 /etc/sudoers.d /etc/apt/apt.conf.d +printf '%s\n' 'agent ALL=(ALL:ALL) NOPASSWD:ALL' > /etc/sudoers.d/epar-agent +printf '%s\n' 'Defaults:agent env_keep += "http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS"' > /etc/sudoers.d/epar-proxy +chmod 0440 /etc/sudoers.d/epar-agent /etc/sudoers.d/epar-proxy +printf '%s\n' 'APT::Periodic::Enable "0";' 'APT::Periodic::Update-Package-Lists "0";' 'APT::Periodic::Unattended-Upgrade "0";' > /etc/apt/apt.conf.d/99epar-disable-periodic +rm -f /etc/systemd/system/timers.target.wants/apt-daily.timer /etc/systemd/system/timers.target.wants/apt-daily-upgrade.timer + +# Docker Sandboxes supplies one private daemon and mounts its dedicated block +# volume here. Never preserve or preload a daemon data-root in the template. +rm -rf /var/lib/docker +install -d -m 0711 /var/lib/docker +if [[ -n "$(find /var/lib/docker -mindepth 1 -print -quit)" ]]; then + echo "/var/lib/docker must be empty in the template" >&2 + exit 1 +fi + +sudo -u agent -H true diff --git a/templates/docker-sandboxes/guest/run-runner.sh b/templates/docker-sandboxes/guest/run-runner.sh new file mode 100644 index 0000000..28eb0fb --- /dev/null +++ b/templates/docker-sandboxes/guest/run-runner.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +runner_dir="${EPAR_RUNNER_WORK_DIR:-/opt/actions-runner}" +pid_file="${EPAR_RUNNER_PID_FILE:-/var/run/actions-runner.pid}" +pid_start_file="${EPAR_RUNNER_PID_START_FILE:-${pid_file}.start}" +log_file="${EPAR_RUNNER_LOG_FILE:-/var/log/actions-runner/run.log}" +startup_check_seconds="${EPAR_RUNNER_STARTUP_CHECK_SECONDS:-1}" + +process_start_time() { + local pid="$1" + local stat_line stat_fields + local -a fields + stat_line="$(cat "/proc/${pid}/stat" 2>/dev/null)" || return 1 + [[ "${stat_line}" == *") "* ]] || return 1 + stat_fields="${stat_line##*) }" + read -r -a fields <<<"${stat_fields}" + [[ "${fields[19]:-}" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "${fields[19]}" +} + +install -d -m 0755 -o agent -g agent "$(dirname "${log_file}")" +old_pid="$(cat "${pid_file}" 2>/dev/null || true)" +if [[ "${old_pid}" =~ ^[1-9][0-9]*$ ]] && kill -0 "${old_pid}" >/dev/null 2>&1; then + echo "actions-runner is already running as PID ${old_pid}" >&2 + exit 1 +fi +rm -f "${pid_file}" "${pid_start_file}" + +[[ -s /opt/epar/host-trust-generation.json ]] +[[ -x /opt/epar/check-host-trust-generation.sh ]] +runner_environment=( + "EPAR_RUNNER_WORK_DIR=${runner_dir}" + "PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + "ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/epar/check-host-trust-generation.sh" +) +sudo -u agent -H env "${runner_environment[@]}" /bin/bash -c 'cd "$1" || exit 1; nohup ./run.sh >>"$2" 2>&1 "${pid_file}" +sleep "${startup_check_seconds}" +pid="$(cat "${pid_file}" 2>/dev/null || true)" +if [[ ! "${pid}" =~ ^[1-9][0-9]*$ ]] || ! kill -0 "${pid}" >/dev/null 2>&1; then + echo "actions-runner listener did not remain running" >&2 + exit 1 +fi +process_cwd="$(readlink -f "/proc/${pid}/cwd" 2>/dev/null || true)" +expected_cwd="$(readlink -f "${runner_dir}")" +if [[ "${process_cwd}" != "${expected_cwd}" ]]; then + echo "actions-runner PID ${pid} has unexpected working directory ${process_cwd:-}" >&2 + exit 1 +fi +process_start_time "${pid}" >"${pid_start_file}" +printf '%s\n' "${pid}" diff --git a/templates/docker-sandboxes/guest/template-entrypoint.sh b/templates/docker-sandboxes/guest/template-entrypoint.sh new file mode 100644 index 0000000..d6c3085 --- /dev/null +++ b/templates/docker-sandboxes/guest/template-entrypoint.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(id -u)" != "1000" || "$(id -g)" != "1000" || "${HOME}" != "/home/agent" ]]; then + echo "EPAR Docker Sandboxes template: agent identity contract is not satisfied" >&2 + exit 1 +fi + +if [[ "${EPAR_SKIP_DOCKER_READY_CHECK:-0}" != "1" ]]; then + echo "EPAR Docker Sandboxes template: waiting for the sandbox-private Docker daemon" + for attempt in $(seq 1 120); do + daemon_count="$( (pgrep -x dockerd 2>/dev/null || true) | wc -l | tr -d '[:space:]')" + if [[ "${daemon_count}" == "1" ]] && docker info >/dev/null 2>&1; then + echo "EPAR Docker Sandboxes template: one sandbox-private Docker daemon is ready" + break + fi + if [[ "${attempt}" == "120" ]]; then + echo "EPAR Docker Sandboxes template: expected exactly one ready dockerd process; observed ${daemon_count}" >&2 + exit 1 + fi + sleep 1 + done +fi + +if [[ "$#" == "0" ]]; then + set -- sleep infinity +fi +exec "$@" diff --git a/templates/docker-sandboxes/guest/verify-template.sh b/templates/docker-sandboxes/guest/verify-template.sh new file mode 100644 index 0000000..9d3c979 --- /dev/null +++ b/templates/docker-sandboxes/guest/verify-template.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /opt/epar +sha256sum --check helpers.sha256 >/dev/null + +[[ "$(id -u agent)" == "1000" ]] +[[ "$(id -g agent)" == "1000" ]] +[[ "$(getent passwd agent | cut -d: -f6)" == "/home/agent" ]] +id -nG agent | tr ' ' '\n' | grep -Fx docker >/dev/null +sudo -u agent -H sudo -n true +[[ "$(pgrep -x dockerd | wc -l | tr -d '[:space:]')" == "1" ]] +docker info >/dev/null +[[ -x /opt/actions-runner/bin/Runner.Listener ]] +[[ -x /opt/epar/check-host-trust-generation.sh ]] +[[ -x /opt/epar/hook-bin/bash ]] +[[ "$(PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin command -v bash)" == "/opt/epar/hook-bin/bash" ]] +[[ -x /usr/bin/python3 ]] +[[ "$(sudo -u agent -H /opt/actions-runner/bin/Runner.Listener --version)" == "2.332.0" ]] +[[ "${EPAR_TEMPLATE_SBX_VERSION}" == "0.35.0" ]] +case "${EPAR_TEMPLATE_PLATFORM}" in + linux/amd64) + [[ "$(uname -m)" == "x86_64" ]] + ;; + linux/arm64) + [[ "$(uname -m)" == "aarch64" ]] + ;; + *) + echo "unsupported EPAR template platform: ${EPAR_TEMPLATE_PLATFORM}" >&2 + exit 1 + ;; +esac diff --git a/templates/docker-sandboxes/helpers.sha256 b/templates/docker-sandboxes/helpers.sha256 new file mode 100644 index 0000000..25a8cd5 --- /dev/null +++ b/templates/docker-sandboxes/helpers.sha256 @@ -0,0 +1,9 @@ +269b1431e6eb9ee6803be1ecfae6814a3cb120170466db16fa1b9ededcd4d39c ./check-host-trust-generation.sh +3f1cee32d05ffad64004377f0276ef8aa46a2848b32bca74ac35efb3d71fd1bf ./check-runner.sh +4fe8e512539f97d00db3c2856f452f017c4de6a04832f1c1af86f1994862df59 ./collect-runner-diagnostics.sh +5d8c515c363ee54b0fabc0773bccefefdb1db18898a6d28f62c52522cbde0f21 ./collect-software-inventory.sh +90a13d73f74e7628f1b7c4c768d300d2a4620387061c056dd67ca42692180e2b ./configure-runner.sh +a32f6bdb3b883272172e2d7318feb40d88e3ebfaffa909527ca57a3c06773ae1 ./prepare-template.sh +208d6fa96472a53b1ae1a5903da65530f6b8811d556cf04e88b99a2dfd88ec73 ./run-runner.sh +0746da2abc0a1033ecdad83ec6e432c7a006c83fefd2ce5424d3932c8f38d9aa ./template-entrypoint.sh +f78e7d9afbd627424cd32cfbab2acb094af8906d059a25df77bc6d5f0bad7849 ./verify-template.sh diff --git a/templates/docker-sandboxes/hook-launcher/main.go b/templates/docker-sandboxes/hook-launcher/main.go new file mode 100644 index 0000000..cb7f043 --- /dev/null +++ b/templates/docker-sandboxes/hook-launcher/main.go @@ -0,0 +1,54 @@ +//go:build linux + +package main + +import ( + "fmt" + "os" + "strings" + "syscall" +) + +const ( + realBash = "/bin/bash" + hookPath = "/opt/epar/check-host-trust-generation.sh" +) + +func main() { + arguments := append([]string{"bash"}, os.Args[1:]...) + environment := os.Environ() + if isHostTrustHookInvocation(os.Args[1:]) { + arguments = append([]string{"bash", "-p"}, os.Args[1:]...) + environment = isolatedHookEnvironment(environment) + } + if err := syscall.Exec(realBash, arguments, environment); err != nil { + fmt.Fprintf(os.Stderr, "EPAR bash launcher: exec failed: %v\n", err) + os.Exit(126) + } +} + +func isHostTrustHookInvocation(arguments []string) bool { + for _, argument := range arguments { + if argument == hookPath { + return true + } + } + return false +} + +func isolatedHookEnvironment(environment []string) []string { + allowed := map[string]bool{ + "LANG": true, + "LC_ALL": true, + "TZ": true, + } + result := make([]string, 0, len(allowed)+2) + for _, entry := range environment { + name, _, found := strings.Cut(entry, "=") + if found && allowed[name] { + result = append(result, entry) + } + } + result = append(result, "PATH=/usr/bin:/bin", "EPAR_HOOK_LAUNCHER=isolated-v1") + return result +} diff --git a/templates/docker-sandboxes/hook-launcher/main_test.go b/templates/docker-sandboxes/hook-launcher/main_test.go new file mode 100644 index 0000000..36ac9d2 --- /dev/null +++ b/templates/docker-sandboxes/hook-launcher/main_test.go @@ -0,0 +1,51 @@ +//go:build linux + +package main + +import ( + "slices" + "testing" +) + +func TestHostTrustHookInvocationRequiresExactArgument(t *testing.T) { + if !isHostTrustHookInvocation([]string{"--noprofile", "--norc", hookPath}) { + t.Fatal("exact hook path was not recognized") + } + for _, arguments := range [][]string{ + {hookPath + ".bak"}, + {"echo " + hookPath}, + {"/tmp/check-host-trust-generation.sh"}, + } { + if isHostTrustHookInvocation(arguments) { + t.Fatalf("non-exact hook invocation was recognized: %q", arguments) + } + } +} + +func TestIsolatedHookEnvironmentDropsWorkflowStartupAndSecretInputs(t *testing.T) { + got := isolatedHookEnvironment([]string{ + "HOME=/home/agent", + "LANG=C.UTF-8", + "LC_ALL=C", + "TZ=UTC", + "PATH=/tmp/attacker:/usr/bin", + "BASH_ENV=/tmp/attack.sh", + "ENV=/tmp/attack.sh", + "PYTHONPATH=/tmp/attacker", + "PYTHONSTARTUP=/tmp/attack.py", + "LD_PRELOAD=/tmp/attack.so", + "DOTNET_STARTUP_HOOKS=/tmp/attack.dll", + "ACTIONS_RUNTIME_TOKEN=sentinel", + "GITHUB_TOKEN=sentinel", + }) + want := []string{ + "LANG=C.UTF-8", + "LC_ALL=C", + "TZ=UTC", + "PATH=/usr/bin:/bin", + "EPAR_HOOK_LAUNCHER=isolated-v1", + } + if !slices.Equal(got, want) { + t.Fatalf("isolated environment mismatch\n got: %q\nwant: %q", got, want) + } +} diff --git a/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json b/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json new file mode 100644 index 0000000..2c26cd1 --- /dev/null +++ b/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "candidate": "A", + "profile": "act-22.04", + "validationStatus": "planned", + "platform": "linux/amd64", + "supportedSbxVersions": ["0.35.0"], + "source": { + "reference": "ghcr.io/catthehacker/ubuntu@sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", + "indexDigest": "sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", + "manifestDigest": "sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8ef546ecb44ca256655" + }, + "runner": { + "execution": "direct-actions-listener", + "version": "2.332.0", + "user": "agent", + "uid": 1000, + "gid": 1000, + "home": "/home/agent", + "directory": "/opt/actions-runner" + }, + "docker": { + "daemonOwner": "docker-sandboxes-runtime", + "expectedDaemonCount": 1, + "imagePreloadsVarLibDocker": false, + "buildRequiresPrivilegedBuildkit": false + } +} diff --git a/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json b/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json new file mode 100644 index 0000000..00fe218 --- /dev/null +++ b/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "candidate": "A", + "profile": "act-22.04", + "validationStatus": "unvalidated", + "platform": "linux/arm64", + "supportedSbxVersions": ["0.35.0"], + "source": { + "reference": "ghcr.io/catthehacker/ubuntu@sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", + "indexDigest": "sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", + "manifestDigest": "sha256:72b9ec71ee5972e02df5053f0000d34dbd2a3d0165b912bf25bbeabd72fba160" + }, + "runner": { + "execution": "direct-actions-listener", + "version": "2.332.0", + "user": "agent", + "uid": 1000, + "gid": 1000, + "home": "/home/agent", + "directory": "/opt/actions-runner" + }, + "docker": { + "daemonOwner": "docker-sandboxes-runtime", + "expectedDaemonCount": 1, + "imagePreloadsVarLibDocker": false, + "buildRequiresPrivilegedBuildkit": false + } +} diff --git a/templates/docker-sandboxes/profiles/full.amd64.compatibility.json b/templates/docker-sandboxes/profiles/full.amd64.compatibility.json new file mode 100644 index 0000000..382ac1e --- /dev/null +++ b/templates/docker-sandboxes/profiles/full.amd64.compatibility.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "candidate": "A", + "profile": "full", + "validationStatus": "planned", + "platform": "linux/amd64", + "supportedSbxVersions": ["0.35.0"], + "source": { + "reference": "ghcr.io/catthehacker/ubuntu@sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", + "indexDigest": "sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", + "manifestDigest": "sha256:58314fa8cbf0f0e5384a37b3444811033320038816ef7c16f30b3e841ed65e51" + }, + "runner": { + "execution": "direct-actions-listener", + "version": "2.332.0", + "user": "agent", + "uid": 1000, + "gid": 1000, + "home": "/home/agent", + "directory": "/opt/actions-runner" + }, + "docker": { + "daemonOwner": "docker-sandboxes-runtime", + "expectedDaemonCount": 1, + "imagePreloadsVarLibDocker": false, + "buildRequiresPrivilegedBuildkit": false + } +} diff --git a/templates/docker-sandboxes/profiles/full.arm64.compatibility.json b/templates/docker-sandboxes/profiles/full.arm64.compatibility.json new file mode 100644 index 0000000..c7c9383 --- /dev/null +++ b/templates/docker-sandboxes/profiles/full.arm64.compatibility.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "candidate": "A", + "profile": "full", + "validationStatus": "unvalidated", + "platform": "linux/arm64", + "supportedSbxVersions": ["0.35.0"], + "source": { + "reference": "ghcr.io/catthehacker/ubuntu@sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", + "indexDigest": "sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", + "manifestDigest": "sha256:245c8981fbf4ac268db015463c6c446b9411481f7e0001537128dc384d46dd0c" + }, + "runner": { + "execution": "direct-actions-listener", + "version": "2.332.0", + "user": "agent", + "uid": 1000, + "gid": 1000, + "home": "/home/agent", + "directory": "/opt/actions-runner" + }, + "docker": { + "daemonOwner": "docker-sandboxes-runtime", + "expectedDaemonCount": 1, + "imagePreloadsVarLibDocker": false, + "buildRequiresPrivilegedBuildkit": false + } +} diff --git a/templates/docker-sandboxes/sources.lock.json b/templates/docker-sandboxes/sources.lock.json new file mode 100644 index 0000000..60dcb98 --- /dev/null +++ b/templates/docker-sandboxes/sources.lock.json @@ -0,0 +1,124 @@ +{ + "schemaVersion": 2, + "defaultPlatform": "linux/amd64", + "supportedPlatforms": ["linux/amd64", "linux/arm64"], + "dockerfileFrontend": { + "inspectionReference": "docker.io/docker/dockerfile@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e", + "reference": "docker.io/docker/dockerfile:1.7.1@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e", + "indexDigest": "sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e" + }, + "sbomGenerator": { + "inspectionReference": "docker.io/docker/buildkit-syft-scanner@sha256:79e7b013cbec16bbb436f312819a49a4a57752b2270c1a9332ae1a10fcc82a68", + "indexDigest": "sha256:79e7b013cbec16bbb436f312819a49a4a57752b2270c1a9332ae1a10fcc82a68" + }, + "goBuilder": { + "version": "1.25.12", + "inspectionReference": "docker.io/library/golang@sha256:9006890ecba0a168034d99516084099ae3114d9f2b7d6572c77f2dde57ebc980", + "indexDigest": "sha256:9006890ecba0a168034d99516084099ae3114d9f2b7d6572c77f2dde57ebc980" + }, + "hookLauncher": { + "sha256": "7fe07f10f484fa6888481a4165e81570187c0aeff422738d3ea5add6b95dd9b7" + }, + "actionsRunner": { + "version": "2.332.0" + }, + "tini": { + "version": "0.19.0" + }, + "platforms": { + "linux/amd64": { + "architecture": "amd64", + "dockerfileFrontendManifestDigest": "sha256:b5f3b260a9678e1d83d2fce86eeddf79420b79147eaba2a25986f47133d73720", + "goBuilderManifestDigest": "sha256:12e171e33ce7ade87ac8ab2bbe65cea9371527285bdab43ca02780a9e6ac60e5", + "goBuilderReference": "docker.io/library/golang@sha256:12e171e33ce7ade87ac8ab2bbe65cea9371527285bdab43ca02780a9e6ac60e5", + "sbomGeneratorManifestDigest": "sha256:13864237fb990943433f89d698590aad1de38d4a7e13d38e7b12f2488c1952e7", + "sbomGeneratorReference": "docker.io/docker/buildkit-syft-scanner@sha256:13864237fb990943433f89d698590aad1de38d4a7e13d38e7b12f2488c1952e7", + "actionsRunner": { + "url": "https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-x64-2.332.0.tar.gz", + "sha256": "f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a" + }, + "tini": { + "url": "https://github.com/krallin/tini/releases/download/v0.19.0/tini-amd64", + "sha256": "93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c" + } + }, + "linux/arm64": { + "architecture": "arm64", + "dockerfileFrontendManifestDigest": "sha256:c8678869a83fab70232869ba24acc1c0be661f4d65135c0eeacb6a8e78420fdd", + "goBuilderManifestDigest": "sha256:afe53a4752b49f57ddebc97501a99394e2f7715236b4241efa830d54efb44434", + "goBuilderReference": "docker.io/library/golang@sha256:afe53a4752b49f57ddebc97501a99394e2f7715236b4241efa830d54efb44434", + "sbomGeneratorManifestDigest": "sha256:860305b3d1667c35142f11f6e9485e322c1c6173702a0831dc68739a34847f2d", + "sbomGeneratorReference": "docker.io/docker/buildkit-syft-scanner@sha256:860305b3d1667c35142f11f6e9485e322c1c6173702a0831dc68739a34847f2d", + "actionsRunner": { + "url": "https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-arm64-2.332.0.tar.gz", + "sha256": "b72f0599cdbd99dd9513ab64fcb59e424fc7359c93b849e8f5efdd5a72f743a6" + }, + "tini": { + "url": "https://github.com/krallin/tini/releases/download/v0.19.0/tini-arm64", + "sha256": "07952557df20bfd2a95f9bef198b445e006171969499a1d361bd9e6f8e5e0e81" + } + } + }, + "supersededRecords": { + "linux/amd64": { + "act-22.04": { + "authoritative": false, + "reason": "Predates current Candidate A helper and architecture changes", + "manifestDigest": "sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8ef546ecb44ca256655", + "templateTag": "epar-docker-sandboxes-catthehacker-act-22.04:20260723-r3-amd64" + }, + "full": { + "authoritative": false, + "reason": "Predates current Candidate A helper and architecture changes", + "manifestDigest": "sha256:58314fa8cbf0f0e5384a37b3444811033320038816ef7c16f30b3e841ed65e51", + "templateTag": "epar-docker-sandboxes-catthehacker-full:20260723-r1-amd64" + } + } + }, + "profiles": { + "act-22.04": { + "sourceRepository": "ghcr.io/catthehacker/ubuntu", + "observedTagReference": "ghcr.io/catthehacker/ubuntu:act-22.04", + "inspectionReference": "ghcr.io/catthehacker/ubuntu@sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", + "immutableReference": "ghcr.io/catthehacker/ubuntu@sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", + "indexDigest": "sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", + "sourceRevision": "e2f8efe464c82732f78e967ee709c00b6af53643", + "platforms": { + "linux/amd64": { + "validationStatus": "planned", + "manifestDigest": "sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8ef546ecb44ca256655", + "templateTag": "epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64", + "compatibilityFile": "act-22.04.amd64.compatibility.json" + }, + "linux/arm64": { + "validationStatus": "unvalidated", + "manifestDigest": "sha256:72b9ec71ee5972e02df5053f0000d34dbd2a3d0165b912bf25bbeabd72fba160", + "templateTag": "epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-arm64", + "compatibilityFile": "act-22.04.arm64.compatibility.json" + } + } + }, + "full": { + "sourceRepository": "ghcr.io/catthehacker/ubuntu", + "observedTagReference": "ghcr.io/catthehacker/ubuntu:full-latest", + "inspectionReference": "ghcr.io/catthehacker/ubuntu@sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", + "immutableReference": "ghcr.io/catthehacker/ubuntu@sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", + "indexDigest": "sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", + "sourceRevision": "96c58e2540a8c11351aed1269df0553663a1b8d7", + "platforms": { + "linux/amd64": { + "validationStatus": "planned", + "manifestDigest": "sha256:58314fa8cbf0f0e5384a37b3444811033320038816ef7c16f30b3e841ed65e51", + "templateTag": "epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64", + "compatibilityFile": "full.amd64.compatibility.json" + }, + "linux/arm64": { + "validationStatus": "unvalidated", + "manifestDigest": "sha256:245c8981fbf4ac268db015463c6c446b9411481f7e0001537128dc384d46dd0c", + "templateTag": "epar-docker-sandboxes-catthehacker-full:20260723-r2-arm64", + "compatibilityFile": "full.arm64.compatibility.json" + } + } + } + } +} From 648c2682d43cc3877bc04ecb105962b8c4f33170 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 28 Jul 2026 17:55:12 +0800 Subject: [PATCH 05/22] docs: reorganize and simplify project documentation --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- AGENTS.md | 2 +- CONTRIBUTING.md | 4 +- README.md | 177 ++------ docs/README.md | 37 ++ .../advanced/cross-architecture-containers.md | 74 +++ docs/advanced/docker-sandboxes-template.md | 68 +++ docs/advanced/no-go-install.md | 2 +- docs/background.md | 25 - docs/configuration.md | 397 ++++++++-------- docs/core-runner-verification.md | 154 ------- docs/development/README.md | 11 + .../adding-provider.md | 2 +- docs/development/core-runner-verification.md | 98 ++++ docs/{ => development}/design.md | 2 +- .../principles.md} | 0 docs/development/releases.md | 23 + docs/image-build.md | 350 ++------------ docs/logging.md | 77 ++-- docs/operations.md | 100 ++-- docs/providers/docker-container.md | 207 ++------- docs/providers/docker-sandboxes.md | 226 +++------- docs/providers/tart.md | 72 +-- docs/providers/wsl.md | 106 ++--- docs/security.md | 22 +- docs/troubleshooting.md | 426 +++++------------- docs/usage.md | 337 +++----------- scripts/sync-wiki.sh | 113 ++++- 28 files changed, 1114 insertions(+), 2000 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/advanced/cross-architecture-containers.md create mode 100644 docs/advanced/docker-sandboxes-template.md delete mode 100644 docs/background.md delete mode 100644 docs/core-runner-verification.md create mode 100644 docs/development/README.md rename docs/{providers => development}/adding-provider.md (92%) create mode 100644 docs/development/core-runner-verification.md rename docs/{ => development}/design.md (97%) rename docs/{development-principles.md => development/principles.md} (100%) create mode 100644 docs/development/releases.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fdc0072..09680ce 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,5 +11,5 @@ - [ ] I kept credentials, private keys, tokens, and machine-specific configuration out of this pull request. - [ ] I added or updated tests where behavior changed. - [ ] I updated relevant documentation. -- [ ] For provider or onboarding changes, I followed the Development and Extension Principles and documented and tested every intentional exception. +- [ ] For provider or onboarding changes, I followed the [Development and Extension Principles](../docs/development/principles.md) and documented and tested every intentional exception. - [ ] I read and followed the contributing guide and code of conduct. diff --git a/AGENTS.md b/AGENTS.md index e04ef15..51698c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Repository Agent Guidance -Before planning or changing provider, startup, configuration, pool, runner-image, or lifecycle behavior, read [Development and Extension Principles](docs/development-principles.md), [Contributing](CONTRIBUTING.md), [Design](docs/design.md), and [Adding a Provider](docs/providers/adding-provider.md). +Before planning or changing provider, startup, configuration, pool, runner-image, or lifecycle behavior, read [Development and Extension Principles](docs/development/principles.md), [Contributing](CONTRIBUTING.md), [Design](docs/development/design.md), and [Adding a Provider](docs/development/adding-provider.md). Treat the missing-config `./start` wizard, the no-Go native-controller path, Catthehacker defaults for providers that can consume Docker images, runner-artifact customization, the shared machine-derived pool-prefix generator, the shared `pool.RunnerName` format, runner routing, strict capacity, logging, host trust, registration, replacement, diagnostics, exact cleanup, and no-silent-fallback behavior as product-wide contracts. Compare an extension with the common manager path and at least one established provider instead of validating only its provider-local implementation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0456c74..0afd3ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Thanks for taking the time to contribute. ## Development Workflow 1. Fork the repository and create a focused branch from `develop`. -2. Read and preserve the [Development and Extension Principles](docs/development-principles.md). +2. Read and preserve the [Development and Extension Principles](docs/development/principles.md). 3. Keep the change small and document any operational or security behavior that it changes. 4. Run the relevant tests locally. The baseline Go test suite is `go test ./...`. 5. Open a pull request targeting `develop` and complete the pull-request template. @@ -27,3 +27,5 @@ Fork pull requests run the safe hosted verification workflow. The live EPAR cana - Document and test every intentional platform or security exception. By contributing, you agree to follow the [Code of Conduct](CODE_OF_CONDUCT.md). + +See the [development documentation](docs/development/) for the architecture, provider extension checklist, verification infrastructure, and release process. diff --git a/README.md b/README.md index b3ccd45..c4699e7 100644 --- a/README.md +++ b/README.md @@ -2,176 +2,75 @@ ![Ephemeral Action Runner banner](docs/assets/brand/epar-banner.jpg) -Ephemeral Action Runner (EPAR) keeps a warm pool of disposable GitHub Actions self-hosted runners on your own machine. - -Each runner is made for one job. EPAR starts it, registers it with GitHub, lets one workflow job run, deletes it, and creates a fresh replacement. +Ephemeral Action Runner (EPAR) keeps a warm pool of disposable GitHub Actions self-hosted runners on a machine you control. A runner accepts one job, is removed, and is replaced with a clean runner so ordinary job files, containers, and caches do not become the next job's starting state. ```mermaid -flowchart TB - EPAR["EPAR"] --> Create["Create runner"] - Create --> Ready["Runner ready"] - Ready --> Job["Run one GitHub Actions job"] - Job --> Delete["Delete runner"] - Delete --> Create +flowchart LR + Start["EPAR starts a runner"] --> Ready["Runner is ready"] + Ready --> Job["One GitHub Actions job"] + Job --> Remove["Runner is removed"] + Remove --> Start ``` -## Use Case - -Private repositories often have limited [GitHub-hosted Actions minutes](https://docs.github.com/en/billing/concepts/product-billing/github-actions#free-use-of-github-actions). If you already have a spare Windows, macOS, Linux, or Docker-capable machine, you can use it for feature-branch CI instead of spending those hosted-runner minutes. - -A normal long-lived self-hosted runner can leave dependencies, files, containers, caches, or other job state behind on that machine. EPAR lowers that risk by running each job in a disposable container, WSL distro, or VM, then deleting it and creating a clean replacement. +## Why EPAR -## Why Use EPAR - -- **Warm pool:** keep ready self-hosted runners online after setup. -- **Disposable jobs:** each runner is cleaned up after one job. -- **Great default image:** Docker Sandboxes, Docker Container, and WSL use or adapt Catthehacker's full Ubuntu runner image by default. -- **Strong Docker-friendly isolation:** Docker Sandboxes puts each runner and its private Docker daemon inside a dedicated microVM. -- **Simple host use:** run Linux GitHub Actions jobs from a Windows, macOS, Linux, or Docker-capable host. +- Keep ready capacity available for private-repository CI without a long-lived runner workspace. +- Give each runner its own disposable container, WSL distribution, or microVM, depending on the provider. +- Run Docker-friendly Linux jobs from a Windows, macOS, Linux, or other Docker-capable host. ## Quick Start -The wizard recommends **Docker Sandboxes** when its prerequisites pass and offers other supported providers when they do not. +The normal path is a source archive plus Docker. EPAR's first run opens a guided setup wizard; it checks what the host supports and writes your ignored local configuration. -### 1. Install Docker +### 1. Install the host tools -The default quick start needs a Docker-compatible daemon: +Install a Docker-compatible daemon: [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Windows or macOS, [OrbStack](https://orbstack.dev/) on macOS, or [Docker Engine](https://docs.docker.com/engine/) on Linux. Docker Sandboxes also needs the supported `sbx` CLI and a prepared EPAR template; see [Docker Sandboxes](docs/providers/docker-sandboxes.md). -- Windows: [Docker Desktop](https://www.docker.com/products/docker-desktop/), or another Docker daemon reachable from PowerShell -- macOS: [Docker Desktop](https://www.docker.com/products/docker-desktop/) or [OrbStack](https://orbstack.dev/) -- Linux: [Docker Engine](https://docs.docker.com/engine/) +### 2. Download EPAR -For Docker Sandboxes, also install and authenticate [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/). See the [provider guide](docs/providers/docker-sandboxes.md) for its prerequisites. +From the [EPAR releases page](https://github.com/solutionforest/ephemeral-action-runner/releases), download GitHub's **Source code (zip)** or **Source code (tar.gz)** for the release you want. Extract it and open a terminal in the extracted folder. -### 2. Download EPAR Source +### 3. Create a GitHub App -Open the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases), select the release you want, and download GitHub's automatically generated **Source code (zip)** or **Source code (tar.gz)**. EPAR releases use these source archives only. +EPAR uses a GitHub App to obtain short-lived runner registration tokens. Follow [GitHub App Setup](docs/github-app.md), then have the App ID, organization name, and private-key file path ready. -Extract the source archive and open a terminal in the extracted folder. The folder is usually named `ephemeral-action-runner-`. - -```bash -cd path/to/ephemeral-action-runner- -``` - -### 3. Create A GitHub App - -EPAR uses a GitHub App to create short-lived runner registration tokens. - -Follow [GitHub App Setup](docs/github-app.md), then keep these three values ready: - -- GitHub App ID -- GitHub organization name -- private key file path - -### 4. Run EPAR - -Run EPAR with the default flow: +### 4. Start EPAR ```bash ./start ``` -On Windows, `./start` also works in modern PowerShell. If your shell does not run it, use `.\start.ps1` or `start.cmd`. - -That's it. - -#### What Happens - -If `.local/config.yml` does not exist, `./start` opens the setup wizard. It checks provider prerequisites, asks for a runner group, and writes the configuration. See [Runner Group Security](docs/runner-groups.md), [Configuration](docs/configuration.md), and the [provider guides](docs/providers/). +In native Windows PowerShell or cmd, use `.\start.ps1` or `start.cmd` if `./start` is not available. The wrapper uses local Go when it works; otherwise it builds a native controller with Docker. If no configuration exists, the interactive wizard asks for the GitHub App, a runner group, and an available provider. The first start can take longer while EPAR prepares the configured runner image or creates the first runner. -Then EPAR checks the configured runner image, builds or replaces it when needed, and starts the configured number of runners. The default config uses `pool.instances: 1`. +Keep the process open while runners should accept work. Press `Ctrl-C` once to stop and wait for cleanup confirmation. For detailed commands, config selection, no-Go startup, verification, and cleanup, read [Usage](docs/usage.md). -The first run can take a while because EPAR may need to build the runner image before it starts the pool. Later runs reuse the aligned image unless the config, EPAR scripts, or source image changed. +## Choose a provider -Keep EPAR running while you want runners online. Stop with `Ctrl-C`; EPAR cleans up matching local instances and GitHub runner records by default. - -#### Optional: Config Or Runner Count - -To choose a config or runner count: - -```bash -./start --config .local/custom-config.yml --instances 2 -``` +Choose a provider based on your host OS, available prerequisites, and isolation needs. **Docker Sandboxes** is recommended when its capability checks pass as it provides the highest isolation level. EPAR never silently falls back to another provider. -If `--instances` is omitted, EPAR uses `pool.instances` from the config. +| Provider | Host OS | Prerequisites | Isolation and compatibility | +| --- | --- | --- | --- | +| [Docker Sandboxes](docs/providers/docker-sandboxes.md) | Linux, macOS, Windows | Docker and the supported `sbx` CLI | Highest isolation level — each runner uses a dedicated microVM with a private Docker daemon. Recommended when capability checks pass. | +| [Docker Container](docs/providers/docker-container.md) | Linux, macOS, Windows | Docker | Standard isolation level — each disposable runner container has a private Docker daemon. | +| [WSL](docs/providers/wsl.md) | Windows | WSL2 and Docker | Standard isolation level — each runner uses a disposable WSL2 Linux environment. | +| [Tart](docs/providers/tart.md) | Apple Silicon macOS | Tart | Experimental — ARM64 Linux VM with limited compatibility for CI jobs that require non-ARM64 Docker images. | -#### GitHub Actions Labels +## Route a workflow to EPAR -GitHub Actions picks a runner by matching the job's `runs-on` list with the labels registered on each runner. Every self-hosted runner gets the `self-hosted` label, so the simplest workflow can use: +Every EPAR runner has GitHub's `self-hosted` label. Add one of the provider labels when a repository has several types of runner: ```yaml -runs-on: [self-hosted] +runs-on: [self-hosted, linux, epar-docker-sandboxes] ``` -If you have multiple self-hosted runners and want this job to run on a specific kind of EPAR runner, add one of its extra labels to the list, e.g.: - -```yaml -runs-on: [self-hosted, linux, X64, epar-docker-sandboxes] -``` - -EPAR also adds an `epar-host-` label by default, so you can see which host registered each runner. You only need to include that label in `runs-on` when you intentionally want a job to target one machine. - -## Other Modes - -The wizard chooses a default only from providers whose prerequisites pass. Docker Sandboxes is the recommended capability-driven default; the other providers remain available for compatibility and platform-specific needs: - -| Provider | Use when | -| --- | --- | -| Docker Container | You have a Docker-compatible daemon on Windows, macOS, or Linux, and want a private Docker daemon per runner. | -| Docker Sandboxes | Recommended when Docker and the exact supported `sbx` are ready and `sbx diagnose --output json` reports at least one pass and zero failures. It places each runner and private Docker daemon in a dedicated microVM. | -| WSL2 | You are on Windows and want runners as disposable WSL distros. | -| Tart (experimental) | You are on Apple Silicon macOS and want to experiment with native ARM64 Linux VMs. The default Tart image is a basic Ubuntu OS image and does not include the normal GitHub-hosted runner dependency set. | - -WSL2 also defaults to Catthehacker's full Ubuntu runner image, but it converts that Docker image into a WSL rootfs during `image build`. - -Tart is not a ready-made substitute for GitHub's hosted Ubuntu runners. If you need that environment, build and maintain your own bootable Tart runner image by adapting the scripts from [actions/runner-images](https://github.com/actions/runner-images), then configure EPAR to use it. EPAR does not automate that conversion. - -See [Usage](docs/usage.md) for WSL, Tart, source builds, custom configs, and advanced options. - -## FAQ - -### Can EPAR run multiple runners at once? - -Yes. Set `pool.instances` in `.local/config.yml`, or pass `--instances N` for one run. - -### Can one machine run runners for multiple GitHub organizations? - -Yes. Use one config per organization, then start EPAR once per config. Each config should use its own GitHub App values and a distinct `pool.namePrefix`. - -### Does each job get a clean runner? - -Yes. EPAR registers disposable ephemeral runners. After a job finishes, EPAR deletes that runner and creates a replacement. - -### Can jobs use Docker, Docker Compose, and Buildx? - -Yes. Docker Sandboxes and Docker Container each give every disposable runner its own private Docker daemon, so job-created containers, networks, and volumes stay inside that runner. - -## Safety +Use labels that describe the environment your job actually needs. In particular, an ARM64 Tart runner is not a replacement for GitHub-hosted `ubuntu-latest` or an x64-only workload. -EPAR is for trusted jobs. It improves cleanup and reduces stale runner state, but it does not make your machine safe for arbitrary untrusted code. +## Trusted jobs only -GitHub also warns against using self-hosted runners with public repositories that can run untrusted pull request workflows. Read GitHub's self-hosted runner guidance before exposing a runner to untrusted users. +EPAR reduces stale state after a job; it is not a hostile-code sandbox. Use it only for workflows and repositories you trust, restrict access with [runner groups](docs/runner-groups.md), and do not expose it to unreviewed public or fork pull-request code. Read [Security](docs/security.md) before choosing a provider. -## More Docs +## Find the right guide -- [Usage](docs/usage.md): setup, image builds, verification, and pool commands. -- [Configuration](docs/configuration.md): config file sections and common edits. -- [GitHub App Setup](docs/github-app.md): required GitHub App permissions and fields. -- [Runner Group Security](docs/runner-groups.md): repository access, wizard choices, and registration preflight policy. -- [Docker Container Provider](docs/providers/docker-container.md): compatible container-based runner mode. -- [Docker Sandboxes Provider](docs/providers/docker-sandboxes.md): recommended Windows microVM runner mode, template build, security boundary, and platform status. -- [WSL Provider](docs/providers/wsl.md): Windows WSL2 runners. -- [Tart Provider (experimental)](docs/providers/tart.md): Apple Silicon ARM64 Linux VM runners and Rosetta compatibility limits. -- [Image Build](docs/image-build.md): image internals and customization. -- [Storage](docs/storage.md): capacity checks, retention, and exact cleanup previews. -- [Development and Extension Principles](docs/development-principles.md): three rules for extending EPAR. -- [Operations](docs/operations.md): logs, cleanup, and troubleshooting. -- [Troubleshooting](docs/troubleshooting.md): symptom-first diagnostics by host and provider. -- [Support](SUPPORT.md): where to start, what diagnostic information to collect, and where to ask for help. -- [Windows Startup](docs/advanced/windows-startup.md): start EPAR after Windows login. -- [macOS Startup](docs/advanced/macos-startup.md): start EPAR after macOS login. -- [Running EPAR Without Installing Go](docs/advanced/no-go-install.md): run from source with no local Go install. -- [Security](docs/security.md): trust boundaries, secret handling, and private vulnerability reporting. -- [Contributing](CONTRIBUTING.md): how to propose and validate changes. -- [Code of Conduct](CODE_OF_CONDUCT.md): community expectations and reporting concerns. -- [Level 1 Core Runner Verification](docs/core-runner-verification.md): trusted live CI setup, canary behavior, and cleanup. +- **Start and configure:** [Documentation hub](docs/README.md), [Usage](docs/usage.md), [Configuration](docs/configuration.md), and [GitHub App setup](docs/github-app.md). +- **Run and maintain:** [Operations](docs/operations.md), [Troubleshooting](docs/troubleshooting.md), [Logging](docs/logging.md), and [Storage](docs/storage.md). +- **Get help or contribute:** [Support](SUPPORT.md), [Contributing](CONTRIBUTING.md), and [Security reporting](docs/security.md). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4202a86 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,37 @@ +# EPAR documentation + +Use these guides after the short [README quick start](../README.md). Start with the task you need to complete, then open a provider guide only when choosing or changing the runner environment. + +## Start and configure + +- [Usage](usage.md): start, initialize, verify, inspect, clean up, labels, and dry runs. +- [GitHub App setup](github-app.md): create the App EPAR uses for short-lived registration tokens. +- [Runner group security](runner-groups.md): restrict which repositories can route work to your runners. +- [Configuration](configuration.md): edit local configuration and provider defaults. + +## Choose a provider + +- [Docker Container](providers/docker-container.md): disposable containers with a private Docker daemon. +- [Docker Sandboxes](providers/docker-sandboxes.md): dedicated microVM runners and the required prebuilt template. +- [WSL](providers/wsl.md): disposable Windows WSL2 runners. +- [Tart](providers/tart.md): experimental Apple Silicon ARM64 Linux VMs. + +## Operate and maintain + +- [Operations](operations.md): supervision, capacity, cleanup, recovery, and maintenance. +- [Troubleshooting](troubleshooting.md): symptom-first diagnostics. +- [Logging](logging.md) and [Storage](storage.md): retention, capacity, and exact cleanup boundaries. +- [Image customization](image-build.md): build layers and custom install scripts. +- [Docker Sandboxes templates](advanced/docker-sandboxes-template.md): build, review, load, size, and retain pinned templates. +- [Cross-architecture containers](advanced/cross-architecture-containers.md): image platforms, emulation, labels, and verification. +- [Docker registry mirrors](advanced/docker-registry-mirrors.md): an optional pull-time optimization. +- [Windows startup](advanced/windows-startup.md), [macOS startup](advanced/macos-startup.md), and [no-Go startup](advanced/no-go-install.md): host-specific launch help. + +## Safety and support + +- [Security](security.md): trusted-job boundary, secrets, provider caveats, and private vulnerability reporting. +- [Support](../SUPPORT.md): information to collect before opening an issue. + +## Contribute + +Read [Contributing](../CONTRIBUTING.md), then use the [developer documentation](development/README.md) for architecture, extension contracts, provider work, and the live core-runner canary. diff --git a/docs/advanced/cross-architecture-containers.md b/docs/advanced/cross-architecture-containers.md new file mode 100644 index 0000000..7076753 --- /dev/null +++ b/docs/advanced/cross-architecture-containers.md @@ -0,0 +1,74 @@ +# Cross-architecture containers + +Use this guide when a trusted EPAR job must run a container image whose CPU architecture differs from the Linux Docker daemon that executes it. It explains the boundary between image selection, emulation, runner labels, and provider support. + +## Start with evidence + +Determine the runner and daemon architecture, inspect the image manifest, and inspect any Compose override before choosing a workaround: + +```bash +uname -m +docker info --format '{{.OSType}}/{{.Architecture}}' +docker image inspect --format '{{.Os}}/{{.Architecture}}' IMAGE +docker buildx imagetools inspect IMAGE +docker compose config +``` + +An x64 Linux daemon normally runs `linux/amd64` images natively; an ARM64 daemon normally runs `linux/arm64` images natively. A `platform:` value in Compose can override the image's normal selection. Pulling or loading a foreign image proves only that the daemon obtained it, not that it can execute it. + +| Symptom | Meaning | Correct next action | +| --- | --- | --- | +| `no matching manifest for linux/amd64` or `linux/arm64` | The registry does not publish the requested platform. | Choose an available image/platform or publish a multi-platform image. QEMU cannot create a missing manifest. | +| `exec format error` or `cannot execute binary file` | The daemon tried to launch an incompatible executable without a usable handler. | Confirm the selected platform and install/verify an emulator only if the workload supports it. | +| `qemu-x86_64: Could not open '/lib64/ld-linux-x86-64.so.2'` | Translation started but the foreign userspace/loader is missing or incompatible. | Use a compatible image or native runner; binfmt registration alone is insufficient. | +| Exit code `139` | A process segfaulted. | Treat architecture as one hypothesis, not proof; inspect the workload log and run a minimal container test. | +| Docker platform warning | Requested and detected platforms differ. | Verify execution; the warning alone is not a failure. | + +## Set up and verify Linux user-mode emulation + +For a trusted Linux job that intentionally runs a foreign Linux container, configure QEMU/binfmt before the first foreign container. Pin the action and helper image according to the repository's dependency policy: + +```yaml +jobs: + test: + runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu] + steps: + - name: Set up ARM64 container emulation + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 + with: + image: docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 + platforms: arm64 + + - name: Verify the foreign container + run: docker run --rm --platform linux/arm64 alpine:3.22 uname -m +``` + +The verification should print `aarch64`. Select only the foreign platforms that the workflow needs. QEMU/binfmt translates Linux user-space executables; it does not change the runner CPU, create a foreign VM, make arbitrary host programs compatible, or guarantee workload performance. Emulated builds, databases, browsers, and compute-heavy tools can be slower or unsupported. + +The setup helper is privileged. Use it only in trusted workflows and treat its pinned action/image revisions as reviewed dependencies. Prefer a native matching runner whenever compatibility, performance, or a security boundary matters. + +## Provider and platform scope + +| Execution surface | What to do | +| --- | --- | +| 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 | The guest is ARM64. The optional Rosetta path is experimental and not equivalent to QEMU/binfmt. Use a distinct label and validate the exact image/workload. | +| Docker Sandboxes | The pinned template and `provider.platform` determine the guest architecture. Treat unsupported host/template combinations as preview-only and use only independently validated combinations. | +| 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. + +## Operational examples + +For an amd64-only service on an ARM64 host, first try a published ARM64 or multi-platform image. If none exists, use a trusted Linux runner with the emulation setup above, then prove the actual service starts and passes its health check. If the service is performance-sensitive or fails under emulation, route it to a native x64 Docker Container, WSL x64, or another native x64 Linux runner instead. + +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. + +## References + +- [Docker Setup QEMU action](https://github.com/docker/setup-qemu-action) +- [Docker multi-platform build strategies](https://docs.docker.com/build/building/multi-platform/) +- [GitHub-hosted runner labels and limitations](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +- [GitHub self-hosted runner container requirements](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#requirements-for-self-hosted-runner-machines) + diff --git a/docs/advanced/docker-sandboxes-template.md b/docs/advanced/docker-sandboxes-template.md new file mode 100644 index 0000000..0f56b59 --- /dev/null +++ b/docs/advanced/docker-sandboxes-template.md @@ -0,0 +1,68 @@ +# Docker Sandboxes Template Build And Retention + +Docker Sandboxes requires an EPAR Candidate A template that has been built, reviewed, and loaded locally before setup. This is separate from `ephemeral-action-runner image build`: the normal image command does not build or load sandbox templates. + +## Before You Start + +Use a native Docker server that matches the template platform. An amd64 server builds `linux/amd64`; an ARM64 server builds `linux/arm64`. The pinned build intentionally does not use emulation. Confirm the exact supported Docker Sandboxes version and diagnostics before loading a template: + +```bash +sbx version +sbx diagnose --output json +``` + +EPAR currently requires `sbx v0.35.0`, at least one diagnostic pass, and no diagnostic failures. The lock file at [`templates/docker-sandboxes/sources.lock.json`](../../templates/docker-sandboxes/sources.lock.json) identifies the allowed source profiles and exact build inputs. It is an approved snapshot, not an automatic update channel. + +## Build And Review + +Run the build once without `-Execute` to inspect its plan. Build a reviewed full or lean profile only after confirming its source and platform: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/build-template.ps1 -Profile full -Platform linux/amd64 -Execute +``` + +The lean profile is `act-22.04`. On Apple Silicon, use PowerShell 7 and `-Platform linux/arm64`. A cold full-profile acquisition can take significant time and storage. + +The build directory contains the template archive, metadata, SBOM, provenance, software inventory, compatibility record, and checksums. Record the SHA-256 of `template-metadata.json` outside that directory before review. The loader uses that operator-provided value as its trust anchor. + +## Load Exactly Once + +After reviewing the evidence, load the archive: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/load-template.ps1 -ArtifactDirectory work/template-builds/docker-sandboxes/full -ExpectedMetadataSha256 sha256: -Execute +``` + +The loader validates the archive, metadata, provenance, SPDX SBOM, inventory, helper hashes, compatibility record, source lock, exact local Docker image identity, and the supported `sbx` version before it invokes `sbx template load`. It reads the tag and cache inventory back afterward and never runs `sbx reset`. + +Keep the archive and its evidence while the template is in service or may require independent review. The Docker Sandboxes cache ID is only 12 hexadecimal characters; it is not the full template identity. EPAR records the full local Docker image identity as `dockerSandboxes.templateDigest` and checks it independently. + +## Configure And Prewarm + +Run `./start` with no configuration, or `ephemeral-action-runner init`. The wizard finds only current lock-selected, locally loaded templates that match their full local identity and platform. It writes the exact tag, full digest, policy fingerprint, and resource reservations; it does not build, load, start, or remove a sandbox during discovery. + +After configuration, prewarm the selected template outside the job path: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-native-controller.ps1 pool verify --config .local/docker-sandboxes.yml --project-root . --instances 1 --cleanup +``` + +Do not add `--register-only`. This creates, verifies, and exactly removes one unregistered sandbox without requesting a GitHub registration token. The first create can still be slow; later creates reuse the host-level template cache. + +## Capacity + +Size `rootDisk` from the exact template/workload's measured guest root peak plus 25% margin and at least 20 GiB writable headroom, rounded up to the next 10 GiB. Size `dockerDisk` from representative Docker workload use plus 25% margin and 20 GiB deletion headroom; the minimum is 100 GiB. Keep at least 50 GiB or 10% of the backing volume free, whichever is greater. + +The template cache and archive are host-cache measurements, not each sandbox's root-disk baseline. EPAR rechecks backing storage, configured reservations, and uncertain cleanup reservations before every create. A failed capacity admission does not silently choose another provider. + +## Retention And Replacement + +Docker Sandboxes retains loaded templates after individual sandboxes are deleted. Before deleting an obsolete EPAR template, confirm no active configuration or live sandbox references its exact tag and cache identity, run `sbx template rm `, then verify absence. Do not use `sbx reset`: it removes the whole Docker Sandboxes cache. + +EPAR storage maintenance never broadly prunes Docker images, BuildKit state, Docker Sandboxes templates, WSL distributions, or Tart images. Use `ephemeral-action-runner storage status` and preview `storage prune` before explicit cleanup. Docker Desktop VHDX compaction is separate offline host maintenance. + +## Evidence And Certification + +The source lock pins the Candidate A source, Actions runner, Tini, helper inputs, and platform manifests. A rolling upstream channel moving later does not change a loaded template; build, review, and load a new versioned template before it becomes selectable. + +The current ARM64 path has pinned inputs and code support but no equivalent recorded native real-host lifecycle or independent-certification evidence. Treat it as capability-ready only after local admission and your own workload validation. An independent certification record, when available, must bind the reviewed native-controller source/build, full template identity, cache ID, metadata/archive digests, and reviewed evidence. diff --git a/docs/advanced/no-go-install.md b/docs/advanced/no-go-install.md index 3b9f7ef..26d11cc 100644 --- a/docs/advanced/no-go-install.md +++ b/docs/advanced/no-go-install.md @@ -1,6 +1,6 @@ # Running EPAR Without Installing Go -The standard path is to download GitHub's automatic **Source code (zip)** or **Source code (tar.gz)** from the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases) and run `go run ./cmd/ephemeral-action-runner` from the extracted source folder. If you do not want Go installed on the host, use EPAR's Docker-based native-controller builder instead. Docker remains required for that build toolchain. +The standard path is to download GitHub's automatic **Source code (zip)** or **Source code (tar.gz)** from the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases) and run `./start` from the extracted source folder. The wrapper uses local Go when it works; if you do not want Go installed on the host, it uses EPAR's Docker-based native-controller builder instead. Docker remains required for that build toolchain. ## Run With Docker diff --git a/docs/background.md b/docs/background.md deleted file mode 100644 index 985c33e..0000000 --- a/docs/background.md +++ /dev/null @@ -1,25 +0,0 @@ -# Background - -The original macOS-runner direction used macOS VM images because those tools are commonly found when searching for Mac self-hosted runners. That is a poor fit for Docker Compose jobs inside the guest: Docker Desktop and OrbStack on macOS rely on their own Linux VM, so using them inside a macOS Tart VM becomes nested virtualization. - -For Docker container actions, service containers, and Compose-heavy jobs, use an EPAR image built with `scripts/guest/ubuntu/install-docker-browser.sh` or `scripts/guest/ubuntu/install-web-e2e.sh` so Docker Engine is installed directly inside the Linux guest. On an M3 Mac that means Ubuntu ARM64. Workflows must target self-hosted ARM labels such as: - -```yaml -runs-on: [self-hosted, linux, ARM64, m3-ubuntu-24.04-docker] -``` - -Do not label these runners as `ubuntu-latest`. GitHub-hosted `ubuntu-latest` is a GitHub-managed image environment, and x64 assumptions may break on ARM64. - -For Docker-heavy Linux CI, Docker Container is the recommended first path when the host already has a Docker runtime that supports privileged containers. It keeps workflow Docker resources inside a private inner daemon per runner instance, so existing Compose stacks with fixed project names or ports usually need fewer repository changes. - -WSL on Windows x64 remains a good EPAR provider for workflows that need native x64 Linux Docker images. Tart on Apple Silicon remains useful when you specifically want VM-based runners on a Mac host, but the VM is ARM64 unless you opt into and validate Rosetta support. Workflows that depend on amd64-only images should target a distinct label such as a Docker Container label with verified amd64 emulation, `epar-tart-rosetta-amd64`, a WSL x64 label, or another x64 Linux runner label. - -On Apple Silicon hosts, amd64 containers inside Docker Container depend on the host runtime's emulation support; validate `docker run --platform linux/amd64 alpine:3.20 uname -m` inside a running EPAR instance before routing amd64-only workflows there. - -## OCI Clarification - -OCI is a registry and artifact ecosystem, not a guarantee that an artifact can run as both a container and a VM. Docker container images and Tart VM images can both live in OCI-compatible registries, but their contents are different. Tart can pull Tart-created VM images from OCI registries; it cannot run arbitrary Docker container images as VMs. - -## Browser Caveat - -GitHub's upstream `install-google-chrome.sh` currently assumes x64 Linux Chrome/Chromium artifacts in important places. Docker/browser-enabled EPAR images therefore use that upstream script on x64 only. On ARM64, Ubuntu's `chromium-browser` package redirects to snap and can hang when the snap store is unreachable, so EPAR installs Playwright-managed Chromium and exposes it through `epar-browser`, `chromium`, and `chromium-browser` wrappers. Runtime validation exercises a real headless Chromium browser against a locally generated marker page when the Docker/browser feature marker is present. Network and TLS behavior remains the responsibility of each workflow. diff --git a/docs/configuration.md b/docs/configuration.md index b71db37..4769f9a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,122 +1,220 @@ # Configuration -EPAR stores local settings in `.local/config.yml` by default. When the file does not exist, the first-run wizard always displays Docker Container, Docker Sandboxes, WSL2, and experimental Tart with a prerequisite result, while refusing unavailable selections. Docker Sandboxes is the recommended Enter default without an operating-system allowlist when Docker is ready, the exact supported `sbx` version is installed, the host architecture maps to an available Linux guest template, and `sbx diagnose --output json` reports at least one pass and zero failures. Diagnostic warnings remain visible but do not disable the choice. The wizard performs read-only local discovery of an active lock-selected EPAR template, its full local image identity and platform, and the current host-global Balanced-policy fingerprint before writing configuration. New Docker Sandboxes configs set `networkBaseline: open`, which adds an EPAR-owned sandbox-scoped `allow **` rule for public HTTP/HTTPS compatibility plus deny-wins host-alias guardrails without changing the host-global policy. The rolling Catthehacker source-channel labels do not refresh automatically; the source lock is an approved snapshot. - -## Pre-release provider rename - -The Docker Container provider has an intentional pre-release naming migration. EPAR does not retain a compatibility alias for the pre-rename provider identifier. - -Before editing an existing configuration: - -1. Stop the current controller. -2. Use the previous release to remove its managed runners and runner containers. -3. Verify that no prior managed GitHub runner records or host containers remain. -4. Update `provider.type`, `image.outputImage`, `provider.sourceImage`, the runner label, and pool prefix to the `docker-container` values in the current example configuration. -5. Rebuild the renamed image, then start the renamed provider. - -The old and renamed controllers must not overlap: their provider labels and controller-lock identity differ, so concurrent operation can create duplicate runners or leave resources outside the active controller's ownership boundary. - -Use `.local/config.yml` for real GitHub App values, local paths, labels, and runner counts. Tracked files under `configs/` are examples. - -## Config Lookup - -EPAR looks for config in this order: - -1. `--config ` -2. `EPAR_CONFIG` -3. `./.local/config.yml` -4. `~/.config/ephemeral-action-runner/config.yml` - -## Sections - -| Section | Purpose | -| --- | --- | -| `github` | GitHub App ID, organization, private key path, and optional GitHub API/web URLs. | -| `provider` | How EPAR creates disposable runners: `docker-container`, `docker-sandboxes`, `wsl`, or `tart`. | -| `image` | Source image/rootfs, output image, runner version, and optional install scripts. | -| `pool` | Runner count, instance name prefix, and replacement retry policy. | -| `storage` | Provider-neutral free-space reserve, grace period, retained generations, and cache limits. | -| `logging` | Manager and transcript sinks, formats, rotation, retention, and log directory. | -| `runner` | GitHub Actions labels, runner group, default-label policy, and whether to add the host-machine label. | -| `security` | Runner-group policy requirements checked before runner registration. | -| `docker` | Optional Docker registry mirrors and private Docker daemon proxy settings. | -| `dockerSandboxes` | Pinned template, policy, staging, capacity, and resource settings for Docker Sandboxes. | -| `timeouts` | Boot, GitHub online, and command timeout values in seconds. | +EPAR reads a small, strict YAML subset: indentation uses spaces, unknown sections and keys fail, comments outside quotes are ignored, and list values may use either `[one, two]` or an indented list. Put real credentials and machine-specific paths in `.local/config.yml`; tracked configuration files are examples. + +## Contents + +- [Lookup and parser rules](#lookup-and-parser-rules) +- [Provider matrix](#provider-matrix) +- [Configuration reference](#configuration-reference) +- [Cross-field rules](#cross-field-rules) +- [Provider defaults](#provider-defaults) +- [Short recipes](#short-recipes) + +## Lookup and parser rules + +EPAR chooses the first available configuration path in this order: `--config `, `EPAR_CONFIG`, `./.local/config.yml`, then `~/.config/ephemeral-action-runner/config.yml`. A relative file path in configuration is resolved from the project root when EPAR consumes it. `~` and `~/...` are expanded for the configuration path, `github.privateKeyPath`, and each `image.trustedCaCertificatePaths` entry; do not assume they expand in other configuration properties. + +`github`, `image`, `pool`, `storage`, `logging`, `runner`, `security`, `provider`, `docker`, `dockerSandboxes`, and `timeouts` are the only accepted top-level sections. `security` contains only the `runnerGroup` subsection. Values are strings unless this reference says integer, number, boolean, or list. Quote a value when it needs YAML-like punctuation; EPAR removes one matching pair of single or double quotes. + +`pool.logDir` is a deprecated compatibility input. If `logging.directory` is absent, EPAR uses it and emits a warning; using both is rejected. `pool.vmPrefix` is an accepted alias for `pool.namePrefix`. `image.profile` and the old `docker-socket` provider are rejected rather than silently migrated. + +## Provider matrix + +| Provider | Host and artifact model | Image defaults | Provider-only configuration | +| --- | --- | --- | --- | +| `docker-container` | A Docker-compatible host creates an outer disposable runner with its own inner Docker daemon. | `docker-image`, `ghcr.io/catthehacker/ubuntu:full-latest`, output `epar-docker-container-catthehacker-ubuntu`. | Optional `provider.platform`; `docker` proxy and mirror settings apply to its private daemon. | +| `docker-sandboxes` | A supported `sbx` host creates a pinned Linux sandbox template. It is preview-only until the exact host/platform combination has independent live evidence. | No source image or build artifact is accepted; the wizard selects and pins a local Candidate A template. | `dockerSandboxes` is required; `provider.platform` is `linux/amd64` or `linux/arm64`; runner-group enforcement must be `enforce`. | +| `wsl` | Windows WSL2 imports a Docker image or rootfs tar into disposable Linux distros. | Docker source defaults to Catthehacker full Ubuntu, x64, with output under `work/images/`. | `provider.installRoot` controls WSL storage. | +| `tart` | Experimental Apple Silicon Linux VM path. | `ghcr.io/cirruslabs/ubuntu:latest`, output `epar-ubuntu-24-arm64`. | `provider.network` and optional `provider.rosettaTag`. Validate the exact workload before relying on Rosetta. | + +Docker Sandboxes never falls back to Docker Container. Its wizard is available only when the supported `sbx` version, host mapping, local template identity, policy fingerprint, capacity evidence, and `sbx diagnose --output json` prerequisite checks pass. Warnings remain visible; failures prevent selection. + +## Configuration reference + +### `github` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `appId` | integer; no default | Required for GitHub operations. | GitHub App ID used to request short-lived runner registration tokens. | +| `organization` | string; no default | Required for GitHub operations. | GitHub organization that owns the runner group and runner records. | +| `privateKeyPath` | string; no default | Required for GitHub operations. | Private-key file readable by the EPAR process. Keep it under ignored `.local/` storage. | +| `apiBaseUrl` | string; `https://api.github.com` | Optional, for GitHub Enterprise Server API endpoints. | Trailing `/` is removed. | +| `webBaseUrl` | string; `https://github.com` | Optional, for GitHub Enterprise Server web endpoints. | Trailing `/` is removed. | + +### `image` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `sourceImage` | string; provider default | Image-building providers. | Docker image reference or WSL rootfs-tar path, selected by `sourceType`. | +| `sourceType` | `docker-image` or `rootfs-tar`; provider default | `rootfs-tar` is WSL-only in normal use. | A rootfs tar cannot use `sourcePlatform`. | +| `sourcePlatform` | Docker platform string; empty except WSL Docker source default `linux/amd64` | Only with `sourceType: docker-image`. | Requests the source image platform; pulling an image does not prove it can execute. See [Cross-architecture containers](advanced/cross-architecture-containers.md). | +| `outputImage` | string; provider default | Image-building providers. | EPAR-owned reusable runner artifact name or path. | +| `upstreamDir` | string; `third_party/runner-images` | Image builds that adapt upstream scripts. | Local checkout/cache location for the pinned upstream runner-image scripts. | +| `upstreamLock` | string; `third_party/runner-images.lock` | Image builds that adapt upstream scripts. | Lock file identifying the approved upstream revision. | +| `runnerVersion` | string; `latest` | Runner image builds. | Runner release selector. Pin a version when repeatability matters. | +| `customInstallScripts` | list of non-empty paths; empty | Optional image customization. | Scripts run while creating the runner image; treat them as trusted build input. | +| `trustedCaCertificatePaths` | list of non-empty paths; empty | Optional additional TLS roots. | PEM or DER CA files are validated and installed in the Ubuntu trust bundle. They supplement, not replace, host trust. | +| `hostTrustMode` | `disabled` or `overlay`; `disabled` | Optional host-root inheritance for ephemeral runners. | `overlay` requires `runner.ephemeral: true`; it collects a current host-trust generation before registration and fails closed on an invalid or stale result. | +| `hostTrustScopes` | list of `system`, `user`; `[system]` | Required and non-empty with `hostTrustMode: overlay`. | Windows/macOS may use `[system, user]`; Linux supports `[system]` only. This is root-anchor inheritance, not exact host TLS-policy emulation. | + +Host trust is a common ephemeral-runner contract, not a Docker Container-only configuration rule. The interactive Docker Container and Docker Sandboxes paths offer it, while the configuration validator applies the same overlay and ephemeral requirements independently of provider type. A host Docker daemon must separately trust a private registry before EPAR can pull an image; the guest overlay cannot repair a failed source-image pull. + +### `pool` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `instances` | integer; `1` | All providers. Must be at least `1`. | Strict cap for provisioning, ready, draining, quarantined, and cleanup-pending local instances. | +| `namePrefix` | 2-40 character name; provider default or wizard-derived machine name plus random suffix | All providers. | Literal prefix for local and GitHub identities. Keep it unique per machine/config and organization. Docker Sandboxes additionally permits only lowercase letters, digits, `-`, and `.`. | +| `vmPrefix` | deprecated alias for `namePrefix` | Existing configs only. | Do not set both aliases with conflicting intent; use `namePrefix` for new configs. | +| `logDir` | deprecated string path; no default | Existing configs only. | Used as `logging.directory` with a warning only when `logging.directory` is absent; using both is rejected. | +| `replacementRetryInitialSeconds` | integer; `15` | All providers. Greater than `0`. | Initial retry delay for transient replacement allocation failures. | +| `replacementRetryMaxSeconds` | integer; `1800` | All providers. At least the initial delay. | Upper retry-delay cap. | +| `replacementRetryMultiplier` | number; `2` | All providers. At least `1`. | Backoff multiplier. | +| `replacementRetryJitterPercent` | integer; `20` | All providers. `0` through `100`. | Randomizes retry delay to avoid synchronized retries. | + +GitHub `429` and `5xx` responses and transient network failures back off replacement allocation; a longer `Retry-After` wins. Invalid configuration, authentication failures, and initial startup remain fail-fast after compensating rollback. + +### `storage` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `minimumFree` | positive byte size; `20GiB` | All providers. | Provider-neutral admission reserve. Docker Sandboxes raises it to `minHostFreeSpace` when that is larger. | +| `gracePeriod` | positive Go duration; `168h` | Conservative housekeeping. | Minimum age before eligible EPAR-owned artifacts can be considered for removal. | +| `keepPrevious` | integer; `0` | Conservative housekeeping. Must be non-negative. | Number of prior reusable artifact generations to retain. | +| `automaticHousekeeping` | `conservative` or `disabled`; `conservative` | All providers. | Conservative mode touches only expired, unleased, exactly owned artifacts; it does not run a broad Docker or WSL prune. | +| `buildCacheLimit` | positive byte size; `64GiB` | Image-building cache. | Bounded EPAR build cache target. | +| `goCacheLimit` | positive byte size; `10GiB` | Native/no-Go Go build cache. | Bounded EPAR Go cache target. | + +### `logging` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `directory` | string; `work/logs` | All providers. Non-empty. | Root for manager, instance, build, error, and benchmark logs. | +| `managerSinks` | non-empty list of `console`, `file`; `[console]` | Manager events. | Choose user-facing manager event destinations. | +| `managerConsoleFormat` | `text` or `json`; `text` | Manager console sink. | Console encoding. | +| `managerConsoleTextFormat` | one-line template; empty | Only when manager console format is `text`. | May use `{time}`, `{level}`, `{message}`, `{attributes}` and must contain `{message}`. | +| `managerFileFormat` | `text` or `json`; `json` | Manager file sink. | File encoding. | +| `transcriptSinks` | non-empty list of `console`, `file`; `[file]` | Raw instance/build transcripts. | Default keeps verbose transcript events out of the console. | +| `transcriptConsoleFormat` | `text` or `json`; `text` | Transcript console sink. | Console encoding. | +| `transcriptConsoleTextFormat` | one-line template; empty | Only when transcript console format is `text`. | May use `{time}`, `{instance}`, `{component}`, `{stream}`, `{message}`, `{session}`, `{category}`, `{provider}`, `{attributes}` and must contain `{message}`. | +| `maxFileSizeMiB` | integer at least `1`; `100` | Rotated logs. | Per-file rotation threshold. | +| `maxBackups` | integer at least `1`; `3` | Rotated logs. | Number of rotated files to retain per stream. | +| `compressBackups` | boolean; `true` | Rotated logs. | Compresses rotated backups. | +| `retentionEnabled` | boolean; `true` | Log retention. | Enables periodic age and total-size retention. | +| `retentionMaxTotalMiB` | integer at least `1`; `1024` | Log retention. | Maximum retained logging size. | +| `managerMaxAgeDays` | integer at least `1`; `14` | Log retention. | Manager-log age limit. | +| `instanceMaxAgeDays` | integer at least `1`; `14` | Log retention. | Instance-transcript age limit. | +| `buildMaxAgeDays` | integer at least `1`; `14` | Log retention. | Build-transcript age limit. | +| `errorMaxAgeDays` | integer at least `1`; `30` | Log retention. | Error-report age limit. | +| `benchmarkMaxAgeDays` | integer at least `1`; `90` | Log retention. | Startup-benchmark age limit. | +| `retentionIntervalMinutes` | integer at least `1`; `60` | Log retention. | Periodic retention interval. | + +### `runner` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `labels` | non-empty list; provider default | All providers. Each label is at most 256 characters. | GitHub Actions routing labels. Keep architecture-sensitive workflows on an explicitly compatible label. | +| `group` | string; empty | Optional organization runner group. | Group must exist and pass the configured runner-group policy. | +| `includeHostLabel` | boolean; `true` | All providers. | Adds sanitized `epar-host-` unless already listed. Set false when workflows must not route by host. | +| `ephemeral` | boolean; `true` | All providers. | Required by Docker Sandboxes and host-trust overlay; each runner accepts one job. | +| `noDefaultLabels` | boolean; `false` | Optional GitHub registration behavior. | Omits GitHub's default self-hosted, OS, and architecture labels, so workflows must use explicitly configured labels. | + +### `security.runnerGroup` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `enforcement` | `warn` or `enforce`; `warn` | All providers; Docker Sandboxes requires `enforce`. | `warn` records a policy failure and continues; `enforce` blocks registration. | +| `requireExplicitGroup` | boolean; `true` | Runner-group preflight. | Requires `runner.group` rather than an implicit default group. | +| `requireNonDefaultGroup` | boolean; `true` | Runner-group preflight. | Rejects the organization default group when enforcement applies. | +| `requiredRepositoryAccess` | `selected`, `private`, or `all`; `selected` | Runner-group preflight. | Maximum allowed repository breadth: selected only, all-private or narrower, or any visibility. | +| `requirePublicRepositoriesDisabled` | boolean; `true` | Runner-group preflight. | Requires the group not to be usable by public repositories. | + +If the complete subsection is absent, EPAR warns and uses the strict recommended checks in `warn` mode. New wizard configurations write an explicit policy. See [Runner Group Security](runner-groups.md). + +### `provider` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `type` | `tart`, `wsl`, `docker-container`, or `docker-sandboxes`; `tart` before provider defaults | Required. | Selects the provider. `docker-socket` is intentionally rejected because EPAR uses a private daemon for Docker Container. | +| `sourceImage` | string; image output for image-building providers, empty for Docker Sandboxes | Required except Docker Sandboxes. | Reusable artifact cloned by Tart, WSL, or Docker Container. Docker Sandboxes rejects it. | +| `network` | string; `default` | Tart image build and runtime. | Tart network mode. Do not assume this configures Docker or Docker Sandboxes networking. | +| `rosettaTag` | simple virtiofs tag; empty | Tart only. | Enables the experimental Tart Rosetta path. Validate each amd64 workload and label it distinctly. | +| `installRoot` | string; `work/wsl` | WSL. | Project-relative WSL distribution storage root. | +| `platform` | Docker platform string; empty except Docker Sandboxes default `linux/amd64` | Docker Container or Docker Sandboxes only. | Docker Sandboxes accepts only `linux/amd64` or `linux/arm64`; it also determines the default architecture label. | + +### `docker` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `registryMirrors` | list of root `http`/`https` URLs; empty | Private Docker daemon users. | Mirrors must not include credentials, query, fragment, or a non-root path. See [Docker Registry Mirrors](advanced/docker-registry-mirrors.md). | +| `httpProxy` | root `http`/`https` URL; empty | Private Docker daemon users. | Becomes `HTTP_PROXY` for the outer Docker Container runner and its inner daemon. Credentials are rejected. | +| `httpsProxy` | root `http`/`https` URL; empty | Private Docker daemon users. | Becomes `HTTPS_PROXY`; credentials are rejected. | +| `noProxy` | comma-separated host/domain/IP/CIDR/`*`; empty | Private Docker daemon users. | Becomes `NO_PROXY`; whitespace, URLs, credentials, empty entries, and invalid CIDRs are rejected. | + +### `dockerSandboxes` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `template` | immutable local template identity; no default | Required with Docker Sandboxes. | Exact Candidate A template selected by the wizard; raw Catthehacker images are not runnable templates. | +| `templateDigest` | lowercase `sha256:<64-hex>`; no default | Required with Docker Sandboxes. | Full local OCI configuration digest for the template identity. | +| `policyGeneration` | lowercase `sha256:<64-hex>`; no default | Required with Docker Sandboxes. | Fingerprint of the verified host-global Balanced policy. Policy drift blocks admission. | +| `networkBaseline` | `open` or `balanced`; `open` | Docker Sandboxes. | `open` adds a sandbox-scoped public-egress rule while denying host aliases; it does not change the host-global policy. | +| `additionalAllow` | unique hostname or `*.domain`, optional port; empty | Docker Sandboxes. | Adds sandbox-scoped allow resources. With `open`, it cannot re-allow EPAR's host-alias deny guardrails. | +| `additionalDeny` | unique hostname or `*.domain`, optional port; empty | Docker Sandboxes. | Adds sandbox-scoped deny resources. A resource cannot be in both allow and deny lists. | +| `stagingRoot` | canonical project-relative `.local/...` path; `.local/docker-sandboxes-staging` | Docker Sandboxes. | Per-create staging root; cannot be absolute, escape `.local`, or overlap `.local/bin` or `.local/state`. | +| `cpus` | positive integer; `4` | Docker Sandboxes. | CPU allocation for each sandbox. | +| `memory` | positive byte size; `8GiB` | Docker Sandboxes. | Per-sandbox memory allocation written by the wizard. | +| `rootDisk` | byte size at least `20GiB`; no schema default | Docker Sandboxes. | Guest root capacity. Wizard sizing uses measured guest use plus margin/headroom when evidence exists. | +| `dockerDisk` | byte size at least `100GiB`; no schema default | Docker Sandboxes. | Inner Docker disk capacity. | +| `minHostFreeSpace` | byte size at least `50GiB`; no schema default | Docker Sandboxes. | Host admission floor; effective reserve is the larger of this and `storage.minimumFree`. Runtime may require a stricter backing-volume percentage. | +| `maxConcurrentCreates` | positive integer; `2` | Docker Sandboxes. | Limits concurrent sandbox creation to control capacity pressure. | + +### `timeouts` + +| Property | Type and default | Required or applies when | Effect and caution | +| --- | --- | --- | --- | +| `bootSeconds` | integer; `180` | All providers. | Time allowed for instance boot/readiness. | +| `githubOnlineSeconds` | integer; `180` | All providers. | Time allowed for GitHub runner online readiness. | +| `commandSeconds` | integer; `900` | All providers. | Default bound for provider command execution. | + +## Cross-field rules + +- `provider.sourceImage` is required for Tart, WSL, and Docker Container, and forbidden for Docker Sandboxes. +- `provider.rosettaTag` is accepted only for Tart. `provider.platform` is accepted only for Docker Container or Docker Sandboxes. Docker Sandboxes accepts only `linux/amd64` and `linux/arm64`. +- Docker Sandboxes requires `runner.ephemeral: true`, `security.runnerGroup.enforcement: enforce`, a valid pinned template/digest/policy generation, resource values, and a lowercase-compatible pool prefix. +- `image.sourcePlatform` requires `image.sourceType: docker-image`; all byte-size fields require a positive `B`, `KiB`, `MiB`, `GiB`, or `TiB` value. +- Host-trust overlay requires a non-empty, duplicate-free scope list and `runner.ephemeral: true`; `user` is not supported on Linux. +- `pool.namePrefix` is an ownership boundary. Tart, WSL, and Docker Container use the configured prefix to select legacy owned resources; Docker Sandboxes uses its durable ledger of exact owned identities. Do not share a prefix between controllers or assume broad prefix cleanup is safe. +- `runner.labels` must never be empty, even when `runner.noDefaultLabels` is false. + +## Provider defaults + +The configuration loader starts with Tart defaults, then applies provider-specific defaults for WSL, Docker Container, and Docker Sandboxes only when the corresponding key was not set explicitly. The first-run wizard writes a concrete configuration and derives a machine-based pool prefix; use its generated values as the normal starting point. + +| Provider | Source and output | Default labels and prefix | +| --- | --- | --- | +| Docker Container | Catthehacker full Ubuntu to `epar-docker-container-catthehacker-ubuntu`. | `self-hosted`, `linux`, `epar-docker-container-catthehacker-ubuntu`; prefix `epar-docker-container`. | +| Docker Sandboxes | Pinned Candidate A template, digest, and policy generation; no `provider.sourceImage`. | `self-hosted`, `linux`, matching `X64`/`ARM64`, `epar-docker-sandboxes`; prefix `epar-docker-sandboxes`. | +| WSL Docker source | Catthehacker full Ubuntu, `linux/amd64`, output `work/images/epar-wsl-catthehacker-ubuntu.tar`. | `self-hosted`, `linux`, `X64`, `epar-wsl-catthehacker-ubuntu`; prefix `epar-wsl`. | +| WSL rootfs tar | `work/images/ubuntu-24.04-clean.rootfs.tar`, output `work/images/epar-ubuntu-24-wsl.tar`. | `self-hosted`, `linux`, `X64`, `epar-wsl-ubuntu-24.04-base`; prefix `epar-wsl`. | +| Tart | `ghcr.io/cirruslabs/ubuntu:latest` to `epar-ubuntu-24-arm64`. | `self-hosted`, `linux`, `ARM64`, `epar-tart-ubuntu-24.04-base`; prefix `epar`. | -## Common Edits +## Short recipes -Change how many runners stay online: +Keep two Docker Container runners warm: ```yaml pool: instances: 2 -``` - -Set a unique instance name prefix for each machine/config in the same GitHub organization: - -```yaml -pool: namePrefix: buildbox01-a4f9c2 ``` -The first-run wizard derives `pool.namePrefix` from the sanitized machine name and a six-character cryptographically random hexadecimal suffix, making it recognizable while reducing collision risk when multiple EPAR configurations are created on the same host. `pool.namePrefix` is the literal beginning of every generated local instance and GitHub runner name. It must be 2-40 characters; the shared name generator appends `-YYYYMMDD-HHMMSS-###`, so a generated name is at most 60 characters and remains within GitHub's runner-name limit and the Docker Sandboxes 63-character limit. Do not reuse the same prefix on different machines or for separate EPAR supervisors in the same organization. Legacy-provider cleanup uses this boundary, while Docker Sandboxes binds and removes exact identities through its ledger. A shared prefix can therefore let one legacy supervisor delete another machine's GitHub runner record and makes every provider's ownership ambiguous. - -Configure replacement retry behavior after a transient GitHub or network outage: - -```yaml -pool: - replacementRetryInitialSeconds: 15 - replacementRetryMaxSeconds: 1800 - replacementRetryMultiplier: 2 - replacementRetryJitterPercent: 20 -``` - -These values default to `15`, `1800`, `2`, and `20`, so existing configurations remain valid without changes. `replacementRetryInitialSeconds` must be positive, `replacementRetryMaxSeconds` must be at least the initial delay, `replacementRetryMultiplier` must be at least `1`, and `replacementRetryJitterPercent` must be from `0` through `100`. - -Configure capacity and bounded retention: - -```yaml -storage: - minimumFree: 20GiB - gracePeriod: 168h - keepPrevious: 0 - automaticHousekeeping: conservative - buildCacheLimit: 64GiB - goCacheLimit: 10GiB -``` - -Existing configurations use these defaults without requiring a new section. See [Storage](storage.md) for reporting and exact cleanup behavior. - -The supervisor backs off only replacement allocation after transient network errors and GitHub HTTP `429` or `5xx` responses. The nominal delay doubles from 15 seconds to a 30-minute cap with the configured jitter; a longer GitHub `Retry-After` response takes precedence. Authentication and deterministic configuration failures remain fail-fast after safe rollback. Initial `pool up` startup also remains fail-fast rather than entering an unattended retry loop. - -`pool.instances` is an absolute local physical-instance cap, not only an online-runner target. Provisioning, ready, draining, quarantined, and cleanup-pending instances all count toward it. Host-trust generation rotation does not receive surge capacity: an old busy runner keeps its slot until it exits or is safely removed. - -Add or change workflow labels: - -```yaml -runner: - labels: - - self-hosted - - linux - - epar-docker-container-catthehacker-ubuntu -``` - -Disable the automatic host-machine label: +Use an explicit runner group with enforced least-breadth access: ```yaml runner: - includeHostLabel: false -``` - -Register runners in an organization runner group and omit GitHub's automatic `self-hosted`, operating-system, and architecture labels: - -```yaml -runner: - group: your-runner-group - labels: [epar-core-unique-label] - includeHostLabel: false - noDefaultLabels: true - + group: trusted-ci security: runnerGroup: enforcement: enforce @@ -126,102 +224,25 @@ security: requirePublicRepositoriesDisabled: true ``` -The group must already exist and allow the target repository to use it. `requiredRepositoryAccess` is a maximum breadth: `selected` allows only selected-repository groups, `private` also allows all-private groups, and `all` accepts any repository visibility. The public-repository requirement remains independent. Existing configs without `security.runnerGroup` use strict recommended requirements in `warn` mode; new wizard configs use `enforce`. See [Runner Group Security](runner-groups.md) for default-group choices, enterprise inheritance, overrides, and failure behavior. - -`runner.noDefaultLabels` defaults to `false`; when it is `true`, workflows must target labels explicitly configured under `runner.labels` and may also target the runner group. - -Use a different config file: - -```bash -go run ./cmd/ephemeral-action-runner start --config .local/wsl.yml -``` - -Configure logging and retention in the top-level `logging` section. The complete schema and local/Kubernetes examples are in [Logging](logging.md). Unknown configuration keys are rejected. For compatibility, a legacy `pool.logDir` value is used as `logging.directory` with a migration warning when the new key is absent; the file is not rewritten automatically. A configuration containing both keys is rejected as ambiguous. - -### Host trust inheritance - -Docker Container runners can inherit the host's trusted TLS root anchors: +Add a host and explicit enterprise root without weakening TLS verification: ```yaml image: hostTrustMode: overlay hostTrustScopes: [system, user] + trustedCaCertificatePaths: [.local/enterprise-root.pem] ``` -`image.hostTrustMode` accepts `disabled` or `overlay`. Existing configs default to `disabled`. A new interactive Docker Container initialization asks whether to enable host trust inheritance; pressing Enter accepts the displayed `yes` default. Enabling the policy is the one-time consent for EPAR to follow later host root additions, removals, and rotations automatically. - -The supported scopes are: - -| Controller host | `system` | `user` | -| --- | --- | --- | -| Windows | Local-machine trusted roots, excluding Windows-disallowed certificates | Current-user trusted roots, excluding Windows-disallowed certificates | -| macOS | System Roots plus CA certificates explicitly trusted for TLS server use in the administrator domain, excluding explicit deny | CA certificates in the user's keychain search list explicitly trusted for TLS server use, excluding explicit deny | -| Linux | The distribution's generated system CA bundle | Not supported | - -Use `[system, user]` on Windows or macOS when the runner should inherit the -same two trust scopes as the account running EPAR. Linux configs must use -`[system]`. Overlay mode is supported only for `provider.type: docker-container` and -requires `runner.ephemeral: true`. -If macOS has disabled user-level Trust Settings, the `user` scope contributes -no certificates until that host policy is enabled again. - -The resulting Ubuntu runner trust is additive: - -```text -Ubuntu default roots -+ host roots from the current EPAR generation -+ image.trustedCaCertificatePaths -``` - -This is root-anchor inheritance, not exact emulation of Windows or macOS TLS -policy. macOS positive trust settings constrained by hostname, application, or -allowed error are not promoted into Ubuntu's unconstrained global root store. -Removing a host root does not remove an independently bundled Ubuntu root or a -certificate still listed under `trustedCaCertificatePaths`. +On Linux, use `hostTrustScopes: [system]`. Do not disable certificate verification to work around a private CA. -### Explicit CA paths - -Trust an additional enterprise TLS inspection or private package-registry CA: - -```yaml -image: - trustedCaCertificatePaths: - - .local/enterprise-root.pem -``` - -Paths may be repository-relative, absolute, or under `~/`. EPAR validates PEM -or DER X.509 CA certificates before building, normalizes them to deterministic -`.crt` files, and installs them before any `apt` or `curl` step. These paths are -independent of host trust inheritance and remain trusted until removed from the -config. - -Route the private Docker daemon through an enterprise network proxy: +Configure a private Docker proxy and mirror without embedding credentials: ```yaml docker: + registryMirrors: [https://mirror.example.test] httpProxy: http://proxy.example.test:3128 httpsProxy: http://proxy.example.test:3128 noProxy: localhost,127.0.0.1,.example.test ``` -These optional values become `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` on the -outer runner container, so its inner `dockerd` inherits them at first -startup. Proxy URLs must not contain credentials. Keep machine-specific proxy -addresses in ignored `.local/config.yml`, not tracked example files. - -## Provider Defaults - -For `provider.type: docker-container`, EPAR defaults to Catthehacker's full Ubuntu runner image and creates a Docker Container image named `epar-docker-container-catthehacker-ubuntu`. - -For `provider.type: docker-sandboxes`, EPAR rejects `provider.sourceImage` and requires an explicitly built and loaded Candidate A template plus its full OCI configuration digest, verified policy fingerprint, staging root, and resource limits. The missing-config wizard offers only the active lock-selected source channels for the host platform: Catthehacker `full-latest` (recommended) and `act-22.04` (current lean profile). Each source channel maps to one exact versioned EPAR template tag; raw Catthehacker images cannot be used directly. The wizard filters historical cached EPAR tags, writes the selected template tag and full local digest to configuration, and does not refresh the approved source-lock snapshot automatically. It reports the shared host template-cache size separately from per-sandbox storage and asks one capacity question. With matching capacity evidence, it derives the total `rootDisk` from measured guest `/` use, a 25% margin, and the recommended 20 GiB writable headroom, then asks for Docker-disk capacity. Without exact root evidence, it asks for provisional total root capacity and writes the Docker-disk default. It writes the 50 GiB host-free floor in both cases; runtime admission strengthens this to at least 10% of the backing volume. Configuration validation enforces a 20 GiB minimum root capacity independently from the 100 GiB Docker-disk minimum. A configured Docker Sandboxes pool never falls back to Docker Container. See the [Docker Sandboxes provider](providers/docker-sandboxes.md) and the tracked example. - -For `provider.type: wsl`, EPAR defaults to Catthehacker's full Ubuntu runner image, converts it into a WSL rootfs, and stores the output under `work/images/`. - -For the experimental `provider.type: tart`, EPAR defaults to `ghcr.io/cirruslabs/ubuntu:latest`, a basic Ubuntu ARM64 VM image. EPAR installs its runner lifecycle but does not add the broad tool and dependency set found in GitHub's hosted runner images. If you require a GitHub-runner-like environment, build and maintain a bootable Tart image yourself by adapting the scripts in [actions/runner-images](https://github.com/actions/runner-images), then set `image.sourceImage` to that Tart image. Rosetta-based amd64 execution also has compatibility limits and must be validated against the exact workflow. - -See the provider docs for details: - -- [Docker Container Provider](providers/docker-container.md) -- [Docker Sandboxes Provider](providers/docker-sandboxes.md) -- [WSL Provider](providers/wsl.md) -- [Tart Provider](providers/tart.md) +For Docker Sandboxes, run `./start` and let the wizard write the pinned template, digest, policy fingerprint, and capacity settings. Do not hand-copy a template tag from another host or replace its digest with a mutable tag. diff --git a/docs/core-runner-verification.md b/docs/core-runner-verification.md deleted file mode 100644 index 7d7386c..0000000 --- a/docs/core-runner-verification.md +++ /dev/null @@ -1,154 +0,0 @@ -# Level 1 Core Runner Verification - -The `Core runner verification` GitHub Actions workflow proves EPAR's central contract against GitHub: create an isolated Docker Container runner, register it for one job, replace it after that job, run a second job on the replacement, and clean up the runner records and outer containers. - -This is an infrastructure canary, not a language or framework compatibility -matrix. The workload checks checkout and artifact transfer, then exercises the -nested Docker daemon with Buildx, Docker Compose, a health check, and an HTTP -request. - -## Architecture - -The workflow has three jobs: - -1. `Core runner controller` runs on a fresh, trusted GitHub-hosted runner. It - builds EPAR and the pinned lightweight core image, pre-cleans the - `epar-ci-core` boundary, and supervises one ephemeral runner. -2. `Core canary 1` runs on that ephemeral runner, validates its basic runtime, - and uploads its runner name and a nonce. -3. `Core canary 2` waits for the first job, runs on the replacement runner, - verifies that the runner name changed, downloads the artifact, and exercises - Buildx and Compose. - -The controller reads the workflow-job records to confirm that both canaries -used the expected group and unique per-run label, ran on distinct -`epar-ci-core-*` runners, and succeeded. Workflow concurrency is serialized -because every run intentionally shares the fixed cleanup prefix. - -## Required GitHub Setup - -### GitHub-hosted controller - -The controller uses the standard GitHub-hosted `ubuntu-latest` runner. No -pre-existing self-hosted controller is required. Each run receives a fresh -Linux VM with Docker, Bash, curl, jq, and GNU `timeout`; the workflow installs -the Go version declared by `go.mod` before building EPAR. - -### Restricted ephemeral-runner group - -In the organization settings, create a runner group named `epar-ci-canary` and restrict its repository access to `solutionforest/ephemeral-action-runner`. - -EPAR registers the temporary runners in this group with no GitHub default labels and only a per-run label such as `epar-core-123456-1`. The canary jobs target both the group and that unique label, so unrelated self-hosted runners cannot accept them. - -This repository is public, so its canary config explicitly sets only `security.runnerGroup.requirePublicRepositoriesDisabled: false`. It keeps enforcement, explicit naming, non-default-group use, and selected-repository access enabled. This narrow exception depends on the trusted controller and protected workflow conditions below and is not the recommended deployment model for public repositories. - -### GitHub App and protected environment - -The GitHub App must be installed in the target organization and have organization self-hosted-runner read/write permission. Create a GitHub Actions environment named `epar-live-ci`, choose **Selected branches and tags**, allow `develop`, `main`, and `refs/pull/*/merge`, and add: - -| Kind | Name | Value | -| --- | --- | --- | -| Environment secret | `EPAR_GITHUB_APP_PRIVATE_KEY` | The complete PEM private key generated for the GitHub App. | -| Environment variable | `EPAR_GITHUB_APP_ID` | The numeric App ID. | -| Environment variable | `EPAR_GITHUB_ORGANIZATION` | The organization login, for example `solutionforest`. | - -Keep the PEM as an environment secret, including its original line breaks. Do -not store it in repository variables, workflow YAML, a tracked config file, or -an artifact. The workflow materializes it in a mode-restricted temporary file -on the controller and removes that file during cleanup. - -Requiring an environment reviewer is possible, but every matching push and same-repository pull request will wait for that approval. - -## Trust Boundaries and Triggers - -The controller job is privileged and secret-bearing. It receives the GitHub App key and a workflow token with Actions write permission, and it can start privileged containers in its disposable GitHub-hosted VM. It runs only for trusted repository changes: pushes to `develop` or `main`, and pull requests whose source branch is in this repository. Fork pull requests still trigger the workflow, but all three core-verification jobs skip before they can receive environment secrets or run on the canary group. - -The two canary jobs do not receive the GitHub App key. They receive only the -minimum workflow permissions needed for checkout and artifact operations, and -their Docker workloads run in the private daemon inside a disposable outer -container. - -The workflow runs on: - -- pull requests targeting `develop` or `main` (fork pull requests still trigger this workflow, but core-verification jobs run only when the source branch belongs to this repository) -- pushes to `develop` or `main` -- manual `workflow_dispatch` after the workflow is present on the repository's default branch - -The workflow does not use `pull_request_target`. Keep the same-repository job guard on every core-verification job so code from a forked pull request cannot reach the controller, environment secrets, or privileged Docker host. - -## Expected Result and Cleanup - -A successful run reports both canary runner names in the job summary. They must -be different, begin with `epar-ci-core-`, belong to `epar-ci-canary`, and carry -the run's `epar-core--` label. The second canary must also pass -the artifact, Buildx, Compose, container health, and HTTP checks. - -The controller performs cleanup before and after the canaries. It stops the -pool supervisor, deletes GitHub runner registrations within the exact -`epar-ci-core` prefix boundary, removes matching outer runner containers, -and deletes its temporary key, generated config, and logs. Before failed-run -cleanup deletes those logs, the controller prints a sanitized final 200 lines -from the pool-supervisor log and each available runner log. Runner launch or -online readiness failures first append bounded process state, `run.log`, -latest `Runner_*.log`, and private Docker daemon tails to the host guest log, so -those diagnostics pass through the same sanitizer before cleanup. A controller -failure then attempts cleanup before canceling the workflow so queued canary -jobs do not remain indefinitely. The next run also pre-cleans the same boundary. - -A sudden controller failure can bypass application cleanup. GitHub discards the -hosted VM and its Docker containers, while the next run pre-cleans stale GitHub -runner registrations within the same prefix boundary. - -## Troubleshooting - -### Controller job remains queued - -- Confirm the organization and repository allow standard GitHub-hosted - runners. -- Check GitHub Actions service status and the account's hosted-runner - concurrency. - -### Controller starts, but canaries remain queued - -- Open the controller log and check image-build errors plus the grouped, - sanitized pool-supervisor and runner-log tails printed before cleanup. -- Confirm `epar-ci-canary` exists with that exact spelling and permits this - repository. -- Confirm the GitHub App is installed in the organization and can administer - organization self-hosted runners. -- Confirm the environment's App ID is numeric, organization value is the login - rather than a display name, and the private-key secret contains the complete - PEM. -- In the organization runner list, look for an online - `epar-ci-core-*` runner carrying the unique label shown in the workflow log. - -### Image build or Docker workload fails - -- Check controller disk space and Docker health. -- Confirm outbound access to the pinned Catthehacker and BusyBox images. -- For nested-Docker startup failures, inspect the grouped Docker Container runner-log tail in the controller output. - -### Workflow is canceled after a controller error - -This is expected failure behavior. The controller cancels the run after its -bounded wait or another fatal orchestration error so unmatched canary jobs do -not stay queued. Diagnose the first controller error rather than treating the -cancellation itself as the root cause. - -## Manual Cleanup - -Use a local, untracked config based on -`configs/docker-container.core.example.yml`, with the same organization, GitHub App -key path, and `pool.namePrefix: epar-ci-core`, then run: - -```bash -go run ./cmd/ephemeral-action-runner cleanup \ - --config .local/core-cleanup.yml \ - --project-root . -``` - -This removes matching organization runner records. Docker containers from the -controller run existed only on its disposable GitHub-hosted VM. If cleanup -still reports an error, remove remaining `epar-ci-core-*` records from the -organization's Actions runner settings. Do not use a broader prefix: EPAR -cleanup is deliberately bounded to `epar-ci-core` and `epar-ci-core-*`. diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 0000000..0deebdd --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,11 @@ +# Development + +These documents are for contributors and maintainers. For installing, configuring, or operating EPAR, start with the [user documentation](../). + +- [Development and Extension Principles](principles.md) defines the product-wide contracts that changes must preserve. +- [Design](design.md) explains the provider-neutral controller and lifecycle responsibilities. +- [Adding a Provider](adding-provider.md) is the implementation and validation checklist for a new provider. +- [Core Runner Verification](core-runner-verification.md) documents the privileged live CI canary. +- [Releases](releases.md) documents the source-only release process. + +Read [Contributing](../../CONTRIBUTING.md) before opening a pull request. diff --git a/docs/providers/adding-provider.md b/docs/development/adding-provider.md similarity index 92% rename from docs/providers/adding-provider.md rename to docs/development/adding-provider.md index 5084b4c..5b3c68e 100644 --- a/docs/providers/adding-provider.md +++ b/docs/development/adding-provider.md @@ -1,6 +1,6 @@ # Adding A Provider -Read [Development and Extension Principles](../development-principles.md) and [Design](../design.md). +Read [Development and Extension Principles](principles.md) and [Design](design.md). Put provider commands and host integration in `internal/provider/`. Shared onboarding, naming, image, pool lifecycle, GitHub, state, capacity, and retention behavior stays in its common package. diff --git a/docs/development/core-runner-verification.md b/docs/development/core-runner-verification.md new file mode 100644 index 0000000..64d11ea --- /dev/null +++ b/docs/development/core-runner-verification.md @@ -0,0 +1,98 @@ +# Core Runner Verification + +The **Core runner verification** workflow proves EPAR's central contract against GitHub: create an isolated Docker Container runner, register it for one job, replace it after that job, run a second job on the replacement, and clean up the runner records and outer containers. + +This is an infrastructure canary, not a language or framework compatibility matrix. Its workload checks checkout and artifact transfer, then exercises the private Docker daemon with Buildx, Docker Compose, a health check, and an HTTP request. + +## Architecture + +The workflow has three jobs: + +1. **Core runner controller** runs on a fresh trusted GitHub-hosted runner, builds EPAR and the pinned lightweight image, pre-cleans the `epar-ci-core` boundary, and supervises one ephemeral runner. +2. **Core canary 1** runs on that runner, validates its runtime, and uploads its runner name and a nonce. +3. **Core canary 2** runs on the replacement, confirms the runner name changed, downloads the artifact, and exercises Buildx and Compose. + +The controller confirms that both canaries used the expected group and per-run label, ran on distinct `epar-ci-core-*` runners, and succeeded. Workflow concurrency is serialized because every run shares the fixed cleanup prefix. + +## Required GitHub Setup + +### Controller + +The controller uses GitHub's `ubuntu-latest` runner. No persistent self-hosted controller is required. + +### Runner Group + +Create an organization runner group named `epar-ci-canary` and restrict it to `solutionforest/ephemeral-action-runner`. + +The workflow registers temporary runners with no GitHub default labels and only a per-run label such as `epar-core-123456-1`. Canary jobs target both the group and that unique label. + +This repository is public, so the canary config deliberately sets `security.runnerGroup.requirePublicRepositoriesDisabled: false` while retaining enforcement, explicit naming, non-default-group use, and selected-repository access. This narrow exception is for the protected canary only and is not the recommended public-repository deployment model. + +### GitHub App And Protected Environment + +Install a GitHub App with organization self-hosted-runner read/write permission. Create an environment named `epar-live-ci`, restrict it to `develop`, `main`, and `refs/pull/*/merge`, and add: + +| Kind | Name | Value | +| --- | --- | --- | +| Environment secret | `EPAR_GITHUB_APP_PRIVATE_KEY` | Complete GitHub App PEM private key | +| Environment variable | `EPAR_GITHUB_APP_ID` | Numeric App ID | +| Environment variable | `EPAR_GITHUB_ORGANIZATION` | Organization login | + +Keep the PEM in the environment secret with its original line breaks. The workflow writes it to a restricted temporary file on the disposable controller and removes it during cleanup. + +## Trust Boundaries And Triggers + +The controller is privileged and secret-bearing. It receives the GitHub App key and an Actions write token, and it starts privileged containers in its disposable GitHub-hosted VM. + +The controller and canaries run only for trusted repository changes: pushes to `develop` or `main`, same-repository pull requests targeting those branches, and manual dispatch after the workflow exists on the default branch. Fork pull requests trigger the workflow but skip all three verification jobs before they can receive environment secrets or use the canary group. + +The workflow does not use `pull_request_target`. Keep the same-repository guard on every verification job. + +The canaries never receive the GitHub App key. They receive only the permissions needed for checkout and artifact operations. + +## Expected Result And Cleanup + +A successful run reports two different runner names beginning with `epar-ci-core-`. Both belong to `epar-ci-canary`, carry the run's unique label, and complete successfully. The second also passes the artifact, Buildx, Compose, health, and HTTP checks. + +The controller cleans before and after the canaries. It stops the supervisor, deletes GitHub runner registrations within the exact `epar-ci-core` prefix boundary, removes matching outer containers, and deletes its temporary key, config, and logs. + +Before failed-run cleanup removes logs, the controller prints sanitized bounded tails from the supervisor and runner logs. A controller failure attempts cleanup before canceling the workflow so unmatched canary jobs do not remain queued. + +A sudden controller loss can bypass application cleanup. GitHub discards the hosted VM and its containers; the next run pre-cleans stale GitHub runner registrations within the same prefix. + +## Troubleshooting + +### Controller Remains Queued + +- Confirm the repository can use standard GitHub-hosted runners. +- Check GitHub Actions service status and hosted-runner concurrency. + +### Canaries Remain Queued + +- Check the controller's sanitized supervisor and runner-log tails. +- Confirm `epar-ci-canary` exists and permits this repository. +- Confirm the GitHub App is installed and can manage organization runners. +- Confirm the environment values contain the numeric App ID, organization login, and complete PEM. +- Check for an online `epar-ci-core-*` runner carrying the unique label shown in the workflow. + +### Image Or Docker Workload Fails + +- Check controller disk space and Docker health. +- Confirm outbound access to the pinned Catthehacker and BusyBox images. +- Inspect the grouped Docker Container runner-log tail in the controller output. + +### Workflow Is Canceled + +Cancellation after a controller error is expected. Diagnose the first controller error; cancellation prevents unmatched canary jobs from remaining queued. + +## Manual Cleanup + +Create an untracked config based on `configs/docker-container.core.example.yml` with the same organization, GitHub App key path, and `pool.namePrefix: epar-ci-core`, then run: + +```bash +go run ./cmd/ephemeral-action-runner cleanup \ + --config .local/core-cleanup.yml \ + --project-root . +``` + +This removes matching organization runner records. The controller's Docker containers existed only on its discarded hosted VM. If cleanup still fails, remove remaining `epar-ci-core-*` records from the organization's Actions runner settings. Do not use a broader prefix. diff --git a/docs/design.md b/docs/development/design.md similarity index 97% rename from docs/design.md rename to docs/development/design.md index b8f0d57..735a549 100644 --- a/docs/design.md +++ b/docs/development/design.md @@ -40,4 +40,4 @@ Each provider reports the storage surfaces and temporary expansion required by b Artifacts are classified as active, current reusable, superseded EPAR-owned, incomplete temporary, or shared/unknown. Conservative housekeeping may remove only expired, unleased, exactly owned local artifacts and bounded dedicated caches. Removing Docker images, volumes, imported templates, WSL distributions, or Tart images requires an explicit previewed storage-prune operation. -See [Adding a Provider](providers/adding-provider.md) for the extension checklist. +See [Adding a Provider](adding-provider.md) for the extension checklist. diff --git a/docs/development-principles.md b/docs/development/principles.md similarity index 100% rename from docs/development-principles.md rename to docs/development/principles.md diff --git a/docs/development/releases.md b/docs/development/releases.md new file mode 100644 index 0000000..15ca590 --- /dev/null +++ b/docs/development/releases.md @@ -0,0 +1,23 @@ +# Releases + +EPAR releases are manually dispatched from GitHub Actions and contain no uploaded binaries. GitHub automatically provides **Source code (zip)** and **Source code (tar.gz)** for each release tag. + +## Create A Release + +1. Create and push an annotated tag: + + ```bash + git tag -a v0.1.0-beta.1 -m "v0.1.0-beta.1" + git push origin v0.1.0-beta.1 + ``` + +2. Confirm immutable releases are enabled in the repository settings. +3. Open **Actions**, choose **Release**, and run the workflow. +4. Enter an existing remote tag matching `[v]MAJOR.MINOR.PATCH` or `[v]MAJOR.MINOR.PATCH-(alpha|beta|rc).N`. +5. Type `publish source-only release` exactly. + +The workflow verifies the tag, confirms its commit is reachable from `origin/main`, checks out and tests that commit, and refuses to overwrite an existing release. Alpha, beta, and release-candidate tags are published as prereleases and are not marked latest. + +## Promote A Prerelease + +To promote a prerelease without changing its commit, create the stable tag at the same commit and provide the existing prerelease tag in `promotion_from`. The workflow verifies that both tags have the same normalized version core and point to the same commit, then creates stable promotion notes instead of a generated change summary. diff --git a/docs/image-build.md b/docs/image-build.md index 728a866..70c0286 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -1,160 +1,31 @@ -# Image Build +# Image Customization -EPAR builds a reusable Ubuntu runner image for the selected provider. The image contains the GitHub Actions runner plus whatever tools you choose through install scripts. - -For Tart, the image build has two image names: - -- `image.sourceImage`: clean upstream VM image, default `ghcr.io/cirruslabs/ubuntu:latest`. -- `image.outputImage`: reusable runner base image, default `epar-ubuntu-24-arm64`. - -These are Tart VM image names. They are stored in Tart's local VM registry and are visible with `tart list`; they are not emitted as repository-local files. - -> [!WARNING] -> Tart support is experimental, and its default source is a basic Ubuntu ARM64 OS image rather than a GitHub-hosted runner image. It does not include the usual dependency inventory from [`actions/runner-images`](https://github.com/actions/runner-images). For a GitHub-runner-like Tart environment, adapt those upstream build scripts to produce and maintain your own bootable Tart VM image, then configure EPAR to use it; EPAR does not automate that image conversion. - -For WSL, the image build produces a rootfs tar. It can start from either a Docker image or an existing rootfs tar: - -- `image.sourceType`: `docker-image` or `rootfs-tar`, default `docker-image` for WSL. -- `image.sourceImage`: source Docker image or rootfs tar, default `ghcr.io/catthehacker/ubuntu:full-latest`. -- `image.sourcePlatform`: Docker platform used when `sourceType` is `docker-image`, default `linux/amd64`. -- `image.outputImage`: reusable EPAR runner rootfs tar, default `work/images/epar-wsl-catthehacker-ubuntu.tar`. - -For `docker-image` sources, EPAR pulls the source image, creates a temporary container, exports that container filesystem to an intermediate rootfs tar, and captures the image environment metadata. Later builds reuse the intermediate rootfs only when the cached source manifest still matches the source image, platform, and digest. The WSL build then imports the rootfs into a temporary distro, enables systemd, installs the runner runtime, writes the captured image env under `/opt/epar`, validates it, exports the reusable tar, and unregisters the temporary distro. Pool instances import from `provider.sourceImage`, which should point at the built reusable tar. - -For Docker Container, the image build uses Docker images: - -- `image.sourceType`: `docker-image`. -- `image.sourceImage`: maintained Catthehacker Ubuntu runner image, default `ghcr.io/catthehacker/ubuntu:full-latest`. -- `image.outputImage`: reusable EPAR runner container image tag, default `epar-docker-container-catthehacker-ubuntu`. - -Docker Container builds a thin wrapper over the source image, installs the GitHub Actions runner, reuses Docker Engine/CLI/Compose/Buildx from the base image when they are already present, adds a container entrypoint that starts `dockerd`, then runs configured install scripts and validation. Pool instances are privileged containers created from `provider.sourceImage`, which should match the built reusable Docker image tag. - -Every build writes an EPAR image manifest. The `start` command compares that manifest with the current config and source image identity before creating runners. If the image is missing, has no manifest, or no longer matches, `start` rebuilds it with replace enabled. The lower-level `image build` command keeps its explicit safety behavior and still requires `--replace` when the output already exists. - -```mermaid -flowchart TD - Source["Clean Ubuntu source
Tart image, WSL source, or Docker image"] --> Build["Temporary build instance or Docker build"] - Build --> Scripts["EPAR guest scripts"] - Scripts --> Runner["GitHub Actions runner"] - Runner --> WSLFull["WSL Docker-source env and Docker validation"] - WSLFull --> DockerContainer["Docker Container private-daemon runtime"] - DockerContainer --> Rosetta["Optional Tart Rosetta layer"] - Rosetta --> Custom["Optional custom install scripts"] - Custom --> Validate["Runtime validation"] - Validate --> Output["Reusable runner image"] - Output --> Pool["Disposable pool instances"] -``` - -## Image Install Scripts - -Several layers control what is pre-installed in the Ubuntu image: - -1. `image.hostTrustMode: overlay`, when enabled for Docker Container, snapshots and installs the controller host's trusted root anchors. -2. `image.trustedCaCertificatePaths`, when configured, installs additional explicitly trusted enterprise CA certificates. -3. EPAR installs both CA sources before guest install steps access the network. -4. `/opt/epar/install-base.sh` is intentionally lean. It does not install Docker, browsers, language runtimes, or project tools. -5. `/opt/epar/install-runner.sh` always installs the GitHub Actions runner. -6. WSL builds from Docker-image sources validate Docker Engine from the base image and preserve source image environment metadata for runner jobs. -7. Docker Container builds validate or install Docker Engine and add the private `dockerd` entrypoint. -8. Tart builds with `provider.rosettaTag` install the optional Rosetta amd64 container layer. -9. `image.customInstallScripts` adds optional tool layers. +EPAR prepares a reusable runner artifact before it creates disposable instances. The artifact differs by provider: Docker Container uses a Docker image, WSL2 uses a rootfs tar, Tart uses a Tart VM image, and Docker Sandboxes uses a separately built and loaded template. ```mermaid flowchart LR - Base["Runner-only base"] --> Runner["GitHub Actions runner"] - Runner --> WSLFull["WSL Docker-source env and Docker validation"] - WSLFull --> DockerContainer["Docker Container private-daemon runtime"] - DockerContainer --> Rosetta["Optional Tart Rosetta layer"] - Rosetta --> Optional["customInstallScripts"] - Optional --> Docker["Docker/browser layer"] - Optional --> Web["web/E2E layer"] - Optional --> Yours["your scripts"] -``` - -The public WSL and Docker Container default examples start from `ghcr.io/catthehacker/ubuntu:full-latest`, so they inherit Docker plus the broader Catthehacker runner tool stack. The recommended Docker Sandboxes template uses its separate pinned Candidate A adaptation of the same full Catthehacker source; it is built and loaded with `scripts/docker-sandboxes/build-template.ps1` and `scripts/docker-sandboxes/load-template.ps1`, not the normal `image build` command. Tart and the WSL lean examples leave `image.customInstallScripts` empty, producing a runner-only Ubuntu image. Docker Container always needs Docker Engine because the provider depends on a private inner Docker daemon. - -EPAR ships reusable install scripts for common cases: - -- `scripts/guest/ubuntu/install-docker-browser.sh` installs Docker Engine, Docker CLI, Compose v2, Buildx, and a Chromium-compatible browser. -- `scripts/guest/ubuntu/install-web-e2e.sh` includes Docker/browser support and ensures Node.js/npm, `zip`, `rsync`, and `mysql-client` for common web app and browser E2E workflows. - -Built-in install scripts call `scripts/guest/ubuntu/wait-apt-ready.sh` before package installs. It stops active `apt-daily` jobs for the current boot only, waits for dpkg locks to clear, and leaves Ubuntu's normal apt timer enablement unchanged in the finalized image. - -### Host trust overlay - -Docker Container can snapshot all trusted root anchors visible in configured host scopes and add them to Ubuntu's default CA store: - -```yaml -image: - hostTrustMode: overlay - hostTrustScopes: [system, user] -``` - -Windows and macOS support `system` and `user`; Linux supports `system` only. -Each canonical root set has a content generation recorded in the EPAR image -manifest. An addition, removal, or rotation creates a new generation, invalidates -image reuse, and causes idle runners from the earlier generation to be replaced. -A job already running keeps its immutable starting generation. - -The pool keeps its normal 15-second liveness interval. With overlay mode -enabled, a native controller recollects host trust every 15 seconds; a -containerized controller checks its read-only feed and refreshes idle-runner -leases every 5 seconds. The official no-Go wrappers run a host-side collector -every 10 seconds. Feed data must be no more than 30 seconds old. A runner receives a -20-second controller lease, and its synchronous -pre-job hook fails closed if the lease is missing, expired, or belongs to a -different generation. A rare assignment racing runner retirement can therefore -fail before workflow steps rather than run with stale trust. - -Host trust is copied into the image build context; the host trust feed is not -mounted into job containers. EPAR does not alter a running job's CA store. - -This is an additive Ubuntu overlay. It does not reproduce every Windows or -macOS trust-policy constraint and does not remove an independently bundled -Ubuntu root. See [Docker Container Host Trust Inheritance](providers/docker-container.md#host-trust-inheritance). - -### Enterprise CA certificates - -If HTTPS traffic is inspected by an enterprise proxy, or install scripts use a -private registry with an internal CA, provide the trusted CA explicitly: - -```yaml -image: - trustedCaCertificatePaths: - - .local/enterprise-root.pem - - ~/company/intermediate.crt + Source["Provider source"] --> Runner["EPAR runner layer"] + Runner --> Trust["Optional CA and host-trust layer"] + Trust --> Custom["Optional install scripts"] + Custom --> Verify["Build validation"] + Verify --> Artifact["Reusable artifact"] + Artifact --> Pool["Disposable runners"] ``` -Each path must be a readable PEM or DER X.509 CA certificate file, not a -directory. Bundled PEM CA files are supported. EPAR rejects invalid or non-CA -content before the build, normalizes certificate filenames from certificate -fingerprints, and runs Ubuntu's `update-ca-certificates` before `apt`, `curl`, -or runner downloads. Certificate paths and content digests are part of the EPAR -image manifest, so certificate rotation invalidates image reuse. +## Choose A Starting Point -This keeps TLS verification enabled. Do not work around certificate errors with -`curl -k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, or package-manager verification -disabling. +| Provider | Default source | Reusable artifact | Build command | +| --- | --- | --- | --- | +| Docker Container | `ghcr.io/catthehacker/ubuntu:full-latest` | Docker image tag | `image build --replace` | +| WSL2 | Catthehacker Docker image converted to rootfs | Rootfs tar | `image build --replace` | +| Tart | `ghcr.io/cirruslabs/ubuntu:latest` | Tart VM image | `image build --replace` | +| Docker Sandboxes | Lock-selected Catthehacker source | Loaded Candidate A template | [template guide](advanced/docker-sandboxes-template.md) | -```yaml -image: - customInstallScripts: - - scripts/guest/ubuntu/install-web-e2e.sh -``` +`./start` compares the configured image artifact with its manifest and builds Docker Container, WSL2, or Tart artifacts when they are missing or no longer match. Docker Sandboxes is intentionally different: `start` validates the configured, already loaded template and never builds or loads it. -The built-in `install-web-e2e.sh` script reuses base-image Node.js/npm only when its numeric Node major version meets the pinned toolset's `.node.default`; otherwise, it installs Node.js/npm through the pinned GitHub runner-image `install-nodejs.sh` script. It also adds `zip`, `rsync`, and `mysql-client`. It does not install MySQL server, project dependencies, `node_modules`, Playwright test packages, Playwright browser cache, Docker credentials, or application runtime secrets. +## Add Tools -Use the web/E2E examples when workflows need this larger toolset: - -```bash -cp configs/tart.web-e2e.example.yml .local/config.yml -``` - -```powershell -Copy-Item configs/wsl.web-e2e.example.yml .local/config.yml -``` - -`image.customInstallScripts` is a list of extra shell scripts: +Use `image.customInstallScripts` for non-secret additions that every runner from the artifact should contain: ```yaml image: @@ -163,189 +34,54 @@ image: - examples/custom-install/install-extra-apt-tools.sh ``` -Relative paths are resolved from the repository root and must stay inside the repository; absolute paths are also accepted. EPAR copies and runs these scripts as root, in the order listed, after the GitHub Actions runner is installed and before image validation/finalization. On Tart, if `provider.rosettaTag` is set, EPAR installs the Rosetta guest support before these custom scripts run. +Scripts run as root in listed order after the Actions runner is installed and before final validation. Keep custom scripts in the repository when practical, assign the customized artifact a distinct name and workflow label, and test it with `pool verify` before normal use. Do not bake GitHub tokens, private keys, registry credentials, application source, dependency caches, or other workflow secrets into an image. -Example script: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y --no-install-recommends \ - make \ - pkg-config \ - shellcheck -``` - -The same script can be used by Tart, WSL, and Docker Container because it runs inside Ubuntu. If the customized image changes workflow capabilities, give it distinct image names and labels so workflows can opt into it explicitly: - -```yaml -image: - outputImage: work/images/epar-ubuntu-24-wsl-web-e2e-extra.tar - customInstallScripts: - - scripts/guest/ubuntu/install-web-e2e.sh - - examples/custom-install/install-extra-apt-tools.sh - -runner: - labels: [self-hosted, linux, X64, epar-wsl-ubuntu-24.04-web-e2e-extra] - -provider: - sourceImage: work/images/epar-ubuntu-24-wsl-web-e2e-extra.tar -``` - -Do not bake secrets, private keys, Docker credentials, project `node_modules`, language package caches, or application runtime artifacts into the image. Those belong in the workflow, repository dependency lock files, or GitHub secrets. - -Docker registry mirrors are runtime configuration, not image content. Keep them in local config under `docker.registryMirrors`; EPAR applies them to each disposable instance before validation. See [Docker Registry Mirrors](advanced/docker-registry-mirrors.md). - -## Upstream Runner Images - -Runner-only Tart images, the default WSL and Docker Container images, and the Docker Sandboxes Catthehacker templates do not require EPAR's pinned `actions/runner-images` checkout. The default WSL and Docker Container images start from `ghcr.io/catthehacker/ubuntu:full-latest`; Docker Sandboxes adapts an approved digest-pinned snapshot of that full source. It already includes Docker Engine, Compose, Buildx, Node/npm, and the broader Catthehacker runner tool stack. - -The built-in Docker/browser and web/E2E scripts require a pinned checkout of `actions/runner-images`: +The built-in `install-web-e2e.sh` adds browser/E2E tooling. It needs EPAR's pinned `actions/runner-images` checkout: ```bash ephemeral-action-runner image update-upstream +ephemeral-action-runner image build --replace ``` -That writes the checked-out commit to `third_party/runner-images.lock`. The checkout directory itself is ignored by Git. - -When one of those built-in scripts is selected, the build copies only the required upstream Ubuntu script subset into the guest or Docker build context: - -- `images/ubuntu/scripts/helpers` -- `images/ubuntu/scripts/build/install-docker.sh` -- `images/ubuntu/scripts/build/install-google-chrome.sh` -- `images/ubuntu/scripts/build/install-nodejs.sh` -- `images/ubuntu/toolsets` - -## Docker Container Images - -Use `configs/docker-container.example.yml` for the default full Catthehacker runner container with a private Docker daemon. For smaller Docker-focused workloads, use `configs/docker-container.act.example.yml`, which starts from `ghcr.io/catthehacker/ubuntu:act-latest` without custom install scripts. It includes Node plus Docker Engine/CLI/Compose/Buildx, but does not guarantee browser dependencies. Use `configs/docker-container.web-e2e.example.yml` when workflows need Playwright or another browser workload; it starts from the same Act base and layers the web/E2E add-on. - -```bash -cp configs/docker-container.example.yml .local/config.yml -./bin/ephemeral-action-runner image build --replace -``` - -Run `image update-upstream` first when using `configs/docker-container.web-e2e.example.yml`, because that optional layer installs browser and Node.js pieces from the pinned upstream runner-images scripts. - -The output image is a Docker image tag: - -```bash -docker image ls epar-docker-container-catthehacker-ubuntu -``` - -The provider creates each runner instance with `docker create --privileged` and no host socket mount. The image entrypoint starts a private `dockerd`, waits for `docker info`, and keeps the container alive while EPAR configures and monitors the GitHub runner process. Workflow Docker resources live inside that inner daemon. The inner daemon defaults to the `vfs` storage driver because it is reliable for nested Docker across Docker Desktop, OrbStack, and Linux Docker hosts; users can bake a different `EPAR_DOCKERD_STORAGE_DRIVER` into a derived image after validating the host. - -## WSL Images - -Use `configs/wsl.example.yml` for the default full Catthehacker runner image converted into WSL: - -```powershell -Copy-Item configs/wsl.example.yml .local/config.yml -./bin/ephemeral-action-runner image build --replace -``` +The default Catthehacker sources and runner-only Tart builds do not require that checkout. Use the exact configuration and provider guide to decide whether a selected script needs it. -The default WSL build uses Docker on the Windows host only to prepare the source rootfs. It runs `docker pull`, `docker create`, `docker export`, and cleanup for `ghcr.io/catthehacker/ubuntu:full-latest`, then imports the exported rootfs into WSL and applies EPAR's normal lifecycle layer. If Docker is unavailable, use Docker Desktop, Docker Engine, or switch to `image.sourceType: rootfs-tar` with a prepared rootfs tar. +## Trust And Enterprise CAs -The output image is a WSL-importable rootfs tar: +Use an explicit CA path when a required CA is independent of the host trust store: -```text -work/images/epar-wsl-catthehacker-ubuntu.tar -``` - -EPAR also writes an intermediate source tar and env cache beside the output, for example: - -```text -work/images/epar-wsl-catthehacker-ubuntu.source.rootfs.tar -work/images/epar-wsl-catthehacker-ubuntu.source.rootfs.tar.env -work/images/epar-wsl-catthehacker-ubuntu.source.rootfs.tar.source.json -work/images/epar-wsl-catthehacker-ubuntu.tar.epar-manifest.json +```yaml +image: + trustedCaCertificatePaths: + - .local/enterprise-root.pem ``` -Later builds reuse that source cache when its source manifest still matches. Delete those files when you intentionally want to reconvert the Docker image. - -WSL runner startup sources `/opt/epar/source-image.env` before launching the GitHub Actions runner. That preserves image metadata such as `ImageOS`, `ImageVersion`, runner tool cache paths, browser paths, and Java paths from the Docker image source. WSL does not use the Docker Container entrypoint; it keeps the systemd and keepalive model used by other WSL images. - -Use `configs/wsl.lean.example.yml` when you want the old smaller rootfs-tar path. That config expects you to export a clean Ubuntu 24.04 WSL distro to `work/images/ubuntu-24.04-clean.rootfs.tar`. - -## Installed Runtime - -The default WSL and Docker Container builds use `ghcr.io/catthehacker/ubuntu:full-latest` as the source image, and the recommended Docker Sandboxes template adapts a digest-pinned snapshot from that full channel. It is larger than the medium Catthehacker act image, but it is the recommended default for public users because common tools such as Node/npm are already present. The WSL lean and web/E2E examples keep demonstrating smaller custom paths that layer only selected dependencies. - -Catthehacker's `full-latest` and `act-latest` tags are rolling references. EPAR records the resolved source digest in its image manifest so a built image remains auditable, but a later `image build --replace` can consume a newer upstream digest. Pin `image.sourceImage` to a tested digest when rebuild reproducibility is required. - -The default build installs: - -- GitHub Actions runner Linux package from `actions/runner` -- minimal OS packages required by that runner package -- additional tools selected by `image.customInstallScripts` +EPAR validates PEM or DER CA certificates and adds them before networked guest install steps. Keep TLS verification enabled. -The optional `install-docker-browser.sh` layer installs: +The wizard can also enable `image.hostTrustMode: overlay` for Docker Container and Docker Sandboxes. It inherits selected host root anchors into fresh runner generations; it is additive to Ubuntu roots and explicit CA paths, not an emulation of every Windows or macOS trust policy. Use `[system, user]` on Windows/macOS or `[system]` on Linux. See [Configuration](configuration.md) and [Security](security.md) before enabling it. -- Docker through upstream `install-docker.sh` -- upstream Google Chrome on x64 -- Playwright-managed Chromium on ARM64, exposed as `epar-browser`, `chromium`, and `chromium-browser` +## Provider Differences -The WSL default and Docker Container provider validate Docker Engine/CLI/Compose/Buildx through `scripts/guest/ubuntu/install-docker-engine.sh`. Docker Container then starts the daemon at container runtime from `/opt/epar/container-entrypoint.sh`; WSL starts Docker through systemd inside the distro. Docker Sandboxes instead validates the private daemon and toolchain as part of its Candidate A template admission and runtime verification. Set `EPAR_FORCE_UPSTREAM_DOCKER_INSTALL=true` inside non-WSL-default image builds only if you intentionally want to replace the base image's Docker packages with the pinned upstream `actions/runner-images` Docker install harness. +### Docker Container -The ARM64 Docker harness prefers upstream `toolset-2404-arm64.json`. If an older upstream checkout does not contain that file, EPAR falls back to a minimal ARM-aware Docker toolset. +The output is a Docker image named by `image.outputImage`; `provider.sourceImage` must point to it. The provider starts a private `dockerd` inside each privileged runner container. Use `configs/docker-container.act.example.yml` for a smaller Docker-focused base or `configs/docker-container.web-e2e.example.yml` for the browser/E2E layer. -The harness skips upstream Docker image cache pulls by default. Set `EPAR_SKIP_UPSTREAM_DOCKER_IMAGE_CACHE=false` inside the guest environment before `install-docker-browser.sh` if exact upstream cache behavior is required. +### WSL2 -## Tart Rosetta Layer +The default source is converted from a Docker image to an intermediate rootfs tar, then EPAR produces `image.outputImage` as the reusable WSL tar. Docker is required during this conversion. For `image.sourceType: rootfs-tar`, export a clean Ubuntu WSL distribution once and use that tar as `image.sourceImage`; see [WSL2 Provider](providers/wsl.md). -Set `provider.rosettaTag: rosetta` on a Tart config to start image-build and pool instances with `tart run --rosetta rosetta`. During Tart image build EPAR installs: +### Tart -- `/opt/epar/setup-rosetta.sh` -- `epar-rosetta.service` -- `/opt/epar/features/rosetta-amd64` +The output is a local Tart VM image. The default is intentionally lean; use a custom bootable Ubuntu source image or focused install scripts when a workflow needs more tooling. `provider.rosettaTag` is an opt-in, experimental Tart-only layer for selected Linux amd64 user-space workloads. -The setup script mounts the Tart Rosetta virtiofs share at `/run/rosetta`, mounts `binfmt_misc` if needed, and registers x86_64 ELF execution through `/run/rosetta/rosetta` with `OCF` flags. +### Docker Sandboxes -Rosetta is not installed for WSL builds and is not enabled for Tart unless `provider.rosettaTag` is non-empty. +Docker Sandboxes has no `image build` path and rejects `provider.sourceImage`. Build and load the reviewed template with [Docker Sandboxes template build and retention](advanced/docker-sandboxes-template.md), then let the wizard write the exact template identity into configuration. -At the end of a build, `/opt/epar/finalize-image.sh` stops Docker/containerd if they exist, clears Docker's persisted default bridge database, removes temporary validation files, and syncs the filesystem. This avoids cloned instances inheriting stale `docker0` bridge metadata from build-time validation. - -Runtime validation always verifies the base runner user and runner files. If the Docker/browser feature marker is present, validation also starts Docker and verifies Docker access as the same Linux user that runs the GitHub Actions runner: +## Verify A Customized Artifact ```bash -sudo -u runner -H docker version -sudo -u runner -H docker compose version -sudo -u runner -H docker buildx version -sudo -u runner -H docker run --rm hello-world -printf '%s\n' '

EPAR browser validation marker

' >/tmp/epar-browser-validation.html -sudo -u runner -H chromium --headless --no-sandbox --dump-dom file:///tmp/epar-browser-validation.html -``` - -If the Rosetta feature marker is present, validation also verifies a real amd64 Linux container: - -```bash -sudo -u runner -H docker run --rm --platform linux/amd64 alpine:3.20 sh -c 'uname -m' -``` - -The expected output is `x86_64`. - -The bundled `scripts/guest/ubuntu/install-web-e2e.sh` script creates a feature marker so `pool verify` also validates `node`, `npm`, `zip`, `unzip`, `tar`, `rsync`, and `mysql` on cloned instances. - -## WSL Bootstrap - -The default WSL config does not require a manually exported Ubuntu tar. It uses Docker to convert `ghcr.io/catthehacker/ubuntu:full-latest` into a rootfs tar during `image build`. - -For lean `image.sourceType: rootfs-tar` configs, create the clean Ubuntu tar once before `image build`. The supported path is to install an Ubuntu 24.04 WSL distro, export it, then use that tar as `image.sourceImage`: - -```powershell -New-Item -ItemType Directory -Force work/images -wsl --install -d Ubuntu-24.04 --no-launch -wsl --export Ubuntu-24.04 work/images/ubuntu-24.04-clean.rootfs.tar +ephemeral-action-runner pool verify --instances 1 --cleanup +ephemeral-action-runner pool verify --instances 1 --register-only --cleanup ``` -After the export exists, EPAR uses disposable imported distros for image builds and runner instances. The WSL provider uses `provider.installRoot`, default `work/wsl`, for those imported distro files. - -References: - -- [WSL basic commands](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) -- [Systemd support in WSL](https://learn.microsoft.com/en-us/windows/wsl/systemd) -- [GitHub Ubuntu runner image build scripts](https://github.com/actions/runner-images/tree/main/images/ubuntu/scripts/build) +The first command checks an unregistered disposable instance. The second also checks GitHub registration. Provider-specific runtime checks run when their feature markers are present; for example, Docker-enabled images validate Docker, Compose, Buildx, and a real container. diff --git a/docs/logging.md b/docs/logging.md index 4fea523..81dd13d 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,10 +1,10 @@ # Logging -EPAR uses `work/logs` under the project root by default. Set `logging.directory` to an absolute path to store logs elsewhere, or to another relative path to resolve it from the project root. +EPAR writes logs under `work/logs` by default. Use `ephemeral-action-runner logs path` to print the resolved directory. ```text work/logs/ -├── epar.log # when managerSinks includes file +├── epar.log ├── epar-last-error.log ├── errors/ ├── instances/ @@ -12,71 +12,60 @@ work/logs/ └── benchmarks/ ``` -Manager events and command transcripts have independent sinks. `console` and `file` may be selected separately or together. Console manager events route debug and info to stdout and warnings and errors to stderr. File manager events go to `epar.log`. File transcripts remain raw command output; console transcripts frame every completed stdout or stderr line with timestamp, instance, component, and stream context. JSON console mode emits one JSON object per line. +## What Goes Where -The local default keeps manager events on the console and command transcripts in files: +Manager events describe EPAR decisions and progress. Command transcripts contain raw instance, provider, and image-build output. + +The local default sends manager events to the console and transcripts to files: ```yaml logging: - directory: work/logs managerSinks: [console] managerConsoleFormat: text - managerConsoleTextFormat: "{time} [{level}] {message}" - managerFileFormat: json transcriptSinks: [file] - transcriptConsoleFormat: text - maxFileSizeMiB: 100 - maxBackups: 3 - compressBackups: true - retentionEnabled: true - retentionMaxTotalMiB: 1024 - managerMaxAgeDays: 14 - instanceMaxAgeDays: 14 - buildMaxAgeDays: 14 - errorMaxAgeDays: 30 - benchmarkMaxAgeDays: 90 - retentionIntervalMinutes: 60 ``` -The default manager console line is compact and human-readable: +Manager file events use `epar.log`. Instance transcripts use `instances/`, build and source transcripts use `builds/`, startup timing records use `benchmarks/`, and timestamped error reports use `errors/`. `epar-last-error.log` always points to the latest error report and is never removed by retention. -```text -2026-07-16T00:14:58.108+08:00 [INFO] cloning instance -``` +Runner logs inside an Ubuntu guest are normally: -When `managerConsoleFormat` is `text`, `managerConsoleTextFormat` accepts `{time}`, `{level}`, `{message}`, and `{attributes}`. `{message}` is required. The default deliberately omits structured attributes for concise local output. To include them, use `"{time} [{level}] {message}{attributes}"`; `{attributes}` expands to structured fields with its own leading space when fields exist. +- `/var/log/actions-runner/run.log` +- `/opt/actions-runner/_diag` +- `/var/log/epar-dockerd.log` when the runner has a private Docker daemon -When `transcriptConsoleFormat` is `text`, the optional `transcriptConsoleTextFormat` accepts `{time}`, `{instance}`, `{component}`, `{stream}`, `{message}`, `{session}`, `{category}`, `{provider}`, and `{attributes}`. Its default is `"{time} {stream} {instance} {component} {message}{attributes}"`. +When runner launch or GitHub readiness fails, EPAR appends bounded process and runner diagnostics to the matching host-side instance transcript. -Custom text formats are rejected when the corresponding console format is `json`. JSON records keep their fixed structured schema so downstream parsers and log shippers can rely on it. +## Console And File Formats -For Kubernetes, use console sinks so the container runtime can collect stdout and stderr: +Console and file sinks can use `text` or `json`. For Kubernetes or another runtime that already collects standard output, send both event types to the console: ```yaml logging: - directory: work/logs managerSinks: [console] managerConsoleFormat: json - managerFileFormat: json transcriptSinks: [console] transcriptConsoleFormat: json - maxFileSizeMiB: 100 - maxBackups: 3 - compressBackups: true - retentionEnabled: true - retentionMaxTotalMiB: 1024 - managerMaxAgeDays: 14 - instanceMaxAgeDays: 14 - buildMaxAgeDays: 14 - errorMaxAgeDays: 30 - benchmarkMaxAgeDays: 90 - retentionIntervalMinutes: 60 ``` -Benchmark JSONL and error reports remain file artifacts in every sink mode. EPAR rotates active manager and transcript files at the configured size, retains the configured number of gzip-compressed backups, and applies category age limits before the aggregate size budget. It protects active files across EPAR processes, does not follow links or reparse points, ignores unknown files, and never removes `epar-last-error.log`. +Text templates can change the human-readable console layout. Manager templates support `{time}`, `{level}`, `{message}`, and `{attributes}`. Transcript templates also support instance, component, stream, session, category, and provider fields. A template must contain `{message}` and is invalid when the corresponding console format is JSON. + +See [Configuration](configuration.md#logging) for every logging property, default, and validation rule. + +## Rotation And Retention + +EPAR rotates active manager and transcript files at `logging.maxFileSizeMiB`, retains the configured number of compressed backups, applies category age limits, then enforces the total retained-size budget. It protects active files across EPAR processes, does not follow links or reparse points, and ignores unknown files. + +Inspect or preview recognized log maintenance with: + +```bash +ephemeral-action-runner logs list +ephemeral-action-runner logs prune --dry-run +``` + +Remove `--dry-run` only after reviewing the exact retention plan. -Wrapper control files and command result files are state rather than logs. New wrappers place them under `work/state`, outside retention scope. +Wrapper control files and command results are state, not logs. Current wrappers place them under `work/state`, outside log retention. -Use `ephemeral-action-runner logs path` to find the resolved root, `ephemeral-action-runner logs list` to inspect recognized artifacts, and `ephemeral-action-runner logs prune --dry-run` to preview retention. Remove `--dry-run` to prune immediately. +## Shipping Logs -EPAR intentionally does not embed OTLP or vendor-specific clients. To ship file artifacts, use an external agent such as the OpenTelemetry Collector `filelog` receiver. See [`examples/observability`](../examples/observability/README.md). +EPAR does not embed vendor-specific or OTLP clients. Use an external collector when logs must leave the host. The [`examples/observability`](../examples/observability/README.md) directory includes local file, Kubernetes console, and OpenTelemetry Collector examples. diff --git a/docs/operations.md b/docs/operations.md index 5e55c30..1013df1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,76 +1,68 @@ # Operations -## Logs +EPAR is a foreground supervisor. Keep it running while the pool should accept jobs; it creates, monitors, retires, and replaces disposable runners within the configured capacity. + +```mermaid +flowchart LR + Start["Start or resume pool"] --> Ready["Maintain ready runners"] + Ready --> Job["One runner accepts one job"] + Job --> Retire["Retire completed runner"] + Retire --> Ready + Retire -->|"Cleanup or ownership is uncertain"| Quarantine["Quarantine; capacity remains occupied"] + Quarantine --> Reconcile["Reconcile exact local and GitHub state"] + Reconcile --> Ready + Ready --> Stop["Ctrl-C or pool down"] + Stop --> Cleanup["Reconcile and clean owned resources"] +``` -Host-side logs go under `work/logs` by default. Run `ephemeral-action-runner logs path` to print the resolved directory. When `managerSinks` includes `file`, manager events use `work/logs/epar.log`; the default manager sink is console-only. Instance transcripts use `work/logs/instances/`, build/source transcripts use `work/logs/builds/`, startup timing JSONL uses `work/logs/benchmarks/`, and timestamped error reports use `work/logs/errors/`. See [Logging](logging.md) for sink, rotation, retention, and shipping configuration. Runner logs inside the Ubuntu guest are under: +## Start and stop deliberately -- `/var/log/actions-runner/run.log` -- `/opt/actions-runner/_diag` +Use `./start` for normal operation because it verifies the configured image or sandbox template before starting the pool. Press `Ctrl-C` once to request a clean stop, then wait for its cleanup confirmation before closing the terminal. `--keep-on-exit` is a debugging option that deliberately leaves owned runner resources running after the supervisor exits. -Guest provisioning command output is streamed to `work/logs/instances/.guest.log`. If runner launch or GitHub online readiness fails, EPAR first appends bounded diagnostics to that host guest log: -runner PID/process state, tails from `run.log` and the latest `Runner_*.log`, -and the private Docker daemon log when present. Diagnostic collection is -best-effort and does not replace the original readiness error. +`pool up` is the lower-level command for a prepared image or template. While no supervisor is running, EPAR cannot retire completed ephemeral runners or create replacements. -On systemd instances, the runner process is launched with `systemd-run` as `actions-runner.service` so provider `exec` calls return immediately after the service starts. On non-systemd instances such as Docker runner containers, EPAR starts `run.sh` in the background, writes `/var/run/actions-runner.pid`, and appends output to `/var/log/actions-runner/run.log`. +## Capacity and replacement -Docker runner containers also write inner Docker daemon logs to `/var/log/epar-dockerd.log` inside the runner container. Host-side Docker commands only show the outer runner container; job-created Compose resources live in the inner daemon. +`pool.instances` is a strict cap on local physical instances, not just ready GitHub runners. Provisioning, ready, draining, quarantined, and cleanup-pending instances all consume a slot. A busy runner retained during a trust-generation change also keeps its slot until it finishes or can be safely removed. -When `docker.registryMirrors` is configured, EPAR writes `/etc/docker/daemon.json` inside each instance before runtime validation. For Docker Container, inspect both `/etc/docker/daemon.json` and `/var/log/epar-dockerd.log` inside the outer runner container. +Only one controller may manage a canonical configuration, provider, and `pool.namePrefix` on a host. Use a distinct unique prefix for an intentionally independent pool; never start a second controller with the same identity. -## Supervisor Exit +At startup and before a replacement, EPAR compares provider inventory with exact GitHub runner records. Healthy pairs are adopted. Proven stopped or unregistered resources are removed. An ambiguous resource is quarantined and consumes capacity instead of being deleted or replaced. -`pool up` cleans up prefixed instances and GitHub runner records when it exits. Use `--keep-on-exit` only when intentionally debugging a live instance after the supervisor stops. While the supervisor is not running, EPAR cannot retire or replace completed runners. +For transient GitHub or network failures during replacement, including `429` and `5xx` responses, EPAR pauses allocation and retries with configured exponential backoff while monitoring and cleanup continue. Authentication failures and invalid configuration remain fail-fast. See [Configuration](configuration.md) to adjust the retry settings. -## Capacity, Reconciliation, and Outage Recovery +## Inspect status and logs -`pool.instances` is a strict cap on prefix-owned local resources. EPAR counts provisioning, ready, draining, quarantined, and cleanup-pending instances, so a GitHub outage or a failed cleanup cannot create a replacement storm. Host-trust rotation has no temporary surge allowance; old busy-generation instances retain their slots until they finish or can be safely retired. +```bash +ephemeral-action-runner status +ephemeral-action-runner logs path +ephemeral-action-runner logs list +``` -Only one controller may mutate a given canonical config, provider, and `pool.namePrefix` at a time. If another EPAR controller holds that lock, stop or reconfigure the other controller instead of forcing concurrent starts; use a distinct unique prefix for intentionally independent pools. +Add `--no-github` to `status` when you need a local-only view. By default, host logs live under `work/logs`; manager events are console-first and instance/build transcripts are file artifacts. A failed launch or readiness check appends bounded guest diagnostics to the relevant instance log. See [Logging](logging.md) for locations, formats, retention, and shipping. -At startup and before allocating a replacement, EPAR reconciles the provider inventory with exact-name GitHub runner records. Healthy pairs are adopted, stopped or proven unregistered local resources are removed, and exact stale GitHub records are deleted when GitHub is reachable. When GitHub is unavailable, an ambiguous local instance is quarantined and continues to consume capacity instead of being deleted or replaced. If old resources already exceed the configured cap, the supervisor creates nothing until safe cleanup or normal draining restores capacity. +## Clean up safely -For transient network failures and GitHub `429` or `5xx` responses during supervised replacement, EPAR pauses allocation and retries with exponential backoff. The default nominal delays are 15, 30, 60, 120, 240, 480, 960, and 1800 seconds, each with ±20% jitter; a longer `Retry-After` response wins. Monitoring and housekeeping continue during that pause, and a successful adoption or fully online replacement resets the delay. Startup remains fail-fast after compensating rollback, and invalid configuration or GitHub authentication failures do not retry indefinitely. +```bash +ephemeral-action-runner cleanup +ephemeral-action-runner pool down +``` -## Cleanup Safety +`pool down` is an alias for `cleanup`. Cleanup is intentionally bounded: Docker Sandboxes uses the durable ledger of exact owned identities, while legacy providers use the configured `pool.namePrefix` boundary. Unknown, shared, or identity-drifted resources are report-only rather than broad deletion targets. Do not reuse a prefix across machines or independent supervisors in the same GitHub organization. -Cleanup only touches local instances and GitHub runner records matching `pool.namePrefix`: +Use `cleanup --no-github` only when you intentionally want to leave GitHub runner records untouched. After a failed Docker Sandboxes diagnostic check, review the retained evidence before using `--acknowledge-failed-diagnostics` to allow its exact cleanup. -```yaml -pool: - namePrefix: epar-tart +## Maintain storage and retention + +```bash +ephemeral-action-runner storage status +ephemeral-action-runner storage prune +ephemeral-action-runner storage prune --execute +ephemeral-action-runner logs prune --dry-run ``` -Generated names look like: +`storage prune` is a preview until `--execute` is supplied. Log pruning is a separate retention operation. EPAR does not run broad Docker prune, Docker Sandboxes reset, WSL reset, Docker Desktop reset, or VHDX compaction. Read [Storage](storage.md) before reclaiming capacity. -```text -epar-tart-20260703-010500-001 -``` +## Get help from the right page -Do not set `namePrefix` to a broad value such as `ubuntu` or `runner`. Keep it within 40 characters so EPAR can append its generated runner-name suffix. -Also do not reuse the same `namePrefix` on different machines or for separate EPAR supervisors in the same GitHub organization. GitHub cleanup is prefix-based, so a shared prefix lets one machine delete another machine's runner records. - -For Docker Container, cleanup removes the outer runner container with `docker rm -f -v`. That also removes the private inner Docker daemon's containers, networks, volumes, and image cache for that EPAR instance. - -## Troubleshooting - -This section is a compact checklist. For symptom-first diagnostics with host/provider-specific commands, see [Troubleshooting](troubleshooting.md). - -- If a Docker/browser or web/E2E image build fails before package installation, run `image update-upstream`. -- If an image build fails with `E: You don't have enough free space in /var/cache/apt/archives/.`, check the Docker daemon or VM storage with `docker system df` and `docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h /`. On Windows Docker Desktop with WSL2, the container-visible disk can be much smaller than Windows Explorer free space; see [Windows Docker Desktop WSL2 Disk Is Smaller Than Expected](troubleshooting.md#windows-docker-desktop-wsl2-disk-is-smaller-than-expected). -- If Docker validation fails for a Docker-enabled image, inspect `work/logs/builds/.guest.log`. -- If browser validation fails on ARM64, confirm `epar-browser` exists inside the guest and inspect `/opt/epar/browser`. -- If a Docker Compose job uses an amd64-only runtime image on an ARM64 Tart runner and fails with `exec format error` or repeated container exits such as status `139`, use a runner label that supports that image instead of changing application runtime settings only for runner compatibility. Suitable targets include Docker Container with verified `linux/amd64` emulation, WSL x64, an x64 Linux host, or a Tart image with Rosetta enabled and validated. -- If a workflow uses fixed Compose project names, fixed container names, or fixed ports, Docker Container is often a better fit than a shared host Docker socket because each runner gets a private inner Docker daemon. Verify by starting two unregistered instances, running the same compose stack in both, and confirming host Docker only shows the outer EPAR runner containers. -- If repeated jobs still pull slowly after configuring a registry mirror, verify the mirror is reachable from inside the runner instance and that it supports the requested registry, image platform, and authentication model. Docker daemon mirrors primarily target Docker Hub; other registry caches may require workflow image references to use the cache registry URL. -- If a mirrored workflow only improves modestly, check where the time is going. Registry mirrors mainly reduce image pull time; container startup, Compose health checks, database initialization, volume sync, browser tests, private image authentication, and CPU-bound or emulated workloads can still dominate the total job time. -- If GitHub registration fails, confirm the app has permission to manage organization self-hosted runners and that the private key path is readable by the host user. -- If GitHub returns transient `500`, `502`, `503`, or `429` errors while the supervisor is replacing a runner, leave the supervisor running so it can retain the strict cap, reconcile exact runner names after recovery, and retry on its configured backoff. Do not manually start a second supervisor with the same `pool.namePrefix`. -- If registration fails before the runner listener starts, EPAR rolls back the local candidate immediately and later reconciles a possible exact-name GitHub record. If the listener already started but GitHub readiness is uncertain, EPAR quarantines that candidate; inspect the instance transcript and wait for GitHub recovery before manually deleting it. -- If stale runners remain, run `ephemeral-action-runner cleanup`. -- If using Tart `softnet`, verify the host has the privileges Tart requires. -- If default WSL image build fails before import, confirm Docker Desktop, Docker Engine, or another Docker daemon is reachable so EPAR can export `ghcr.io/catthehacker/ubuntu:full-latest` into a rootfs tar. For lean WSL configs, confirm the clean Ubuntu rootfs was exported from an Ubuntu 24.04 WSL distro. -- If WSL image build fails before systemd is ready, confirm WSL2 is enabled and inspect `work/logs/builds/.guest.log`. -- If Docker Container startup fails, confirm the host Docker runtime supports privileged containers and inspect `/var/log/epar-dockerd.log` inside the runner container. -- If Docker Container `docker run` fails with nested overlay mount errors, keep the default `EPAR_DOCKERD_STORAGE_DRIVER=vfs`. Only switch to `overlay2` or `auto` in a derived image after proving that storage driver works on the exact host runtime. -- If the default WSL or Docker Container build cannot validate Docker, confirm the source image still provides `docker`, `dockerd`, Compose, Buildx, and `iptables`. If you intentionally use a clean Ubuntu source image instead of Catthehacker's runner image, run `image update-upstream` first and use a config that installs Docker from EPAR's pinned `actions/runner-images` Docker install harness. +Use [Troubleshooting](troubleshooting.md) for symptom-led diagnosis and host/provider commands. Use [Support](../SUPPORT.md) when you need help and [Security](security.md) for trust-boundary guidance or private vulnerability reporting. diff --git a/docs/providers/docker-container.md b/docs/providers/docker-container.md index adc9d67..a978551 100644 --- a/docs/providers/docker-container.md +++ b/docs/providers/docker-container.md @@ -1,20 +1,24 @@ # Docker Container Provider -The Docker Container provider creates one privileged Ubuntu-based runner container per EPAR instance. That outer container starts its own private Docker daemon, and the GitHub Actions runner executes inside the same container. +Docker Container creates one privileged Ubuntu runner container per EPAR instance. The outer container starts a private inner Docker daemon, so workflow containers, networks, volumes, and image cache stay inside that disposable runner. -This is useful when a host already has a reliable Docker runtime and you want disposable runner environments without creating full VMs. It is also useful for Docker Compose-heavy jobs because each runner instance has a separate inner Docker daemon. Deleting the EPAR container deletes that instance's job containers, networks, volumes, and inner image cache. +## When To Use It -EPAR does not support a host Docker socket provider. +Choose Docker Container on a Docker-capable host when privileged containers are acceptable and you want strong per-runner Docker resource separation. It is a practical fit for Compose-heavy jobs, including jobs that reuse fixed Compose project names or ports. EPAR does not support a host-Docker-socket provider. -## When To Choose It +## Support Status -Choose Docker Container first for Docker-heavy Linux workflows when privileged containers are acceptable on the host. It is especially useful when the target repository already has Compose scripts, fixed project names, fixed internal ports, or amd64-only runtime images. In those cases, selecting a compatible runner label and Docker platform is usually cleaner than changing application runtime settings for CI compatibility. +This is a supported provider on hosts whose Docker runtime can run privileged Linux containers. It is trusted-job infrastructure: `--privileged` weakens the normal container boundary, so do not use it for arbitrary untrusted workflow code. -Choose Tart or WSL instead when you specifically need their host model: Tart for VM-based Apple Silicon runners, WSL for Windows-hosted Linux runners, and x64 WSL/Linux hosts for native amd64 performance. +## Prerequisites -## Configuration +- A working Docker-compatible daemon that permits `docker run --privileged`. +- Enough host Docker storage for the reusable image and the requested disposable runners. +- A GitHub App and runner group configured as described in [Runner Group Security](../runner-groups.md). -Use `configs/docker-container.example.yml` for a base runner image: +## Minimal Configuration + +Start with [`configs/docker-container.example.yml`](../../configs/docker-container.example.yml): ```yaml image: @@ -28,189 +32,40 @@ provider: network: default ``` -Use `configs/docker-container.act.example.yml` for a smaller Docker-focused runner. Its Catthehacker Act base includes Node plus Docker Engine/CLI/Compose/Buildx, but does not guarantee a browser runtime: - -```yaml -image: - sourceType: docker-image - sourceImage: ghcr.io/catthehacker/ubuntu:act-latest - outputImage: epar-docker-container-catthehacker-act - -runner: - labels: [self-hosted, linux, epar-docker-container-catthehacker-act] +`provider.platform` is optional and maps to Docker's `--platform` for the reusable image and runner containers. Give cross-architecture configurations a distinct workflow label and verify actual execution on the intended host. -provider: - sourceImage: epar-docker-container-catthehacker-act -``` +Use `configs/docker-container.act.example.yml` for a smaller Docker-focused Catthehacker base, or `configs/docker-container.web-e2e.example.yml` when browser/E2E tooling is required. The full configuration, host-trust settings, proxies, and registry mirrors belong in [Configuration](../configuration.md). -Use `configs/docker-container.web-e2e.example.yml` as a smaller customized-image example. It starts from `ghcr.io/catthehacker/ubuntu:act-latest` and layers only the web/E2E add-on: +## Normal Workflow -```yaml -image: - sourceType: docker-image - sourceImage: ghcr.io/catthehacker/ubuntu:act-latest - outputImage: epar-docker-container-catthehacker-ubuntu-web-e2e - customInstallScripts: - - scripts/guest/ubuntu/install-web-e2e.sh - -runner: - labels: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu-web-e2e] - includeHostLabel: true - -provider: - sourceImage: epar-docker-container-catthehacker-ubuntu-web-e2e -``` +1. Create a local configuration with the wizard or copy an example. +2. Run `./start`. EPAR builds or refreshes the reusable image when its manifest no longer matches the configuration, then starts the pool. +3. Target the configured label in a workflow, for example `runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu]`. -`provider.platform` is optional and maps to Docker's `--platform` flag for image builds and runner containers. Use a label that reflects the actual platform your workflows should target. +The outer container has no host Docker socket mount and does not publish host ports by default. The inner daemon defaults to the reliable nested-Docker `vfs` storage driver. Use a different `EPAR_DOCKERD_STORAGE_DRIVER` only in a derived image after validating the exact host runtime. -Optional Docker registry mirrors are configured under the provider-neutral `docker` section: +## Limitations -```yaml -docker: - registryMirrors: - - http://host.docker.internal:5050 -``` - -When a Docker Container mirror URL uses `host.docker.internal`, EPAR adds Docker's `host-gateway` alias to the outer runner container so the inner daemon can reach a host-published mirror on Linux Docker Engine. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). - -If the inner daemon must use an enterprise HTTP proxy, configure its startup -environment explicitly: - -```yaml -docker: - httpProxy: http://proxy.example.test:3128 - httpsProxy: http://proxy.example.test:3128 - noProxy: localhost,127.0.0.1,.example.test -``` - -EPAR sets these values on the outer container before it starts, allowing -`dockerd` to inherit them on its first launch. Empty values preserve direct -networking. Proxy URLs are limited to credential-free HTTP(S) roots; use network -controls rather than embedding proxy passwords. Put host-specific endpoints in -ignored `.local/config.yml`. If the proxy performs TLS inspection, enable host -trust inheritance or configure the authorized root under -`image.trustedCaCertificatePaths` so verified HTTPS continues to work. - -## Host Trust Inheritance - -On Windows, macOS, and Linux controller hosts, Docker Container can add the host's trusted TLS root anchors to each disposable Ubuntu runner: - -```yaml -image: - hostTrustMode: overlay - hostTrustScopes: [system, user] -``` - -Use `[system, user]` on Windows or macOS. Use `[system]` on Linux; Linux has no portable per-user TLS root store. Overlay mode requires ephemeral Docker Container runners. Existing configs remain disabled. New interactive Docker Container setup shows a yes/no prompt and defaults to enabling inheritance. - -EPAR treats the host's root set as a versioned generation. It installs that -generation alongside Ubuntu's default roots and any certificates from -`image.trustedCaCertificatePaths`. The pool keeps its normal 15-second liveness -interval. A native controller recollects host trust every 15 seconds; a -containerized controller checks its read-only feed and refreshes idle-runner -leases every 5 seconds. Official no-Go launchers keep collection outside the -Linux toolchain container: their host-side watcher refreshes a read-only feed -every 10 seconds. The wrapper fails rather than use -the toolchain container's unrelated CA bundle when the configured feed is -missing, empty, invalid, or older than 30 seconds. - -When a generation changes, EPAR stops leasing old idle runners, removes them, -builds the replacement image, and registers replacement capacity. A busy runner -finishes its current job without having its trust changed. A synchronous -`ACTIONS_RUNNER_HOOK_JOB_STARTED` gate requires a current 20-second controller -lease and matching image generation. If GitHub assigns an old runner while it -is being retired, the gate fails before repository workflow steps run; the job -can still appear failed because GitHub assignment has already happened. - -The host trust snapshot and no-Go feed are controller inputs only. They are not -mounted into runner containers, and workflow code cannot use the feed as a host -filesystem channel. - -This feature inherits root anchors, not every host TLS-policy rule. The overlay -is additive, so removing a host root does not remove a matching root already in -Ubuntu or explicitly configured by path. Host Docker daemon trust is also -separate: a source-image pull can fail before EPAR can build the guest overlay. -Applications with private trust stores, including Java keystores, can require -additional configuration. - -## Image Build - -Docker Container images are Docker image tags, not Tart images or rootfs tar files: - -```bash -./bin/ephemeral-action-runner image build --replace -docker image ls epar-docker-container-catthehacker-ubuntu -``` - -The default build starts from `ghcr.io/catthehacker/ubuntu:full-latest`, installs the GitHub Actions runner and EPAR helper scripts, and reuses the base image's Docker Engine/CLI/Compose/Buildx. The generated image also includes `/opt/epar/container-entrypoint.sh`, which starts the private inner `dockerd` when the runner container starts. - -Run `image update-upstream` only when selected install scripts need EPAR's pinned `actions/runner-images` checkout, such as the web/E2E script. - -## Runtime Behavior - -EPAR maps provider operations to Docker commands: - -- clone/create: `docker create --privileged --label epar.provider=docker-container ...` -- start: `docker start`, then wait for inner `docker info` -- exec: `docker exec` -- address: `docker inspect` -- stop: `docker stop` -- delete: `docker rm -f -v` -- list: `docker ps -a --filter label=epar.provider=docker-container` - -The provider does not mount `/var/run/docker.sock`, an OrbStack socket, or any host Docker socket into the runner container. It also does not publish host ports by default. If two jobs use the same Docker Compose project name or container ports, they are separated by their private inner Docker daemons. - -The inner daemon starts with `EPAR_DOCKERD_STORAGE_DRIVER=vfs` by default. `vfs` is slower than `overlay2`, but it is the most reliable default for nested Docker on Docker Desktop, OrbStack, and other privileged-container hosts where overlay mounts can fail inside the runner container. Advanced users can bake `EPAR_DOCKERD_STORAGE_DRIVER=overlay2` or `EPAR_DOCKERD_STORAGE_DRIVER=auto` into a derived image after validating that the exact host runtime supports it. - -On Apple Silicon hosts using Docker Desktop or OrbStack, the inner daemon may be able to run `linux/amd64` containers through the host runtime's emulation support. Validate this on the exact host before routing amd64-only workflows to Docker Container: - -```bash -docker exec docker run --rm --platform linux/amd64 alpine:3.20 uname -m -``` - -Expected output: - -```text -x86_64 -``` - -The runner process uses EPAR's non-systemd fallback: - -- `/opt/epar/run-runner.sh` starts `/opt/actions-runner/run.sh` in the background. -- `/var/run/actions-runner.pid` records the runner PID. -- `/opt/epar/check-runner.sh` reports liveness. -- `/var/log/actions-runner/run.log` records runner output. +- The inner Docker daemon is private, but its CPU, memory, and disk use still comes from the host. +- The runner's inner image cache disappears with the instance. +- Docker Desktop, OrbStack, and Linux Docker Engine can differ in privileged-container and foreign-architecture behavior. +- Registry mirrors and proxy services are external infrastructure; EPAR configures the runner daemon but does not operate or secure those services. ## Verification -Local runtime check without GitHub registration: - ```bash -./bin/ephemeral-action-runner pool verify --instances 1 --cleanup +ephemeral-action-runner pool verify --instances 1 --cleanup +ephemeral-action-runner pool verify --instances 1 --register-only --cleanup ``` -Full registration check: +For an ARM64 host that must run amd64 Docker images, verify execution inside a live EPAR runner rather than relying on image pull success: ```bash -./bin/ephemeral-action-runner pool verify --instances 2 --register-only --cleanup -``` - -Dry-run command construction: - -```bash -./bin/ephemeral-action-runner pool verify --dry-run --instances 1 +docker exec docker run --rm --platform linux/amd64 alpine:3.20 uname -m ``` -The dry run should show `docker create` with `--privileged` and no host socket mount. - -For Docker Compose-heavy jobs that use fixed project names or ports, a useful isolation smoke test is to start two unregistered Docker Container instances, run the same compose stack in both with the same project name, and confirm the host Docker daemon only shows the two outer EPAR containers. The job-created containers should appear only when you run `docker exec docker ps` against each instance. +Expected output is `x86_64`. -## Caveats +## Troubleshooting -- Docker Container requires privileged containers. Treat it as trusted-job infrastructure. -- It is not a security boundary for hostile code. -- Inner Docker image cache is per runner instance and disappears on cleanup. -- Optional registry mirrors can reduce repeated pull time, but they are external services that must be secured and monitored separately. -- Cross-architecture containers, for example `linux/amd64` images on an ARM64 host, depend on the host Docker runtime's emulation support. -- Host Docker resource usage still matters because each runner container and inner daemon consumes CPU, memory, and disk on the same host. -- Docker Desktop, OrbStack, and Linux Docker Engine can have different privileged-container behavior. Validate on the exact host runtime you plan to use. +See [Troubleshooting](../troubleshooting.md) for privileged-container checks, nested-Docker storage-driver failures, architecture emulation, disk pressure, and TLS errors. diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index ff4552f..b9d93dc 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -1,217 +1,105 @@ # Docker Sandboxes Provider -The `docker-sandboxes` provider runs each GitHub Actions listener directly inside its own Docker Sandboxes microVM. Each microVM has a private filesystem, network boundary, and Docker daemon. This is a materially stronger host boundary than Docker Container, but EPAR does not describe it as universally safe for arbitrary hostile workflows. - -Docker Sandboxes is EPAR's capability-driven recommended default when its local prerequisites pass, without an operating-system allowlist. It provides the strongest current host boundary among EPAR providers by placing the listener, filesystem, network boundary, and private Docker daemon in a dedicated microVM. EPAR never falls back from a configured Docker Sandboxes pool to Docker Container. Set `EPAR_DISABLE_DOCKER_SANDBOXES=1` to stop admission during an incident or compatibility problem; the switch causes Docker Sandboxes startup to fail closed. - -## Architecture +Docker Sandboxes runs each GitHub Actions listener in a separate microVM with a private filesystem, network boundary, and Docker daemon. It is EPAR's strongest current host boundary, but it remains trusted-job infrastructure rather than a sandbox for arbitrary hostile workflows. ```mermaid flowchart TB subgraph Host["Native controller host"] - EPAR["EPAR native controller"] - GitHubKey["GitHub App private key"] - Ledger["Transactional ownership ledger"] - EmptyStage["Fresh empty staging directory"] - SBX["Docker Sandboxes daemon and CLI"] - TemplateCache["Host-level immutable template cache"] + EPAR["EPAR controller"] + SBX["Docker Sandboxes"] + Ledger["Exact ownership ledger"] + Cache["Shared template cache"] end - subgraph VM["One disposable Docker Sandboxes microVM"] - Listener["Ephemeral Actions listener"] - Work["Guest-only Actions _work"] - GuestFS["Private guest filesystem"] - Dockerd["Private Docker daemon and block volume"] - JobContainers["Workflow, service, and job containers"] - Policy["Sandbox-scoped network rules"] + subgraph Sandbox["One disposable microVM"] + Runner["Ephemeral Actions runner"] + Docker["Private Docker daemon"] + Jobs["Workflow and service containers"] end - GitHubKey -->|"installation and registration token exchange stays native"| EPAR - EPAR -->|"exact constrained sbx operations"| SBX + EPAR --> SBX EPAR --> Ledger - TemplateCache --> SBX - SBX --> VM - EmptyStage -->|"only approved host path; not Actions _work"| VM - EPAR -->|"registration token through sbx exec stdin"| Listener - Listener --> Work - Listener --> Dockerd - Dockerd --> JobContainers - Policy --> Listener - Policy --> JobContainers - GuestFS --> Listener -``` - -For comparison, Docker Container creates a privileged outer container directly on the host Docker daemon. The outer container starts a private inner Docker daemon for workflow Docker operations: - -```mermaid -flowchart TB - subgraph Host["Docker host"] - EPAR["EPAR controller"] - HostDocker["Host Docker daemon"] - subgraph Outer["Privileged EPAR runner container"] - Listener["Ephemeral Actions listener"] - InnerDocker["Private inner Docker daemon"] - JobContainers["Workflow, service, and job containers"] - end - end - EPAR --> HostDocker - HostDocker --> Outer - Listener --> InnerDocker - InnerDocker --> JobContainers -``` - -Docker Container is therefore a container-backed runner with an inner private daemon. Docker Sandboxes adds the microVM boundary around the listener, filesystem, network, and private daemon. - -## Candidate A template - -EPAR uses Candidate A only. The template extends a platform-specific pinned Catthehacker manifest directly, installs the matching pinned Actions runner and Tini artifacts, and lets Docker Sandboxes attach and start the sandbox-private Docker daemon. It does not run the 70+ GiB image as a second nested container, preload `/var/lib/docker`, or use Candidate B. - -The first-run wizard recommends the Catthehacker `full-latest` rolling source channel and can also use the current lean `act-22.04` source channel. Each choice still requires its corresponding locally built and loaded, versioned EPAR template from the approved source-lock snapshot; raw Catthehacker images cannot be used directly as Docker Sandboxes templates. Build the smaller pinned `act-22.04` profile first when you want the lean path. The default platform remains `linux/amd64`: - -```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/build-template.ps1 -Profile act-22.04 -Platform linux/amd64 -Execute -``` - -On Apple Silicon, use PowerShell 7 and select the native ARM64 guest: - -```powershell -pwsh -NoProfile -File scripts/docker-sandboxes/build-template.ps1 -Profile act-22.04 -Platform linux/arm64 -Execute -``` - -Executed builds require the native Docker server platform to match `-Platform`. The pinned build stages intentionally do not use emulation, so an x86_64 Docker server cannot execute the ARM64 build and an ARM64 Docker server cannot execute the x86_64 build. Plan-only mode remains non-mutating and can inspect either platform. - -The build emits the OCI image digest, the full local Docker image identity, and a SHA-256 digest for `template-metadata.json`. Record the metadata digest outside the artifact directory before review, and set `dockerSandboxes.templateDigest` to the full local Docker image identity printed by the script. Docker Sandboxes v0.35.0 exposes only a 12-hex cache ID in `sbx template ls --json`; that value is not a full digest. EPAR independently requires the exact full identity from the local Docker image store and compares the inventory only to the expected 12-hex cache ID. - -Load the resulting archive exactly once after reviewing its SBOM, provenance, software inventory, compatibility metadata, and checksums: - -```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/load-template.ps1 -ArtifactDirectory work/template-builds/docker-sandboxes/act-22.04 -ExpectedMetadataSha256 sha256: -Execute -``` - -The default ARM64 output directory is `work/template-builds/docker-sandboxes/act-22.04-arm64`; use that directory with `pwsh -NoProfile -File scripts/docker-sandboxes/load-template.ps1` on Apple Silicon. - -The loader first verifies the operator-supplied metadata digest, anchors that metadata to the repository source lock and complete template build context, hashes every evidence artifact and the archive, validates the Buildx metadata, max-mode provenance, SPDX JSON SBOM, software inventory, helper hashes, compatibility record, and exact full local Docker image identity, then requires exactly `sbx` v0.35.0. Only `-Execute` may invoke `sbx template load`, and it does so at most once before reading the exact tag and 12-hex cache ID back from local inventory. It never invokes `sbx reset`. - -The loader retains the archive because re-import and independent certification can require its exact bytes. Delete only a reviewed obsolete archive, and preserve its metadata, SBOM, provenance, inventory, hashes, and any certification record. - -The pinned full profile is intentionally attempted only after the smaller profile passes. Its approved source-lock snapshot uses index `sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c`; see `templates/docker-sandboxes/sources.lock.json` for the pinned `linux/amd64` and `linux/arm64` manifests and other build inputs. The rolling `full-latest` channel may move upstream after that snapshot is approved. EPAR does not refresh it automatically: rebuild, validate, and load a new versioned EPAR template before it becomes selectable. Source lock schema 2 keeps active inputs under platform-scoped records. Older amd64 tags remain only as non-authoritative `supersededRecords` and are never selected by the build script or treated as current proof. - -The ARM64 path is implementation-complete but lacks equivalent real-host evidence: its OCI manifests and helper downloads are pinned, the build and loader select `linux/arm64`, and any native `arm64` controller host can admit that guest after the capability and exact-template checks pass. Cross-compilation of the native controller and non-mutating asset validation passed, but no ARM64 image has been built, loaded, or run on a real ARM64 host for the evidence recorded in this repository, and no ARM64 independent-certification record exists. - -## Capacity and cold-start handling - -The large Catthehacker filesystem is part of the outer sandbox template, not an image pulled into every sandbox-private Docker daemon. The expensive cold acquisition and first lazy materialization happen during an explicit prewarm operation outside the job path. Docker Sandboxes retains loaded templates in its host-level template cache, so later runner creation reuses the cached template. Each sandbox still receives a separate Docker block volume for workflow-created images and layers. A template archive or cache size is a host-cache measurement; it is not a per-sandbox root-disk baseline and must not be added to `rootDisk`. - -This design addresses capacity and cold start explicitly: - -- Load each approved template once, verify its tag and configuration ID, then prime it with one EPAR-managed unregistered create/verify/exact-delete cycle before admitting jobs. -- Do not pull the 70+ GiB Catthehacker image into each private daemon. -- Size the root disk from the measured guest `/` peak for the exact template and workload, plus 25%, plus at least 20 GiB writable headroom, rounded up to the next 10 GiB. `rootDisk` is the resulting total guest root-filesystem capacity, not the headroom value by itself. -- Size the Docker disk to at least 100 GiB and at least the measured representative peak plus 25% and 20 GiB deletion headroom. -- Keep at least 50 GiB or 10% of the backing volume free, whichever is larger. -- Count provisioning and uncertain-cleanup reservations against capacity; do not allocate past unresolved state. -- Do not create standby microVMs initially. Cached templates provide the first optimization without retaining live, stateful guests. - -After loading the template and completing the configuration, prime its lazy state on Windows with: - -```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-native-controller.ps1 pool verify --config .local/docker-sandboxes.yml --project-root . --instances 1 --cleanup + Cache --> SBX + SBX --> Runner + Runner --> Docker + Docker --> Jobs ``` -Do not add `--register-only` to this prewarm command. EPAR verifies the template, trust, private daemon, policy, filesystem boundary, ports, ownership ledger, and exact cleanup without requesting a GitHub registration token or creating a GitHub runner. The first post-load create may still take minutes and must show visible progress outside the job path; only the measured subsequent creates count toward the cached-create performance gate. +## When To Use It -The values in the example are recommended starting reservations. Runtime admission rechecks exact storage and capacity before every create, and operators can adjust the generated configuration for their workload. +Choose Docker Sandboxes when its local checks pass and you want a microVM boundary around the runner and its Docker workload. The first-run wizard makes it the default only when its exact admission checks pass. A configured Docker Sandboxes pool never silently falls back to Docker Container or another provider. -## Storage Retention +## Support Status -Docker Sandboxes caches templates after individual sandboxes are deleted. Before removing an old EPAR template, verify that no active configuration or live sandbox uses its exact tag and cache ID, run `sbx template rm `, and verify that the same identity is absent. Do not use `sbx reset` as EPAR maintenance because it removes the entire Docker Sandboxes cache. +EPAR selects this provider by capability, not by an operating-system allowlist: Docker must work, the exact supported `sbx` version must pass machine-readable diagnostics, the controller architecture must have a matching native guest template, and capacity/template admission must pass. Windows x86_64 has the recorded real-host lifecycle evidence. The ARM64 implementation is architecture-complete, but equivalent real-host build, load, lifecycle, and independent-certification evidence has not yet been recorded. macOS and Linux also lack equivalent EPAR real-host evidence in this repository. -Docker images, BuildKit records, and named Go-cache volumes live in Docker's shared store and may be used by other projects or EPAR checkouts. EPAR does not run broad Docker prune commands automatically. Inspect them separately, remove only exact confirmed objects, and treat shared BuildKit sizes as overlapping image storage rather than guaranteed additional recovery. For constrained build hosts, configure Docker or BuildKit garbage-collection limits deliberately; see [Docker build garbage collection](https://docs.docker.com/build/cache/garbage-collection/). +## Prerequisites -On Windows, deleting Docker objects makes internal blocks reusable but does not automatically shrink Docker Desktop's dynamically expanding VHDX. Host-file compaction is separate offline maintenance and requires the virtual disk to be detached or attached read-only; see [Microsoft's `compact vdisk` requirements](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/compact-vdisk). +- A working Docker CLI and daemon. +- Docker Sandboxes CLI exactly `v0.35.0`; `sbx diagnose --output json` must report at least one passing check and no failed checks. +- A native `amd64` or `arm64` controller with the matching `linux/amd64` or `linux/arm64` EPAR template. EPAR does not use emulation to admit a mismatched template. +- A locally built and loaded, lock-selected Candidate A template whose full local identity matches configuration. +- Enough Docker Sandboxes backing storage for the configured root disk, Docker disk, existing reservations, and host-free watermark. +- A GitHub runner group that meets enforced policy. Docker Sandboxes requires `security.runnerGroup.enforcement: enforce` and `runner.ephemeral: true`. -For a missing `.local/config.yml`, `./start` and `init` always show Docker Container, Docker Sandboxes, WSL2, and experimental Tart with prerequisite status instead of hiding unavailable providers. The available Enter default is displayed first. Docker Sandboxes becomes that default when Docker is ready, the host architecture maps to an available Linux guest template, the exact supported `sbx` version is installed, `sbx diagnose --output json` reports `summary.pass > 0` and `summary.fail == 0`, and the provider backing volume can admit at least the minimum valid sandbox reservation plus its host-free watermark. Warnings and skipped checks are displayed but do not disable an otherwise healthy installation. This is a capability test rather than an OS allowlist; Docker's current troubleshooting guidance identifies the same command as its machine-readable diagnostic path. The wizard runs read-only admission and inventory checks, then offers only the active lock-selected source channels for that platform: Catthehacker `full-latest` (recommended) and `act-22.04` (current lean profile). A profile appears only when its exact versioned EPAR template tag is locally loaded, the Docker Sandboxes cache entry and full local Docker image identity agree, and the platform matches. Historical and superseded cached EPAR tags that are not the active profile tags are deliberately not choices. The wizard shows the friendly source-channel label before selection, then separately prints and saves the selected exact EPAR template tag and full local digest. Automatic source refresh is not implemented, so the lock remains an approved snapshot even if the rolling upstream channel moves. It fingerprints and validates the active host-global Balanced-policy baseline, writes a sandbox-scoped Open public-egress default, and displays the shared host template-cache size separately from per-sandbox capacity. The wizard asks one capacity question. When EPAR contains a measurement record for the selected exact template digest, it automatically derives `rootDisk` with the recommended 20 GiB writable headroom, asks for the workload-dependent Docker disk, and writes the 50 GiB host-free floor. Without exact root evidence, it asks for the provisional total root capacity and writes the 100 GiB Docker-disk and 50 GiB host-free defaults. Before writing configuration, setup evaluates the exact selected root and Docker reservations against the provider backing volume and reports the available space, required reserve, and shortfall if admission fails. Runtime admission repeats the measurement immediately before every create and remains authoritative because free space and active reservations can change. If a read-only check fails, the selected provider is retained while the wizard offers to retry those checks; declining exits without writing a config, and it never silently falls back or repeats the provider menu. The wizard does not build, load, prewarm, create, start, stop, or remove a sandbox during this discovery step. See [Docker's diagnostics guidance](https://docs.docker.com/ai/sandboxes/troubleshooting/). +Build and load the template before running the wizard. See [Docker Sandboxes template build and retention](../advanced/docker-sandboxes-template.md). -For the current exact Windows amd64 full-template proof, the host cache entry is approximately 17.44 GiB while the measured guest `/` peak after the representative Buildx and Compose probe is approximately 309.73 MiB. With the required 25% safety margin and 20 GiB writable headroom, rounding to the next 10 GiB produces a provisional `rootDisk` of 30 GiB. Earlier 70+ GB Docker Desktop storage observations and source-image virtual-size figures describe different accounting domains and are not inputs to this calculation. +## Minimal Configuration -Docker documents `DOCKER_SANDBOXES_ROOT_SIZE` and `DOCKER_SANDBOXES_DOCKER_SIZE` as independent root and `/var/lib/docker` capacities. Docker also documents the Docker-data volume as sparse and the template cache as reusable across sandbox deletion. EPAR locates Docker Sandboxes' documented platform storage root: `%LOCALAPPDATA%\DockerSandboxes` on Windows, `~/Library/Application Support/com.docker.sandboxes` on macOS, or `${XDG_STATE_HOME:-~/.local/state}/sandboxes` on Linux. If the provider root does not exist yet, setup measures its nearest existing parent on the same filesystem; after creation, runtime measures the exact provider root. EPAR fails closed if the storage volume cannot be measured, reserves the configured root and Docker capacities conservatively, keeps the stronger of the configured free-space floor, 50 GiB, and 10% of the provider-storage volume, and repeats admission immediately before each create. A setup-time result can become stale as cache state, concurrent reservations, and free space change, so runtime admission remains authoritative. See [Docker's disk-space troubleshooting](https://docs.docker.com/ai/sandboxes/troubleshooting/#sandbox-runs-out-of-disk-space), [template base-image storage](https://docs.docker.com/ai/sandboxes/customize/templates/#base-images), and [template caching](https://docs.docker.com/ai/sandboxes/customize/templates/#template-caching). - -## Configuration - -Start from `configs/docker-sandboxes.example.yml`. `provider.sourceImage` is invalid for this provider; template identity belongs under `dockerSandboxes`: +Start with [`configs/docker-sandboxes.example.yml`](../../configs/docker-sandboxes.example.yml). The wizard writes the exact values after it verifies local admission; do not substitute a raw Catthehacker image for the template. ```yaml -# Any supported native host with an amd64 guest template provider: type: docker-sandboxes platform: linux/amd64 dockerSandboxes: template: epar-docker-sandboxes-catthehacker-full: - templateDigest: sha256: - policyGeneration: sha256: + templateDigest: sha256: + policyGeneration: sha256: networkBaseline: open - additionalAllow: [] - additionalDeny: [] stagingRoot: .local/docker-sandboxes-staging cpus: 4 memory: 8GiB - rootDisk: - dockerDisk: + rootDisk: 30GiB + dockerDisk: 100GiB maxConcurrentCreates: 2 - minHostFreeSpace: -``` - -`networkBaseline: open` does not rewrite the host-global Docker Sandboxes preset. EPAR keeps verifying the configured global Balanced fingerprint and adds an exact sandbox-scoped `allow **` rule plus deny-wins rules for `host.docker.internal`, `gateway.docker.internal`, `kubernetes.docker.internal`, and `host.containers.internal`. EPAR owns, reads back, fingerprints, and removes all of these rules during exact cleanup. The host-alias denies are required because Docker Sandboxes v0.35.0 can otherwise proxy an allowed `host.docker.internal` request to a native-host loopback service. This avoids reactive public-domain allowlisting for general CI while preserving the host-service boundary and Docker Sandboxes' separate private-address, link-local, and inter-sandbox isolation. Public egress is still an exfiltration path for any source or secret available to a workflow, so EPAR's trusted-workload runner-group contract remains mandatory. - -Set `networkBaseline: balanced` to omit the wildcard rule and use default-deny public egress with `additionalAllow`. In either mode, `additionalDeny` supplies sandbox-scoped hostname denies, and deny rules take precedence. The current EPAR configuration grammar deliberately does not expose arbitrary raw policy arguments or global scope. Private-range CIDR rules are not generated automatically. Provider-boundary behavior, including `host.docker.internal`, remains part of live platform validation and runtime admission. - -For Apple Silicon, change the architecture-bearing values together: - -```yaml -runner: - labels: [self-hosted, linux, ARM64, epar-docker-sandboxes] - -provider: - type: docker-sandboxes - platform: linux/arm64 - -dockerSandboxes: - template: epar-docker-sandboxes-catthehacker-full: - templateDigest: sha256: + minHostFreeSpace: 50GiB ``` -Platform admission is architecture-exact and has no wizard OS allowlist: +`provider.sourceImage` is invalid for this provider. `templateDigest` is the full local image identity, not Docker Sandboxes' short cache ID. The `rootDisk`, `dockerDisk`, and host-free settings are reservations: the minimums are 20 GiB, 100 GiB, and 50 GiB respectively, and runtime also enforces at least 10% free on the backing volume. See [Configuration](../configuration.md) for the complete schema. -| Native controller architecture | Required guest platform | Architecture label | Default-selection behavior | -| --- | --- | --- | --- | -| `amd64` | `linux/amd64` | `X64` | Default when Docker, the supported `sbx` diagnostics, and exact template admission pass | -| `arm64` | `linux/arm64` | `ARM64` | Default when Docker, the supported `sbx` diagnostics, and exact template admission pass | +`networkBaseline: open` adds EPAR-owned sandbox-scoped public egress plus deny-wins guardrails for host aliases; it does not change the host-global Docker Sandboxes policy. Use `balanced` with `additionalAllow` for default-deny public egress. Additional allow/deny entries are exact hostnames or `*.domain[:port]`; they cannot override the Open host-alias denies. -Other architectures fail before admission can reserve or create a new sandbox because EPAR has no matching runner/template architecture. Cleanup and status access remain available for already-owned state. EPAR does not use emulation to admit an architecture-mismatched template. +## Normal Workflow -Docker Sandboxes also requires `security.runnerGroup.enforcement: enforce`; a warning-only runner-group policy is rejected during configuration validation before any sandbox is reserved. +1. Build and load a reviewed template using the advanced guide. +2. Run `./start` with no config, or `ephemeral-action-runner init`, and select Docker Sandboxes when its checks pass. The wizard records the exact template, policy fingerprint, architecture, and reservations. +3. Prewarm the selected template without GitHub registration: -Allowed network entries are exact hostnames or `*.domain[:port]`. The unrestricted `**` pattern is rejected by default configuration. Docker Sandboxes v0.35.0 adds one non-editable shell-kit allow rule for `openrouter.ai` to each shell sandbox. EPAR accepts only that exact built-in rule shape: `kit:`, exact `sandbox:` scope and target, network `allow`, one `openrouter.ai` resource, `scoped` origin, active state, and non-editable status. Provider-generated rule and policy IDs may vary, so the stable configured `policyGeneration` fingerprints the complete global baseline while EPAR also fingerprints and reports the complete effective readback, including the dynamic built-in and configured sandbox-scoped rules, before registration. Missing optional built-ins are allowed; duplicates, changed fields, inactive rules, unrelated scoped rules, or unattributed rules fail closed. EPAR-created sandbox-scoped rules are bound in the ownership ledger and removed by their exact IDs during cleanup; the built-in rule remains provider-owned and disappears with the exact sandbox. + ```powershell + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-native-controller.ps1 pool verify --config .local/docker-sandboxes.yml --project-root . --instances 1 --cleanup + ``` -The staging root is not a shared checkout. EPAR creates one fresh, canonical, empty, owner-restricted directory per sandbox and exposes only that directory as Docker Sandboxes' required read-write workspace. EPAR does not use `sbx create --clone`: clone mode starts a Git daemon and publishes a host-loopback port, which conflicts with the no-published-port admission boundary. EPAR rejects symlinks, junctions, reparse points, alternate data streams, path escapes, weak permissions, overlapping roots, and pre-existing contents, verifies that the staging directory is still empty before admission, and never executes or parses guest-created staging content. Actions `_work` remains under `/opt/actions-runner` on the private guest filesystem. Cleanup purges the exact owned staging object only after the exact sandbox is absent. +4. Start the pool with `./start`. EPAR validates the already loaded template; it does not build or load one for you. -## Lifecycle and secrets +Each allocation receives an empty owner-restricted staging directory, but Actions `_work` stays on the guest filesystem. EPAR verifies the guest, policy, private daemon, and host-trust generation before requesting a short-lived registration token. The token remains on the native host except for registration through `sbx exec` standard input. -EPAR records intent in `.local/state/` before each external side effect. The ledger binds the exact sandbox name and stable provider ID, staging path, template and policy identities, resource reservation, registration state, GitHub runner name and ID, and cleanup outcome. Reconciliation never selects or deletes sandboxes by prefix and never uses a global reset. +## Limitations -Docker Sandboxes uses the same runner-name contract as the established providers: `-YYYYMMDD-HHMMSS-NNN`. EPAR seeds each allocation range from the durable ownership ledger's next sequence, so a controller restart does not reset the suffix to `001`; the timestamp and sequence together distinguish allocations while the user-selected prefix remains visible without truncation or hashing. The complete configured prefix plus EPAR's suffix fits within the provider's 63-character name limit. +- Public egress remains an exfiltration path for workflow code and secrets. Use only trusted repositories and runner groups. +- Docker Sandboxes template cache storage is shared host state; it is not a per-sandbox root-disk measurement. +- A stopped sandbox is diagnostic state, not proof of deletion. Unknown state consumes capacity and blocks replacement. +- `EPAR_DISABLE_DOCKER_SANDBOXES=1` fails admission closed during an incident or compatibility problem. -The GitHub App key and installation-token exchange stay on the native host. EPAR sends the short-lived registration token through `sbx exec` standard input; it is permitted only in the guest `config.sh --token` process during registration and is excluded from host arguments, `sbx` arguments, templates, staging, the ledger, diagnostics, and logs. The guest must be unregistered and pass template, trust, Docker daemon, and policy verification before EPAR requests that token. +## Verification -Host trust roots are streamed into a fresh unregistered guest before its private Docker daemon makes any registry request. EPAR updates the guest certificate bundle, records the sole verified `dockerd` identity, pulls and removes an immutable multi-platform Alpine image through that daemon, and confirms that the same daemon remains ready before it persists the trust generation or requests a registration token. Candidate A does not attempt to stop and relaunch Docker Sandboxes' `dockerd`: v0.35.0 exposes no supported per-sandbox daemon restart lifecycle, and the negative PoC showed that a raw process restart can leave the guest without its private daemon. A trust-generation change therefore requires disposal and creation of a fresh sandbox. This follows the [Docker daemon certificate guidance](https://docs.docker.com/reference/cli/dockerd/) and Moby's [registry TLS implementation documentation](https://pkg.go.dev/github.com/moby/moby/registry), which constructs an endpoint TLS configuration from the system certificate pool. +Use the prewarm command above for an unregistered lifecycle check. To include GitHub registration, run: -A stopped sandbox is diagnostic state, not proof of disposal. Final cleanup uses exact `sbx rm --force`, then verifies that the sandbox identity, sandbox-scoped policy rules, GitHub runner record, and staging directory are absent. Uncertain state retains its capacity reservation and blocks replacement allocation. - -If diagnostics fail, automatic cleanup stops and retains the exact sandbox. After reviewing and preserving the failed-diagnostics evidence reported by `status`, an operator may run `ephemeral-action-runner cleanup --acknowledge-failed-diagnostics`. This explicit acknowledgement permits exact disposal while preserving the durable failed evidence in the ownership ledger; normal startup and automatic cleanup never apply the override. - -## Capability default and independent certification +```bash +ephemeral-action-runner pool verify --config .local/docker-sandboxes.yml --instances 1 --register-only --cleanup +``` -Docker's current documentation provides installation and runtime support paths for Windows, macOS, and Linux. EPAR does not duplicate that platform list in the first-run default decision: a native host is eligible when Docker works, the exact EPAR-supported `sbx` version passes machine-readable diagnostics with at least one pass and zero failures, the architecture is `amd64` or `arm64`, and the exact template and admission checks pass. +The shared pool treats provisioning, ready, draining, quarantined, and cleanup-pending instances as capacity-consuming states. Cleanup uses the durable exact-identity ledger, including the exact sandbox, policy rules, GitHub runner record, and staging directory; it never uses an `sbx` reset or broad prefix deletion. -Windows 11 x86_64 has the accepted real-host lifecycle, replacement, cleanup, and comparative CI runtime evidence recorded during this implementation. macOS and Linux do not yet have equivalent EPAR real-host evidence in this repository, but that evidence gap no longer disables the capability-ready wizard default. The separate embedded independent-certification table remains empty. A future independently certified record must bind reviewed evidence to the exact full template image identity, exact 12-hex cache ID, operator-anchored metadata and archive digests, and an exact clean native-controller source/build identity. This stronger certification record is separate from default selection and is not a claim that arbitrary hostile workflows are universally safe. +## Troubleshooting -See Docker's documentation for the underlying [security defaults](https://docs.docker.com/ai/sandboxes/security/defaults/), [architecture](https://docs.docker.com/ai/sandboxes/architecture/), and [custom template behavior](https://docs.docker.com/ai/sandboxes/customize/templates/). +For symptoms and recovery, see [Troubleshooting](../troubleshooting.md). If failed diagnostics retain a sandbox, inspect `status` and preserve the reported evidence before using `cleanup --acknowledge-failed-diagnostics`; ordinary cleanup never applies that override. diff --git a/docs/providers/tart.md b/docs/providers/tart.md index 30901f1..190a9a0 100644 --- a/docs/providers/tart.md +++ b/docs/providers/tart.md @@ -1,51 +1,67 @@ # Tart Provider (Experimental) -The Tart provider is experimental. It targets Apple Silicon macOS hosts and currently supports Ubuntu ARM64 guests. Tart itself can run macOS ARM64 VMs, but EPAR's image build, runner service, validation, and cleanup scripts currently depend on Ubuntu, systemd, and Linux process interfaces, so macOS guests are not yet an EPAR provider mode. +Tart runs each disposable EPAR runner in an Ubuntu ARM64 VM on Apple Silicon macOS. EPAR's Tart integration is experimental. -> [!WARNING] -> The default Tart source, `ghcr.io/cirruslabs/ubuntu:latest`, is a basic Ubuntu ARM64 OS image. It does not contain the broad dependency set normally present in GitHub's hosted images from [`actions/runner-images`](https://github.com/actions/runner-images), including many language SDKs, CLIs, browsers, and build tools. Tart uses Apple's Virtualization framework, so an Apple Silicon host runs an ARM64 VM. Rosetta translates supported x86_64 Linux user-space programs inside that ARM64 guest; it does not create an x64 VM, and not every amd64 image or workload is compatible. +## When To Use It -EPAR currently validates the Ubuntu path: +Choose Tart only when you have Apple Silicon macOS and specifically want Linux ARM64 VMs. Use Docker Container or WSL/x64 Linux when native amd64 workflow compatibility is more important. -- clone a reusable Tart image -- start the VM headless -- use the Tart guest agent for `exec` and IP discovery -- validate the base GitHub Actions runner runtime -- register an ephemeral GitHub runner from the host -- delete the VM after the runner exits +## Support Status -Use `configs/tart.example.yml` for the basic runner-only Ubuntu image or `configs/tart.web-e2e.example.yml` for the existing opt-in web/E2E and Rosetta experiment. EPAR installs the GitHub Actions runner and its lifecycle scripts, but the default does not install Docker, .NET, PowerShell, Go, browsers, or the rest of GitHub's hosted-runner tool inventory. +EPAR supports Ubuntu ARM64 guests, not macOS guests. The default source is a basic Ubuntu VM image, not a GitHub-hosted runner image; it does not include the broad language, browser, Docker, and CLI inventory associated with `actions/runner-images`. -If a workflow needs a GitHub-runner-like environment, build and maintain your own bootable Tart source image. Adapt the Ubuntu build scripts and tool definitions from [`actions/runner-images`](https://github.com/actions/runner-images) to that image, validate the resulting ARM64 tools, push it as a Tart VM image, and set `image.sourceImage` to it. Alternatively, add narrowly scoped `image.customInstallScripts` for only the dependencies your workflows require. EPAR does not convert the Catthehacker Docker image or automatically reproduce the complete GitHub-hosted image for Tart. +## Prerequisites -When Docker/browser support is selected on ARM64, EPAR exposes a Chromium-compatible browser through `epar-browser`, `chromium`, and `chromium-browser`; it is not guaranteed to be Google Chrome. +- Apple Silicon macOS and a working `tart` CLI. +- A bootable Ubuntu ARM64 Tart source image. +- Enough local VM storage for a reusable image and active disposable VMs. -The default network mode is Tart NAT. `softnet` is accepted by the provider, but it can require host-side privileges. +## Minimal Configuration -If Docker is installed in a custom Tart guest, optional `docker.registryMirrors` settings are applied to the guest Docker daemon when each disposable VM starts. Use a mirror URL that is reachable from inside the Tart VM; `host.docker.internal` is Docker-container-specific and may not resolve in Tart guests. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). - -## Experimental Rosetta Support For Linux Amd64 Containers - -Tart on Apple Silicon runs ARM64 VMs, but Tart can expose Apple's Linux Rosetta runtime to the guest with `tart run --rosetta `. EPAR supports this as an opt-in Tart-only setting: +Start with [`configs/tart.example.yml`](../../configs/tart.example.yml): ```yaml +image: + sourceImage: ghcr.io/cirruslabs/ubuntu:latest + outputImage: epar-ubuntu-24-arm64 + provider: type: tart - rosettaTag: rosetta + sourceImage: epar-ubuntu-24-arm64 + network: default ``` -When `provider.rosettaTag` is set, EPAR starts Tart instances with `--rosetta rosetta`, installs `/opt/epar/setup-rosetta.sh` during image build, enables `epar-rosetta.service`, and registers an x86_64 Linux `binfmt_misc` handler inside the guest. Images with the Rosetta feature marker validate: +Use a distinct image and label when adding tools through `image.customInstallScripts`. If workflows need a GitHub-runner-like environment, build and maintain your own bootable Ubuntu Tart image; EPAR does not convert Catthehacker Docker images into Tart VMs. + +## Normal Workflow + +1. Create the configuration and run `./start` to build the reusable Tart image and start the pool. +2. Target the ARM64 Tart label in workflows. +3. Use `pool verify` before routing a new workload to the provider. + +Tart clones the reusable image, starts the VM headless, uses the guest agent for command execution and IP discovery, and removes the VM after the ephemeral runner exits. + +## Limitations + +- This provider is experimental and intended for trusted jobs. +- The default image is runner-only. Add only the dependencies your workflows need, or maintain a fuller source image yourself. +- `provider.network: softnet` may require additional host privileges; NAT is the default. +- Rosetta can translate some Linux amd64 user-space workloads in an ARM64 guest, but it does not turn the VM into an x64 VM or guarantee every amd64 workload. + +## Verification ```bash -sudo -u runner -H docker run --rm --platform linux/amd64 alpine:3.20 sh -c 'uname -m' +ephemeral-action-runner pool verify --instances 1 --cleanup ``` -The expected output is `x86_64`. +For the optional Rosetta experiment, use `configs/tart.web-e2e.example.yml` or set a distinct `provider.rosettaTag`. Verify a real container execution before routing amd64 workflows: + +```bash +docker run --rm --platform linux/amd64 alpine:3.20 uname -m +``` -Host prerequisites: +Run that command inside the Tart guest; expected output is `x86_64`. -- Apple Silicon macOS -- Tart version with `--rosetta` support -- Apple's Rosetta package installed on the macOS host +## Troubleshooting -This is experimental support for Linux amd64 user-space containers. It is not nested virtualization and it does not make the Ubuntu VM an x64 VM. For native amd64 performance and compatibility, use a Windows WSL x64 provider or another x64 Linux host. For Tart Rosetta-capable runners, expose a distinct label such as `epar-tart-rosetta-amd64` so workflows can opt into the behavior explicitly. +See [Troubleshooting](../troubleshooting.md) for platform and runtime failures. Keep Rosetta-capable runners behind a dedicated label so workflows opt in deliberately. diff --git a/docs/providers/wsl.md b/docs/providers/wsl.md index a133260..5e4da47 100644 --- a/docs/providers/wsl.md +++ b/docs/providers/wsl.md @@ -1,21 +1,24 @@ -# WSL Provider +# WSL2 Provider -The WSL provider targets Windows hosts running WSL2. It manages disposable Ubuntu distros for trusted GitHub Actions jobs. +WSL2 runs each disposable GitHub Actions runner in an imported Ubuntu WSL distribution on a native Windows host. EPAR uses the shared pool lifecycle, including strict capacity, registration, replacement, and cleanup. -The provider maps EPAR lifecycle operations to `wsl.exe`: +## When To Use It -- clone/create: `wsl --import --version 2` -- start/exec: `wsl -d --user root --exec ` -- stop: `wsl --terminate ` -- delete: `wsl --unregister ` -- export image: `wsl --export ` -- list: `wsl --list --verbose` +Choose WSL2 for Windows-hosted Linux runners when you want native x64 Linux execution and a WSL distribution per runner. It is often the clearest choice for workflows that need native amd64 Docker execution on Windows. -When a disposable runner is started, EPAR also keeps a quiet host-side `wsl.exe -d ` process open. This prevents WSL from auto-stopping an imported distro that is otherwise only running systemd services. `pool up`, `pool verify --cleanup`, and `cleanup` terminate that keepalive by terminating or unregistering the distro. +## Support Status -## Configuration +WSL2 is supported only on native Windows with WSL default version 2. It is not equivalent to one full VM per job: distros share the WSL kernel and host integration surface, so use it for trusted jobs. -Use `configs/wsl.example.yml` as the starting point: +## Prerequisites + +- Native Windows, `wsl.exe --status` reporting default version 2, and an Ubuntu-compatible WSL environment. +- A working Docker daemon for the default Catthehacker Docker-image source during `image build`; later runner startup does not require Docker Desktop unless jobs use Docker. +- Enough storage for the pulled source image, intermediate rootfs tar, temporary WSL build distro, reusable tar, and active pool. + +## Minimal Configuration + +Start with [`configs/wsl.example.yml`](../../configs/wsl.example.yml): ```yaml image: @@ -23,12 +26,6 @@ image: sourceImage: ghcr.io/catthehacker/ubuntu:full-latest sourcePlatform: linux/amd64 outputImage: work/images/epar-wsl-catthehacker-ubuntu.tar - customInstallScripts: - # - examples/custom-install/install-extra-apt-tools.sh - -runner: - labels: [self-hosted, linux, X64, epar-wsl-catthehacker-ubuntu] - includeHostLabel: true provider: type: wsl @@ -36,71 +33,34 @@ provider: installRoot: work/wsl ``` -`image.sourceType: docker-image` tells EPAR to convert the source Docker image into a WSL-importable rootfs tar during `image build`. `image.outputImage` is the reusable runner tar produced by `image build`. `provider.sourceImage` is the tar imported for disposable runner instances. - -Use `configs/wsl.lean.example.yml` when you want the smaller tar-first path. Existing WSL configs that point `image.sourceImage` at a `.tar`, `.tar.gz`, or `.tgz` file are treated as `image.sourceType: rootfs-tar` for backward compatibility. Use `configs/wsl.web-e2e.example.yml` when workflows need the larger lean web/E2E install script and its `epar-wsl-ubuntu-24.04-web-e2e` label. - -## Docker Image Sources - -For the default full WSL image, EPAR uses Docker on the Windows host during `image build`: - -1. `docker pull --platform linux/amd64 ghcr.io/catthehacker/ubuntu:full-latest` -2. `docker create` a temporary stopped container -3. `docker container inspect` to capture image environment metadata -4. `docker export` the container filesystem into an intermediate rootfs tar -5. `docker rm -f -v` cleanup +Use `configs/wsl.lean.example.yml` with a pre-exported Ubuntu rootfs tar for a smaller path, or `configs/wsl.web-e2e.example.yml` for browser/E2E tooling. See [Configuration](../configuration.md) for labels, runner-group policy, capacity, and mirrors. -That exported rootfs is then imported into a temporary WSL distro. EPAR copies `/opt/epar` scripts, enables systemd, installs the GitHub Actions runner, writes the captured env to `/opt/epar/source-image.env`, validates Docker Engine from the base image, finalizes the image, and exports `image.outputImage`. +## Normal Workflow -The intermediate source tar and env metadata are cached beside `image.outputImage`. Delete `*.source.rootfs.tar` and `*.source.rootfs.tar.env` when you intentionally want to reconvert the Docker image. +1. Run `./start`; EPAR converts the default Docker source into a rootfs tar, builds the reusable WSL runner tar, and starts the pool. +2. Target the configured WSL label, normally `epar-wsl-catthehacker-ubuntu`. +3. Stop the supervisor with Ctrl-C and wait for cleanup confirmation. -Docker is required only for the Docker-image conversion step. Running WSL pool instances afterward does not require Docker Desktop unless your workflows need Docker Desktop or another host-side Docker service. +EPAR imports each runner from `provider.sourceImage`, enables systemd in the reusable image, and keeps a quiet host-side WSL process alive while a runner waits for work. That process is intentional: it prevents an otherwise idle systemd distro from stopping automatically. -## Systemd And Docker +## Limitations -The WSL image build writes `/etc/wsl.conf` with systemd enabled and `appendWindowsPath=false`, restarts the temporary distro, then installs the GitHub Actions runner inside the distro. Disabling Windows PATH injection keeps validation and jobs from accidentally resolving host-installed tools such as Windows Docker or Node. +- The default full image needs Docker only for source conversion; an image build can fail when Docker Desktop/Engine storage is full even if Windows has free disk space. +- EPAR does not install cross-architecture emulation. An x64 WSL runner can pull an ARM64 image but cannot execute it natively. +- The default Docker-enabled runner uses Docker Engine inside WSL, not a mounted Windows Docker socket. -The default WSL full image expects Docker Engine, dockerd, Compose v2, Buildx, and iptables to already exist in `ghcr.io/catthehacker/ubuntu:full-latest`. EPAR validates those tools and marks the image with `/opt/epar/features/docker-engine` so `pool verify` proves: +## Verification -```bash -sudo -u runner -H docker version -sudo -u runner -H docker compose version -sudo -u runner -H docker buildx version -sudo -u runner -H docker run --rm hello-world +```powershell +ephemeral-action-runner pool verify --instances 1 --cleanup ``` -Browser support is validated only when `image.customInstallScripts` includes `scripts/guest/ubuntu/install-docker-browser.sh` or `scripts/guest/ubuntu/install-web-e2e.sh`: +For a lean rootfs source, export Ubuntu 24.04 once before building: -```bash -sudo -u runner -H docker version -sudo -u runner -H docker compose version -sudo -u runner -H docker buildx version -sudo -u runner -H docker run --rm hello-world -printf '%s\n' '

EPAR browser validation marker

' >/tmp/epar-browser-validation.html -sudo -u runner -H chromium --headless --no-sandbox --dump-dom file:///tmp/epar-browser-validation.html +```powershell +wsl --export Ubuntu-24.04 work/images/ubuntu-24.04-clean.rootfs.tar ``` -The provider does not mount the Windows Docker Desktop socket. Docker-enabled jobs run against Docker Engine inside the WSL distro. - -Runner startup sources `/opt/epar/source-image.env` before launching `/opt/actions-runner/run.sh`. This lets GitHub Actions jobs inherit source image variables such as `ImageOS`, `ImageVersion`, `RUNNER_TOOL_CACHE`, browser paths, and Java paths. WSL keeps its own systemd and host keepalive model; it does not reuse Docker Container entrypoint. - -WSL x64 is the preferred EPAR target for workflows that pull amd64-only Docker runtime images. - -An x64 WSL runner can store an ARM64 image with `docker pull` or `docker load`, but it cannot run that image natively. Running ARM64 containers requires an explicitly configured emulation layer such as QEMU registered through `binfmt_misc`, or a native ARM64 runner. EPAR does not install cross-architecture emulation in WSL by default, so validate both the architecture and execution path before routing ARM64-dependent jobs to an x64 WSL label. - -If `docker.registryMirrors` is configured, EPAR applies it to Docker Engine inside each disposable WSL distro before validation. Use a mirror URL reachable from inside WSL, such as an organization DNS name or a host/LAN address. See [Docker Registry Mirrors](../advanced/docker-registry-mirrors.md). - -## Caveats - -- WSL2 is not the same isolation boundary as a full VM per job. -- WSL distros share the WSL kernel and host integration surface. -- Use this provider for trusted internal jobs unless your environment has reviewed and accepted the isolation model. -- The default Docker-image source needs Docker Desktop, Docker Engine, or another reachable Docker daemon during `image build`. -- The full Catthehacker runner image is large and needs enough disk for the pulled Docker image, the intermediate source rootfs tar, the temporary WSL import, and the final WSL tar. -- Expect one long-lived host `wsl.exe` process per running disposable runner. This is intentional and keeps the WSL distro alive while it waits for jobs. -- Cleanup only unregisters distros whose names match `pool.namePrefix`. - -References: +## Troubleshooting -- [WSL basic commands](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) -- [Systemd support in WSL](https://learn.microsoft.com/en-us/windows/wsl/systemd) +See [Troubleshooting](../troubleshooting.md) for Docker source conversion, WSL import failures, WSL/Docker storage, and architecture errors. Do not use `wsl --unregister` on an unrelated distro as general EPAR cleanup. diff --git a/docs/security.md b/docs/security.md index 08fd3eb..7b9023f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -40,23 +40,11 @@ WSL2 has a weaker isolation story than one full VM per job. Treat the WSL provid `image.customInstallScripts` run as root during image build and their effects are captured in the reusable image. Use them only for non-secret tooling and configuration. Do not bake Docker credentials, GitHub tokens, private keys, or project secrets into runner images. -Certificates configured through `image.trustedCaCertificatePaths` are embedded -in the reusable image and become public trust anchors for every process in its -runner instances. CA certificates are not treated as secrets. Add only CA roots -or intermediates that your organization has explicitly authorized, and rebuild -the image when they are rotated or revoked. - -`image.hostTrustMode: overlay` is a broader policy choice: after the operator -enables it, EPAR follows every root anchor in the configured host scopes, -including later additions, removals, and rotations. Windows and macOS user scope -can include roots installed by software running as that account. Enable it only -when the host trust administrators are also authorized to control runner trust. - -Host trust inheritance is additive to Ubuntu's default roots and explicit CA -paths. It does not emulate every Windows or macOS certificate-policy constraint, -and removing a host root cannot revoke an identical Ubuntu-bundled or explicitly -configured anchor. EPAR applies host changes through immutable runner generations: -running jobs keep their starting trust, while stale idle runners are replaced. +Certificates configured through `image.trustedCaCertificatePaths` are embedded in the reusable image and become public trust anchors for every process in its runner instances. CA certificates are not treated as secrets. Add only CA roots or intermediates that your organization has explicitly authorized, and rebuild the image when they are rotated or revoked. + +`image.hostTrustMode: overlay` is a broader policy choice: after the operator enables it, EPAR follows every root anchor in the configured host scopes, including later additions, removals, and rotations. Windows and macOS user scope can include roots installed by software running as that account. Enable it only when the host trust administrators are also authorized to control runner trust. + +Host trust inheritance is additive to Ubuntu's default roots and explicit CA paths. It does not emulate every Windows or macOS certificate-policy constraint, and removing a host root cannot revoke an identical Ubuntu-bundled or explicitly configured anchor. EPAR applies host changes through immutable runner generations: running jobs keep their starting trust, while stale idle runners are replaced. The GitHub App private key remains on the host. Guest instances receive only short-lived registration tokens at runtime. Do not bake tokens or private keys into runner images. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dbf750e..504c301 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,57 +1,35 @@ # Troubleshooting -This page is organized by symptom, host OS, and EPAR provider. Start with the sections that match the machine and provider you are using. +Start with the symptom that most closely matches the failure. EPAR is trusted-job infrastructure: keep TLS verification enabled, preserve the first relevant log, and do not use broad Docker/WSL resets or prune commands as a first response. -## Quick Diagnostics +## Contents -Start with the commands that match your host and provider. +- [Quick diagnostics](#quick-diagnostics) +- [Windows no-Go startup prints an HTTP/2 named-pipe diagnostic](#windows-no-go-startup-prints-an-http2-named-pipe-diagnostic) +- [A Docker workload fails with an architecture error](#a-docker-workload-fails-with-an-architecture-error) +- [Docker Sandboxes is unavailable or its preflight fails](#docker-sandboxes-is-unavailable-or-its-preflight-fails) +- [Docker Sandboxes rejects template, policy, or capacity](#docker-sandboxes-rejects-template-policy-or-capacity) +- [A runner is held for diagnostics or an acknowledgement](#a-runner-is-held-for-diagnostics-or-an-acknowledgement) +- [Docker image build runs out of space](#docker-image-build-runs-out-of-space) +- [Docker image build fails with TLS certificate errors](#docker-image-build-fails-with-tls-certificate-errors) +- [Windows Docker Desktop WSL2 disk is smaller than expected](#windows-docker-desktop-wsl2-disk-is-smaller-than-expected) +- [Docker Container startup fails](#docker-container-startup-fails) +- [WSL provider image build fails early](#wsl-provider-image-build-fails-early) +- [GitHub runner registration fails](#github-runner-registration-fails) -### All Hosts +## Quick diagnostics -EPAR logs are written under `work/logs` by default. The latest top-level error report is usually: - -```text -work/logs/epar-last-error.log -``` - -Image build logs use provider-specific names, for example: - -```text -work/logs/builds/epar-docker-container-catthehacker-ubuntu.docker-build.log -work/logs/builds/epar-wsl-catthehacker-ubuntu.wsl-build.log -``` - -Check the EPAR version and selected config: +EPAR writes logs under `work/logs` by default. Start with `work/logs/epar-last-error.log`, then inspect the matching build log in `work/logs/builds/` or instance transcript in `work/logs/instances/`. Manager events are console-only by default; raw transcripts are file-only unless `logging.transcriptSinks` includes `console`. ```bash ./start --help go run ./cmd/ephemeral-action-runner version -``` - -If you are running without local Go, use `./start --help`; the wrapper will run EPAR through the containerized Go toolchain. - -### Docker-Backed Workflows - -Use these on any host when the provider is Docker Container, when WSL image preparation starts from a Docker image, or when the no-Go wrapper is in use: - -```bash docker version docker info docker system df -docker image ls -``` - -To see the free space available to containers on the active Docker daemon: - -```bash -docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / ``` -For a custom source image, replace `ghcr.io/catthehacker/ubuntu:full-latest` with the value from `image.sourceImage`. - -### Windows Hosts - -For Windows hosts that use WSL2, Docker Desktop's WSL2 backend, or the WSL provider: +Without local Go, use `./start --help`; the wrapper selects the containerized toolchain. For Windows WSL2, Docker Desktop's WSL2 backend, or the WSL provider, also run: ```powershell wsl --version @@ -60,45 +38,23 @@ docker context ls docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / ``` -`ghcr.io/catthehacker/ubuntu:full-latest` is the default source image for EPAR's default config. If your config uses a custom source image, replace it with your configured `image.sourceImage`. - -Docker Desktop's WSL2 backend stores Docker data in a WSL-backed virtual disk. Windows Explorer free space and container-visible free space are related, but they are not the same number. - -### Linux Docker Engine Hosts - -On native Linux, Docker data usually lives under Docker's root directory. Check it directly: - -```bash -docker info --format '{{.DockerRootDir}}' -df -h "$(docker info --format '{{.DockerRootDir}}')" -``` - -### macOS Docker Hosts +Container-visible free space is the relevant value for Docker builds. Windows Explorer or Finder free space does not necessarily equal the free space in Docker Desktop, OrbStack, or another Linux VM backing the daemon. -Docker Desktop and OrbStack keep Linux container data inside their own VM/storage area. Use Docker's own view first: +## Windows no-Go startup prints an HTTP/2 named-pipe diagnostic -```bash -docker system df -docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / -``` - -If the container-visible disk is full, adjust or clean the Docker/OrbStack storage from that product's settings. Finder free space by itself may not reflect the Linux VM storage available to containers. - -## Windows No-Go Startup Prints An HTTP/2 Named-Pipe Diagnostic - -### Symptoms +### Symptom -During the containerized Go-toolchain bootstrap, Docker Desktop may print a line like: +The Windows no-Go bootstrap prints a line like: ```text -2026/07/22 02:36:46 http2: server: error reading preface from client //./pipe/dockerDesktopLinuxEngine: file has already been closed +http2: server: error reading preface from client //./pipe/dockerDesktopLinuxEngine: file has already been closed ``` -This is a Docker Desktop/Windows named-pipe transport diagnostic that can be emitted when a client connection closes. It is not an EPAR runner-group or GitHub API error. If the bootstrap `docker build` exits successfully and the EPAR wizard or command continues, the line does not indicate that EPAR failed. +### Diagnosis and remediation -The Windows no-Go wrapper suppresses only this exact diagnostic when the bootstrap build succeeds. If the Docker build fails, the wrapper preserves the complete Docker stderr, including this line, because it may then be relevant context. +Docker Desktop can emit this named-pipe transport diagnostic when a client connection closes. If the bootstrap `docker build` succeeds and the wizard or command continues, it is not an EPAR runner-group or GitHub API failure. The wrapper suppresses only this exact successful-build diagnostic and keeps full Docker stderr when the build fails. -If startup stops instead of continuing, verify the active Docker Desktop engine and context: +If startup stops, verify the selected engine and context: ```powershell docker version @@ -106,298 +62,163 @@ docker info docker context show ``` -Resolve any failed command or unhealthy engine reported by those checks. Do not treat every named-pipe message as harmless when Docker also returns a nonzero exit code. - -## Docker Container Fails Because Its Architecture Does Not Match The Runner - -### Symptoms +Resolve a failed command or unhealthy engine; do not treat every named-pipe line as harmless when Docker returned a nonzero exit code. -A Docker or Docker Compose service may fail immediately with one of these messages: +## A Docker workload fails with an architecture error -```text -exec /bin/sh: exec format error -exec user process caused: exec format error -cannot execute binary file: Exec format error -``` - -Docker may also warn that the requested image platform does not match the detected host platform. That warning alone is not a failure: the container can still run when a compatible emulation handler is already registered. - -These related messages point to different problems: +### Symptom -- `no matching manifest for linux/arm64/v8` or `no matching manifest for linux/amd64` means the image does not publish the requested platform. QEMU cannot supply a missing image manifest; choose an available platform or publish a multi-platform image. -- `qemu-x86_64: Could not open '/lib64/ld-linux-x86-64.so.2'` means translation started but the expected foreign-architecture loader or userspace is unavailable or incompatible. Registration alone may not make that image work. -- Exit code `139` indicates a segmentation fault. Emulation can expose workload-specific incompatibilities, but this code by itself does not prove an architecture mismatch. +Docker or Compose exits with `exec format error`, `cannot execute binary file`, a platform-mismatch warning, `no matching manifest`, a QEMU loader error, or exit code `139`. -### Confirm The Host And Image Platforms +### Diagnosis and remediation -Check the runner architecture and the Docker daemon that will execute the container: +Inspect the runner, daemon, image manifest, and Compose platform setting: ```bash uname -m docker info --format '{{.OSType}}/{{.Architecture}}' +docker image inspect --format '{{.Os}}/{{.Architecture}}' IMAGE +docker buildx imagetools inspect IMAGE +docker compose config ``` -Inspect the locally selected image platform: +`no matching manifest` means the image does not publish the requested platform; emulation cannot create a missing manifest. A platform warning alone does not prove failure, and exit code `139` alone does not prove an architecture mismatch. Use the exact image and workload evidence. For the architecture model, QEMU setup, provider scope, and verification commands, see [Cross-architecture containers](advanced/cross-architecture-containers.md). -```bash -docker image inspect --format '{{.Os}}/{{.Architecture}}' IMAGE -``` +## Docker Sandboxes is unavailable or its preflight fails -Inspect all platforms published by a registry image: +### Symptom -```bash -docker buildx imagetools inspect IMAGE -``` +The wizard marks Docker Sandboxes unavailable, or `sbx diagnose --output json` reports failures. -For Docker Compose, also inspect the resolved configuration and look for a service-level `platform:` value: +### Diagnosis and remediation + +Check the installed CLI and the diagnostic result before editing configuration: ```bash -docker compose config +sbx version +sbx diagnose --output json ``` -An x64 Linux Docker daemon normally runs `linux/amd64` images natively, and an ARM64 daemon normally runs `linux/arm64` images natively. Pulling or loading a foreign image does not prove that the daemon can execute it. +EPAR requires the exact supported `sbx` version, a controller architecture with an available Linux guest template, and at least one diagnostic pass with zero failures. Diagnostic warnings remain visible but do not by themselves disable the provider. Fix the failed prerequisite, rerun the diagnostic, then restart `./start`; do not manually force a provider selection or substitute Docker Container for a configured Docker Sandboxes pool. -### Match GitHub-Hosted Linux Behavior With Explicit QEMU Setup +## Docker Sandboxes rejects template, policy, or capacity -GitHub's Ubuntu runner image installs Docker, but its published installation script and software inventory do not promise pre-registered foreign-architecture emulators. When a trusted Linux job must run foreign-architecture containers, configure the requirement explicitly before the first such container starts: - -```yaml -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Set up ARM64 container emulation - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 - with: - image: docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 - platforms: arm64 - - - name: Verify the foreign container - run: docker run --rm --platform linux/arm64 alpine:3.22 uname -m -``` - -The expected output is `aarch64`. Add only the platforms the workflow needs; emulated compilation and compute-heavy workloads can be substantially slower than native execution. Keep architecture-sensitive jobs on native runners when performance or full compatibility matters. +### Symptom -`docker/setup-qemu-action` registers user-mode QEMU interpreters through Linux `binfmt_misc`. It helps Linux containers launch foreign-architecture user-space executables; it does not change the runner's CPU architecture, create a foreign-architecture VM, or make arbitrary host executables and libraries compatible. The action uses a privileged helper container, so use it only in trusted workflows and pin reviewed action and helper-image revisions according to your dependency policy. +Startup reports a template identity/digest mismatch, policy-generation drift, an admission failure, or insufficient capacity. -Provider notes: +### Diagnosis and remediation -- Docker Container: run the setup action inside the EPAR job before Docker Compose or other foreign-image commands. It configures the disposable runner's Docker execution environment; no EPAR configuration switch is required. -- WSL: run the setup action inside the WSL runner when its Linux Docker daemon must execute a foreign image. An x64 WSL runner does not gain ARM64 container support merely by pulling or loading an ARM64 image. -- Tart: Tart runs an ARM64 VM on Apple Silicon. Its optional Rosetta path is experimental and is not equivalent to QEMU/binfmt compatibility. Prefer Docker Container or a native matching architecture when a workload is not compatible. -- GitHub-hosted Windows and macOS: GitHub documents Docker container actions and service containers as Linux-runner features. A Windows or macOS hardware label alone is therefore not a substitute for a Linux Docker daemon with emulation configured. +Docker Sandboxes uses an explicit local Candidate A template, full OCI configuration digest, and host-global Balanced-policy fingerprint. A mutable tag, a template copied from another host, a changed template digest, or a changed policy generation is intentionally rejected. Return to the wizard or the documented template promotion procedure to obtain a fresh, locally verified configuration; do not weaken the fingerprint or replace it with a guessed value. -Official references: +Capacity admission accounts for the configured root disk, inner Docker disk, per-sandbox memory, concurrent creation limit, and host-free reserve. Inspect the storage location reported by the failure and free or expand the relevant backing storage. The minimums are `20GiB` root disk, `100GiB` Docker disk, and `50GiB` host free space; runtime can require more. Avoid broad cleanup commands: they can delete stopped containers and intentionally retained resources. -- [Docker Setup QEMU action](https://github.com/docker/setup-qemu-action) -- [Docker multi-platform build strategies](https://docs.docker.com/build/building/multi-platform/) -- [GitHub-hosted runner labels and limitations](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) -- [GitHub self-hosted runner container requirements](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#requirements-for-self-hosted-runner-machines) -- [GitHub Ubuntu runner Docker installation](https://github.com/actions/runner-images/blob/main/images/ubuntu/scripts/build/install-docker.sh) +`networkBaseline: open` is a sandbox-scoped public-egress compatibility rule with EPAR host-alias deny guardrails. It does not alter the host-global policy. If a required service is blocked, use a narrow `additionalAllow` hostname rule; do not allow `host.docker.internal`, `gateway.docker.internal`, `kubernetes.docker.internal`, or `host.containers.internal` through the Open-policy guardrails. -## Docker Image Build Runs Out Of Space +## A runner is held for diagnostics or an acknowledgement ### Symptom -During `start` or `image build`, the log contains: +An instance is retained, quarantined, or shown as requiring an acknowledgement after a provisioning, policy, or runtime failure. -```text -E: You don't have enough free space in /var/cache/apt/archives/. -``` +### Diagnosis and remediation -or another package install fails with `No space left on device`. +Preserve the instance and inspect `work/logs/instances/.guest.log`, the matching runner diagnostics, and controller output before acknowledging or removing it. EPAR deliberately keeps uncertain ownership, failed cleanup, and unverified remote state inside the strict `pool.instances` cap instead of creating a replacement storm. -### What It Means +If an incident requires stopping new work immediately, stop the controller with `Ctrl-C` or the service manager that launched it. This prevents replacement; it does not erase retained evidence. Use the configured EPAR cleanup command only after identifying the exact affected pool. Do not use a broad `docker system prune`, WSL unregister, or reset as an incident-disable switch. -This error is raised inside the temporary container or guest that is building the runner image. It usually means the Docker daemon or VM backing that build is out of writable layer space. It does not necessarily mean the host OS drive has no free space. +Set `EPAR_DISABLE_DOCKER_SANDBOXES=1` before starting EPAR when Docker Sandboxes admission must remain disabled during an incident or compatibility investigation. This fails the provider closed without changing configuration or deleting evidence. -Check the active Docker daemon: +After reviewing retained Docker Sandboxes diagnostics, acknowledge that review only for the exact configured pool: ```bash -docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / -docker system df +ephemeral-action-runner cleanup --acknowledge-failed-diagnostics ``` -If `/` inside the container is nearly full, clean up Docker data or increase the Docker VM/data-disk limit before retrying. +## Docker image build runs out of space + +### Symptom + +`start` or `image build` reports `No space left on device` or `E: You don't have enough free space in /var/cache/apt/archives/.`. -### Cleanup Direction +### Diagnosis and remediation -Review Docker's usage first: +The temporary guest or Docker writable layer is full; this does not necessarily mean the host OS drive is full. Inspect the active Docker daemon: ```bash +docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / docker system df docker system df -v ``` -Docker prune commands remove resources that Docker considers unused. Depending on the command, that can include stopped containers, unused images, build cache, unused networks, or unused volumes. Review the Docker command help and the data on the machine before pruning, especially if you expect to restart stopped containers or keep data in Docker volumes. +Increase the relevant Docker/VM data-disk allocation or deliberately remove unneeded data after reviewing it. Docker prune commands can remove stopped containers, unused images, build cache, networks, and volumes; they are not a safe generic fix. -## Docker Image Build Fails With SSL Certificate Errors +## Docker image build fails with TLS certificate errors ### Symptom -During `start`, `image build`, or a CI job, HTTPS access fails with certificate errors such as: - -```text -curl: (60) SSL certificate problem: unable to get local issuer certificate -``` - -```text -Certificate verification failed: The certificate is NOT trusted. -The certificate issuer is unknown. Could not handshake: Error in the certificate verification. -``` - -### What It Means +HTTPS access fails with `curl: (60)`, `certificate verification failed`, or an unknown issuer during an EPAR build or job. -Antivirus, endpoint-security, firewall, or corporate-proxy software may be inspecting HTTPS and re-signing certificates with a root CA trusted by the host but not present in the Ubuntu runner image. +### Diagnosis and remediation -### How to Fix - -Do not disable certificate verification. Use EPAR's host trust overlay so the disposable Docker Container runners automatically inherit the host's trusted root CAs while retaining Ubuntu's standard roots. - -New interactive Docker Container configurations enable the overlay by default. For an older Windows or macOS configuration, add: - -```yaml -image: - hostTrustMode: overlay - hostTrustScopes: [system, user] -``` - -Linux supports only the system scope, so use `hostTrustScopes: [system]` instead. Rebuild the image after changing the configuration: - -```powershell -go run ./cmd/ephemeral-action-runner image build --replace -``` - -Without Go installed, run `scripts\run-with-docker.ps1 image build --replace` on Windows or `scripts/run-with-docker.sh image build --replace` on macOS or Linux. Use the official wrapper because it collects trust from the real host rather than the temporary Linux toolchain container. - -EPAR uses the resulting consolidated Ubuntu trust bundle during both image construction and CI jobs. Node.js, Python Requests, and pip receive compatible runtime defaults without overwriting values already supplied by the source image or runner environment. - -Programs with private certificate stores can still require application-specific configuration. Java keystores remain a separate concern. - -### Advanced: Add a Certificate Explicitly - -Use `image.trustedCaCertificatePaths` when a required CA is not trusted by the selected host stores or when the configuration must pin a specific CA independently of host trust. - -Export the CA as PEM, Base-64 encoded X.509 `.CER`, or DER-encoded `.CER`, place it in the repository, and add it to the configuration: +Do not disable certificate verification. Configure host trust overlay for an ephemeral runner and rebuild the image: ```yaml image: hostTrustMode: overlay hostTrustScopes: [system, user] - trustedCaCertificatePaths: - - .local/private-root.cer ``` -Explicit certificates are validated and added to the same Ubuntu trust bundle; they are combined with, not substituted for, the host trust overlay. +Use `[system]` on Linux. Overlay mode collects the current host roots, validates them before registration, and combines them with Ubuntu roots and any `image.trustedCaCertificatePaths`; it is root-anchor inheritance rather than exact Windows/macOS TLS-policy emulation. It requires `runner.ephemeral: true`. -### Host Trust Overlay Is Missing, Stale, Or Mismatched - -This section applies when a Docker Container config contains: - -```yaml -image: - hostTrustMode: overlay -``` - -EPAR fails closed when host collection returns no roots, the official no-Go bridge is missing, its feed is invalid or more than 30 seconds old, or a runner's 20-second lease does not match the image's trust generation. A pre-job mismatch can fail an already assigned GitHub job before repository steps run. Inspect the controller output, runner guest log, and the no-Go watcher's log in the host trust cache for the first collection, feed, or lease error. - -Check these boundaries: - -- Windows and macOS support `hostTrustScopes: [system, user]`; Linux supports `[system]` only. -- Overlay mode requires `provider.type: docker-container` and `runner.ephemeral: true`. -- Use the official `./start`, `start.ps1`, or release launcher for the no-Go path. A bare Linux toolchain container cannot inspect Windows Certificate Stores or macOS Keychain and must not substitute its own CA bundle. -- On an uncommon Linux distribution, set `EPAR_HOST_TRUST_BUNDLE` to the distribution-generated PEM CA bundle before launching EPAR. -- Confirm host and guest clocks are correct; feed and lease expiry checks use timestamps and reject stale data. - -Do not disable the pre-job gate or TLS verification. Restore host collection, then let EPAR build and register the current immutable trust generation. - -Host Docker daemon trust is separate from runner trust. If `docker pull` of the source image fails, configure the authorized CA for Docker Desktop, OrbStack, or the host Docker Engine first; the Ubuntu overlay does not exist until after that pull succeeds. - -## Windows Docker Desktop WSL2 Disk Is Smaller Than Expected - -### Symptom - -On a Windows machine where WSL2 storage was set up before 2021, the Docker container filesystem may report about 251 GB total: +Use the normal host entry point so EPAR can inspect the real Windows certificate stores or macOS Keychain: ```powershell -docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / -``` - -Example: - -```text -Filesystem Size Used Avail Use% Mounted on -overlay 251G 211G 28G 89% / -``` - -On a Windows machine where WSL2 storage was set up after the 2022 WSL default-size change, the same command may report about 1007 GB total: - -```text -Filesystem Size Used Avail Use% Mounted on -overlay 1007G 127G 830G 14% / +./start +go run ./cmd/ephemeral-action-runner image build --replace ``` -### Why It Happens - -This is a Windows Docker Desktop / WSL2 storage detail, not an EPAR image-size issue. The command reports the size of Docker Desktop's Linux container storage, not the size of `ghcr.io/catthehacker/ubuntu:full-latest`. - -For Windows machines where WSL2 storage was set up before 2021, the default WSL2 virtual disk maximum may be about 256 GB. For WSL2 setups created after the WSL 0.58.0 change, released in 2022, Microsoft's documentation says the default maximum for each WSL2 VHD is 1 TB. This can explain why one Windows machine reports about 251 GB while another reports about 1007 GB for the container-visible filesystem. - -Windows Explorer free space by itself is not enough to confirm Docker has build space. The container-visible filesystem must have enough free space for the image pull, build layers, package manager cache, and final runner image. +On no-Go Windows, use `scripts\run-with-docker.ps1 image build --replace`; on macOS/Linux, use `scripts/run-with-docker.sh image build --replace`. A bare Linux toolchain container is not a replacement for the host-trust bridge. If the source-image `docker pull` itself fails, configure the authorized CA in Docker Desktop, OrbStack, or Docker Engine first. -For more background, see: - -- -- -- - -## Docker Container Build Fails With `unknown flag: --progress` +## Windows Docker Desktop WSL2 disk is smaller than expected ### Symptom -The Docker Container image build fails with: - -```text -unknown flag: --progress -``` +`docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h /` shows much less capacity than Windows Explorer. -### What It Means +### Diagnosis and remediation -This happens when the Docker client used for the build routes `docker build` through the legacy builder, or when the client does not have Buildx-style build support. It is most visible when EPAR is run through a containerized Go toolchain whose bundled Docker client differs from the host `docker.exe`. +Docker Desktop stores Linux container data in a WSL-backed virtual disk. Older WSL2 installations can have a smaller default VHD maximum than newer ones, but the reported container filesystem is the evidence that matters for image pulls and builds. Inspect Docker usage first, then change Docker Desktop/WSL storage using the product's supported settings. See [Microsoft WSL disk-space guidance](https://learn.microsoft.com/windows/wsl/disk-space) and [Docker Desktop WSL guidance](https://docs.docker.com/desktop/features/wsl/). -Current EPAR builds use legacy-builder-compatible Docker build arguments. If you still see this error, confirm you are running a revision that includes that fix and check which Docker client is actually executing the command: - -```bash -docker version -docker build --help -docker buildx version -``` +## Docker Container startup fails -## Docker Container Startup Fails +### Privileged containers -### Privileged Containers - -Docker Container requires the host Docker runtime to allow privileged Linux containers. Confirm the Docker host supports: +Docker Container requires a host Docker runtime that permits privileged Linux containers: ```bash docker run --rm --privileged alpine:3.20 true ``` -### Nested Docker Storage Driver +### Nested Docker storage driver -If Docker Container starts but nested Docker operations fail with overlay mount errors, keep the default inner daemon storage driver: +If nested Docker operations fail with overlay-mount errors, retain the default inner storage driver: ```text EPAR_DOCKERD_STORAGE_DRIVER=vfs ``` -Use `overlay2` or `auto` in a derived image only after proving that storage driver works on the exact host runtime. +Use `overlay2` or `auto` only in a derived image after proving it works on the exact host runtime. + +## WSL provider image build fails early -## WSL Provider Image Build Fails Early +### Symptom -This section applies to Windows hosts using `provider.type: wsl`. +The WSL image build fails before import, during import with `0xffffffff`, or before systemd is ready. -If the default WSL image build fails before importing or starting the temporary distro, confirm Docker is reachable because the default WSL full image converts a Docker image into a rootfs tar: +### Diagnosis and remediation + +The default WSL build obtains a Docker source image before importing it into WSL. Verify Docker and WSL first: ```powershell docker version @@ -405,58 +226,19 @@ docker pull ghcr.io/catthehacker/ubuntu:full-latest wsl -l -v ``` -### WSL Import Exits With `0xffffffff` - -If the Docker export completes but the first temporary-distro import fails like this: - -```text -wsl.exe --import ... --version 2 failed: exit status 0xffffffff: -``` - -WSL may be in an unstable service or VM session. The error can also appear as -`Wsl/Service/CreateInstance/E_UNEXPECTED` or `Catastrophic failure` when -starting an existing distro. When the import itself fails, the advertised WSL -build and guest logs may be empty or absent because no guest was created yet. +For `Wsl/Service/CreateInstance/E_UNEXPECTED`, `Catastrophic failure`, or import exit `0xffffffff`, stop EPAR, save work in other distros, then run `wsl --shutdown`. This stops every running WSL distro, including Docker Desktop's backend. Restart Docker Desktop, verify a normal distro command returns `0`, then rerun `./start`; a matching cached source rootfs is reused. If it persists, update WSL, shut it down again, reboot, and consult [Microsoft's WSL troubleshooting guidance](https://learn.microsoft.com/windows/wsl/troubleshooting#error-code-0x8000ffff-unexpected-failure). -Reset the WSL session before deleting or rebuilding a completed source rootfs: +If a guest exists but systemd does not become ready, inspect `work/logs/builds/.wsl-build.log` and `work/logs/builds/.guest.log`. Do not unregister a distro until you have identified the exact EPAR-owned target and accepted that unregistration is irreversible. -1. Stop EPAR and quit Docker Desktop cleanly from its tray menu. -2. Run: +## GitHub runner registration fails - ```powershell - wsl --shutdown - ``` - -3. Start Docker Desktop again and wait until it is ready. -4. Verify WSL and Docker. Replace `Ubuntu-24.04` if your installed distro has a - different name: - - ```powershell - wsl -d Ubuntu-24.04 --user root --exec /bin/true - $LASTEXITCODE - docker version - ``` - -5. When the WSL command returns `0` and Docker is ready, rerun `./start` or - `.\start`. EPAR reuses a matching cached - `work/images/*.source.rootfs.tar`, avoiding another large Docker export. - -`wsl --shutdown` stops every running WSL distro, including Docker Desktop's WSL -backend. Save work in other distros first. If the failure persists after the -reset, run `wsl --update`, shut WSL down again, reboot Windows, and retry once. -For persistent `0x8000FFFF` or `E_UNEXPECTED` failures, follow -[Microsoft's WSL troubleshooting guidance](https://learn.microsoft.com/windows/wsl/troubleshooting#error-code-0x8000ffff-unexpected-failure). - -If the WSL image build fails after import but before systemd is ready, inspect: +### Symptom -```text -work/logs/builds/.wsl-build.log -work/logs/builds/.guest.log -``` +EPAR cannot request a registration token, add a runner to a group, or observe the runner online. -## GitHub Runner Registration Fails +### Diagnosis and remediation -Confirm the GitHub App has organization self-hosted runner read/write permission and that the private key path in the config is readable from the EPAR process: +Verify GitHub App organization self-hosted-runner read/write permission and a readable private key: ```yaml github: @@ -465,10 +247,12 @@ github: privateKeyPath: .local/github-app.pem ``` -If stale runner records remain after an interrupted run: +Then inspect runner-group policy and the first registration error. A strict policy can intentionally block a group that is default, overly broad, or public-repository enabled. See [Runner Group Security](runner-groups.md). + +For a confirmed stale EPAR resource, run the configured cleanup command: ```bash go run ./cmd/ephemeral-action-runner cleanup ``` -Cleanup only targets runner names matching `pool.namePrefix`, so keep that prefix unique per machine/config within the GitHub organization. +Cleanup is bounded by the configured pool and durable exact lifecycle identities; it does not authorize a broad prefix deletion, wildcard, Docker prune, or removal of unknown/shared resources. Keep `pool.namePrefix` unique per controller and organization. diff --git a/docs/usage.md b/docs/usage.md index a8fc02b..fb80b85 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,364 +1,131 @@ # Usage -This page is the operational walkthrough. Start with the supported host you already have: - -- Docker Container on a Docker-capable host -- WSL2 on Windows -- Tart on Apple Silicon macOS +Use this page for normal EPAR tasks. Start with the host and provider you already have; the [documentation hub](README.md) links to the provider-specific guides. ## Prerequisites -Install the host tools you need: - -| Required for | Tool | +| Task | Required tool or access | | --- | --- | -| Source archive quick start | Go 1.25 or newer, or Docker (see [no-Go-install](advanced/no-go-install.md)) | -| Updating the pinned `actions/runner-images` checkout | Git | -| macOS provider | Tart | -| Windows provider | WSL2 | -| Windows WSL2 default image build | Docker Desktop, Docker Engine, or another working Docker daemon for the one-time Docker image export | -| Docker Container provider | Docker Engine, OrbStack, or Docker Desktop with privileged container support | -| Optional Docker registry mirrors | A running mirror service on the host, LAN, intranet, or cloud registry cache | -| Runner registration | GitHub App with organization self-hosted runner read/write permission | - -Packer, GitHub CLI, and sshpass are not required. - -Set up the GitHub App before registering runners. The image build command can run without GitHub credentials, but `pool verify --register-only`, `pool up`, `status`, and GitHub cleanup need the app settings. See [GitHub App Setup](github-app.md). - -## Get The Source - -For normal use, open the [EPAR Releases page](https://github.com/solutionforest/ephemeral-action-runner/releases), select the release you want, and download GitHub's automatically generated **Source code (zip)** or **Source code (tar.gz)**. Extract the source archive and open a terminal in the extracted folder: - -```bash -cd path/to/ephemeral-action-runner- -go run ./cmd/ephemeral-action-runner version -``` - -The examples below use `go run ./cmd/ephemeral-action-runner` for the public source-first path. +| Run a source archive | Go 1.25 or newer, or Docker for the no-Go controller builder | +| Register or inspect GitHub runners | A GitHub App with organization self-hosted runner read/write permission | +| Docker Container | Docker Engine, Docker Desktop, or OrbStack with privileged Linux-container support | +| Docker Sandboxes | Docker, the supported `sbx` CLI, healthy `sbx diagnose --output json`, and an approved EPAR template already built and loaded | +| WSL | Native Windows, WSL2, and Docker when preparing the default WSL image | +| Tart | Native Apple Silicon macOS and Tart | -Don't want to install Go at all? See [Running EPAR Without Installing Go](advanced/no-go-install.md) for running the source archive in a container. +Get the source from the [EPAR releases page](https://github.com/solutionforest/ephemeral-action-runner/releases), extract the source archive, and work from that folder. You do not need Packer, GitHub CLI, or `sshpass`. -## One-Command Start +## Start a pool -Run EPAR from the source folder. On macOS, Linux, WSL, or Git Bash, use the `./start` wrapper; on native Windows PowerShell/cmd, use `.\start.ps1` or `start.cmd`. Either uses Go if installed, or uses a containerized Go toolchain to build and cache a CGO-disabled native controller under `.local/bin` automatically (see [Running EPAR Without Installing Go](advanced/no-go-install.md)): +On macOS, Linux, WSL, or Git Bash, run: ```bash ./start ``` -Equivalent without the wrapper: - -```bash -go run ./cmd/ephemeral-action-runner -``` - -If no config exists, EPAR starts the initializer, asks for the GitHub App ID, organization, and private key path, then lists the organization's live runner groups. The group choice is explicit and has no Enter-for-default or offline fallback. Default and broad-access groups require warning confirmation; public-enabled groups are blocked by the generated safety policy. See [Runner Group Security](runner-groups.md). The provider menu always displays the same four choices and prints a prerequisite status beneath each one: option 1 Docker Container requires Docker; option 2 Docker Sandboxes requires Docker, a supported host architecture, the exact supported `sbx` version, and healthy `sbx diagnose --output json` results; option 3 WSL2 requires native Windows, Docker, and `wsl.exe --status` reporting default version 2; option 4 experimental Tart requires native macOS and a successful `tart --version`. Unavailable choices remain visible but cannot be selected. Docker Sandboxes is the Enter default on any OS when those capability checks pass: diagnostics must contain at least one pass and zero failures, while warnings such as an available update are accepted. Setup performs read-only checks for compatible `sbx` admission, an exact locally built and loaded EPAR template, the matching full local Docker image identity and platform, and the current host-global Balanced-policy fingerprint. If discovery fails, the wizard retains the Docker Sandboxes selection and offers to retry the checks without repeating the provider menu; declining exits without writing a config or falling back. The wizard asks only one capacity question, chosen from the available measurement evidence, and writes the other recommended values for later editing. New Docker Sandboxes configs default to sandbox-scoped open public HTTP/HTTPS egress with deny-wins host-alias guardrails so ordinary CI dependency endpoints do not require reactive allowlisting; this does not change the machine's global Docker Sandboxes policy. The wizard asks whether a new Docker Container or Docker Sandboxes config should inherit the controller host's trusted TLS roots and defaults to yes; existing configs remain disabled unless they explicitly set `image.hostTrustMode: overlay`. EPAR then checks provider admission and runner-group policy, prepares the configured environment, and starts the configured number of runners. The default config uses `pool.instances: 1`. - -Pass flags through `./start` to choose a config or runner count: - -```bash -./start --config .local/config.yml --instances 2 -``` - -Equivalent without the wrapper: - -```bash -go run ./cmd/ephemeral-action-runner start --config .local/config.yml --instances 2 -``` - -On Windows PowerShell: +On native Windows PowerShell, run: ```powershell -.\start.ps1 --config .local\wsl.yml --instances 2 +.\start.ps1 ``` -Equivalent without the wrapper: +The wrapper uses local Go when available, otherwise it uses Docker to build and cache a native controller under `.local/bin`. See [Running EPAR Without Installing Go](advanced/no-go-install.md) for the fallback details. The equivalent direct source command is: -```powershell -go run ./cmd/ephemeral-action-runner start --config .local\wsl.yml --instances 2 +```bash +go run ./cmd/ephemeral-action-runner start ``` -Stop the foreground process with Ctrl-C. Cleanup is enabled by default. +When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group, shows every provider with its current prerequisite result, and refuses unavailable selections. Docker Sandboxes becomes the Enter default only when Docker, the supported `sbx` version, its diagnostics, the host platform, and the exact locally built-and-loaded template pass admission. The wizard does not build or load a template, and it never falls back from a selected provider. -If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.instances` from the config. Passing `--instances N` overrides the config for that run. +Before choosing Docker Sandboxes, follow [Docker Sandboxes](providers/docker-sandboxes.md) to build, review, and load the approved template for the host platform. The wizard verifies the template cache entry and its full local image identity before it writes configuration. -## Configure Only +## Create or choose configuration -Use `init` when you only want to create a config without building an image or starting runners. It uses the same prerequisite-aware provider menu as missing-config `./start`; Docker Sandboxes is the recommended default on any OS when Docker and the supported `sbx` diagnostics are ready, and Docker Container, WSL2, and experimental Tart remain visible with their current availability status: +Create configuration without starting runners: ```bash go run ./cmd/ephemeral-action-runner init ``` -On Windows PowerShell: - -```powershell -go run ./cmd/ephemeral-action-runner init -``` - -For other WSL or Tart variants, or for custom labels, copy one example config into `.local/config.yml`, then edit the GitHub App fields, `runner.group`, and any labels you want to expose to workflows. Example group names are placeholders. - -| Host and image | Example config | -| --- | --- | -| Windows Docker Sandboxes, recommended full Catthehacker template | `configs/docker-sandboxes.example.yml` | -| macOS Tart, experimental basic Ubuntu ARM64 image | `configs/tart.example.yml` | -| macOS Tart, web/E2E with Rosetta amd64 Docker support | `configs/tart.web-e2e.example.yml` | -| Windows WSL2, default full Catthehacker runner image | `configs/wsl.example.yml` | -| Windows WSL2, lean runner-only tar | `configs/wsl.lean.example.yml` | -| Windows WSL2, lean web/E2E tar | `configs/wsl.web-e2e.example.yml` | -| Docker Container, default full Catthehacker runner image | `configs/docker-container.example.yml` | -| Docker Container, Docker-focused Catthehacker Act image | `configs/docker-container.act.example.yml` | -| Docker Container, smaller web/E2E custom image | `configs/docker-container.web-e2e.example.yml` | - -Tart is experimental. Its default image is a basic Ubuntu ARM64 OS image with the EPAR runner lifecycle, not the dependency-rich environment described by [`actions/runner-images`](https://github.com/actions/runner-images). If your workflows depend on that environment, adapt the upstream build scripts to create and maintain your own bootable Tart image and point `image.sourceImage` at it; EPAR does not automatically create one. - -macOS: +Pass a config path and an instance count through the wrapper: ```bash -mkdir -p .local -cp configs/tart.example.yml .local/config.yml -``` - -Windows: - -```powershell -New-Item -ItemType Directory -Force .local -Copy-Item configs/wsl.example.yml .local/config.yml +./start --config .local/ci.yml --instances 2 ``` -Default Docker Container manually: +Equivalent direct command: ```bash -mkdir -p .local -cp configs/docker-container.example.yml .local/config.yml -``` - -EPAR looks for config in this order: - -1. `--config ` -2. `EPAR_CONFIG` -3. `./.local/config.yml` -4. `~/.config/ephemeral-action-runner/config.yml` - -Tracked configs are examples only. Keep real app IDs and private key paths in an ignored config file. - -## Optional Docker Registry Mirrors - -If repeated jobs spend time pulling the same Docker Hub images into fresh runner Docker daemons, configure mirrors in your ignored local config: - -```yaml -docker: - registryMirrors: - - http://host.docker.internal:5050 +go run ./cmd/ephemeral-action-runner start --config .local/ci.yml --instances 2 ``` -This is optional. Without it, EPAR behaves normally and pulls directly from registries. Mirror benefits vary by workflow and mainly affect Docker image pull time; they do not make application startup, volume sync, health checks, browser tests, or CPU-bound work faster. - -EPAR only configures runner-side Docker daemons; it does not run or secure the mirror service. Docker Engine, Docker Desktop, or OrbStack can run a local `registry:2` pull-through cache on the EPAR host, or you can use a mirror reachable on the LAN/intranet. For private images, keep using `docker login` inside the workflow unless your mirror is deliberately configured and secured with upstream credentials. See [Docker Registry Mirrors](advanced/docker-registry-mirrors.md). - -## Prepare A WSL Source - -Skip this section for Tart and Docker Container. - -The default WSL config starts from `ghcr.io/catthehacker/ubuntu:full-latest`. During `image build`, EPAR runs Docker on the Windows host to pull that image, create a temporary container, export its filesystem into a rootfs tar, and then import that tar into WSL for EPAR's normal runner bootstrap. Docker is needed for this preparation step. Running WSL runner instances afterward does not require Docker Desktop unless your jobs need it. - -If you use `configs/wsl.lean.example.yml`, `configs/wsl.web-e2e.example.yml`, or another `image.sourceType: rootfs-tar` config, create the clean Ubuntu 24.04 source tar once: +On Windows PowerShell, use backslash paths when that is clearer: ```powershell -New-Item -ItemType Directory -Force work/images -wsl --install -d Ubuntu-24.04 --no-launch -wsl --export Ubuntu-24.04 work/images/ubuntu-24.04-clean.rootfs.tar -``` - -After that, EPAR imports disposable temporary distros for image builds and pool instances. - -## Build The Runner Image Manually - -The `start` command builds or replaces the configured image automatically. Use this section when developing from source, debugging image builds, or intentionally separating image preparation from runner startup. - -Default WSL and Docker Container builds and runner-only Tart builds do not need the upstream `actions/runner-images` checkout: - -```bash -go run ./cmd/ephemeral-action-runner image build --replace +.\start.ps1 --config .local\ci.yml --instances 2 +go run ./cmd/ephemeral-action-runner start --config .local\ci.yml --instances 2 ``` -If `image.customInstallScripts` includes EPAR's Docker/browser or web/E2E scripts, update the pinned upstream checkout first: +If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.instances` from the selected config. EPAR resolves configuration from `--config`, `EPAR_CONFIG`, `.local/config.yml`, then `~/.config/ephemeral-action-runner/config.yml`. Tracked files in `configs/` are examples; keep App values and key paths in an ignored local file. See [Configuration](configuration.md) for every setting and [Runner Group Security](runner-groups.md) before broadening repository access. -```bash -go run ./cmd/ephemeral-action-runner image update-upstream -go run ./cmd/ephemeral-action-runner image build --replace -``` +Press `Ctrl-C` once to stop a foreground pool and wait for cleanup confirmation. Use `--keep-on-exit` only to retain owned resources for deliberate debugging. -The Tart web/E2E example sets `provider.rosettaTag: rosetta`. Tart builds with that option start with `tart run --rosetta rosetta`, install Rosetta guest support, and validate that Docker can run a `linux/amd64` Alpine container returning `x86_64`. +## Verify before sending jobs -Tart output is a local Tart image name, such as `epar-ubuntu-24-arm64`. Confirm it with: - -```bash -tart list -``` - -The default WSL output is a rootfs tar path: - -```text -work/images/epar-wsl-catthehacker-ubuntu.tar -``` - -When the WSL source is a Docker image, EPAR also writes an intermediate source rootfs tar and env cache next to the output image, for example `work/images/epar-wsl-catthehacker-ubuntu.source.rootfs.tar` and `.env`. Later builds reuse that source cache; delete those files when you intentionally want to reconvert the Docker image. - -EPAR also writes image manifests so `start` can tell whether the local image still matches the config. Docker Container stores the manifest hash as a Docker image label and stores the manifest at `/opt/epar/image-manifest.json`. WSL stores `/opt/epar/image-manifest.json` inside the exported image and writes a sidecar next to the tar. - -Docker Container output is a Docker image tag, such as `epar-docker-container-catthehacker-ubuntu`. Confirm it with: - -```bash -docker image ls epar-docker-container-catthehacker-ubuntu -``` - -Build logs are written under `work/logs/builds` by default. Run `ephemeral-action-runner logs path` to resolve a customized logging root and see [Logging](logging.md) for rotation and retention. - -## Customize The Image - -Docker Sandboxes adapts a pinned full Catthehacker source into its recommended template; WSL and Docker Container use the full Catthehacker runner image by default. For Docker-focused jobs, `configs/docker-container.act.example.yml` uses the smaller Catthehacker Act image, which includes Node and the Docker Engine/CLI/Compose/Buildx stack EPAR needs. It does not guarantee browser dependencies; use `configs/docker-container.web-e2e.example.yml` for Playwright or other browser tests. Tart and the WSL lean examples are runner-only. Use `image.customInstallScripts` when you want a different WSL or Docker Container image shape, such as the smaller web/E2E examples. Docker Sandboxes customization uses its separate template build pipeline: - -```yaml -image: - customInstallScripts: - - scripts/guest/ubuntu/install-web-e2e.sh - - examples/custom-install/install-extra-apt-tools.sh -``` - -Scripts run as root during image build, after the GitHub Actions runner is installed and before validation/finalization. See [Image Build](image-build.md) for the full layering model and custom script guidance. - -## Verify Runners - -For a local runtime check without GitHub registration: +Verify one disposable runner without GitHub registration: ```bash go run ./cmd/ephemeral-action-runner pool verify --instances 1 --cleanup ``` -For a full registration check: +Verify registration and online/idle state: ```bash go run ./cmd/ephemeral-action-runner pool verify --instances 2 --register-only --cleanup ``` -Healthy output should show each generated instance name moving through: - -1. clone -2. start -3. runtime validation -4. GitHub online/idle, when registration is enabled -5. cleanup - -Runtime validation always checks the base runner files and runner user. Images with optional feature markers also validate those features: - -- Docker/browser images validate Docker, Compose v2, Buildx, `hello-world`, and a headless browser. -- Default WSL full images validate Docker, Compose v2, Buildx, and `hello-world`. -- Docker Container images validate the private inner Docker daemon inside each runner container. -- Tart Rosetta images validate `docker run --platform linux/amd64 alpine:3.20` and expect `uname -m` to return `x86_64`. -- Web/E2E images also validate `node`, `npm`, `zip`, `unzip`, `tar`, `rsync`, and `mysql`. - -When `docker.registryMirrors` is configured, EPAR applies the mirror configuration before runtime validation. - -If a Docker Container workflow depends on amd64-only images while the host is ARM64, validate host emulation inside a running EPAR instance: - -```bash -docker exec docker run --rm --platform linux/amd64 alpine:3.20 uname -m -``` +`--cleanup` removes verification resources after the check. Docker Sandboxes uses its exact ownership records; legacy providers use the configured pool-name boundary. Use [Operations](operations.md) for the distinction and recovery guidance. -The expected output is `x86_64`. +## Run, inspect, and clean up -## Run A Foreground Pool Manually +`start` is the normal command because it checks the reusable image or template first. `pool up` is for a pool you have deliberately prepared: ```bash go run ./cmd/ephemeral-action-runner pool up --instances 2 -``` - -`start` is the recommended public command because it also checks the image before starting runners. `pool up` is the lower-level supervisor command for users who already prepared the image. - -`pool up` keeps the requested number of runners online. Each GitHub ephemeral runner exits after one job. EPAR then retires that instance and creates a fresh replacement. The requested count is a strict physical local-instance cap: provisioning, ready, draining, quarantined, and cleanup-pending resources all consume a slot, including old runners during host-trust rotation. - -When GitHub registration or readiness has a transient network, `429`, or `5xx` failure during supervised replacement, EPAR pauses allocation and retries with the configured exponential backoff while continuing monitoring and cleanup. It does not create extra candidates while remote state is uncertain; see [Configuration](configuration.md#common-edits) and [Operations](operations.md#capacity-reconciliation-and-outage-recovery) for retry settings and recovery steps. - -Stop the supervisor with Ctrl-C. By default, EPAR cleans up active instances and matching GitHub runner records before it exits. - -For startup after login, see [Windows Startup](advanced/windows-startup.md) or [macOS Startup](advanced/macos-startup.md). - -Use these flags only for debugging: - -- `--keep-on-exit`: leave instances running when the supervisor exits. -- `--replace-completed=false`: do not create replacements after completed jobs. - -## Status And Cleanup - -```bash go run ./cmd/ephemeral-action-runner status go run ./cmd/ephemeral-action-runner cleanup ``` -Cleanup only touches local instances and GitHub runners whose names match `pool.namePrefix`. - -## Runner Labels - -By default, EPAR appends an `epar-host-` label to the configured labels. The machine name is lowercased, unsafe characters are replaced with `-`, and the final label is kept within GitHub's 256-character label limit. Set `runner.includeHostLabel: false` to disable it. - -Use provider-specific labels in workflows. For the Tart web/E2E Rosetta image, target the existing web/E2E label plus the Rosetta label when the job needs amd64 Docker images: - -```yaml -runs-on: [self-hosted, linux, ARM64, epar-tart-ubuntu-24.04-web-e2e, epar-tart-rosetta-amd64] -``` - -For the default WSL image, target the default WSL label: - -```yaml -runs-on: [self-hosted, linux, X64, epar-wsl-catthehacker-ubuntu] -``` +Use `status --no-github` or `cleanup --no-github` when you intentionally need to skip GitHub runner status or deletion. `pool down` is an alias for cleanup. -For the default Docker Container image, target the default Docker Container label: +For a command-construction preview on compatible providers, add `--dry-run`: -```yaml -runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu] +```bash +go run ./cmd/ephemeral-action-runner pool verify --dry-run --instances 1 ``` -For the recommended Docker Sandboxes full template, target the provider label: +Docker Sandboxes intentionally does not support this dry run because EPAR must read back the exact prewarmed template identity. Use its admission and template checks instead. -```yaml -runs-on: [self-hosted, linux, X64, epar-docker-sandboxes] -``` +## Target the right runner -For the Docker-focused Act image, target its dedicated label: +GitHub matches every value in `runs-on` against a runner's labels. The smallest workflow selector is: ```yaml -runs-on: [self-hosted, linux, epar-docker-container-catthehacker-act] +runs-on: [self-hosted] ``` -For Docker Container web/E2E images, target the custom web/E2E label: +Add a provider or workload label to avoid routing work to the wrong environment: ```yaml -runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu-web-e2e] -``` - -When that Docker Container runner is used for amd64-only runtime images, keep the workflow's Docker platform explicit, for example `DOCKER_PLATFORM=linux/amd64` or the equivalent variable used by your compose scripts, and verify the host runtime supports amd64 emulation as described above. - -Do not use `ubuntu-latest` for these self-hosted runners. - -## Dry Run - -Use `--dry-run` to inspect provider command construction without mutating local instances: - -```bash -go run ./cmd/ephemeral-action-runner pool verify --dry-run --instances 2 +runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu] ``` -## Maintainer Source-Only Releases - -Releases are manually dispatched from GitHub Actions and contain no uploaded assets. GitHub automatically provides **Source code (zip)** and **Source code (tar.gz)** downloads for each release tag. - -```bash -git tag -a v0.1.0-beta.1 -m "v0.1.0-beta.1" -git push origin v0.1.0-beta.1 -``` +EPAR adds an `epar-host-` label by default. Use it only when a job must target one specific host. Give each independent pool in the same organization a unique `pool.namePrefix`; this is also its cleanup boundary. -Before dispatching, a repository administrator must enable immutable releases in the repository. In **Actions → Release → Run workflow**, supply an existing remote tag that matches `[v]MAJOR.MINOR.PATCH` or `[v]MAJOR.MINOR.PATCH-(alpha|beta|rc).N`, then type `publish source-only release` exactly. The workflow requires annotated tags, verifies the remote tag, confirms its commit is reachable from `origin/main`, checks out and tests that exact commit, and refuses to overwrite an existing release. +## Common next tasks -Alpha, beta, and RC tags are published as prereleases and are not marked latest. To promote a prerelease to a stable tag without changing the commit, provide the existing prerelease tag in `promotion_from`; the workflow verifies that the tag and its GitHub Release exist, that its normalized `MAJOR.MINOR.PATCH` core matches the stable tag, and that both tags point to the same commit, then creates stable promotion notes instead of a generated delta. +- [Customize a runner image](image-build.md). +- [Configure Docker registry mirrors](advanced/docker-registry-mirrors.md). +- [Start EPAR after login on Windows](advanced/windows-startup.md) or [macOS](advanced/macos-startup.md). +- [Inspect logs, capacity, cleanup, and recovery](operations.md). +- [Diagnose a symptom](troubleshooting.md). diff --git a/scripts/sync-wiki.sh b/scripts/sync-wiki.sh index fb1a16c..7fabba8 100755 --- a/scripts/sync-wiki.sh +++ b/scripts/sync-wiki.sh @@ -26,6 +26,14 @@ copy_assets() { mkdir -p "$out_dir/assets" cp -R "$repo_root/docs/assets/." "$out_dir/assets/" fi + if [ -d "$repo_root/configs" ]; then + mkdir -p "$out_dir/configs" + cp -R "$repo_root/configs/." "$out_dir/configs/" + fi + if [ -f "$repo_root/templates/docker-sandboxes/sources.lock.json" ]; then + mkdir -p "$out_dir/templates/docker-sandboxes" + cp "$repo_root/templates/docker-sandboxes/sources.lock.json" "$out_dir/templates/docker-sandboxes/" + fi } copy_page() { @@ -44,22 +52,33 @@ rewrite_links() { perl -0pi -e ' my %map = ( "README.md" => "Home", + "docs/README.md" => "Documentation", "docs/usage.md" => "Usage", "usage.md" => "Usage", + "docs/configuration.md" => "Configuration", + "configuration.md" => "Configuration", "docs/github-app.md" => "GitHub-App-Setup", "github-app.md" => "GitHub-App-Setup", + "docs/runner-groups.md" => "Runner-Groups", + "runner-groups.md" => "Runner-Groups", "docs/image-build.md" => "Image-Build", "image-build.md" => "Image-Build", - "docs/design.md" => "Design", + "docs/development/design.md" => "Design", + "development/design.md" => "Design", "design.md" => "Design", - "docs/development-principles.md" => "Development-Principles", - "development-principles.md" => "Development-Principles", + "docs/development/principles.md" => "Development-Principles", + "development/principles.md" => "Development-Principles", + "principles.md" => "Development-Principles", "docs/operations.md" => "Operations", "operations.md" => "Operations", + "docs/logging.md" => "Logging", + "logging.md" => "Logging", + "docs/storage.md" => "Storage", + "storage.md" => "Storage", + "docs/troubleshooting.md" => "Troubleshooting", + "troubleshooting.md" => "Troubleshooting", "docs/security.md" => "Security", "security.md" => "Security", - "docs/background.md" => "Background", - "background.md" => "Background", "docs/providers/tart.md" => "Tart-Provider", "providers/tart.md" => "Tart-Provider", "tart.md" => "Tart-Provider", @@ -72,15 +91,43 @@ rewrite_links() { "docs/providers/docker-sandboxes.md" => "Docker-Sandboxes-Provider", "providers/docker-sandboxes.md" => "Docker-Sandboxes-Provider", "docker-sandboxes.md" => "Docker-Sandboxes-Provider", - "docs/providers/adding-provider.md" => "Adding-A-Provider", - "providers/adding-provider.md" => "Adding-A-Provider", + "docs/development/adding-provider.md" => "Adding-A-Provider", + "development/adding-provider.md" => "Adding-A-Provider", "adding-provider.md" => "Adding-A-Provider", "docs/advanced/docker-registry-mirrors.md" => "Docker-Registry-Mirrors", "advanced/docker-registry-mirrors.md" => "Docker-Registry-Mirrors", "docker-registry-mirrors.md" => "Docker-Registry-Mirrors", + "docs/advanced/docker-sandboxes-template.md" => "Docker-Sandboxes-Template", + "advanced/docker-sandboxes-template.md" => "Docker-Sandboxes-Template", + "docker-sandboxes-template.md" => "Docker-Sandboxes-Template", + "docs/advanced/cross-architecture-containers.md" => "Cross-Architecture-Containers", + "advanced/cross-architecture-containers.md" => "Cross-Architecture-Containers", + "cross-architecture-containers.md" => "Cross-Architecture-Containers", + "docs/advanced/no-go-install.md" => "No-Go-Install", + "advanced/no-go-install.md" => "No-Go-Install", + "no-go-install.md" => "No-Go-Install", + "docs/advanced/windows-startup.md" => "Windows-Startup", + "advanced/windows-startup.md" => "Windows-Startup", + "windows-startup.md" => "Windows-Startup", "docs/advanced/macos-startup.md" => "macOS-Startup", "advanced/macos-startup.md" => "macOS-Startup", "macos-startup.md" => "macOS-Startup", + "docs/development/README.md" => "Development", + "development/README.md" => "Development", + "docs/development/" => "Development", + "development/" => "Development", + "" => "Documentation", + "docs/development/core-runner-verification.md" => "Core-Runner-Verification", + "development/core-runner-verification.md" => "Core-Runner-Verification", + "core-runner-verification.md" => "Core-Runner-Verification", + "docs/development/releases.md" => "Releases", + "development/releases.md" => "Releases", + "releases.md" => "Releases", + "examples/observability/README.md" => "Observability-Examples", + "observability/README.md" => "Observability-Examples", + "SUPPORT.md" => "Support", + "CONTRIBUTING.md" => "Contributing", + "CODE_OF_CONDUCT.md" => "Code-of-Conduct", ); s{\]\(([^)]+)\)}{ @@ -94,6 +141,8 @@ rewrite_links() { while ($path =~ s{^\.\./}{}) {} if ($path =~ s{^docs/assets/}{assets/}) { "]($path$anchor)"; + } elsif ($path =~ m{^(?:configs|templates)/}) { + "]($path$anchor)"; } elsif (exists $map{$path}) { "]($map{$path}$anchor)"; } else { @@ -107,21 +156,37 @@ rewrite_links() { copy_assets copy_page "README.md" "Home" +copy_page "docs/README.md" "Documentation" copy_page "docs/usage.md" "Usage" +copy_page "docs/configuration.md" "Configuration" copy_page "docs/github-app.md" "GitHub-App-Setup" +copy_page "docs/runner-groups.md" "Runner-Groups" copy_page "docs/image-build.md" "Image-Build" -copy_page "docs/design.md" "Design" -copy_page "docs/development-principles.md" "Development-Principles" +copy_page "docs/development/design.md" "Design" +copy_page "docs/development/principles.md" "Development-Principles" copy_page "docs/operations.md" "Operations" +copy_page "docs/logging.md" "Logging" +copy_page "docs/storage.md" "Storage" +copy_page "docs/troubleshooting.md" "Troubleshooting" copy_page "docs/security.md" "Security" -copy_page "docs/background.md" "Background" copy_page "docs/providers/tart.md" "Tart-Provider" copy_page "docs/providers/wsl.md" "WSL-Provider" copy_page "docs/providers/docker-container.md" "Docker-Container-Provider" copy_page "docs/providers/docker-sandboxes.md" "Docker-Sandboxes-Provider" -copy_page "docs/providers/adding-provider.md" "Adding-A-Provider" +copy_page "docs/development/adding-provider.md" "Adding-A-Provider" copy_page "docs/advanced/docker-registry-mirrors.md" "Docker-Registry-Mirrors" +copy_page "docs/advanced/docker-sandboxes-template.md" "Docker-Sandboxes-Template" +copy_page "docs/advanced/cross-architecture-containers.md" "Cross-Architecture-Containers" +copy_page "docs/advanced/no-go-install.md" "No-Go-Install" +copy_page "docs/advanced/windows-startup.md" "Windows-Startup" copy_page "docs/advanced/macos-startup.md" "macOS-Startup" +copy_page "docs/development/README.md" "Development" +copy_page "docs/development/core-runner-verification.md" "Core-Runner-Verification" +copy_page "docs/development/releases.md" "Releases" +copy_page "examples/observability/README.md" "Observability-Examples" +copy_page "SUPPORT.md" "Support" +copy_page "CONTRIBUTING.md" "Contributing" +copy_page "CODE_OF_CONDUCT.md" "Code-of-Conduct" for file in "$out_dir"/*.md; do rewrite_links "$file" @@ -131,7 +196,9 @@ cat > "$out_dir/_Sidebar.md" <<'SIDEBAR' # EPAR Docs - [Home](Home) +- [Documentation](Documentation) - [Usage](Usage) +- [Configuration](Configuration) - [GitHub App Setup](GitHub-App-Setup) - [Image Build](Image-Build) @@ -146,12 +213,30 @@ cat > "$out_dir/_Sidebar.md" <<'SIDEBAR' ## Operations - [Operations](Operations) +- [Logging](Logging) +- [Observability Examples](Observability-Examples) +- [Storage](Storage) +- [Troubleshooting](Troubleshooting) - [Security](Security) -- [Design](Design) -- [Development Principles](Development-Principles) -- [Background](Background) + +## Advanced + +- [Docker Sandboxes Template](Docker-Sandboxes-Template) +- [Cross-Architecture Containers](Cross-Architecture-Containers) - [Docker Registry Mirrors](Docker-Registry-Mirrors) +- [No-Go Installation](No-Go-Install) +- [Windows Startup](Windows-Startup) - [macOS Startup](macOS-Startup) + +## Development + +- [Development Guide](Development) +- [Design](Design) +- [Development Principles](Development-Principles) +- [Core Runner Verification](Core-Runner-Verification) +- [Releases](Releases) +- [Contributing](Contributing) +- [Support](Support) SIDEBAR source_ref="${GITHUB_SHA:-}" From 2edb3ed86cea5b3b297da05abba2165982f1d6e1 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 28 Jul 2026 18:29:30 +0800 Subject: [PATCH 06/22] fix startup wizard and sbx readiness --- .github/workflows/host-trust-verification.yml | 6 + README.md | 4 +- cmd/ephemeral-action-runner/init.go | 109 ++++++++----- cmd/ephemeral-action-runner/init_test.go | 90 ++++++++++- cmd/ephemeral-action-runner/start.go | 42 +++-- cmd/ephemeral-action-runner/start_test.go | 60 +++++++- docs/advanced/docker-sandboxes-template.md | 7 +- docs/configuration.md | 4 +- docs/providers/docker-sandboxes.md | 4 +- docs/security.md | 2 +- docs/troubleshooting.md | 5 +- docs/usage.md | 4 +- internal/config/config.go | 4 +- .../dockersandboxes/network_policy.go | 3 - .../provider/dockersandboxes/policy/policy.go | 10 +- .../dockersandboxes/policy/policy_test.go | 2 +- .../dockersandboxes/promotion/preflight.go | 57 ++----- .../promotion/preflight_test.go | 71 +++++++-- .../dockersandboxes/promotion/promotion.go | 5 - .../promotion/promotion_test.go | 2 - internal/provider/dockersandboxes/provider.go | 78 ++-------- .../provider/dockersandboxes/provider_test.go | 145 +++++++++--------- .../provider/dockersandboxes/validation.go | 6 - internal/storage/inventory/template.go | 11 +- internal/storage/inventory/template_test.go | 1 - scripts/docker-sandboxes/build-template.ps1 | 4 +- scripts/docker-sandboxes/load-template.ps1 | 45 +++++- scripts/docker-sandboxes/validate-assets.ps1 | 4 +- scripts/run-with-docker.sh | 4 +- scripts/test/docker-sandboxes-plan-smoke.ps1 | 6 +- scripts/test/start-command-forwarding.sh | 16 ++ start | 4 +- templates/docker-sandboxes/Dockerfile | 2 - .../guest/collect-software-inventory.sh | 1 - .../docker-sandboxes/guest/verify-template.sh | 1 - templates/docker-sandboxes/helpers.sha256 | 4 +- .../act-22.04.amd64.compatibility.json | 1 - .../act-22.04.arm64.compatibility.json | 1 - .../profiles/full.amd64.compatibility.json | 1 - .../profiles/full.arm64.compatibility.json | 1 - 40 files changed, 497 insertions(+), 330 deletions(-) diff --git a/.github/workflows/host-trust-verification.yml b/.github/workflows/host-trust-verification.yml index 83f6809..19dd832 100644 --- a/.github/workflows/host-trust-verification.yml +++ b/.github/workflows/host-trust-verification.yml @@ -247,6 +247,11 @@ jobs: if: runner.os != 'Windows' run: bash scripts/test/no-go-first-run-smoke.sh + - name: Verify start command forwarding + shell: bash + if: runner.os != 'Windows' + run: bash scripts/test/start-command-forwarding.sh + - name: Verify native-controller cache retention shell: bash if: runner.os != 'Windows' @@ -265,6 +270,7 @@ jobs: scripts/test/host-trust-wrapper-smoke.sh \ scripts/test/native-controller-cache-retention.sh \ scripts/test/no-go-first-run-smoke.sh \ + scripts/test/start-command-forwarding.sh \ scripts/guest/ubuntu/apply-trusted-ca-runtime.sh \ scripts/guest/ubuntu/check-host-trust-generation.sh diff --git a/README.md b/README.md index c4699e7..ba3acb7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The normal path is a source archive plus Docker. EPAR's first run opens a guided ### 1. Install the host tools -Install a Docker-compatible daemon: [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Windows or macOS, [OrbStack](https://orbstack.dev/) on macOS, or [Docker Engine](https://docs.docker.com/engine/) on Linux. Docker Sandboxes also needs the supported `sbx` CLI and a prepared EPAR template; see [Docker Sandboxes](docs/providers/docker-sandboxes.md). +Install a Docker-compatible daemon: [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Windows or macOS, [OrbStack](https://orbstack.dev/) on macOS, or [Docker Engine](https://docs.docker.com/engine/) on Linux. Docker Sandboxes also needs the `sbx` CLI to pass its diagnostics and a prepared EPAR template; see [Docker Sandboxes](docs/providers/docker-sandboxes.md). ### 2. Download EPAR @@ -50,7 +50,7 @@ Choose a provider based on your host OS, available prerequisites, and isolation | Provider | Host OS | Prerequisites | Isolation and compatibility | | --- | --- | --- | --- | -| [Docker Sandboxes](docs/providers/docker-sandboxes.md) | Linux, macOS, Windows | Docker and the supported `sbx` CLI | Highest isolation level — each runner uses a dedicated microVM with a private Docker daemon. Recommended when capability checks pass. | +| [Docker Sandboxes](docs/providers/docker-sandboxes.md) | Linux, macOS, Windows | Docker, the `sbx` CLI, and healthy `sbx diagnose --output json` results | Highest isolation level — each runner uses a dedicated microVM with a private Docker daemon. Recommended when capability checks pass. | | [Docker Container](docs/providers/docker-container.md) | Linux, macOS, Windows | Docker | Standard isolation level — each disposable runner container has a private Docker daemon. | | [WSL](docs/providers/wsl.md) | Windows | WSL2 and Docker | Standard isolation level — each runner uses a disposable WSL2 Linux environment. | | [Tart](docs/providers/tart.md) | Apple Silicon macOS | Tart | Experimental — ARM64 Linux VM with limited compatibility for CI jobs that require non-ARM64 Docker images. | diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index 458230e..c7ff42a 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -155,7 +155,9 @@ type initOptions struct { Force bool SkipDockerCheck bool SkipHostTrustCheck bool + EmbeddedInStart bool In io.Reader + Reader *bufio.Reader Out io.Writer } @@ -219,7 +221,10 @@ func runInitWithOptions(opts initOptions) error { fmt.Fprintln(opts.Out, "See README.md and docs/github-app.md for the GitHub App steps.") fmt.Fprintln(opts.Out, "") - reader := bufio.NewReader(opts.In) + reader := opts.Reader + if reader == nil { + reader = bufio.NewReader(opts.In) + } appID, err := promptRequiredInt64(opts.Out, reader, "GitHub App ID") if err != nil { return err @@ -313,9 +318,11 @@ func runInitWithOptions(opts initOptions) error { return err } + fmt.Fprintf(opts.Out, "\nCreated %s\n", opts.ConfigPath) + if opts.EmbeddedInStart { + return nil + } fmt.Fprintf(opts.Out, ` -Created %s - Next: %s start @@ -323,7 +330,7 @@ Manual/advanced: %s image build --replace %s pool verify --instances 2 --register-only --cleanup %s pool up --instances 2 -`, opts.ConfigPath, binaryName, binaryName, binaryName, binaryName) +`, binaryName, binaryName, binaryName, binaryName) return nil } @@ -709,6 +716,38 @@ type initProviderPrerequisites struct { func promptInitProvider(ctx context.Context, projectRoot string, out io.Writer, reader *bufio.Reader, skipDockerCheck bool) (string, sandboxpromotion.Record, *initDockerSandboxesProfile, error) { hostPlatform := initSandboxPromotionPlatform() record, promoted := initSandboxPromotionLookup(hostPlatform) + for { + providerType, promotionPassed, refresh, err := promptInitProviderChoice(ctx, projectRoot, hostPlatform, record, promoted, out, reader, skipDockerCheck) + if err != nil { + return "", sandboxpromotion.Record{}, nil, err + } + if refresh { + fmt.Fprintln(out, "Refreshing provider prerequisites...") + continue + } + if providerType != "docker-sandboxes" { + return providerType, sandboxpromotion.Record{}, nil, nil + } + if promotionPassed { + return providerType, record, nil, nil + } + if !promoted { + if _, _, err := dockerSandboxesPlatform(hostPlatform); err == nil { + profile, accepted, profileErr := promptDockerSandboxesProfile(ctx, projectRoot, hostPlatform, out, reader) + if profileErr != nil { + return "", sandboxpromotion.Record{}, nil, profileErr + } + if !accepted { + return "", sandboxpromotion.Record{}, nil, errors.New("Docker Sandboxes setup did not complete; no config was written") + } + return providerType, sandboxpromotion.Record{}, profile, nil + } + } + return "", sandboxpromotion.Record{}, nil, errors.New("Docker Sandboxes selection is unavailable") + } +} + +func promptInitProviderChoice(ctx context.Context, projectRoot string, hostPlatform sandboxpromotion.Platform, record sandboxpromotion.Record, promoted bool, out io.Writer, reader *bufio.Reader, skipDockerCheck bool) (string, bool, bool, error) { prerequisites := detectInitProviderPrerequisites(ctx, hostPlatform, skipDockerCheck) operationalDefault := !promoted && prerequisites.DockerSandboxesAvailable promotionPassed := false @@ -757,29 +796,11 @@ func promptInitProvider(ctx context.Context, projectRoot string, out io.Writer, } else if promoted || !prerequisites.DockerAvailable { defaultProvider = "" } - providerType, err := promptProviderOptions(out, reader, prerequisites, promoted, promotionPassed, operationalDefault, defaultProvider) + providerType, refresh, err := promptProviderOptions(out, reader, prerequisites, promoted, promotionPassed, operationalDefault, defaultProvider) if err != nil { - return "", sandboxpromotion.Record{}, nil, err - } - if providerType != "docker-sandboxes" { - return providerType, sandboxpromotion.Record{}, nil, nil - } - if promotionPassed { - return providerType, record, nil, nil - } - if !promoted { - if _, _, err := dockerSandboxesPlatform(hostPlatform); err == nil { - profile, accepted, profileErr := promptDockerSandboxesProfile(ctx, projectRoot, hostPlatform, out, reader) - if profileErr != nil { - return "", sandboxpromotion.Record{}, nil, profileErr - } - if !accepted { - return "", sandboxpromotion.Record{}, nil, errors.New("Docker Sandboxes setup did not complete; no config was written") - } - return providerType, sandboxpromotion.Record{}, profile, nil - } + return "", false, false, err } - return "", sandboxpromotion.Record{}, nil, errors.New("Docker Sandboxes selection is unavailable") + return providerType, promotionPassed, refresh, nil } func promptDockerSandboxesProfile(ctx context.Context, projectRoot string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (*initDockerSandboxesProfile, bool, error) { @@ -1059,16 +1080,13 @@ func formatInitUintByteCount(value uint64) string { } func dockerSandboxesRootMeasurement(hostPlatform sandboxpromotion.Platform, template initDockerSandboxesTemplate) (initDockerSandboxesRootMeasurement, bool) { - const ( - evidenceSBXVersion = "0.35.0" - fullTemplateDigest = "sha256:00303a3e249a1baf8b0585d20273af408c27182dcfc827a98aa25ffe66b1f67f" - ) - if dockersandboxes.SupportedVersion != evidenceSBXVersion || hostPlatform != sandboxpromotion.WindowsAMD64 || template.Digest != fullTemplateDigest || template.Platform != "linux/amd64" { + const fullTemplateDigest = "sha256:00303a3e249a1baf8b0585d20273af408c27182dcfc827a98aa25ffe66b1f67f" + if hostPlatform != sandboxpromotion.WindowsAMD64 || template.Digest != fullTemplateDigest || template.Platform != "linux/amd64" { return initDockerSandboxesRootMeasurement{}, false } return initDockerSandboxesRootMeasurement{ PeakBytes: 324_780_032, - Evidence: "exact full-template Buildx and Compose validation probe on Docker Sandboxes v0.35.0", + Evidence: "exact full-template Buildx and Compose validation probe on Docker Sandboxes", }, true } @@ -1240,10 +1258,13 @@ func detectInitProviderPrerequisites(ctx context.Context, hostPlatform sandboxpr result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — provider storage %s has %s available; the minimum valid sandbox and host reserve require %s (shortfall %s)", capacityResult.StorageRoot, formatInitUintByteCount(capacityResult.AvailableBytes), formatInitUintByteCount(capacityResult.RequiredBytes), formatInitUintByteCount(capacityResult.DeficitBytes)) default: result.DockerSandboxesAvailable = true - result.DockerSandboxesStatus = fmt.Sprintf("READY — Docker and sbx v%s diagnostics passed (%d pass, %d warn, %d fail, %d skip); minimum storage admission passed", dockersandboxes.SupportedVersion, readiness.ChecksPassed, readiness.ChecksWarned, readiness.ChecksFailed, readiness.ChecksSkipped) + result.DockerSandboxesStatus = fmt.Sprintf("READY — Docker and sbx diagnostics passed (%d pass, %d warn, %d fail, %d skip); minimum storage admission passed", readiness.ChecksPassed, readiness.ChecksWarned, readiness.ChecksFailed, readiness.ChecksSkipped) } } else { - result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — sbx v%s readiness failed: %v", dockersandboxes.SupportedVersion, err) + result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — sbx readiness failed: %v", err) + if !strings.Contains(err.Error(), "sbx diagnose --output json") { + result.DockerSandboxesStatus += ". Run 'sbx diagnose --output json' and review the hints for each failed check." + } } } @@ -1271,7 +1292,7 @@ func detectInitProviderPrerequisites(ctx context.Context, hostPlatform sandboxpr return result } -func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites initProviderPrerequisites, promoted, promotionPassed, operationalDefault bool, defaultProvider string) (string, error) { +func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites initProviderPrerequisites, promoted, promotionPassed, operationalDefault bool, defaultProvider string) (string, bool, error) { options := make([]initProviderOption, 0, len(providerregistry.Descriptors())) for _, descriptor := range providerregistry.Descriptors() { option := initProviderOption{ @@ -1299,12 +1320,12 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in option.Available = prerequisites.TartAvailable option.Status = prerequisites.TartStatus default: - return "", fmt.Errorf("registered provider %q has no prerequisite contribution", descriptor.Type) + return "", false, fmt.Errorf("registered provider %q has no prerequisite contribution", descriptor.Type) } options = append(options, option) } if err := validateWizardProviderOptions(options); err != nil { - return "", err + return "", false, err } defaultNumber := prioritizeDefaultProviderOption(options, defaultProvider) @@ -1322,6 +1343,7 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in fmt.Fprintf(out, " %s. %s%s\n", option.Number, option.Label, defaultLabel) fmt.Fprintf(out, " Prerequisites: %s\n", option.Status) } + fmt.Fprintln(out, " R. Refresh provider prerequisites") for { var value string var hitEOF bool @@ -1330,7 +1352,7 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in fmt.Fprint(out, "Runner provider: ") value, err = reader.ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { - return "", err + return "", false, err } hitEOF = errors.Is(err, io.EOF) if hitEOF { @@ -1341,9 +1363,12 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in value, hitEOF, err = promptDefault(out, reader, "Runner provider", defaultNumber) } if err != nil { - return "", err + return "", false, err } normalized := strings.ToLower(value) + if normalized == "r" || normalized == "refresh" { + return "", true, nil + } var selected *initProviderOption for index := range options { option := &options[index] @@ -1362,18 +1387,18 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in } } if selected != nil && selected.Available { - return selected.Type, nil + return selected.Type, false, nil } if selected != nil { fmt.Fprintf(out, "%s is unavailable: %s\n", selected.Label, selected.Status) } else { - fmt.Fprintln(out, "Choose an available provider number or name shown above.") + fmt.Fprintln(out, "Choose an available provider number or name shown above, or R to refresh.") } if hitEOF { if selected != nil { - return "", fmt.Errorf("runner provider %q is unavailable: %s", value, selected.Status) + return "", false, fmt.Errorf("runner provider %q is unavailable: %s", value, selected.Status) } - return "", fmt.Errorf("invalid runner provider %q", value) + return "", false, fmt.Errorf("invalid runner provider %q", value) } } } diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index f0af647..e667ca3 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -764,9 +764,12 @@ func TestInitWSL2ChoiceDefaultsToDockerContainerAndRepromptsInvalidValues(t *tes if !strings.Contains(string(configBytes), "type: docker-container") { t.Fatalf("config did not use the default Docker Container provider:\n%s", configBytes) } - if !strings.Contains(out.String(), "Choose an available provider number or name shown above.") { + if !strings.Contains(out.String(), "Choose an available provider number or name shown above, or R to refresh.") { t.Fatalf("init output did not explain invalid provider input:\n%s", out.String()) } + if strings.Contains(out.String(), "Start runners now?") { + t.Fatalf("standalone init unexpectedly asked whether to start runners:\n%s", out.String()) + } } func TestInitDockerSandboxesGeneratesConfigFromDiscoveredTemplate(t *testing.T) { @@ -871,6 +874,75 @@ func TestDockerSandboxesPrerequisitesRejectInsufficientMinimumCapacity(t *testin } } +func TestDockerSandboxesPrerequisitesExplainHowToInspectFailedDiagnosticHints(t *testing.T) { + stubNoWSL2(t) + oldReadiness := initDockerSandboxesReadiness + oldCapacityCheck := initDockerSandboxesCapacityCheck + initDockerSandboxesReadiness = func(context.Context) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.HostReadiness{}, errors.New("docker sandboxes diagnostics reported 1 failed check") + } + initDockerSandboxesCapacityCheck = func(uint64, uint64, uint64) (initDockerSandboxesCapacityResult, error) { + t.Fatal("capacity check must not run after failed diagnostics") + return initDockerSandboxesCapacityResult{}, nil + } + t.Cleanup(func() { + initDockerSandboxesReadiness = oldReadiness + initDockerSandboxesCapacityCheck = oldCapacityCheck + }) + + got := detectInitProviderPrerequisites(context.Background(), sandboxpromotion.WindowsAMD64, true) + if got.DockerSandboxesAvailable { + t.Fatal("Docker Sandboxes was available despite failed diagnostics") + } + for _, want := range []string{"1 failed check", "sbx diagnose --output json", "hints for each failed check"} { + if !strings.Contains(got.DockerSandboxesStatus, want) { + t.Fatalf("Docker Sandboxes status omitted %q: %s", want, got.DockerSandboxesStatus) + } + } +} + +func TestInitProviderRefreshRechecksAvailabilityAndRedrawsMenu(t *testing.T) { + record := validInitPromotionRecord() + stubInitSandboxPromotion(t, record, sandboxpromotion.PreflightResult{}) + oldReadiness := initDockerSandboxesReadiness + readinessCalls := 0 + initDockerSandboxesReadiness = func(context.Context) (dockersandboxes.HostReadiness, error) { + readinessCalls++ + if readinessCalls == 1 { + return dockersandboxes.HostReadiness{}, errors.New("docker sandboxes diagnostics reported 1 failed check") + } + return dockersandboxes.HostReadiness{ChecksPassed: 8, ChecksWarned: 1}, nil + } + t.Cleanup(func() { + initDockerSandboxesReadiness = oldReadiness + }) + + var out bytes.Buffer + providerType, selectedRecord, profile, err := promptInitProvider(context.Background(), t.TempDir(), &out, bufio.NewReader(strings.NewReader("2\nr\n1\n")), true) + if err != nil { + t.Fatal(err) + } + if providerType != "docker-sandboxes" || selectedRecord.Template != record.Template || profile != nil { + t.Fatalf("refreshed selection = provider %q, record template %q, profile %+v", providerType, selectedRecord.Template, profile) + } + if readinessCalls != 2 { + t.Fatalf("Docker Sandboxes readiness calls = %d, want 2", readinessCalls) + } + for _, want := range []string{ + "R. Refresh provider prerequisites", + "Docker Sandboxes (independently certified for this exact platform) is unavailable", + "Refreshing provider prerequisites...", + "PASS: the exact promoted platform, host, sbx, template, policy, and resource gates passed.", + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("provider refresh output omitted %q:\n%s", want, out.String()) + } + } + if got := strings.Count(out.String(), "R. Refresh provider prerequisites"); got != 2 { + t.Fatalf("provider menu render count = %d, want 2:\n%s", got, out.String()) + } +} + func TestDockerSandboxesProfileRejectsSelectedCapacityWithoutWritingConfig(t *testing.T) { policyFingerprint := "sha256:" + strings.Repeat("b", 64) stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ @@ -1421,9 +1493,7 @@ func TestInitPromotionGateFailuresRequireExplicitProviderAndExplainAction(t *tes }{ {name: "kill switch", gate: "operator kill switch", detail: sandboxpromotion.DisableEnvironment, disable: true}, {name: "native controller", gate: "native controller", detail: "native controller is unavailable"}, - {name: "authentication", gate: "authentication", detail: "authentication is not valid"}, {name: "daemon", gate: "daemon health", detail: "daemon is not running"}, - {name: "version", gate: "sbx version", detail: "version is not exactly v0.35.0"}, {name: "virtualization", gate: "virtualization", detail: "virtualization is unavailable"}, {name: "template", gate: "promoted template", detail: "template identity differs"}, {name: "policy", gate: "promoted policy", detail: "policy fingerprint differs"}, @@ -1740,6 +1810,7 @@ func utf16LE(text string, includeBOM bool) []byte { func stubNoWSL2(t *testing.T) { t.Helper() + stubDockerSandboxesUnavailable(t) oldGOOS := initGOOS oldWSLStatus := initWSLStatus initGOOS = "linux" @@ -1755,6 +1826,7 @@ func stubNoWSL2(t *testing.T) { func stubWSL2Available(t *testing.T) { t.Helper() + stubDockerSandboxesUnavailable(t) oldGOOS := initGOOS oldWSLStatus := initWSLStatus initGOOS = "windows" @@ -1767,6 +1839,17 @@ func stubWSL2Available(t *testing.T) { }) } +func stubDockerSandboxesUnavailable(t *testing.T) { + t.Helper() + oldReadiness := initDockerSandboxesReadiness + initDockerSandboxesReadiness = func(context.Context) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.HostReadiness{}, errors.New("Docker Sandboxes unavailable in provider-neutral wizard test") + } + t.Cleanup(func() { + initDockerSandboxesReadiness = oldReadiness + }) +} + func stubTartAvailable(t *testing.T) { t.Helper() oldGOOS := initGOOS @@ -1874,7 +1957,6 @@ func validInitPromotionRecord() sandboxpromotion.Record { return sandboxpromotion.Record{ Platform: sandboxpromotion.WindowsAMD64, EPARRevision: digest("1"), - SBXVersion: "0.35.0", Template: "epar-docker-sandboxes-catthehacker-full:promoted", TemplateDigest: digest("a"), TemplateCacheID: strings.Repeat("a", 12), diff --git a/cmd/ephemeral-action-runner/start.go b/cmd/ephemeral-action-runner/start.go index 4c090fa..87a264b 100644 --- a/cmd/ephemeral-action-runner/start.go +++ b/cmd/ephemeral-action-runner/start.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "errors" "flag" @@ -11,6 +12,7 @@ import ( "time" "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/invocation" "github.com/solutionforest/ephemeral-action-runner/internal/pool" ) @@ -108,7 +110,8 @@ func runStartWithOptions(opts startOptions) (err error) { if err != nil { return err } - configPath, err := ensureConfigForStart(startOptions{ + configPath, startNow, err := ensureConfigForStart(startOptions{ + Context: opts.Context, ProjectRoot: projectRoot, ConfigPath: opts.ConfigPath, In: opts.In, @@ -117,6 +120,9 @@ func runStartWithOptions(opts startOptions) (err error) { if err != nil { return err } + if !startNow { + return nil + } manager, err := opts.ManagerFactory(configPath, projectRoot, opts.DryRun, opts.Register) if err != nil { return err @@ -185,32 +191,44 @@ func runStartWithOptions(opts startOptions) (err error) { return err } -func ensureConfigForStart(opts startOptions) (string, error) { +func ensureConfigForStart(opts startOptions) (string, bool, error) { path, exists, err := resolveStartConfigPath(opts.ProjectRoot, opts.ConfigPath) if err != nil { - return "", err + return "", false, err } if exists { - return path, nil + return path, true, nil } if path == "" { path = filepath.Join(opts.ProjectRoot, ".local", "config.yml") } if !stdinIsInteractive() { - return "", fmt.Errorf("no EPAR config found; run %s init from the EPAR directory, or pass --config after creating a config. See README.md and docs/github-app.md for GitHub App setup", binaryName) + return "", false, fmt.Errorf("no EPAR config found; run %s init from the EPAR directory, or pass --config after creating a config. See README.md and docs/github-app.md for GitHub App setup", binaryName) } fmt.Fprintf(opts.Out, "No EPAR config found. Starting first-run setup.\n\n") + reader := bufio.NewReader(opts.In) if err := runInitWithOptions(initOptions{ - Context: opts.Context, - ProjectRoot: projectRootOrCwd(opts.ProjectRoot), - ConfigPath: path, - In: opts.In, - Out: opts.Out, + Context: opts.Context, + ProjectRoot: projectRootOrCwd(opts.ProjectRoot), + ConfigPath: path, + EmbeddedInStart: true, + In: opts.In, + Reader: reader, + Out: opts.Out, }); err != nil { - return "", err + return "", false, err + } + fmt.Fprintln(opts.Out, "") + startNow, err := promptYesNo(opts.Out, reader, fmt.Sprintf("Start runners now? Choose No to exit and review %s", path), true) + if err != nil { + return "", false, err + } + if !startNow { + fmt.Fprintf(opts.Out, "\nConfig saved at %s. Exiting before runner startup.\nReview the config, then run %s when ready.\n", path, invocation.Command()) + return path, false, nil } fmt.Fprintf(opts.Out, "\nContinuing with %s\n", path) - return path, nil + return path, true, nil } func resolveStartConfigPath(projectRoot, explicit string) (string, bool, error) { diff --git a/cmd/ephemeral-action-runner/start_test.go b/cmd/ephemeral-action-runner/start_test.go index c9e2546..5a2ab1c 100644 --- a/cmd/ephemeral-action-runner/start_test.go +++ b/cmd/ephemeral-action-runner/start_test.go @@ -81,6 +81,9 @@ func TestStartPropagatesConfigAndInstances(t *testing.T) { if !strings.Contains(out.String(), "Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.") { t.Fatalf("start guidance = %q", out.String()) } + if strings.Contains(out.String(), "Start runners now?") { + t.Fatalf("existing config unexpectedly triggered the new-config start prompt:\n%s", out.String()) + } } func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { @@ -121,14 +124,62 @@ func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, ".local", "config.yml")); err != nil { t.Fatalf("config was not created: %v", err) } - if !strings.Contains(out.String(), "Continuing with") { - t.Fatalf("output missing continuation message:\n%s", out.String()) + for _, want := range []string{"Start runners now?", "Continuing with"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("output missing %q:\n%s", want, out.String()) + } } if fake.ensureCalls != 1 || fake.runCalls != 1 { t.Fatalf("ensure/run calls = %d/%d, want 1/1", fake.ensureCalls, fake.runCalls) } } +func TestStartInteractiveMissingConfigCanExitToReview(t *testing.T) { + dir := t.TempDir() + stubNoWSL2(t) + stubInitRunnerGroupClient(t) + oldInteractive := stdinIsInteractive + oldDocker := dockerAvailable + oldResolveHostTrust := initResolveHostTrust + t.Cleanup(func() { + stdinIsInteractive = oldInteractive + dockerAvailable = oldDocker + initResolveHostTrust = oldResolveHostTrust + }) + stdinIsInteractive = func() bool { return true } + dockerAvailable = func(context.Context) error { return nil } + initResolveHostTrust = func(context.Context, hosttrust.Options) (hosttrust.Snapshot, error) { + return hosttrust.Snapshot{}, nil + } + + var out bytes.Buffer + err := runStartWithOptions(startOptions{ + Context: context.Background(), + ProjectRoot: dir, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\nn\n"), + Out: &out, + ManagerFactory: func(string, string, bool, bool) (starterManager, error) { + t.Fatal("manager factory should not run after choosing to review the new config") + return nil, nil + }, + }) + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(dir, ".local", "config.yml") + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("config was not created: %v", err) + } + for _, want := range []string{"Start runners now?", "Config saved at " + configPath, "Exiting before runner startup", "Review the config"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("review exit output omitted %q:\n%s", want, out.String()) + } + } + if strings.Contains(out.String(), "Continuing with") { + t.Fatalf("review exit unexpectedly continued startup:\n%s", out.String()) + } +} + func TestStartInteractiveMissingConfigCanSelectWSL2(t *testing.T) { dir := t.TempDir() stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) @@ -191,12 +242,17 @@ func TestStartInteractiveMissingConfigCanSelectDockerSandboxes(t *testing.T) { }, nil) oldInteractive := stdinIsInteractive oldDocker := dockerAvailable + oldResolveHostTrust := initResolveHostTrust t.Cleanup(func() { stdinIsInteractive = oldInteractive dockerAvailable = oldDocker + initResolveHostTrust = oldResolveHostTrust }) stdinIsInteractive = func() bool { return true } dockerAvailable = func(context.Context) error { return nil } + initResolveHostTrust = func(context.Context, hosttrust.Options) (hosttrust.Snapshot, error) { + return hosttrust.Snapshot{}, nil + } fake := &fakeStarterManager{} var out bytes.Buffer diff --git a/docs/advanced/docker-sandboxes-template.md b/docs/advanced/docker-sandboxes-template.md index 0f56b59..5dd6028 100644 --- a/docs/advanced/docker-sandboxes-template.md +++ b/docs/advanced/docker-sandboxes-template.md @@ -4,14 +4,13 @@ Docker Sandboxes requires an EPAR Candidate A template that has been built, revi ## Before You Start -Use a native Docker server that matches the template platform. An amd64 server builds `linux/amd64`; an ARM64 server builds `linux/arm64`. The pinned build intentionally does not use emulation. Confirm the exact supported Docker Sandboxes version and diagnostics before loading a template: +Use a native Docker server that matches the template platform. An amd64 server builds `linux/amd64`; an ARM64 server builds `linux/arm64`. The pinned build intentionally does not use emulation. Confirm Docker Sandboxes readiness before loading a template: ```bash -sbx version sbx diagnose --output json ``` -EPAR currently requires `sbx v0.35.0`, at least one diagnostic pass, and no diagnostic failures. The lock file at [`templates/docker-sandboxes/sources.lock.json`](../../templates/docker-sandboxes/sources.lock.json) identifies the allowed source profiles and exact build inputs. It is an approved snapshot, not an automatic update channel. +EPAR requires at least one diagnostic pass and no diagnostic failures. Warnings and skipped checks are accepted. When a diagnostic fails, review the failed item and its hint in the JSON output. The lock file at [`templates/docker-sandboxes/sources.lock.json`](../../templates/docker-sandboxes/sources.lock.json) identifies the allowed source profiles and exact build inputs. It is an approved snapshot, not an automatic update channel. ## Build And Review @@ -33,7 +32,7 @@ After reviewing the evidence, load the archive: powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/load-template.ps1 -ArtifactDirectory work/template-builds/docker-sandboxes/full -ExpectedMetadataSha256 sha256: -Execute ``` -The loader validates the archive, metadata, provenance, SPDX SBOM, inventory, helper hashes, compatibility record, source lock, exact local Docker image identity, and the supported `sbx` version before it invokes `sbx template load`. It reads the tag and cache inventory back afterward and never runs `sbx reset`. +The loader validates the archive, metadata, provenance, SPDX SBOM, inventory, helper hashes, compatibility record, source lock, exact local Docker image identity, and `sbx diagnose --output json` result before it invokes `sbx template load`. It reads the tag and cache inventory back afterward and never runs `sbx reset`. Keep the archive and its evidence while the template is in service or may require independent review. The Docker Sandboxes cache ID is only 12 hexadecimal characters; it is not the full template identity. EPAR records the full local Docker image identity as `dockerSandboxes.templateDigest` and checks it independently. diff --git a/docs/configuration.md b/docs/configuration.md index 4769f9a..23247f8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,11 +24,11 @@ EPAR chooses the first available configuration path in this order: `--config 0 && readiness.ChecksFailed == 0, DaemonState: daemonState, ChecksPassed: readiness.ChecksPassed, ChecksWarned: readiness.ChecksWarned, @@ -618,9 +599,6 @@ func (p *Provider) Delete(ctx context.Context, instance provider.Instance) error } func (p *Provider) Inventory(ctx context.Context) ([]provider.InventoryItem, error) { - if err := p.ensureVersion(ctx); err != nil { - return nil, err - } return p.inventoryVerified(ctx) } @@ -636,9 +614,6 @@ func (p *Provider) inventoryVerified(ctx context.Context) ([]provider.InventoryI // template cache inventory. It does not create, load, or otherwise mutate a // template. func (p *Provider) CachedTemplates(ctx context.Context) ([]CachedTemplate, error) { - if err := p.ensureVersion(ctx); err != nil { - return nil, err - } result, err := p.run(ctx, commandRequest{ args: []string{"template", "ls", "--json"}, operation: "read docker sandbox template cache", @@ -782,9 +757,6 @@ func (p *Provider) assertIdentity(ctx context.Context, instance provider.Instanc if err := validateInstance(instance, true); err != nil { return false, err } - if err := p.ensureVersion(ctx); err != nil { - return false, err - } items, err := p.inventoryVerified(ctx) if err != nil { return false, err @@ -801,38 +773,6 @@ func (p *Provider) assertIdentity(ctx context.Context, instance provider.Instanc return false, nil } -func (p *Provider) ensureVersion(ctx context.Context) error { - p.versionMu.Lock() - defer p.versionMu.Unlock() - if p.versionVerified { - return nil - } - return p.verifyVersionLocked(ctx) -} - -func (p *Provider) verifyVersionLocked(ctx context.Context) error { - for attempt := 1; attempt <= versionCheckAttempts; attempt++ { - result, err := p.run(ctx, commandRequest{args: []string{"version"}, operation: "check docker sandboxes version"}) - if err != nil { - return err - } - if isSupportedVersion(result.Stdout) { - p.versionVerified = true - return nil - } - if attempt < versionCheckAttempts { - timer := time.NewTimer(versionRetryDelay) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - } - } - } - return fmt.Errorf("unsupported docker sandboxes version after %d checks; exactly v%s is required", versionCheckAttempts, SupportedVersion) -} - func (p *Provider) run(ctx context.Context, request commandRequest) (provider.ExecResult, error) { if err := validateCommandRequest(request); err != nil { return provider.ExecResult{}, err diff --git a/internal/provider/dockersandboxes/provider_test.go b/internal/provider/dockersandboxes/provider_test.go index 6857d9e..ffc5098 100644 --- a/internal/provider/dockersandboxes/provider_test.go +++ b/internal/provider/dockersandboxes/provider_test.go @@ -17,15 +17,16 @@ import ( ) const ( - testName = "epar-sandbox-1" - testID = "9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b" - testWorkspace = "/var/lib/epar/staging/job-1" - testTemplate = "docker.io/docker/sandbox-templates:shell-docker" - testDigest = "sha256:39cf20eca8610000000000000000000000000000000000000000000000000000" - readyListJSON = `{"sandboxes":[{"id":"9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b","name":"epar-sandbox-1","status":"running","workspaces":["/var/lib/epar/staging/job-1"],"agent":"shell","additive_field":true}]}` - emptyPortsJSON = `[]` - templateListJSON = `{"images":[{"id":"39cf20eca861","repository":"docker.io/docker/sandbox-templates","tag":"shell-docker","flavor":"shell-docker","created_at":"2026-07-22T07:13:19Z","size":599103243}]}` - inspectionJSON = `{"name":"epar-sandbox-1","agent":"shell","kits":[],"state":"running","image":"docker.io/docker/sandbox-templates:shell-docker","image_digest":"sha256:39cf20eca8610000000000000000000000000000000000000000000000000000","workspace":"/var/lib/epar/staging/job-1","network":"epar-sandbox-1","network_policy":{"scope":"global"},"proxy":"172.17.0.1:3128","mcp_gateway":false,"sessions":0,"daemon_version":"v0.35.0","daemon_uptime":"1h"}` + testName = "epar-sandbox-1" + testID = "9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b" + testWorkspace = "/var/lib/epar/staging/job-1" + testTemplate = "docker.io/docker/sandbox-templates:shell-docker" + testDigest = "sha256:39cf20eca8610000000000000000000000000000000000000000000000000000" + readyListJSON = `{"sandboxes":[{"id":"9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b","name":"epar-sandbox-1","status":"running","workspaces":["/var/lib/epar/staging/job-1"],"agent":"shell","additive_field":true}]}` + emptyPortsJSON = `[]` + templateListJSON = `{"images":[{"id":"39cf20eca861","repository":"docker.io/docker/sandbox-templates","tag":"shell-docker","flavor":"shell-docker","created_at":"2026-07-22T07:13:19Z","size":599103243}]}` + inspectionJSON = `{"name":"epar-sandbox-1","agent":"shell","kits":[],"state":"running","image":"docker.io/docker/sandbox-templates:shell-docker","image_digest":"sha256:39cf20eca8610000000000000000000000000000000000000000000000000000","workspace":"/var/lib/epar/staging/job-1","network":"epar-sandbox-1","network_policy":{"scope":"global"},"proxy":"172.17.0.1:3128","mcp_gateway":false,"sessions":0,"daemon_version":"fixture-current","daemon_uptime":"1h"}` + healthyDiagnoseJSON = `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"healthy","detail":"","hint":""}],"summary":{"pass":1,"warn":0,"fail":0,"skip":0}}` ) var testInstance = provider.Instance{Name: testName, ProviderID: testID, Source: "shell", State: "running"} @@ -101,7 +102,7 @@ func scriptedProvider(t *testing.T, steps ...commandStep) (*Provider, func()) { } } -func TestCreateUsesOnlyPinnedVersionAndExactArgv(t *testing.T) { +func TestCreateUsesHealthyDiagnosticsAndExactArgv(t *testing.T) { wantCreate := []string{ "create", "--name", testName, "--cpus", "4", @@ -110,7 +111,7 @@ func TestCreateUsesOnlyPinnedVersionAndExactArgv(t *testing.T) { "shell", testWorkspace, } p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: templateListJSON}}, commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: `{"sandboxes":[]}`}}, @@ -167,7 +168,7 @@ func TestSplitTemplateReferenceCanonicalizesDockerHubNames(t *testing.T) { func TestCreateFailsClosedOnCachedTemplateIdentityMismatch(t *testing.T) { mismatch := strings.Replace(templateListJSON, "39cf20eca861", "aaaaaaaaaaaa", 1) p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: mismatch}}, ) @@ -179,7 +180,7 @@ func TestCreateFailsClosedOnCachedTemplateIdentityMismatch(t *testing.T) { func TestCreateFailsClosedWhenFullLocalImageIdentityDiffersDespiteMatchingCacheID(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, ) p.inspectImage = func(context.Context, string) (string, error) { @@ -193,7 +194,7 @@ func TestCreateFailsClosedWhenFullLocalImageIdentityDiffersDespiteMatchingCacheI func TestCreateFailsClosedWhenFullLocalImageIdentityCannotBeRead(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, ) p.inspectImage = func(context.Context, string) (string, error) { @@ -208,7 +209,7 @@ func TestCreateFailsClosedWhenFullLocalImageIdentityCannotBeRead(t *testing.T) { func TestCreateFailsClosedWithoutEchoingGlobalSecretMetadata(t *testing.T) { const listedMetadata = "github registry masked-prefix masked-suffix" p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: listedMetadata}}, ) _, err := p.Create(context.Background(), validCreateRequest()) @@ -226,6 +227,14 @@ func TestInstanceAdmissionUsesExactInspectionAndRejectsAttachedCapabilities(t *t } done() }) + t.Run("different daemon version", func(t *testing.T) { + fixture := strings.Replace(inspectionJSON, `"daemon_version":"fixture-current"`, `"daemon_version":"fixture-next"`, 1) + p, done := identityAdmissionScript(t, commandStep{args: []string{"inspect", "--json", testName}, result: provider.ExecResult{Stdout: fixture}}) + if err := p.VerifyInstanceAdmission(context.Background(), testInstance); err != nil { + t.Fatal(err) + } + done() + }) for _, mutation := range []struct { name string old string @@ -264,7 +273,6 @@ func TestInstanceAdmissionRejectsPublishedPortInventory(t *testing.T) { commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, commandStep{args: []string{"ports", testName, "--json"}, result: provider.ExecResult{Stdout: test.fixture}}, ) - p.versionVerified = true if err := p.VerifyInstanceAdmission(context.Background(), testInstance); err == nil || !strings.Contains(err.Error(), test.message) { t.Fatalf("published port admission error = %v", err) } @@ -309,9 +317,8 @@ func TestParseTemplateInventoryAcceptsCustomTemplateWithoutFlavor(t *testing.T) } } -func TestCachedTemplatesUsesExactVersionAndMachineReadableInventory(t *testing.T) { +func TestCachedTemplatesUsesMachineReadableInventoryWithoutVersionGate(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: templateListJSON}}, ) templates, err := p.CachedTemplates(context.Background()) @@ -330,7 +337,6 @@ func TestCachedTemplatesUsesExactVersionAndMachineReadableInventory(t *testing.T func TestCachedTemplatesFailsClosedOnMalformedInventory(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: `{"images":[{"id":"not-a-cache-id"}]}`}}, ) if _, err := p.CachedTemplates(context.Background()); err == nil { @@ -345,7 +351,6 @@ func TestReadGlobalNetworkPolicyUsesGlobalOnlyReadback(t *testing.T) { {"id":"sandbox-1","name":"sandbox","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"deny","resources":["**"],"origin":"scoped","status":"inactive","editable":true,"sandbox_id":"epar-sandbox-1"} ]`) p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, commandStep{args: []string{"policy", "ls", "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: fixture}}, ) rules, err := p.ReadGlobalNetworkPolicy(context.Background()) @@ -360,7 +365,6 @@ func TestReadGlobalNetworkPolicyUsesGlobalOnlyReadback(t *testing.T) { func TestReadGlobalNetworkPolicyFailsClosedOnMalformedJSON(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, commandStep{args: []string{"policy", "ls", "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: `{"rules":null}`}}, ) if _, err := p.ReadGlobalNetworkPolicy(context.Background()); err == nil { @@ -435,22 +439,20 @@ func TestInspectLocalTemplatePreservesValidatedIdentityAndPlatform(t *testing.T) } } -func TestVersionGateFailsClosedBeforeMutation(t *testing.T) { +func TestDiagnosticsGateFailsClosedBeforeMutation(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.36.0\n"}}, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.36.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"fail","message":"unhealthy","detail":"","hint":"restart the daemon"}],"summary":{"pass":0,"warn":0,"fail":1,"skip":0}}`}}, ) _, err := p.Create(context.Background(), validCreateRequest()) - if err == nil || !strings.Contains(err.Error(), "exactly v0.35.0") { + if err == nil || !strings.Contains(err.Error(), "1 failed check") || !strings.Contains(err.Error(), "sbx diagnose --output json") || !strings.Contains(err.Error(), "hints for each failed check") { t.Fatalf("err = %v", err) } done() } -func TestVersionGateRetriesOneTransientUnsupportedRead(t *testing.T) { +func TestAdmissionUsesDiagnosticsWithoutReadingVersion(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: ""}}, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, ) if err := p.VerifyAdmission(context.Background()); err != nil { @@ -459,38 +461,22 @@ func TestVersionGateRetriesOneTransientUnsupportedRead(t *testing.T) { done() } -func TestVersionGateAcceptsExactInstalledV035Output(t *testing.T) { - if !isSupportedVersion("sbx version: v0.35.0 01e01520456e4126a9653471e7072e4d9b280321\r\n") { - t.Fatal("installed v0.35.0 output was rejected") - } - for _, output := range []string{ - "sbx version: v0.35.0 untrusted-suffix\n", - "sbx version: v0.35.1 01e01520456e4126a9653471e7072e4d9b280321\n", - "v0.35.0\n", - } { - if isSupportedVersion(output) { - t.Fatalf("unsupported version output was accepted: %q", output) - } - } -} - -func TestAdmissionRechecksVersionAfterSuccessfulCache(t *testing.T) { +func TestAdmissionRechecksDiagnostics(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.36.0\n"}}, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.36.0\n"}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"fail","message":"unhealthy","detail":"","hint":"restart the daemon"}],"summary":{"pass":0,"warn":0,"fail":1,"skip":0}}`}}, ) if err := p.VerifyAdmission(context.Background()); err != nil { t.Fatal(err) } - if err := p.VerifyAdmission(context.Background()); err == nil || !strings.Contains(err.Error(), "exactly v0.35.0") { - t.Fatalf("in-place sbx version drift was accepted: %v", err) + if err := p.VerifyAdmission(context.Background()); err == nil || !strings.Contains(err.Error(), "1 failed check") || !strings.Contains(err.Error(), "sbx diagnose --output json") || !strings.Contains(err.Error(), "hints for each failed check") { + t.Fatalf("failed diagnostics were accepted: %v", err) } done() } -func TestInventoryParsesV035WrapperAndFailsClosedOnSchemaDrift(t *testing.T) { +func TestInventoryParsesWrapperAndFailsClosedOnSchemaDrift(t *testing.T) { items, err := parseInventory([]byte(readyListJSON)) if err != nil { t.Fatal(err) @@ -590,7 +576,6 @@ func TestExecPreservesGuestArgvStdinAndRedactsAllSurfaces(t *testing.T) { func TestExecCancellationPropagatesWithoutRealSbx(t *testing.T) { p := New("sbx-test-double") - p.versionVerified = true call := 0 p.runCommand = func(ctx context.Context, request commandRequest) (provider.ExecResult, error) { call++ @@ -675,7 +660,6 @@ func TestStopAndDeleteAreIdempotentWhenInventorySaysMissing(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { p, done := scriptedProvider(t, commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: `{"sandboxes":[]}`}}) - p.versionVerified = true if err := test.call(p); err != nil { t.Fatal(err) } @@ -687,7 +671,6 @@ func TestStopAndDeleteAreIdempotentWhenInventorySaysMissing(t *testing.T) { func TestIdentityMismatchFailsBeforeStateMutation(t *testing.T) { mismatch := strings.Replace(readyListJSON, testID, "different-id", 1) p, done := scriptedProvider(t, commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: mismatch}}) - p.versionVerified = true if err := p.Delete(context.Background(), testInstance); err == nil || !strings.Contains(err.Error(), "identity changed") { t.Fatalf("err = %v", err) } @@ -698,11 +681,10 @@ func TestDiagnosticsUsesBoundedMachineReadableCommands(t *testing.T) { p, done := scriptedProvider(t, commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, commandStep{args: []string{"daemon", "status", "--json"}, result: provider.ExecResult{Stdout: `{"status":"running","socket":"\\\\.\\pipe\\docker_kaname_sandboxd","logs":"C:\\logs\\daemon.log"}`}}, - commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"ok","detail":"","hint":""}],"summary":{"pass":1,"warn":0,"fail":0,"skip":0}}`}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"ok","detail":"","hint":""},{"name":"optional update","status":"warn","message":"available","detail":"","hint":""},{"name":"optional integration","status":"skip","message":"not configured","detail":"","hint":""}],"summary":{"pass":1,"warn":1,"fail":0,"skip":1}}`}}, ) - p.versionVerified = true diagnostics, err := p.Diagnostics(context.Background(), testInstance) - if err != nil || !diagnostics.Healthy || diagnostics.ChecksPassed != 1 { + if err != nil || !diagnostics.Healthy || diagnostics.ChecksPassed != 1 || diagnostics.ChecksWarned != 1 || diagnostics.ChecksSkipped != 1 { t.Fatalf("diagnostics = %#v, err = %v", diagnostics, err) } done() @@ -710,8 +692,7 @@ func TestDiagnosticsUsesBoundedMachineReadableCommands(t *testing.T) { func TestVerifyHostReadinessAcceptsWarningsWithPassingChecksAndNoFailures(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, - commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"healthy","detail":"","hint":""},{"name":"binary version","status":"warn","message":"update available","detail":"","hint":""}],"summary":{"pass":1,"warn":1,"fail":0,"skip":0}}`}}, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"healthy","detail":"","hint":""},{"name":"optional update","status":"warn","message":"available","detail":"","hint":""}],"summary":{"pass":1,"warn":1,"fail":0,"skip":0}}`}}, ) readiness, err := p.VerifyHostReadiness(context.Background()) if err != nil { @@ -743,11 +724,38 @@ func TestVerifyHostReadinessRejectsFailedOrEmptyDiagnostics(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { p, done := scriptedProvider(t, - commandStep{args: []string{"version"}, result: provider.ExecResult{Stdout: "sbx version v0.35.0\n"}}, commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: test.fixture}}, ) - if _, err := p.VerifyHostReadiness(context.Background()); err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("VerifyHostReadiness() error = %v, want %q", err, test.want) + if _, err := p.VerifyHostReadiness(context.Background()); err == nil || !strings.Contains(err.Error(), test.want) || !strings.Contains(err.Error(), "sbx diagnose --output json") { + t.Fatalf("VerifyHostReadiness() error = %v, want %q and diagnostic command", err, test.want) + } + done() + }) + } +} + +func TestVerifyHostReadinessRejectsCommandAndSchemaFailures(t *testing.T) { + tests := []struct { + name string + step commandStep + want string + }{ + { + name: "command failure", + step: commandStep{args: []string{"diagnose", "--output", "json"}, err: errors.New("diagnose unavailable")}, + want: "diagnose unavailable", + }, + { + name: "malformed json", + step: commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"summary":`}}, + want: "unsupported json schema", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + p, done := scriptedProvider(t, test.step) + if _, err := p.VerifyHostReadiness(context.Background()); err == nil || !strings.Contains(err.Error(), test.want) || !strings.Contains(err.Error(), "sbx diagnose --output json") { + t.Fatalf("VerifyHostReadiness() error = %v, want %q and diagnostic command", err, test.want) } done() }) @@ -766,7 +774,6 @@ func TestDiagnosticsRejectsOversizedOutput(t *testing.T) { commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, commandStep{args: []string{"daemon", "status", "--json"}, result: provider.ExecResult{Stdout: strings.Repeat("x", diagnosticOutputLimit+1)}}, ) - p.versionVerified = true if _, err := p.Diagnostics(context.Background(), testInstance); err == nil || !strings.Contains(err.Error(), "output limit") { t.Fatalf("err = %v", err) } @@ -784,7 +791,6 @@ func TestPolicyCommandsAreSandboxScopedAndReadBackExactRules(t *testing.T) { commandStep{args: []string{"policy", "allow", "network", "--sandbox", testName, "api.example.com,*.packages.example.com:443"}}, commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: policyJSON}}, ) - p.versionVerified = true if err := p.ApplyNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{rule}); err != nil { t.Fatal(err) } @@ -797,7 +803,6 @@ func TestPolicyCommandsAreSandboxScopedAndReadBackExactRules(t *testing.T) { commandStep{args: []string{"policy", "allow", "network", "--sandbox", testName, "**"}}, commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: policyFixture(`[` + globalRule + `,` + openRule + `]`)}}, ) - p.versionVerified = true if err := p.ApplyNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{{ Decision: provider.NetworkPolicyAllow, Resources: []string{"**"}, @@ -813,7 +818,6 @@ func TestPolicyCommandsAreSandboxScopedAndReadBackExactRules(t *testing.T) { commandStep{args: []string{"policy", "rm", "network", "--sandbox", testName, "--id", "rule-1"}}, commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: policyFixture(`[` + globalRule + `]`)}}, ) - p.versionVerified = true remove := provider.NetworkPolicyRule{ ID: "rule-1", PolicyID: "local", @@ -838,7 +842,6 @@ func TestPolicyRemovalRefusesChangedStableIdentity(t *testing.T) { commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: fixture}}, ) - p.versionVerified = true changed := provider.NetworkPolicyRule{ ID: "rule-1", PolicyID: "local", @@ -884,7 +887,7 @@ func TestPolicyReadPreservesGlobalAndSandboxAttribution(t *testing.T) { } } -func TestPolicyReadPreservesExactV035KitAttribution(t *testing.T) { +func TestPolicyReadPreservesExactKitAttribution(t *testing.T) { fixture := policyFixture(`[{"id":"kit-rule-1","name":"kit:epar-sandbox-1","policy_id":"kit-policy-1","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"allow","resources":["openrouter.ai"],"origin":"scoped","status":"active","editable":false,"sandbox_id":"epar-sandbox-1"}]`) rules, err := parseNetworkPolicy([]byte(fixture), testName) if err != nil { @@ -895,13 +898,13 @@ func TestPolicyReadPreservesExactV035KitAttribution(t *testing.T) { } } -func TestPolicyReadRejectsMismatchedV035SandboxAttribution(t *testing.T) { +func TestPolicyReadRejectsMismatchedSandboxAttribution(t *testing.T) { for _, fixture := range []string{ policyFixture(`[{"id":"rule-1","name":"rule","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:other-sandbox","resource_type":"network","decision":"allow","resources":["api.example.com"],"origin":"scoped","status":"active","editable":true,"sandbox_id":"epar-sandbox-1"}]`), policyFixture(`[{"id":"rule-1","name":"rule","policy_id":"local","scope":"sandbox:epar-sandbox-1","applies_to":"sandbox:epar-sandbox-1","resource_type":"network","decision":"allow","resources":["api.example.com"],"origin":"scoped","status":"active","editable":true,"sandbox_id":"other-sandbox"}]`), } { if _, err := parseNetworkPolicy([]byte(fixture), testName); err == nil { - t.Fatalf("mismatched v0.35.0 sandbox attribution was accepted: %s", fixture) + t.Fatalf("mismatched sandbox attribution was accepted: %s", fixture) } } } @@ -913,7 +916,6 @@ func TestPolicyRemovalRefusesGlobalRule(t *testing.T) { commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: fixture}}, ) - p.versionVerified = true if err := p.RemoveNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{global}); err == nil || !strings.Contains(err.Error(), "refusing to remove") { t.Fatalf("err = %v", err) } @@ -927,7 +929,6 @@ func TestPolicyRemovalRefusesFilesystemRule(t *testing.T) { commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, commandStep{args: []string{"policy", "ls", testName, "--include-inactive", "--json"}, result: provider.ExecResult{Stdout: fixture}}, ) - p.versionVerified = true if err := p.RemoveNetworkPolicy(context.Background(), testInstance, []provider.NetworkPolicyRule{filesystem}); err == nil || !strings.Contains(err.Error(), "refusing to remove") { t.Fatalf("err = %v", err) } @@ -1019,7 +1020,6 @@ func identityScript(t *testing.T, operation commandStep) (*Provider, func()) { commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, operation, ) - p.versionVerified = true return p, done } @@ -1030,7 +1030,6 @@ func identityAdmissionScript(t *testing.T, inspection commandStep) (*Provider, f commandStep{args: []string{"ports", testName, "--json"}, result: provider.ExecResult{Stdout: emptyPortsJSON}}, inspection, ) - p.versionVerified = true return p, done } diff --git a/internal/provider/dockersandboxes/validation.go b/internal/provider/dockersandboxes/validation.go index b334584..58b98b5 100644 --- a/internal/provider/dockersandboxes/validation.go +++ b/internal/provider/dockersandboxes/validation.go @@ -19,7 +19,6 @@ var ( templateDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) profilePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) hostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) - versionOutputPattern = regexp.MustCompile(`^(?:Docker Sandboxes|docker sandboxes|sbx) version:? v?([0-9]+\.[0-9]+\.[0-9]+)(?: [a-f0-9]{40})?\r?\n?$`) ) func validateCreateRequest(request provider.CreateRequest) error { @@ -211,8 +210,3 @@ func validPort(value string) bool { port, err := strconv.Atoi(value) return err == nil && port >= 1 && port <= 65535 } - -func isSupportedVersion(output string) bool { - matches := versionOutputPattern.FindStringSubmatch(output) - return len(matches) == 2 && matches[1] == SupportedVersion -} diff --git a/internal/storage/inventory/template.go b/internal/storage/inventory/template.go index 5b8474a..2dfa5b2 100644 --- a/internal/storage/inventory/template.go +++ b/internal/storage/inventory/template.go @@ -45,10 +45,9 @@ type templateMetadata struct { ArchiveBytes uint64 `json:"archiveBytes"` } `json:"template"` Compatibility struct { - SupportedSbxVersions []string `json:"supportedSbxVersions"` - Candidate string `json:"candidate"` - DockerDaemonOwner string `json:"dockerDaemonOwner"` - ExpectedDockerDaemonCount int `json:"expectedDockerDaemonCount"` + Candidate string `json:"candidate"` + DockerDaemonOwner string `json:"dockerDaemonOwner"` + ExpectedDockerDaemonCount int `json:"expectedDockerDaemonCount"` } `json:"compatibility"` } @@ -211,8 +210,8 @@ func validateTemplateMetadata(metadata templateMetadata) error { if !templateDigestPattern.MatchString(metadata.Template.ArchiveSHA256) || metadata.Template.ArchiveBytes == 0 { return fmt.Errorf("template metadata archive digest or size is invalid") } - if len(metadata.Compatibility.SupportedSbxVersions) != 1 || metadata.Compatibility.SupportedSbxVersions[0] != "0.35.0" || metadata.Compatibility.Candidate != "A" || metadata.Compatibility.DockerDaemonOwner != "docker-sandboxes-runtime" || metadata.Compatibility.ExpectedDockerDaemonCount != 1 { - return fmt.Errorf("template metadata compatibility is not Candidate A for sbx v0.35.0") + if metadata.Compatibility.Candidate != "A" || metadata.Compatibility.DockerDaemonOwner != "docker-sandboxes-runtime" || metadata.Compatibility.ExpectedDockerDaemonCount != 1 { + return fmt.Errorf("template metadata compatibility does not preserve the Candidate A runtime contract") } return nil } diff --git a/internal/storage/inventory/template_test.go b/internal/storage/inventory/template_test.go index 57ccbb9..5c7fb45 100644 --- a/internal/storage/inventory/template_test.go +++ b/internal/storage/inventory/template_test.go @@ -202,7 +202,6 @@ func validTemplateMetadata(profile, platform, suffix, archive string, archiveSHA "archiveBytes": archiveBytes, }, "compatibility": map[string]any{ - "supportedSbxVersions": []string{"0.35.0"}, "candidate": "A", "dockerDaemonOwner": "docker-sandboxes-runtime", "expectedDockerDaemonCount": 1, diff --git a/scripts/docker-sandboxes/build-template.ps1 b/scripts/docker-sandboxes/build-template.ps1 index a27aba4..8617683 100644 --- a/scripts/docker-sandboxes/build-template.ps1 +++ b/scripts/docker-sandboxes/build-template.ps1 @@ -116,7 +116,6 @@ $buildPlan = [ordered]@{ sourceIndexDigest = $profileLock.indexDigest sourceManifestDigest = $profilePlatformLock.manifestDigest templateTag = $profilePlatformLock.templateTag - sbxCompatibility = '0.35.0 only' outputDirectory = $OutputDirectory storage = [ordered]@{ surface = [System.IO.Path]::GetPathRoot($OutputDirectory) @@ -336,7 +335,7 @@ if ($LASTEXITCODE -ne 0) { throw 'docker image save failed' } -Write-Host '[6/7] Copying helper hashes and sbx v0.35.0-only compatibility metadata.' +Write-Host '[6/7] Copying helper hashes and compatibility metadata.' [System.IO.File]::WriteAllBytes($artifactPaths.helpers, $helperManifestBytes) [System.IO.File]::WriteAllBytes($artifactPaths.compatibility, $compatibilityInputBytes) @@ -383,7 +382,6 @@ $templateMetadata = [ordered]@{ templateContextDigest = $templateContextDigest } compatibility = [ordered]@{ - supportedSbxVersions = @('0.35.0') candidate = 'A' dockerDaemonOwner = 'docker-sandboxes-runtime' expectedDockerDaemonCount = 1 diff --git a/scripts/docker-sandboxes/load-template.ps1 b/scripts/docker-sandboxes/load-template.ps1 index 03cb17e..e845994 100644 --- a/scripts/docker-sandboxes/load-template.ps1 +++ b/scripts/docker-sandboxes/load-template.ps1 @@ -77,8 +77,8 @@ if ($actualMetadataSha256 -ne $ExpectedMetadataSha256) { throw "Template metadata trust-anchor mismatch: operator expected $ExpectedMetadataSha256, got $actualMetadataSha256" } $metadata = Get-Content -Raw -LiteralPath $metadataPath | ConvertFrom-Json -if ($metadata.schemaVersion -ne 2 -or @($metadata.compatibility.supportedSbxVersions).Count -ne 1 -or $metadata.compatibility.supportedSbxVersions[0] -ne '0.35.0') { - throw 'Artifact compatibility metadata must use schema 2 and allow exactly sbx v0.35.0' +if ($metadata.schemaVersion -ne 2) { + throw 'Artifact metadata must use schema 2' } if ($metadata.platform -ne 'linux/amd64' -and $metadata.platform -ne 'linux/arm64') { throw "Artifact metadata contains an unsupported platform: $($metadata.platform)" @@ -176,7 +176,7 @@ if ($LASTEXITCODE -ne 0 -or $localTemplateDigest -ne $metadata.template.template Write-Host "Verified operator-anchored metadata: $actualMetadataSha256" Write-Host "Verified archive: $archivePath" Write-Host "Full local Docker image identity: $($metadata.template.tag)@$($metadata.template.templateDigest)" -Write-Host "Expected sbx v0.35.0 cache ID: $($metadata.template.cacheID)" +Write-Host "Expected Docker Sandboxes template cache ID: $($metadata.template.cacheID)" if (-not $Execute) { Write-Host 'Plan only. All evidence was verified without invoking sbx. Re-run with -Execute and the same expected metadata digest to invoke sbx template load at most once.' exit 0 @@ -209,9 +209,40 @@ if ($metadata.platform -ne $expectedPlatform) { throw "Template platform $($metadata.platform) cannot be loaded for Docker Sandboxes on $hostOS/$hostArchitecture; expected $expectedPlatform" } -$sbxVersionOutput = ((& sbx version) -join [Environment]::NewLine).Trim() -if ($LASTEXITCODE -ne 0 -or $sbxVersionOutput -notmatch '^(?:Docker Sandboxes|docker sandboxes|sbx) version:? v?0\.35\.0(?: [a-f0-9]{40})?$') { - throw "Exactly sbx v0.35.0 is required; version output was: $sbxVersionOutput" +$diagnosticText = ((& sbx diagnose --output json) -join [Environment]::NewLine).Trim() +if ($LASTEXITCODE -ne 0) { + throw "Docker Sandboxes diagnostics failed. Run 'sbx diagnose --output json' and review the hints for each failed check." +} +try { + $diagnostics = $diagnosticText | ConvertFrom-Json +} +catch { + throw "Docker Sandboxes diagnostics returned invalid JSON. Run 'sbx diagnose --output json' and review its output." +} +$diagnosticChecks = @($diagnostics.checks) +$knownStatuses = @('pass', 'warn', 'fail', 'skip') +$actualCounts = @{ pass = 0; warn = 0; fail = 0; skip = 0 } +foreach ($check in $diagnosticChecks) { + $status = ([string]$check.status).ToLowerInvariant() + if ([string]::IsNullOrWhiteSpace([string]$check.name) -or -not $knownStatuses.Contains($status)) { + throw "Docker Sandboxes diagnostics returned an unsupported check. Run 'sbx diagnose --output json' and review its output." + } + $actualCounts[$status]++ +} +$summaryValid = $diagnosticChecks.Count -gt 0 -and $null -ne $diagnostics.summary +foreach ($status in $knownStatuses) { + $property = if ($summaryValid) { $diagnostics.summary.PSObject.Properties[$status] } else { $null } + $summaryCount = 0 + if ($null -eq $property -or -not [int]::TryParse([string]$property.Value, [ref]$summaryCount) -or $summaryCount -ne $actualCounts[$status]) { + $summaryValid = $false + break + } +} +if (-not $summaryValid) { + throw "Docker Sandboxes diagnostics returned an unsupported summary. Run 'sbx diagnose --output json' and review its output." +} +if ($actualCounts['fail'] -ne 0 -or $actualCounts['pass'] -lt 1) { + throw "Docker Sandboxes diagnostics reported $($actualCounts['pass']) passing and $($actualCounts['fail']) failed checks. Run 'sbx diagnose --output json' and review the hints for each failed check." } function Get-TemplateInventory { @@ -268,5 +299,5 @@ if ($matchingTemplates[0].id -ne $metadata.template.cacheID) { throw "Loaded template cache ID mismatch: expected $($metadata.template.cacheID), got $($matchingTemplates[0].id)" } Write-Host "Template load readback completed with cache ID $($metadata.template.cacheID)." -Write-Host 'The 12-hex cache ID is the complete identity exposed by sbx v0.35.0; it is not a full digest. Full identity is anchored independently by the operator-verified metadata and local Docker image readback.' +Write-Host 'The 12-hex cache ID is the complete identity exposed by the local template inventory; it is not a full digest. Full identity is anchored independently by the operator-verified metadata and local Docker image readback.' Write-Host 'The script does not preload /var/lib/docker and will not invoke sbx template load again.' diff --git a/scripts/docker-sandboxes/validate-assets.ps1 b/scripts/docker-sandboxes/validate-assets.ps1 index 6e78cee..00851bf 100644 --- a/scripts/docker-sandboxes/validate-assets.ps1 +++ b/scripts/docker-sandboxes/validate-assets.ps1 @@ -218,7 +218,7 @@ if ($guestText -match '(?im)\btail\s+(?:-[^\s]+\s+)*["'']?\$\{?(?:log_file|runne throw 'Guest helpers must not copy runner or job log content into controller-visible output' } -Write-Host '[4/6] Parsing compatibility metadata and enforcing sbx v0.35.0 only.' +Write-Host '[4/6] Parsing compatibility metadata.' foreach ($profileName in $expectedProfiles.Keys) { $profile = $lock.profiles.PSObject.Properties[$profileName].Value foreach ($platformName in $expectedPlatforms.Keys) { @@ -233,8 +233,6 @@ foreach ($profileName in $expectedProfiles.Keys) { Assert-Equal "$profileName $platformName source reference" $compatibility.source.reference $profile.immutableReference Assert-Equal "$profileName $platformName source index" $compatibility.source.indexDigest $profile.indexDigest Assert-Equal "$profileName $platformName source manifest" $compatibility.source.manifestDigest $profilePlatform.manifestDigest - Assert-Equal "$profileName $platformName supported sbx count" @($compatibility.supportedSbxVersions).Count 1 - Assert-Equal "$profileName $platformName supported sbx version" $compatibility.supportedSbxVersions[0] '0.35.0' Assert-Equal "$profileName $platformName daemon count" $compatibility.docker.expectedDaemonCount 1 Assert-Equal "$profileName $platformName daemon owner" $compatibility.docker.daemonOwner 'docker-sandboxes-runtime' Assert-Equal "$profileName $platformName /var/lib/docker preload" $compatibility.docker.imagePreloadsVarLibDocker $false diff --git a/scripts/run-with-docker.sh b/scripts/run-with-docker.sh index 55deedd..fecf64b 100755 --- a/scripts/run-with-docker.sh +++ b/scripts/run-with-docker.sh @@ -89,7 +89,9 @@ run_controller() { -v "${repo_root}:/app" -w /app -v "${docker_sock}:/var/run/docker.sock" ) - docker_args+=("${go_cache_docker_flags[@]}") + if ((${#go_cache_docker_flags[@]})); then + docker_args+=("${go_cache_docker_flags[@]}") + fi docker_args+=("$dev_image" go run ./cmd/ephemeral-action-runner "$@") docker "${docker_args[@]}" } diff --git a/scripts/test/docker-sandboxes-plan-smoke.ps1 b/scripts/test/docker-sandboxes-plan-smoke.ps1 index 4657820..4b69d75 100644 --- a/scripts/test/docker-sandboxes-plan-smoke.ps1 +++ b/scripts/test/docker-sandboxes-plan-smoke.ps1 @@ -113,7 +113,7 @@ try { sourceLockSha256 = Get-Sha256File -Path $lockPath templateContextDigest = Get-TemplateContextDigest } - compatibility = [ordered]@{ supportedSbxVersions = @('0.35.0'); candidate = 'A'; dockerDaemonOwner = 'docker-sandboxes-runtime'; expectedDockerDaemonCount = 1 } + compatibility = [ordered]@{ candidate = 'A'; dockerDaemonOwner = 'docker-sandboxes-runtime'; expectedDockerDaemonCount = 1 } artifacts = $artifactRecords } Write-Json -Path $paths.metadata -Value $metadata @@ -143,6 +143,10 @@ try { if ($LASTEXITCODE -ne 0 -or ($loadPlan -join "`n") -notmatch 'All evidence was verified without invoking sbx' -or (Test-Path -LiteralPath $sbxMarker)) { throw 'load-template.ps1 plan-only smoke test failed or invoked sbx' } + $loaderSource = Get-Content -Raw -LiteralPath (Join-Path $repositoryRoot 'scripts\docker-sandboxes\load-template.ps1') + if ($loaderSource -match '&\s+sbx\s+version' -or $loaderSource -notmatch 'sbx diagnose --output json' -or $loaderSource -notmatch 'hints for each failed check') { + throw 'load-template.ps1 must use diagnostic readiness without an installed-version gate and explain how to inspect failed-check hints' + } Write-Host 'Docker Sandboxes build/load plan-only smoke tests passed.' } finally { diff --git a/scripts/test/start-command-forwarding.sh b/scripts/test/start-command-forwarding.sh index a98d097..47be385 100644 --- a/scripts/test/start-command-forwarding.sh +++ b/scripts/test/start-command-forwarding.sh @@ -23,6 +23,22 @@ export EPAR_GO_BIN=go export EPAR_USE_DOCKER_RUN=0 export EPAR_START_FORWARD_LOG="$test_root/arguments" +"$repo_root/start" +actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" +expected="run ./cmd/ephemeral-action-runner start " +if [[ "$actual" != "$expected" ]]; then + echo "default start forwarding mismatch: got '$actual', want '$expected'" >&2 + exit 1 +fi + +"$repo_root/start" --config .local/custom-config.yml --instances 2 +actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" +expected="run ./cmd/ephemeral-action-runner start --config .local/custom-config.yml --instances 2 " +if [[ "$actual" != "$expected" ]]; then + echo "start flag forwarding mismatch: got '$actual', want '$expected'" >&2 + exit 1 +fi + "$repo_root/start" storage prune --provider docker-sandboxes actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" expected="run ./cmd/ephemeral-action-runner storage prune --provider docker-sandboxes " diff --git a/start b/start index d2eb1f6..1c3a572 100755 --- a/start +++ b/start @@ -26,7 +26,9 @@ EPAR_HOST_TRUST_HELPER="${script_dir}/scripts/host-trust/host-trust-feed.sh" GO_BIN="${EPAR_GO_BIN:-go}" USE_DOCKER_RUN="${EPAR_USE_DOCKER_RUN:-auto}" epar_args=("$@") -if ((${#epar_args[@]} == 0)) || [[ "${epar_args[0]}" == -* ]]; then +if ((${#epar_args[@]} == 0)); then + epar_args=(start) +elif [[ "${epar_args[0]}" == -* ]]; then epar_args=(start "${epar_args[@]}") fi controller_command="${epar_args[0]}" diff --git a/templates/docker-sandboxes/Dockerfile b/templates/docker-sandboxes/Dockerfile index cafece3..224e2bc 100644 --- a/templates/docker-sandboxes/Dockerfile +++ b/templates/docker-sandboxes/Dockerfile @@ -63,7 +63,6 @@ ENV HOME=/home/agent \ LOGNAME=agent \ EPAR_ACTIONS_RUNNER_DIR=/opt/actions-runner \ EPAR_RUNNER_WORK_DIR=/opt/actions-runner \ - EPAR_TEMPLATE_SBX_VERSION=0.35.0 \ EPAR_TEMPLATE_PLATFORM=${TEMPLATE_PLATFORM} \ DEBIAN_FRONTEND=dialog @@ -71,7 +70,6 @@ LABEL com.docker.sandboxes.start-docker=true \ io.solutionforest.epar.template.candidate="A" \ io.solutionforest.epar.template.profile="${SOURCE_PROFILE}" \ io.solutionforest.epar.template.platform="${TEMPLATE_PLATFORM}" \ - io.solutionforest.epar.template.sbx-version="0.35.0" \ io.solutionforest.epar.template.runner-version="2.332.0" \ io.solutionforest.epar.template.source-index-digest="${SOURCE_INDEX_DIGEST}" \ io.solutionforest.epar.template.source-manifest-digest="${SOURCE_MANIFEST_DIGEST}" \ diff --git a/templates/docker-sandboxes/guest/collect-software-inventory.sh b/templates/docker-sandboxes/guest/collect-software-inventory.sh index 16cc496..54f629b 100644 --- a/templates/docker-sandboxes/guest/collect-software-inventory.sh +++ b/templates/docker-sandboxes/guest/collect-software-inventory.sh @@ -3,7 +3,6 @@ set -euo pipefail printf 'schemaVersion\t1\n' printf 'platform\t%s\n' "${EPAR_TEMPLATE_PLATFORM:?EPAR_TEMPLATE_PLATFORM is required}" -printf 'templateSbxVersion\t%s\n' "${EPAR_TEMPLATE_SBX_VERSION:-unknown}" printf '\n[os-release]\n' LC_ALL=C sort /etc/os-release printf '\n[dpkg]\n' diff --git a/templates/docker-sandboxes/guest/verify-template.sh b/templates/docker-sandboxes/guest/verify-template.sh index 9d3c979..1b069e2 100644 --- a/templates/docker-sandboxes/guest/verify-template.sh +++ b/templates/docker-sandboxes/guest/verify-template.sh @@ -17,7 +17,6 @@ docker info >/dev/null [[ "$(PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin command -v bash)" == "/opt/epar/hook-bin/bash" ]] [[ -x /usr/bin/python3 ]] [[ "$(sudo -u agent -H /opt/actions-runner/bin/Runner.Listener --version)" == "2.332.0" ]] -[[ "${EPAR_TEMPLATE_SBX_VERSION}" == "0.35.0" ]] case "${EPAR_TEMPLATE_PLATFORM}" in linux/amd64) [[ "$(uname -m)" == "x86_64" ]] diff --git a/templates/docker-sandboxes/helpers.sha256 b/templates/docker-sandboxes/helpers.sha256 index 25a8cd5..8b74185 100644 --- a/templates/docker-sandboxes/helpers.sha256 +++ b/templates/docker-sandboxes/helpers.sha256 @@ -1,9 +1,9 @@ 269b1431e6eb9ee6803be1ecfae6814a3cb120170466db16fa1b9ededcd4d39c ./check-host-trust-generation.sh 3f1cee32d05ffad64004377f0276ef8aa46a2848b32bca74ac35efb3d71fd1bf ./check-runner.sh 4fe8e512539f97d00db3c2856f452f017c4de6a04832f1c1af86f1994862df59 ./collect-runner-diagnostics.sh -5d8c515c363ee54b0fabc0773bccefefdb1db18898a6d28f62c52522cbde0f21 ./collect-software-inventory.sh +9d659942a7a0958c07cb0f0f069c699ec865020f0f9302bc005680a88c10c8e6 ./collect-software-inventory.sh 90a13d73f74e7628f1b7c4c768d300d2a4620387061c056dd67ca42692180e2b ./configure-runner.sh a32f6bdb3b883272172e2d7318feb40d88e3ebfaffa909527ca57a3c06773ae1 ./prepare-template.sh 208d6fa96472a53b1ae1a5903da65530f6b8811d556cf04e88b99a2dfd88ec73 ./run-runner.sh 0746da2abc0a1033ecdad83ec6e432c7a006c83fefd2ce5424d3932c8f38d9aa ./template-entrypoint.sh -f78e7d9afbd627424cd32cfbab2acb094af8906d059a25df77bc6d5f0bad7849 ./verify-template.sh +d1e3b6808118e4c04bfff8db2b42228cb7a376910874b4a1fb486eaaa55d5d5f ./verify-template.sh diff --git a/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json b/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json index 2c26cd1..257b254 100644 --- a/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json +++ b/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json @@ -4,7 +4,6 @@ "profile": "act-22.04", "validationStatus": "planned", "platform": "linux/amd64", - "supportedSbxVersions": ["0.35.0"], "source": { "reference": "ghcr.io/catthehacker/ubuntu@sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", "indexDigest": "sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", diff --git a/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json b/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json index 00fe218..0d49402 100644 --- a/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json +++ b/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json @@ -4,7 +4,6 @@ "profile": "act-22.04", "validationStatus": "unvalidated", "platform": "linux/arm64", - "supportedSbxVersions": ["0.35.0"], "source": { "reference": "ghcr.io/catthehacker/ubuntu@sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", "indexDigest": "sha256:b40b8af93baee90b83f29c834440873300c8478809535786dbf79fa836c086ac", diff --git a/templates/docker-sandboxes/profiles/full.amd64.compatibility.json b/templates/docker-sandboxes/profiles/full.amd64.compatibility.json index 382ac1e..7fc9468 100644 --- a/templates/docker-sandboxes/profiles/full.amd64.compatibility.json +++ b/templates/docker-sandboxes/profiles/full.amd64.compatibility.json @@ -4,7 +4,6 @@ "profile": "full", "validationStatus": "planned", "platform": "linux/amd64", - "supportedSbxVersions": ["0.35.0"], "source": { "reference": "ghcr.io/catthehacker/ubuntu@sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", "indexDigest": "sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", diff --git a/templates/docker-sandboxes/profiles/full.arm64.compatibility.json b/templates/docker-sandboxes/profiles/full.arm64.compatibility.json index c7c9383..f9d82ca 100644 --- a/templates/docker-sandboxes/profiles/full.arm64.compatibility.json +++ b/templates/docker-sandboxes/profiles/full.arm64.compatibility.json @@ -4,7 +4,6 @@ "profile": "full", "validationStatus": "unvalidated", "platform": "linux/arm64", - "supportedSbxVersions": ["0.35.0"], "source": { "reference": "ghcr.io/catthehacker/ubuntu@sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", "indexDigest": "sha256:76581ac3f31aa1ad7cb558b47c3e836b9cbcd82dc08fc69349f77e3967bea50c", From 2a972975f7ea24db412440d10a12935addb8ce09 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 29 Jul 2026 14:21:08 +0800 Subject: [PATCH 07/22] feat: automate trusted provider image provisioning Share guided Catthehacker image onboarding and source-aware storage admission across Docker-capable providers. Build and import verified Docker Sandboxes templates through an EPAR-owned Buildx builder with operational host trust, durable receipts, wrapper parity, tests, and updated documentation. --- README.md | 2 +- cmd/ephemeral-action-runner/init.go | 491 +++--- cmd/ephemeral-action-runner/init_test.go | 496 +++++- cmd/ephemeral-action-runner/main.go | 26 +- cmd/ephemeral-action-runner/provider_test.go | 6 +- cmd/ephemeral-action-runner/start.go | 81 +- cmd/ephemeral-action-runner/start_test.go | 64 +- cmd/ephemeral-action-runner/storage.go | 70 +- configs/docker-container.act.example.yml | 2 +- configs/docker-container.core.example.yml | 2 +- configs/docker-container.example.yml | 2 +- configs/docker-container.web-e2e.example.yml | 2 +- configs/docker-sandboxes.example.yml | 20 +- configs/tart.example.yml | 2 +- configs/tart.web-e2e.example.yml | 2 +- configs/wsl.example.yml | 2 +- configs/wsl.lean.example.yml | 2 +- configs/wsl.web-e2e.example.yml | 2 +- docs/README.md | 4 +- docs/advanced/docker-sandboxes-template.md | 32 +- docs/advanced/no-go-install.md | 2 +- docs/configuration.md | 26 +- docs/development/principles.md | 2 +- docs/image-build.md | 14 +- docs/providers/docker-sandboxes.md | 39 +- docs/storage.md | 8 +- docs/troubleshooting.md | 22 +- docs/usage.md | 10 +- internal/config/config.go | 137 +- internal/config/config_test.go | 55 +- internal/image/artifact_lifecycle.go | 4 +- internal/image/build.go | 13 +- internal/image/buildx.go | 297 +++- internal/image/buildx_test.go | 64 + internal/image/coordinator.go | 28 +- internal/image/docker_sandboxes.go | 1525 +++++++++++++++++ internal/image/docker_sandboxes_test.go | 313 ++++ internal/image/manifest.go | 2 + internal/image/storage_plan.go | 193 +++ internal/image/storage_plan_test.go | 76 + internal/image/terminology_test.go | 45 + internal/image/verified_download.go | 142 ++ internal/image/verified_download_test.go | 79 + internal/pool/host_trust.go | 46 + internal/pool/host_trust_test.go | 25 + internal/pool/image_service.go | 28 +- internal/pool/manager.go | 30 +- internal/pool/provider_lifecycle.go | 16 +- internal/pool/runner_script_test.go | 12 +- internal/pool/storage_preflight.go | 56 +- internal/pool/storage_preflight_test.go | 16 + .../dockersandboxes/capacity/capacity.go | 56 +- .../dockersandboxes/capacity/capacity_test.go | 21 +- .../provider/dockersandboxes/live_test.go | 2 +- .../dockersandboxes/promotion/preflight.go | 12 +- .../dockersandboxes/promotion/promotion.go | 2 +- .../promotion/promotion_test.go | 2 +- internal/provider/dockersandboxes/provider.go | 97 +- .../provider/dockersandboxes/provider_test.go | 31 + internal/provider/factory.go | 7 + internal/provider/provider.go | 19 + .../provider/registry/contributions_test.go | 8 + internal/provider/registry/registry.go | 59 +- internal/provider/registry/registry_test.go | 2 +- internal/provider/storage.go | 13 +- internal/storage/capacity_test.go | 10 +- internal/storage/inventory/template.go | 45 +- internal/storage/inventory/template_test.go | 21 +- internal/storage/types.go | 19 +- scripts/build-native-controller.ps1 | 20 +- scripts/build-native-controller.sh | 21 +- scripts/docker-sandboxes/build-template.ps1 | 416 +---- scripts/docker-sandboxes/load-template.ps1 | 310 +--- scripts/docker-sandboxes/validate-assets.ps1 | 29 +- scripts/host-trust/host-trust-feed.ps1 | 19 +- scripts/host-trust/host-trust-feed.sh | 18 +- scripts/host-trust/wrapper-lib.ps1 | 92 +- scripts/host-trust/wrapper-lib.sh | 54 +- scripts/run-with-docker.ps1 | 12 +- scripts/run-with-docker.sh | 14 +- scripts/test/docker-sandboxes-plan-smoke.ps1 | 169 +- scripts/test/host-trust-wrapper-smoke.ps1 | 10 + start | 5 +- start.ps1 | 11 +- templates/docker-sandboxes/.dockerignore | 11 + templates/docker-sandboxes/Dockerfile | 21 +- .../docker-sandboxes/custom-install/run.sh | 4 + .../guest/check-host-trust-generation.sh | 6 +- .../guest/install-trusted-ca-certificates.sh | 22 + .../docker-sandboxes/guest/run-runner.sh | 31 +- templates/docker-sandboxes/helpers.sha256 | 5 +- .../act-22.04.amd64.compatibility.json | 4 +- .../act-22.04.arm64.compatibility.json | 4 +- .../profiles/full.amd64.compatibility.json | 4 +- .../profiles/full.arm64.compatibility.json | 4 +- templates/docker-sandboxes/sources.lock.json | 4 +- 96 files changed, 4651 insertions(+), 1730 deletions(-) create mode 100644 internal/image/docker_sandboxes.go create mode 100644 internal/image/docker_sandboxes_test.go create mode 100644 internal/image/storage_plan.go create mode 100644 internal/image/storage_plan_test.go create mode 100644 internal/image/terminology_test.go create mode 100644 internal/image/verified_download.go create mode 100644 internal/image/verified_download_test.go create mode 100644 templates/docker-sandboxes/custom-install/run.sh create mode 100644 templates/docker-sandboxes/guest/install-trusted-ca-certificates.sh diff --git a/README.md b/README.md index ba3acb7..4acd834 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The normal path is a source archive plus Docker. EPAR's first run opens a guided ### 1. Install the host tools -Install a Docker-compatible daemon: [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Windows or macOS, [OrbStack](https://orbstack.dev/) on macOS, or [Docker Engine](https://docs.docker.com/engine/) on Linux. Docker Sandboxes also needs the `sbx` CLI to pass its diagnostics and a prepared EPAR template; see [Docker Sandboxes](docs/providers/docker-sandboxes.md). +Install a Docker-compatible daemon: [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Windows or macOS, [OrbStack](https://orbstack.dev/) on macOS, or [Docker Engine](https://docs.docker.com/engine/) on Linux. Docker Sandboxes also needs the `sbx` CLI to pass its diagnostics; the wizard provisions its EPAR runner template. See [Docker Sandboxes](docs/providers/docker-sandboxes.md). ### 2. Download EPAR diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index c7ff42a..b83af73 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -25,7 +25,8 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" - "github.com/solutionforest/ephemeral-action-runner/internal/invocation" + imageartifact "github.com/solutionforest/ephemeral-action-runner/internal/image" + "github.com/solutionforest/ephemeral-action-runner/internal/logging" "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes" sandboxcapacity "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/capacity" sandboxpolicy "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/policy" @@ -60,8 +61,30 @@ var initDockerSandboxesPreflight = func(ctx context.Context, record sandboxpromo return sandboxpromotion.LocalPreflight(ctx, record, projectRoot, os.Getenv("EPAR_CONTROLLER_IN_DOCKER") != "1", sourceRevision) } -var initDockerSandboxesReadiness = func(ctx context.Context) (dockersandboxes.HostReadiness, error) { - return dockersandboxes.New("sbx").VerifyHostReadiness(ctx) +var initDockerSandboxesLookPath = exec.LookPath + +var initDockerSandboxesStartDaemon = func(ctx context.Context, binary string) error { + return dockersandboxes.New(binary).StartDaemon(ctx) +} + +var initDockerSandboxesDiagnose = func(ctx context.Context, binary string) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.New(binary).VerifyHostReadiness(ctx) +} + +var initDockerSandboxesReadiness = prepareInitDockerSandboxesReadiness + +func prepareInitDockerSandboxesReadiness(ctx context.Context) (dockersandboxes.HostReadiness, error) { + binary := "sbx" + var daemonStartErr error + if installed, err := initDockerSandboxesLookPath(binary); err == nil { + binary = installed + daemonStartErr = initDockerSandboxesStartDaemon(ctx, binary) + } + readiness, readinessErr := initDockerSandboxesDiagnose(ctx, binary) + if readinessErr != nil && daemonStartErr != nil { + return dockersandboxes.HostReadiness{}, fmt.Errorf("automatic 'sbx daemon start --detach' failed: %v; diagnostics also failed: %w", daemonStartErr, readinessErr) + } + return readiness, readinessErr } type initDockerSandboxesTemplate struct { @@ -117,13 +140,14 @@ type initDockerSandboxesCapacityResult struct { } type initDockerSandboxesProfile struct { + Provider string HostPlatform sandboxpromotion.Platform - Template string - TemplateDigest string + GuestPlatform string + SourceImage string + CustomScripts []string PolicyFingerprint string RootDisk string DockerDisk string - MinHostFreeSpace string } var initDiscoverDockerSandboxes = discoverDockerSandboxes @@ -132,6 +156,29 @@ var initDockerSandboxesRootMeasurementFor = dockerSandboxesRootMeasurement var initDockerSandboxesCapacityCheck = checkInitDockerSandboxesCapacity +var initResolveDockerSandboxesSource = imageartifact.ResolveCatthehackerSource + +var initDockerSandboxesPolicyFingerprint = func(ctx context.Context) (string, error) { + adapter := dockersandboxes.New("") + if err := adapter.VerifyAdmission(ctx); err != nil { + return "", fmt.Errorf("Docker Sandboxes admission check failed: %w", err) + } + rules, err := adapter.ReadGlobalNetworkPolicy(ctx) + if err != nil { + return "", fmt.Errorf("read Docker Sandboxes global network policy: %w", err) + } + return sandboxpolicy.Fingerprint(rules) +} + +var initEnsureDockerSandboxesTemplate = func(ctx context.Context, projectRoot, configPath string) error { + manager, err := newImageProvisioningManager(configPath, projectRoot) + if err != nil { + return err + } + defer manager.Close() + return manager.EnsureImage(ctx) +} + type initRunnerGroupClient interface { ListRunnerGroups(context.Context) ([]gh.RunnerGroup, error) ListRunnerGroupRepositories(context.Context, int64) ([]gh.RunnerGroupRepository, error) @@ -248,7 +295,7 @@ func runInitWithOptions(opts initOptions) error { if err != nil { return err } - providerType, promotionRecord, selectedProfile, err := promptInitProvider(opts.Context, opts.ProjectRoot, opts.Out, reader, opts.SkipDockerCheck) + providerType, _, selectedProfile, err := promptInitProvider(opts.Context, opts.ProjectRoot, opts.Out, reader, opts.SkipDockerCheck) if err != nil { return err } @@ -290,36 +337,37 @@ func runInitWithOptions(opts initOptions) error { } } - content := defaultDockerContainerConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup) + profile := selectedProfile + if providerType != "tart" && profile == nil { + return fmt.Errorf("%s image selection did not produce a provisioning profile", providerType) + } + var content string switch providerType { + case "docker-container": + content = defaultDockerContainerConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile) case "wsl": - content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup) + content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup, *profile) case "tart": content = defaultTartConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup) case "docker-sandboxes": - profile := selectedProfile - if profile == nil { - var profileErr error - profile, profileErr = promotedDockerSandboxesProfile(promotionRecord) - if profileErr != nil { - return profileErr - } - } guestPlatform, runnerArchitectureLabel, platformErr := dockerSandboxesPlatform(profile.HostPlatform) if platformErr != nil { return platformErr } content = defaultDockerSandboxesConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile, guestPlatform, runnerArchitectureLabel) + default: + return fmt.Errorf("unsupported provider.type %q", providerType) } if err := os.MkdirAll(filepath.Dir(opts.ConfigPath), 0755); err != nil { return err } - if err := os.WriteFile(opts.ConfigPath, []byte(content), 0600); err != nil { + if err := logging.WritePrivateFileAtomic(opts.ConfigPath, []byte(content)); err != nil { return err } fmt.Fprintf(opts.Out, "\nCreated %s\n", opts.ConfigPath) if opts.EmbeddedInStart { + fmt.Fprintln(opts.Out, "Initialization succeeded. Startup will now provision the selected runner artifact and apply storage admission before side effects.") return nil } fmt.Fprintf(opts.Out, ` @@ -717,7 +765,7 @@ func promptInitProvider(ctx context.Context, projectRoot string, out io.Writer, hostPlatform := initSandboxPromotionPlatform() record, promoted := initSandboxPromotionLookup(hostPlatform) for { - providerType, promotionPassed, refresh, err := promptInitProviderChoice(ctx, projectRoot, hostPlatform, record, promoted, out, reader, skipDockerCheck) + providerType, _, refresh, err := promptInitProviderChoice(ctx, projectRoot, hostPlatform, record, promoted, out, reader, skipDockerCheck) if err != nil { return "", sandboxpromotion.Record{}, nil, err } @@ -725,25 +773,20 @@ func promptInitProvider(ctx context.Context, projectRoot string, out io.Writer, fmt.Fprintln(out, "Refreshing provider prerequisites...") continue } - if providerType != "docker-sandboxes" { + if providerType == "tart" { return providerType, sandboxpromotion.Record{}, nil, nil } - if promotionPassed { - return providerType, record, nil, nil - } - if !promoted { - if _, _, err := dockerSandboxesPlatform(hostPlatform); err == nil { - profile, accepted, profileErr := promptDockerSandboxesProfile(ctx, projectRoot, hostPlatform, out, reader) - if profileErr != nil { - return "", sandboxpromotion.Record{}, nil, profileErr - } - if !accepted { - return "", sandboxpromotion.Record{}, nil, errors.New("Docker Sandboxes setup did not complete; no config was written") - } - return providerType, sandboxpromotion.Record{}, profile, nil + if providerType == "docker-container" || providerType == "docker-sandboxes" || providerType == "wsl" { + profile, accepted, profileErr := promptDockerImageProfile(ctx, projectRoot, providerType, hostPlatform, out, reader) + if profileErr != nil { + return "", sandboxpromotion.Record{}, nil, profileErr + } + if !accepted { + return "", sandboxpromotion.Record{}, nil, fmt.Errorf("%s image setup did not complete; no config was written", providerType) } + return providerType, sandboxpromotion.Record{}, profile, nil } - return "", sandboxpromotion.Record{}, nil, errors.New("Docker Sandboxes selection is unavailable") + return "", sandboxpromotion.Record{}, nil, fmt.Errorf("provider %q has no registered image onboarding flow", providerType) } } @@ -804,176 +847,202 @@ func promptInitProviderChoice(ctx context.Context, projectRoot string, hostPlatf } func promptDockerSandboxesProfile(ctx context.Context, projectRoot string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (*initDockerSandboxesProfile, bool, error) { - fmt.Fprintln(out, "") - fmt.Fprintln(out, "Docker Sandboxes setup:") - fmt.Fprintln(out, " Docker Sandboxes is the recommended default because Docker and sbx diagnostics passed on this machine.") - fmt.Fprintln(out, " Docker Sandboxes provides EPAR's strongest current host boundary, but it is not a guarantee that arbitrary hostile workflows are safe.") - fmt.Fprintln(out, " Setup performs read-only checks for an exact locally built and loaded EPAR template and the current host-global Balanced policy.") - fmt.Fprintln(out, " New EPAR sandboxes default to open public HTTP/HTTPS egress with owned deny-wins host-alias guardrails; Docker Sandboxes' private-network isolation remains in force.") - if os.Getenv(sandboxpromotion.DisableEnvironment) == "1" { - fmt.Fprintf(out, " Unavailable: %s=1 disables Docker Sandboxes admission.\n", sandboxpromotion.DisableEnvironment) - return nil, false, fmt.Errorf("Docker Sandboxes setup is disabled by %s; no config was written", sandboxpromotion.DisableEnvironment) - } + return promptDockerImageProfile(ctx, projectRoot, "docker-sandboxes", hostPlatform, out, reader) +} - guestPlatform, _, err := dockerSandboxesPlatform(hostPlatform) +func promptDockerImageProfile(ctx context.Context, projectRoot, providerType string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (*initDockerSandboxesProfile, bool, error) { + guestPlatform, err := initDockerGuestPlatform(providerType, hostPlatform) if err != nil { - fmt.Fprintf(out, " Docker Sandboxes is unavailable: %v\n", err) - return nil, false, fmt.Errorf("Docker Sandboxes setup is unavailable: %w", err) + return nil, false, fmt.Errorf("%s image setup is unavailable: %w", providerType, err) } - var discovery initDockerSandboxesDiscovery - for { - discoveryContext, cancel := context.WithTimeout(ctx, 45*time.Second) - discovery, err = initDiscoverDockerSandboxes(discoveryContext, projectRoot, guestPlatform) - cancel() - if err == nil && len(discovery.Templates) > 0 { - break - } - if err != nil { - fmt.Fprintf(out, " Docker Sandboxes setup preparation failed: %v\n", err) - fmt.Fprintln(out, " Action: repair the reported local admission check. For template or cache failures, build and load a Candidate A template using docs/providers/docker-sandboxes.md.") - } else { - fmt.Fprintln(out, " Docker Sandboxes setup found no exact locally built and loaded EPAR template for this platform.") - fmt.Fprintln(out, " Action: build and load a Candidate A template using docs/providers/docker-sandboxes.md.") - } - retry, promptErr := promptYesNo(out, reader, "Retry Docker Sandboxes setup checks?", false) - if promptErr != nil { - return nil, false, promptErr - } - if !retry { - return nil, false, errors.New("Docker Sandboxes setup checks did not pass; no config was written") - } + descriptor, found := providerregistry.DescriptorFor(providerType) + if !found || !descriptor.GuidedArtifacts || len(descriptor.WizardImageProfiles) == 0 { + return nil, false, fmt.Errorf("%s has no registered guided image onboarding contribution", providerType) } fmt.Fprintln(out, "") - fmt.Fprintln(out, "Verified local Docker Sandboxes source profiles:") - for index, template := range discovery.Templates { - suffix := "" + fmt.Fprintf(out, "%s image setup:\n", descriptor.DisplayName) + fmt.Fprintln(out, " Choose the desired Catthehacker Ubuntu image. EPAR will create the configuration now and provision or update the reusable runner artifact during startup.") + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Runner base image:") + for index, profile := range descriptor.WizardImageProfiles { + defaultLabel := "" if index == 0 { - if _, measured := initDockerSandboxesRootMeasurementFor(hostPlatform, template); measured { - suffix = " (recommended measured default)" - } else { - suffix = " (default selection; capacity unmeasured)" - } + defaultLabel = " (default)" } - fmt.Fprintf(out, " %d. %s — %s [%s, cache %s, %s on host]%s\n", index+1, template.Label, template.SourceChannel, template.Platform, template.CacheID, formatInitByteCount(template.Size), suffix) + fmt.Fprintf(out, " %d. %s — %s%s\n", index+1, profile.Name, profile.Tag, defaultLabel) } - fmt.Fprintln(out, " This wizard saves the exact verified EPAR template tag and full local digest in configuration.") - fmt.Fprintln(out, " Automatic Docker Sandboxes source refresh is not implemented yet; build and load a new locked EPAR template manually before running setup.") - var selected initDockerSandboxesTemplate + customChoice := strconv.Itoa(len(descriptor.WizardImageProfiles) + 1) + fmt.Fprintf(out, " %s. Another catthehacker/ubuntu tag, such as go-24.04\n", customChoice) + fmt.Fprintln(out, " Image catalog: https://github.com/catthehacker/docker_images#images-available") + + var source imageartifact.ResolvedDockerSource for { - value, hitEOF, promptErr := promptDefault(out, reader, "Docker Sandboxes source profile", "1") + choice, hitEOF, promptErr := promptDefault(out, reader, "Runner base image", "1") if promptErr != nil { return nil, false, promptErr } - index, parseErr := strconv.Atoi(value) - if parseErr == nil && index >= 1 && index <= len(discovery.Templates) { - selected = discovery.Templates[index-1] + normalizedChoice := strings.ToLower(choice) + input := "" + for index, profile := range descriptor.WizardImageProfiles { + if normalizedChoice == strconv.Itoa(index+1) || normalizedChoice == profile.Name { + input = profile.Name + break + } + } + if normalizedChoice == customChoice { + input, promptErr = promptRequired(out, reader, "catthehacker/ubuntu tag") + if promptErr != nil { + return nil, false, promptErr + } + } + if input == "" { + fmt.Fprintf(out, " Choose a built-in image from 1 to %d, or %s for another catthehacker/ubuntu tag.\n", len(descriptor.WizardImageProfiles), customChoice) + if hitEOF { + return nil, false, fmt.Errorf("invalid runner base image %q", choice) + } + continue + } + resolveContext, cancel := context.WithTimeout(ctx, 90*time.Second) + source, err = initResolveDockerSandboxesSource(resolveContext, input, guestPlatform) + cancel() + if err == nil { break } - fmt.Fprintf(out, "Docker Sandboxes source profile must be a number from 1 to %d.\n", len(discovery.Templates)) + fmt.Fprintf(out, " That image cannot be used for %s: %v\n", guestPlatform, err) + fmt.Fprintln(out, " Choose an existing ghcr.io/catthehacker/ubuntu tag that publishes this platform.") if hitEOF { - return nil, false, fmt.Errorf("invalid Docker Sandboxes source profile %q", value) + return nil, false, fmt.Errorf("resolve runner source image: %w", err) } } - fmt.Fprintf(out, " Resolved exact EPAR template tag: %s\n", selected.Reference) - fmt.Fprintf(out, " Resolved full local template digest: %s\n", selected.Digest) - fmt.Fprintln(out, "") - fmt.Fprintln(out, "Selected template capacity:") - fmt.Fprintf(out, " Shared host template cache: %s (already present; do not add this byte count arithmetically to each sandbox root disk).\n", formatInitByteCount(selected.Size)) - fmt.Fprintln(out, " Source-image virtual or unpacked-size figures are not guest root-disk requirements.") - fmt.Fprintln(out, "") - fmt.Fprintln(out, "Default resource reservations are recommended starting values and can be adjusted in the generated config.") - var rootDisk string - var dockerDisk string - if measurement, ok := initDockerSandboxesRootMeasurementFor(hostPlatform, selected); ok { - fmt.Fprintf(out, " Measured guest root peak: %s (%s).\n", formatInitByteCount(measurement.PeakBytes), measurement.Evidence) - fmt.Fprintln(out, " Root formula: measured peak + 25% safety margin + the recommended 20GiB writable headroom, rounded up to 10GiB.") - derivedRootDisk, deriveErr := sandboxcapacity.DeriveRootDisk(uint64(measurement.PeakBytes), sandboxcapacity.RootWritableHeadroom) - if deriveErr != nil { - return nil, false, fmt.Errorf("derive sandbox root filesystem capacity: %w", deriveErr) - } - if derivedRootDisk > math.MaxInt64 { - return nil, false, errors.New("derived sandbox root filesystem capacity exceeds the supported size") - } - rootDisk = formatInitByteCount(int64(derivedRootDisk)) - fmt.Fprintf(out, " Automatically selected sandbox root filesystem total capacity: %s.\n", rootDisk) - fmt.Fprintln(out, " Docker storage is the most workload-dependent reservation and is the only capacity question asked.") - dockerDisk, err = promptInitByteSize(out, reader, "Sandbox Docker disk", "100GiB", config.DockerSandboxesMinimumDockerDiskBytes) + var customScripts []string + addScripts, err := promptYesNo(out, reader, "Run custom install scripts while building the runner artifact?", false) + if err != nil { + return nil, false, err + } + if addScripts { + fmt.Fprintln(out, " Scripts run as root during the image build. Do not put secrets in scripts or build inputs.") + for { + script, promptErr := promptRequired(out, reader, "Custom install script path") + if promptErr != nil { + return nil, false, promptErr + } + normalized, validationErr := validateInitCustomInstallScript(projectRoot, script) + if validationErr != nil { + fmt.Fprintf(out, " Invalid custom install script: %v\n", validationErr) + continue + } + customScripts = append(customScripts, normalized) + another, promptErr := promptYesNo(out, reader, "Add another custom install script?", false) + if promptErr != nil { + return nil, false, promptErr + } + if !another { + break + } + } + } + + policyFingerprint := "" + if providerType == "docker-sandboxes" { + policyFingerprint, err = initDockerSandboxesPolicyFingerprint(ctx) if err != nil { return nil, false, err } - } else { - fmt.Fprintln(out, " Measured guest root peak: unavailable for this exact template digest.") - fmt.Fprintln(out, " Root capacity is the least certain reservation and is the only capacity question asked; the 30GiB default is provisional and is not derived from the template cache size.") - var promptErr error - rootDisk, promptErr = promptInitByteSize(out, reader, "Sandbox root filesystem total capacity", "30GiB", int64(sandboxcapacity.MinimumRootDisk)) - if promptErr != nil { - return nil, false, promptErr - } - dockerDisk = "100GiB" - fmt.Fprintln(out, " Automatically selected sandbox Docker disk: 100GiB.") } - minHostFreeSpace := "50GiB" - fmt.Fprintln(out, " Automatically selected minimum host free space: 50GiB.") - fmt.Fprintln(out, " EPAR rechecks current Docker Sandboxes storage free space, existing and uncertain reservations, and raises the effective host watermark to at least 10% of the backing volume before every sandbox create.") - rootDiskBytes, err := config.ParseByteSize(rootDisk) + sourceEstimate, err := imageartifact.EstimateSourceSize(source.CompressedLayerBytes, 0) if err != nil { - return nil, false, fmt.Errorf("parse sandbox root filesystem capacity: %w", err) + return nil, false, err } - dockerDiskBytes, err := config.ParseByteSize(dockerDisk) + const dockerDisk = config.DockerSandboxesDefaultDockerDisk + dockerDiskBytes, _ := config.ParseByteSize(dockerDisk) + artifactPlan, err := imageartifact.PlanArtifactStorage(providerType, sourceEstimate, false, uint64(dockerDiskBytes)) if err != nil { - return nil, false, fmt.Errorf("parse sandbox Docker disk capacity: %w", err) + return nil, false, err } - minHostFreeSpaceBytes, err := config.ParseByteSize(minHostFreeSpace) - if err != nil { - return nil, false, fmt.Errorf("parse minimum host free space: %w", err) + var availableText = "unknown" + if capacity, probeErr := storage.ProbeFilesystemCapacity(projectRoot, time.Now()); probeErr == nil && capacity.Known { + availableText = formatInitUintByteCount(capacity.AvailableBytes) } - capacityResult, err := initDockerSandboxesCapacityCheck(uint64(rootDiskBytes), uint64(dockerDiskBytes), uint64(minHostFreeSpaceBytes)) + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Runner artifact estimate (informational; configuration creation is not blocked):") + fmt.Fprintf(out, " Source: %s\n", source.Reference) + fmt.Fprintf(out, " Platform: %s\n", source.Platform) + if len(customScripts) == 0 { + fmt.Fprintln(out, " Custom install scripts: none") + } else { + fmt.Fprintf(out, " Custom install scripts: %s\n", strings.Join(customScripts, ", ")) + } + fmt.Fprintf(out, " Estimated download: %s compressed layers\n", formatInitUintByteCount(source.CompressedLayerBytes)) + fmt.Fprintf(out, " Estimated expanded source: %s\n", formatInitUintByteCount(sourceEstimate.ExpandedBytes)) + fmt.Fprintf(out, " Estimated incremental physical peak: %s\n", formatInitUintByteCount(artifactPlan.EstimatedIncrementalPeak)) + fmt.Fprintf(out, " Available physical space: %s\n", availableText) + fmt.Fprintln(out, " Fixed free-space reserve: 1GiB") + fmt.Fprintf(out, " Estimate confidence: %s\n", artifactPlan.Confidence) + if providerType == "docker-sandboxes" { + fmt.Fprintf(out, " Automatic sandbox root limit: %s (sparse logical maximum)\n", formatInitUintByteCount(artifactPlan.LogicalRootMaximumBytes)) + fmt.Fprintf(out, " Inner Docker limit: %s (independent sparse logical maximum)\n", formatInitUintByteCount(artifactPlan.LogicalDockerMaximumBytes)) + } + fmt.Fprintln(out, " Expected duration: several minutes; full-latest can take substantially longer on a cold cache.") + confirmed, err := promptYesNo(out, reader, "Create this configuration?", true) if err != nil { - return nil, false, fmt.Errorf("Docker Sandboxes setup cannot measure provider storage capacity: %w; no config was written", err) + return nil, false, err } - if capacityResult.CapacityStatus != storage.CapacityReady { - fmt.Fprintln(out, "") - fmt.Fprintln(out, "Docker Sandboxes capacity admission failed:") - fmt.Fprintf(out, " Provider storage: %s\n", capacityResult.StorageRoot) - fmt.Fprintf(out, " Available: %s\n", formatInitUintByteCount(capacityResult.AvailableBytes)) - fmt.Fprintf(out, " Sandbox reservation: %s\n", formatInitUintByteCount(capacityResult.Reservation)) - fmt.Fprintf(out, " Required host reserve: %s\n", formatInitUintByteCount(capacityResult.HostWatermark)) - fmt.Fprintf(out, " Required before creation: %s\n", formatInitUintByteCount(capacityResult.RequiredBytes)) - fmt.Fprintf(out, " Shortfall: %s\n", formatInitUintByteCount(capacityResult.DeficitBytes)) - fmt.Fprintf(out, " Preview exact policy-selected cleanup with: %s\n", invocation.Command("storage", "prune", "--provider", "docker-sandboxes")) - return nil, false, errors.New("Docker Sandboxes storage is insufficient for the selected profile; no config was written") - } - fmt.Fprintf(out, " Verified Docker Sandboxes storage admission on %s: %s available; %s required before creation.\n", capacityResult.StorageRoot, formatInitUintByteCount(capacityResult.AvailableBytes), formatInitUintByteCount(capacityResult.RequiredBytes)) - profile := &initDockerSandboxesProfile{ + if !confirmed { + return nil, false, nil + } + return &initDockerSandboxesProfile{ + Provider: providerType, HostPlatform: hostPlatform, - Template: selected.Reference, - TemplateDigest: selected.Digest, - PolicyFingerprint: discovery.PolicyFingerprint, - RootDisk: rootDisk, + GuestPlatform: guestPlatform, + SourceImage: source.Reference, + CustomScripts: customScripts, + PolicyFingerprint: policyFingerprint, + RootDisk: config.DockerSandboxesAutomaticRootDisk, DockerDisk: dockerDisk, - MinHostFreeSpace: minHostFreeSpace, - } - if err := config.ValidateDockerSandboxes(config.DockerSandboxesConfig{ - Template: profile.Template, - TemplateDigest: profile.TemplateDigest, - PolicyGeneration: profile.PolicyFingerprint, - NetworkBaseline: config.DockerSandboxesNetworkBaselineOpen, - StagingRoot: ".local/docker-sandboxes-staging", - CPUs: 4, - Memory: "8GiB", - RootDisk: profile.RootDisk, - DockerDisk: profile.DockerDisk, - MaxConcurrentCreates: 2, - MinHostFreeSpace: profile.MinHostFreeSpace, - }); err != nil { - return nil, false, fmt.Errorf("validate discovered Docker Sandboxes profile: %w", err) - } - fmt.Fprintf(out, " Verified policy fingerprint: %s\n", discovery.PolicyFingerprint) - return profile, true, nil + }, true, nil +} + +func initDockerGuestPlatform(providerType string, hostPlatform sandboxpromotion.Platform) (string, error) { + if providerType == "docker-sandboxes" { + platform, _, err := dockerSandboxesPlatform(hostPlatform) + return platform, err + } + _, architecture, found := strings.Cut(string(hostPlatform), "/") + if !found { + return "", fmt.Errorf("unsupported controller platform %q", hostPlatform) + } + switch architecture { + case "amd64", "arm64": + return "linux/" + architecture, nil + default: + return "", fmt.Errorf("unsupported Docker image architecture %q", architecture) + } +} + +func validateInitCustomInstallScript(projectRoot, configured string) (string, error) { + value := strings.TrimSpace(configured) + if value == "" || filepath.IsAbs(value) || filepath.VolumeName(value) != "" { + return "", fmt.Errorf("path must be project-relative") + } + path := config.ProjectPath(projectRoot, value) + relative, err := filepath.Rel(projectRoot, path) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path must remain under the project root") + } + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("path must name a regular file") + } + return filepath.ToSlash(filepath.Clean(relative)), nil } func checkInitDockerSandboxesCapacity(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { + _ = rootDisk + _ = dockerDisk storageRoot, err := sandboxcapacity.DockerSandboxesStorageRoot() if err != nil { return initDockerSandboxesCapacityResult{}, err @@ -995,10 +1064,7 @@ func checkInitDockerSandboxesCapacity(rootDisk, dockerDisk, minHostFreeSpace uin if err != nil { return initDockerSandboxesCapacityResult{}, fmt.Errorf("probe %s for %s: %w", probePath, storageRoot, err) } - if rootDisk > math.MaxUint64-dockerDisk { - return initDockerSandboxesCapacityResult{}, errors.New("sandbox root and Docker disk reservation overflows") - } - reservation := rootDisk + dockerDisk + reservation := uint64(0) hostWatermark, err := sandboxcapacity.HostWatermark(minHostFreeSpace, capacity.TotalBytes) if err != nil { return initDockerSandboxesCapacityResult{}, err @@ -1250,16 +1316,8 @@ func detectInitProviderPrerequisites(ctx context.Context, hostPlatform sandboxpr readiness, err := initDockerSandboxesReadiness(readinessContext) cancel() if err == nil { - capacityResult, capacityErr := initDockerSandboxesCapacityCheck(sandboxcapacity.MinimumRootDisk, sandboxcapacity.MinimumDockerDisk, sandboxcapacity.MinimumHostFreeSpace) - switch { - case capacityErr != nil: - result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — provider storage capacity cannot be measured: %v", capacityErr) - case capacityResult.CapacityStatus != storage.CapacityReady: - result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — provider storage %s has %s available; the minimum valid sandbox and host reserve require %s (shortfall %s)", capacityResult.StorageRoot, formatInitUintByteCount(capacityResult.AvailableBytes), formatInitUintByteCount(capacityResult.RequiredBytes), formatInitUintByteCount(capacityResult.DeficitBytes)) - default: - result.DockerSandboxesAvailable = true - result.DockerSandboxesStatus = fmt.Sprintf("READY — Docker and sbx diagnostics passed (%d pass, %d warn, %d fail, %d skip); minimum storage admission passed", readiness.ChecksPassed, readiness.ChecksWarned, readiness.ChecksFailed, readiness.ChecksSkipped) - } + result.DockerSandboxesAvailable = true + result.DockerSandboxesStatus = fmt.Sprintf("READY — Docker and sbx diagnostics passed (%d pass, %d warn, %d fail, %d skip)", readiness.ChecksPassed, readiness.ChecksWarned, readiness.ChecksFailed, readiness.ChecksSkipped) } else { result.DockerSandboxesStatus = fmt.Sprintf("UNAVAILABLE — sbx readiness failed: %v", err) if !strings.Contains(err.Error(), "sbx diagnose --output json") { @@ -1655,7 +1713,7 @@ func (b *boundedBuffer) Write(p []byte) (int, error) { return b.Buffer.Write(p) } -func defaultDockerContainerConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection) string { +func defaultDockerContainerConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile) string { return fmt.Sprintf(`github: appId: %d organization: %s @@ -1665,7 +1723,8 @@ func defaultDockerContainerConfig(appID int64, organization, privateKeyPath stri image: sourceType: docker-image - sourceImage: ghcr.io/catthehacker/ubuntu:full-latest + sourceImage: %s + sourcePlatform: %s outputImage: epar-docker-container-catthehacker-ubuntu upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock @@ -1673,6 +1732,7 @@ image: hostTrustMode: %s hostTrustScopes: [%s] customInstallScripts: +%s pool: instances: 1 @@ -1683,7 +1743,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative @@ -1737,7 +1797,7 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) +`, appID, organization, privateKeyPath, profile.SourceImage, profile.GuestPlatform, hostTrustMode, strings.Join(hostTrustScopes, ", "), renderInitCustomInstallScripts(profile.CustomScripts), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } func promotedDockerSandboxesPlatform(record sandboxpromotion.Record) (string, string, error) { @@ -1763,21 +1823,6 @@ func dockerSandboxesPlatform(platform sandboxpromotion.Platform) (string, string } } -func promotedDockerSandboxesProfile(record sandboxpromotion.Record) (*initDockerSandboxesProfile, error) { - if _, _, err := promotedDockerSandboxesPlatform(record); err != nil { - return nil, err - } - return &initDockerSandboxesProfile{ - HostPlatform: record.Platform, - Template: record.Template, - TemplateDigest: record.TemplateDigest, - PolicyFingerprint: record.PolicyFingerprint, - RootDisk: formatPromotionBytes(record.RootDiskBytes), - DockerDisk: formatPromotionBytes(record.DockerDiskBytes), - MinHostFreeSpace: formatPromotionBytes(record.MinHostFreeSpaceBytes), - }, nil -} - func defaultDockerSandboxesConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile, guestPlatform, runnerArchitectureLabel string) string { return fmt.Sprintf(`github: appId: %d @@ -1787,6 +1832,12 @@ func defaultDockerSandboxesConfig(appID int64, organization, privateKeyPath stri webBaseUrl: https://github.com image: + sourceType: docker-image + sourceImage: %s + sourcePlatform: %s + runnerVersion: latest + customInstallScripts: +%s hostTrustMode: %s hostTrustScopes: [%s] @@ -1799,7 +1850,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative @@ -1845,8 +1896,6 @@ provider: platform: %s dockerSandboxes: - template: %s - templateDigest: %s policyGeneration: %s networkBaseline: open stagingRoot: .local/docker-sandboxes-staging @@ -1855,24 +1904,26 @@ dockerSandboxes: rootDisk: %s dockerDisk: %s maxConcurrentCreates: 2 - minHostFreeSpace: %s timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerArchitectureLabel, runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled, guestPlatform, profile.Template, profile.TemplateDigest, profile.PolicyFingerprint, profile.RootDisk, profile.DockerDisk, profile.MinHostFreeSpace) +`, appID, organization, privateKeyPath, profile.SourceImage, guestPlatform, renderInitCustomInstallScripts(profile.CustomScripts), hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerArchitectureLabel, runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled, guestPlatform, profile.PolicyFingerprint, profile.RootDisk, profile.DockerDisk) } -func formatPromotionBytes(value uint64) string { - const gib = uint64(1 << 30) - if value != 0 && value%gib == 0 { - return strconv.FormatUint(value/gib, 10) + "GiB" +func renderInitCustomInstallScripts(paths []string) string { + if len(paths) == 0 { + return " # - examples/custom-install/install-extra-apt-tools.sh" } - return strconv.FormatUint(value, 10) + "B" + var lines []string + for _, path := range paths { + lines = append(lines, " - "+strconv.Quote(path)) + } + return strings.Join(lines, "\n") } -func defaultWSLConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection) string { +func defaultWSLConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile) string { return fmt.Sprintf(`github: appId: %d organization: %s @@ -1882,14 +1933,14 @@ func defaultWSLConfig(appID int64, organization, privateKeyPath string, poolName image: sourceType: docker-image - sourceImage: ghcr.io/catthehacker/ubuntu:full-latest - sourcePlatform: linux/amd64 + sourceImage: %s + sourcePlatform: %s outputImage: work/images/epar-wsl-catthehacker-ubuntu.tar upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest customInstallScripts: - # - examples/custom-install/install-extra-apt-tools.sh +%s pool: instances: 1 @@ -1901,7 +1952,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative @@ -1956,7 +2007,7 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) +`, appID, organization, privateKeyPath, profile.SourceImage, profile.GuestPlatform, renderInitCustomInstallScripts(profile.CustomScripts), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } func defaultTartConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection) string { @@ -1988,7 +2039,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index e667ca3..b828890 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -5,8 +5,11 @@ import ( "bytes" "context" "errors" + "io" "os" + "os/exec" "path/filepath" + "reflect" "runtime" "slices" "strings" @@ -17,6 +20,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + imageartifact "github.com/solutionforest/ephemeral-action-runner/internal/image" "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes" sandboxpromotion "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/promotion" providerregistry "github.com/solutionforest/ephemeral-action-runner/internal/provider/registry" @@ -159,7 +163,7 @@ func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { if !strings.Contains(string(configText), "replacementRetryInitialSeconds: 15\n replacementRetryMaxSeconds: 1800\n replacementRetryMultiplier: 2\n replacementRetryJitterPercent: 20\n") { t.Fatalf("generated config did not include replacement retry settings:\n%s", configText) } - if !strings.Contains(string(configText), "storage:\n minimumFree: 20GiB\n gracePeriod: 168h\n keepPrevious: 0\n automaticHousekeeping: conservative\n buildCacheLimit: 64GiB\n goCacheLimit: 10GiB\n") { + if !strings.Contains(string(configText), "storage:\n minimumFree: 1GiB\n gracePeriod: 168h\n keepPrevious: 0\n automaticHousekeeping: conservative\n buildCacheLimit: 64GiB\n goCacheLimit: 10GiB\n") { t.Fatalf("generated config did not include bounded storage settings:\n%s", configText) } if got := strings.Join(cfg.Runner.Labels, ","); !strings.Contains(got, "epar-docker-container-catthehacker-ubuntu") { @@ -471,7 +475,7 @@ func TestInitCanDisableHostTrustOverlay(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n\n\nn\n"), Out: &bytes.Buffer{}, }); err != nil { t.Fatal(err) @@ -521,7 +525,7 @@ func TestInitAcceptsCustomPoolNamePrefix(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\ncustom-prefix\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n\ncustom-prefix\n\n"), Out: &bytes.Buffer{}, }); err != nil { t.Fatal(err) @@ -547,7 +551,7 @@ func TestInitRepromptsInvalidPoolNamePrefix(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n-bad\nfixed-prefix\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n\n-bad\nfixed-prefix\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -772,7 +776,7 @@ func TestInitWSL2ChoiceDefaultsToDockerContainerAndRepromptsInvalidValues(t *tes } } -func TestInitDockerSandboxesGeneratesConfigFromDiscoveredTemplate(t *testing.T) { +func TestInitDockerSandboxesGeneratesDesiredImageConfigAndProvisionsTemplate(t *testing.T) { stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) stubNoWSL2(t) policyFingerprint := "sha256:" + strings.Repeat("b", 64) @@ -792,7 +796,7 @@ func TestInitDockerSandboxesGeneratesConfigFromDiscoveredTemplate(t *testing.T) ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n2\nnot-a-size\n30GiB\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n2\nn\nnot-a-size\n30GiB\n\n\nn\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -808,11 +812,15 @@ func TestInitDockerSandboxesGeneratesConfigFromDiscoveredTemplate(t *testing.T) if got, want := cfg.Provider.Platform, "linux/amd64"; got != want { t.Fatalf("provider.platform = %q, want %q", got, want) } - if got, want := cfg.DockerSandboxes.Template, "docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64"; got != want { - t.Fatalf("dockerSandboxes.template = %q, want %q", got, want) + if got, want := cfg.Image.SourceImage, "ghcr.io/catthehacker/ubuntu:act-latest"; got != want { + t.Fatalf("image.sourceImage = %q, want %q", got, want) + } + configContent, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) } - if got, want := cfg.DockerSandboxes.TemplateDigest, "sha256:"+strings.Repeat("c", 64); got != want { - t.Fatalf("dockerSandboxes.templateDigest = %q, want %q", got, want) + if strings.Contains(string(configContent), "templateDigest:") || strings.Contains(string(configContent), "\n template:") { + t.Fatalf("generated config retained local template identities:\n%s", configContent) } if got, want := cfg.DockerSandboxes.PolicyGeneration, policyFingerprint; got != want { t.Fatalf("dockerSandboxes.policyGeneration = %q, want %q", got, want) @@ -821,22 +829,285 @@ func TestInitDockerSandboxesGeneratesConfigFromDiscoveredTemplate(t *testing.T) t.Fatalf("dockerSandboxes.networkBaseline = %q, want %q", got, want) } for key, values := range map[string]struct{ got, want string }{ - "rootDisk": {cfg.DockerSandboxes.RootDisk, "30GiB"}, - "dockerDisk": {cfg.DockerSandboxes.DockerDisk, "100GiB"}, - "minHostFreeSpace": {cfg.DockerSandboxes.MinHostFreeSpace, "50GiB"}, + "rootDisk": {cfg.DockerSandboxes.RootDisk, "auto"}, + "dockerDisk": {cfg.DockerSandboxes.DockerDisk, "50GiB"}, } { if values.got != values.want { t.Fatalf("dockerSandboxes.%s = %q, want %q", key, values.got, values.want) } } - for _, want := range []string{"Docker Sandboxes — recommended", "provides EPAR's strongest current host boundary", "current host-global Balanced policy", "default to open public HTTP/HTTPS egress with owned deny-wins host-alias guardrails", "Verified local Docker Sandboxes source profiles:", "Catthehacker Ubuntu Full (recommended) — ghcr.io/catthehacker/ubuntu:full-latest", "Catthehacker Ubuntu Act 22.04 (current lean profile) — ghcr.io/catthehacker/ubuntu:act-22.04", "Automatic Docker Sandboxes source refresh is not implemented yet", "Resolved exact EPAR template tag: docker.io/library/epar-docker-sandboxes-catthehacker-act-22.04:20260723-r4-amd64", "Resolved full local template digest: sha256:" + strings.Repeat("c", 64), "8GiB on host", "default selection; capacity unmeasured", "Shared host template cache: 4GiB (already present; do not add this byte count arithmetically to each sandbox root disk).", "Measured guest root peak: unavailable", "Sandbox root filesystem total capacity is invalid", "Automatically selected sandbox Docker disk: 100GiB.", "Automatically selected minimum host free space: 50GiB.", "Verified policy fingerprint: " + policyFingerprint} { + for _, want := range []string{"Docker Sandboxes image setup:", "Runner base image:", "1. full — full-latest (default)", "2. act — act-latest", "Image catalog:", "Runner artifact estimate (informational; configuration creation is not blocked):", "Source: ghcr.io/catthehacker/ubuntu:act-latest", "Automatic sandbox root limit:"} { if !strings.Contains(out.String(), want) { t.Fatalf("init output omitted %q:\n%s", want, out.String()) } } } -func TestDockerSandboxesPrerequisitesRejectInsufficientMinimumCapacity(t *testing.T) { +func TestInitDockerSandboxesWritesConfigBeforeOrdinaryProvisioning(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + Templates: []initDockerSandboxesTemplate{{Platform: "linux/amd64", Size: 4 << 30}}, + PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), + }, nil) + initEnsureDockerSandboxesTemplate = func(context.Context, string, string) error { + return errors.New("simulated import readback failure") + } + + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + err := runInitWithOptions(initOptions{ + ProjectRoot: dir, + ConfigPath: path, + SkipDockerCheck: true, + SkipHostTrustCheck: true, + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\n\n\n\nn\n"), + Out: io.Discard, + }) + if err != nil { + t.Fatalf("runInitWithOptions() error = %v", err) + } + if _, statErr := os.Stat(path); statErr != nil { + t.Fatalf("configuration was not created before ordinary provisioning: %v", statErr) + } +} + +func TestDockerSandboxesWizardResolvesEveryBuiltInImageChoice(t *testing.T) { + for _, test := range []struct { + choice string + want string + }{ + {choice: "", want: "ghcr.io/catthehacker/ubuntu:full-latest"}, + {choice: "1", want: "ghcr.io/catthehacker/ubuntu:full-latest"}, + {choice: "2", want: "ghcr.io/catthehacker/ubuntu:act-latest"}, + {choice: "3", want: "ghcr.io/catthehacker/ubuntu:dotnet-latest"}, + {choice: "4", want: "ghcr.io/catthehacker/ubuntu:js-latest"}, + } { + t.Run(test.want, func(t *testing.T) { + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), + }, nil) + input := test.choice + "\nn\n\n\n" + profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, io.Discard, bufio.NewReader(strings.NewReader(input))) + if err != nil { + t.Fatal(err) + } + if !accepted || profile == nil || profile.SourceImage != test.want { + t.Fatalf("wizard profile = %+v, accepted=%t, want source %q", profile, accepted, test.want) + } + }) + } +} + +func TestSharedDockerImageWizardCoversDockerContainerSandboxesAndWSL(t *testing.T) { + for _, providerType := range []string{"docker-container", "docker-sandboxes", "wsl"} { + t.Run(providerType, func(t *testing.T) { + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), + }, nil) + var out bytes.Buffer + profile, accepted, err := promptDockerImageProfile(context.Background(), t.TempDir(), providerType, sandboxpromotion.WindowsAMD64, &out, bufio.NewReader(strings.NewReader("\nn\n\n"))) + if err != nil { + t.Fatal(err) + } + if !accepted || profile == nil || profile.Provider != providerType || profile.SourceImage != "ghcr.io/catthehacker/ubuntu:full-latest" { + t.Fatalf("shared wizard profile = %+v, accepted=%t", profile, accepted) + } + for _, want := range []string{"1. full — full-latest (default)", "2. act — act-latest", "3. dotnet — dotnet-latest", "4. js — js-latest", "Runner artifact estimate (informational; configuration creation is not blocked):"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("%s wizard output omitted %q:\n%s", providerType, want, out.String()) + } + } + }) + } +} + +func TestDockerSandboxesWizardCollectsCustomTagAndInstallScript(t *testing.T) { + stubInitDockerSandboxesSetup(t, sandboxpromotion.DarwinARM64, initDockerSandboxesDiscovery{ + PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), + }, nil) + projectRoot := t.TempDir() + scriptPath := filepath.Join(projectRoot, "scripts", "install-extra.sh") + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\nset -euo pipefail\n"), 0o700); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + profile, accepted, err := promptDockerSandboxesProfile(context.Background(), projectRoot, sandboxpromotion.DarwinARM64, &out, bufio.NewReader(strings.NewReader("5\ngo-24.04\ny\nscripts/install-extra.sh\nn\n\n\n"))) + if err != nil { + t.Fatal(err) + } + if !accepted || profile == nil || profile.SourceImage != "ghcr.io/catthehacker/ubuntu:go-24.04" || profile.HostPlatform != sandboxpromotion.DarwinARM64 { + t.Fatalf("wizard profile = %+v, accepted=%t", profile, accepted) + } + if len(profile.CustomScripts) != 1 || profile.CustomScripts[0] != "scripts/install-extra.sh" { + t.Fatalf("custom scripts = %#v", profile.CustomScripts) + } + for _, want := range []string{"Scripts run as root", "Do not put secrets", "Platform: linux/arm64", "Custom install scripts: scripts/install-extra.sh"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("wizard output omitted %q:\n%s", want, out.String()) + } + } +} + +func TestDockerSandboxesWizardRepromptsAfterUnresolvableTag(t *testing.T) { + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), + }, nil) + resolver := initResolveDockerSandboxesSource + initResolveDockerSandboxesSource = func(ctx context.Context, input, platform string) (imageartifact.ResolvedDockerSource, error) { + if input == "missing" { + return imageartifact.ResolvedDockerSource{}, errors.New("tag does not exist") + } + return resolver(ctx, input, platform) + } + var out bytes.Buffer + profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, &out, bufio.NewReader(strings.NewReader("5\nmissing\n4\nn\n\n\n"))) + if err != nil { + t.Fatal(err) + } + if !accepted || profile == nil || profile.SourceImage != "ghcr.io/catthehacker/ubuntu:js-latest" { + t.Fatalf("wizard profile = %+v, accepted=%t", profile, accepted) + } + if !strings.Contains(out.String(), "That image cannot be used for linux/amd64: tag does not exist") { + t.Fatalf("wizard did not explain the failed tag resolution:\n%s", out.String()) + } +} + +func TestDockerSandboxesWizardConfirmationRefusalReturnsNoProfile(t *testing.T) { + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), + }, nil) + profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, io.Discard, bufio.NewReader(strings.NewReader("1\nn\nn\n"))) + if err != nil { + t.Fatal(err) + } + if profile != nil || accepted { + t.Fatalf("confirmation refusal returned profile=%+v accepted=%t", profile, accepted) + } +} + +func TestPrepareDockerSandboxesReadinessStartsInstalledDaemonBeforeDiagnostics(t *testing.T) { + oldLookPath := initDockerSandboxesLookPath + oldStartDaemon := initDockerSandboxesStartDaemon + oldDiagnose := initDockerSandboxesDiagnose + t.Cleanup(func() { + initDockerSandboxesLookPath = oldLookPath + initDockerSandboxesStartDaemon = oldStartDaemon + initDockerSandboxesDiagnose = oldDiagnose + }) + + const binary = `C:\Program Files\Docker\Docker\resources\bin\sbx.exe` + var operations []string + initDockerSandboxesLookPath = func(name string) (string, error) { + if name != "sbx" { + t.Fatalf("looked up %q, want sbx", name) + } + return binary, nil + } + initDockerSandboxesStartDaemon = func(_ context.Context, actual string) error { + if actual != binary { + t.Fatalf("daemon binary = %q, want %q", actual, binary) + } + operations = append(operations, "start") + return nil + } + initDockerSandboxesDiagnose = func(_ context.Context, actual string) (dockersandboxes.HostReadiness, error) { + if actual != binary { + t.Fatalf("diagnostic binary = %q, want %q", actual, binary) + } + operations = append(operations, "diagnose") + return dockersandboxes.HostReadiness{ChecksPassed: 8}, nil + } + + readiness, err := prepareInitDockerSandboxesReadiness(context.Background()) + if err != nil { + t.Fatal(err) + } + if readiness.ChecksPassed != 8 || !reflect.DeepEqual(operations, []string{"start", "diagnose"}) { + t.Fatalf("readiness = %+v, operations = %v", readiness, operations) + } +} + +func TestPrepareDockerSandboxesReadinessSkipsDaemonStartWhenSBXIsMissing(t *testing.T) { + oldLookPath := initDockerSandboxesLookPath + oldStartDaemon := initDockerSandboxesStartDaemon + oldDiagnose := initDockerSandboxesDiagnose + t.Cleanup(func() { + initDockerSandboxesLookPath = oldLookPath + initDockerSandboxesStartDaemon = oldStartDaemon + initDockerSandboxesDiagnose = oldDiagnose + }) + + initDockerSandboxesLookPath = func(string) (string, error) { + return "", exec.ErrNotFound + } + initDockerSandboxesStartDaemon = func(context.Context, string) error { + t.Fatal("daemon start was attempted without an installed sbx executable") + return nil + } + initDockerSandboxesDiagnose = func(_ context.Context, binary string) (dockersandboxes.HostReadiness, error) { + if binary != "sbx" { + t.Fatalf("diagnostic binary = %q, want sbx", binary) + } + return dockersandboxes.HostReadiness{}, exec.ErrNotFound + } + + if _, err := prepareInitDockerSandboxesReadiness(context.Background()); !errors.Is(err, exec.ErrNotFound) { + t.Fatalf("readiness error = %v, want executable-not-found error", err) + } +} + +func TestPrepareDockerSandboxesReadinessUsesSuccessfulDiagnosticsAfterStartWarning(t *testing.T) { + oldLookPath := initDockerSandboxesLookPath + oldStartDaemon := initDockerSandboxesStartDaemon + oldDiagnose := initDockerSandboxesDiagnose + t.Cleanup(func() { + initDockerSandboxesLookPath = oldLookPath + initDockerSandboxesStartDaemon = oldStartDaemon + initDockerSandboxesDiagnose = oldDiagnose + }) + + initDockerSandboxesLookPath = func(string) (string, error) { return "sbx-test", nil } + initDockerSandboxesStartDaemon = func(context.Context, string) error { return errors.New("daemon already running") } + initDockerSandboxesDiagnose = func(context.Context, string) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.HostReadiness{ChecksPassed: 8, ChecksWarned: 1}, nil + } + + readiness, err := prepareInitDockerSandboxesReadiness(context.Background()) + if err != nil { + t.Fatalf("healthy diagnostics were rejected after a daemon-start warning: %v", err) + } + if readiness.ChecksPassed != 8 || readiness.ChecksWarned != 1 { + t.Fatalf("readiness = %+v", readiness) + } +} + +func TestPrepareDockerSandboxesReadinessReportsStartAndDiagnosticFailures(t *testing.T) { + oldLookPath := initDockerSandboxesLookPath + oldStartDaemon := initDockerSandboxesStartDaemon + oldDiagnose := initDockerSandboxesDiagnose + t.Cleanup(func() { + initDockerSandboxesLookPath = oldLookPath + initDockerSandboxesStartDaemon = oldStartDaemon + initDockerSandboxesDiagnose = oldDiagnose + }) + + initDockerSandboxesLookPath = func(string) (string, error) { return "sbx-test", nil } + initDockerSandboxesStartDaemon = func(context.Context, string) error { return errors.New("daemon startup failed") } + initDockerSandboxesDiagnose = func(context.Context, string) (dockersandboxes.HostReadiness, error) { + return dockersandboxes.HostReadiness{}, errors.New("daemon diagnostic failed") + } + + _, err := prepareInitDockerSandboxesReadiness(context.Background()) + if err == nil || !strings.Contains(err.Error(), "sbx daemon start --detach") || !strings.Contains(err.Error(), "daemon startup failed") || !strings.Contains(err.Error(), "daemon diagnostic failed") { + t.Fatalf("combined readiness error = %v", err) + } +} + +func TestDockerSandboxesPrerequisitesIgnoreStorageCapacity(t *testing.T) { stubNoWSL2(t) oldReadiness := initDockerSandboxesReadiness oldCapacityCheck := initDockerSandboxesCapacityCheck @@ -844,19 +1115,8 @@ func TestDockerSandboxesPrerequisitesRejectInsufficientMinimumCapacity(t *testin return dockersandboxes.HostReadiness{ChecksPassed: 8, ChecksWarned: 1}, nil } initDockerSandboxesCapacityCheck = func(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { - if rootDisk != 20<<30 || dockerDisk != 100<<30 || minHostFreeSpace != 50<<30 { - t.Fatalf("minimum capacity check = root %d, Docker %d, reserve %d", rootDisk, dockerDisk, minHostFreeSpace) - } - return initDockerSandboxesCapacityResult{ - StorageRoot: `C:\Users\runner\AppData\Local\DockerSandboxes`, - AvailableBytes: 140 << 30, - TotalBytes: 1 << 40, - Reservation: 120 << 30, - HostWatermark: 50 << 30, - RequiredBytes: 170 << 30, - DeficitBytes: 30 << 30, - CapacityStatus: storage.CapacityInsufficient, - }, nil + t.Fatal("provider prerequisite detection must not perform storage admission") + return initDockerSandboxesCapacityResult{}, nil } t.Cleanup(func() { initDockerSandboxesReadiness = oldReadiness @@ -864,13 +1124,8 @@ func TestDockerSandboxesPrerequisitesRejectInsufficientMinimumCapacity(t *testin }) got := detectInitProviderPrerequisites(context.Background(), sandboxpromotion.WindowsAMD64, true) - if got.DockerSandboxesAvailable { - t.Fatal("Docker Sandboxes was available despite insufficient minimum capacity") - } - for _, want := range []string{`C:\Users\runner\AppData\Local\DockerSandboxes`, "140GiB available", "require 170GiB", "shortfall 30GiB"} { - if !strings.Contains(got.DockerSandboxesStatus, want) { - t.Fatalf("Docker Sandboxes status omitted %q: %s", want, got.DockerSandboxesStatus) - } + if !got.DockerSandboxesAvailable { + t.Fatalf("Docker Sandboxes was unavailable despite passing tooling diagnostics: %s", got.DockerSandboxesStatus) } } @@ -922,7 +1177,7 @@ func TestInitProviderRefreshRechecksAvailabilityAndRedrawsMenu(t *testing.T) { if err != nil { t.Fatal(err) } - if providerType != "docker-sandboxes" || selectedRecord.Template != record.Template || profile != nil { + if providerType != "docker-sandboxes" || selectedRecord.Template != "" || profile == nil || profile.SourceImage != "ghcr.io/catthehacker/ubuntu:full-latest" { t.Fatalf("refreshed selection = provider %q, record template %q, profile %+v", providerType, selectedRecord.Template, profile) } if readinessCalls != 2 { @@ -943,7 +1198,7 @@ func TestInitProviderRefreshRechecksAvailabilityAndRedrawsMenu(t *testing.T) { } } -func TestDockerSandboxesProfileRejectsSelectedCapacityWithoutWritingConfig(t *testing.T) { +func TestDockerSandboxesProfileShowsEstimateWithoutCapacityAdmission(t *testing.T) { policyFingerprint := "sha256:" + strings.Repeat("b", 64) stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ Templates: []initDockerSandboxesTemplate{{ @@ -959,33 +1214,22 @@ func TestDockerSandboxesProfileRejectsSelectedCapacityWithoutWritingConfig(t *te }, nil) oldCapacityCheck := initDockerSandboxesCapacityCheck initDockerSandboxesCapacityCheck = func(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { - if rootDisk != 30<<30 || dockerDisk != 100<<30 || minHostFreeSpace != 50<<30 { - t.Fatalf("selected capacity check = root %d, Docker %d, reserve %d", rootDisk, dockerDisk, minHostFreeSpace) - } - return initDockerSandboxesCapacityResult{ - StorageRoot: `C:\Users\runner\AppData\Local\DockerSandboxes`, - AvailableBytes: 140 << 30, - TotalBytes: 1 << 40, - Reservation: 130 << 30, - HostWatermark: 50 << 30, - RequiredBytes: 180 << 30, - DeficitBytes: 40 << 30, - CapacityStatus: storage.CapacityInsufficient, - }, nil + t.Fatal("image onboarding must not perform storage admission") + return initDockerSandboxesCapacityResult{}, nil } t.Cleanup(func() { initDockerSandboxesCapacityCheck = oldCapacityCheck }) var out bytes.Buffer - profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, &out, bufio.NewReader(strings.NewReader("1\n30GiB\n"))) - if err == nil || !strings.Contains(err.Error(), "no config was written") { - t.Fatalf("capacity rejection error = %v", err) + profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, &out, bufio.NewReader(strings.NewReader("1\nn\n\n"))) + if err != nil { + t.Fatalf("informational estimate error = %v", err) } - if profile != nil || accepted { - t.Fatalf("capacity rejection returned profile=%+v accepted=%t", profile, accepted) + if profile == nil || !accepted { + t.Fatalf("informational estimate returned profile=%+v accepted=%t", profile, accepted) } - for _, want := range []string{"Docker Sandboxes capacity admission failed:", "Available: 140GiB", "Required before creation: 180GiB", "Shortfall: 40GiB", "storage prune --provider docker-sandboxes"} { + for _, want := range []string{"Runner artifact estimate (informational; configuration creation is not blocked):", "Available physical space:", "Fixed free-space reserve: 1GiB", "sparse logical maximum"} { if !strings.Contains(out.String(), want) { - t.Fatalf("capacity rejection output omitted %q:\n%s", want, out.String()) + t.Fatalf("informational estimate output omitted %q:\n%s", want, out.String()) } } } @@ -1025,7 +1269,7 @@ func TestInitCapabilityReadyDockerSandboxesIsDefaultWithoutPreviewAcknowledgemen ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n\n\n\n\nn\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -1045,9 +1289,10 @@ func TestInitCapabilityReadyDockerSandboxesIsDefaultWithoutPreviewAcknowledgemen "2. Docker Container — private daemon", "Docker Sandboxes — recommended (default)", "Runner provider (press Enter to use 1):", - "Docker Sandboxes setup:", - "recommended default because Docker and sbx diagnostics passed on this machine", - "Default resource reservations are recommended starting values", + "Docker Sandboxes image setup:", + "Runner base image:", + "full — full-latest (default)", + "Runner artifact estimate (informational; configuration creation is not blocked):", } { if !strings.Contains(out.String(), want) { t.Fatalf("capability-default output omitted %q:\n%s", want, out.String()) @@ -1130,7 +1375,7 @@ func TestReadDockerSandboxesActiveProfilesRejectsUnexpectedSourceChannel(t *test } } -func TestInitDockerSandboxesDerivesRootDiskFromExactMeasurement(t *testing.T) { +func TestInitDockerSandboxesUsesGuidedRootDiskDefault(t *testing.T) { stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) stubNoWSL2(t) policyFingerprint := "sha256:" + strings.Repeat("b", 64) @@ -1173,7 +1418,7 @@ func TestInitDockerSandboxesDerivesRootDiskFromExactMeasurement(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n2\n99GiB\n100GiB\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\n\n\n\nn\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -1182,21 +1427,17 @@ func TestInitDockerSandboxesDerivesRootDiskFromExactMeasurement(t *testing.T) { if err != nil { t.Fatal(err) } - if got, want := cfg.DockerSandboxes.RootDisk, "30GiB"; got != want { + if got, want := cfg.DockerSandboxes.RootDisk, "auto"; got != want { t.Fatalf("dockerSandboxes.rootDisk = %q, want %q", got, want) } - if got, want := cfg.DockerSandboxes.Template, template.Reference; got != want { - t.Fatalf("dockerSandboxes.template = %q, want measured default %q", got, want) + if got, want := cfg.Image.SourceImage, "ghcr.io/catthehacker/ubuntu:full-latest"; got != want { + t.Fatalf("image.sourceImage = %q, want guided default %q", got, want) } for _, want := range []string{ - "17.44GiB on host", - "default selection; capacity unmeasured", - "Shared host template cache: 17.44GiB (already present; do not add this byte count arithmetically to each sandbox root disk).", - "Measured guest root peak: 309.73MiB (test workload).", - "Automatically selected sandbox root filesystem total capacity: 30GiB.", - "Sandbox Docker disk must be at least 100GiB.", - "Automatically selected minimum host free space: 50GiB.", - "EPAR rechecks current Docker Sandboxes storage free space, existing and uncertain reservations", + "1. full — full-latest (default)", + "Automatic sandbox root limit:", + "Estimated download:", + "Expected duration:", } { if !strings.Contains(out.String(), want) { t.Fatalf("init output omitted %q:\n%s", want, out.String()) @@ -1278,7 +1519,7 @@ func TestInitDockerSandboxesDiscoveryRetryKeepsProviderSelectionAndWritesVerifie ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\ny\n\n\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\n\nn\n\n\n\nn\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -1296,14 +1537,11 @@ func TestInitDockerSandboxesDiscoveryRetryKeepsProviderSelectionAndWritesVerifie if got := strings.Count(out.String(), "Continue with explicit preview setup?"); got != 0 { t.Fatalf("preview acknowledgements = %d, want 0 after capability-driven default:\n%s", got, out.String()) } - if got := strings.Count(out.String(), "Retry Docker Sandboxes setup checks?"); got != 1 { - t.Fatalf("setup retry prompts = %d, want 1:\n%s", got, out.String()) - } - if discoveryCalls != 2 { - t.Fatalf("Docker Sandboxes discovery calls = %d, want 2", discoveryCalls) + if discoveryCalls != 3 { + t.Fatalf("Docker Sandboxes discovery calls = %d, want 3 including policy readback", discoveryCalls) } - if !strings.Contains(out.String(), "Docker Sandboxes setup preparation failed: template cache unavailable") || !strings.Contains(out.String(), "build and load a Candidate A template") { - t.Fatalf("init output did not explain Docker Sandboxes discovery recovery:\n%s", out.String()) + if !strings.Contains(out.String(), "That image cannot be used for linux/amd64: template cache unavailable") || !strings.Contains(out.String(), "Choose an existing ghcr.io/catthehacker/ubuntu tag") { + t.Fatalf("init output did not explain Docker Sandboxes source recovery:\n%s", out.String()) } } @@ -1323,7 +1561,7 @@ func TestInitDockerSandboxesDiscoveryRetryDeclinedExitsWithoutRepeatingProviderO In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\nn\n"), Out: &out, }) - if err == nil || !strings.Contains(err.Error(), "no config was written") { + if err == nil || !strings.Contains(err.Error(), "resolve runner source image") { t.Fatalf("retry-declined error = %v", err) } if got := strings.Count(out.String(), "Runner provider:"); got != 1 { @@ -1408,11 +1646,8 @@ func TestInitPromotedDockerSandboxesDefaultsOnlyAfterPassingPreflight(t *testing if !slices.Contains(cfg.Runner.Labels, "X64") { t.Fatalf("runner.labels = %q, want the mapped X64 guest architecture", cfg.Runner.Labels) } - if got, want := cfg.DockerSandboxes.Template, record.Template; got != want { - t.Fatalf("dockerSandboxes.template = %q, want %q", got, want) - } - if got, want := cfg.DockerSandboxes.TemplateDigest, record.TemplateDigest; got != want { - t.Fatalf("dockerSandboxes.templateDigest = %q, want %q", got, want) + if got, want := cfg.Image.SourceImage, "ghcr.io/catthehacker/ubuntu:full-latest"; got != want { + t.Fatalf("image.sourceImage = %q, want %q", got, want) } if got, want := cfg.DockerSandboxes.PolicyGeneration, record.PolicyFingerprint; got != want { t.Fatalf("dockerSandboxes.policyGeneration = %q, want %q", got, want) @@ -1421,9 +1656,8 @@ func TestInitPromotedDockerSandboxesDefaultsOnlyAfterPassingPreflight(t *testing got string want string }{ - "rootDisk": {cfg.DockerSandboxes.RootDisk, "120GiB"}, - "dockerDisk": {cfg.DockerSandboxes.DockerDisk, "100GiB"}, - "minHostFreeSpace": {cfg.DockerSandboxes.MinHostFreeSpace, "50GiB"}, + "rootDisk": {cfg.DockerSandboxes.RootDisk, "auto"}, + "dockerDisk": {cfg.DockerSandboxes.DockerDisk, "50GiB"}, } { if values.got != values.want { t.Fatalf("dockerSandboxes.%s = %q, want %q", key, values.got, values.want) @@ -1735,12 +1969,28 @@ func stubInitHostAndRandom(t *testing.T, hostname string, random []byte) { t.Helper() oldHostname := initHostname oldRandomRead := initRandomRead + oldResolver := initResolveDockerSandboxesSource initHostname = func() (string, error) { return hostname, nil } initRandomRead = fixedRandomRead(random) + initResolveDockerSandboxesSource = func(_ context.Context, input, platform string) (imageartifact.ResolvedDockerSource, error) { + reference, err := imageartifact.NormalizeCatthehackerSource(input) + if err != nil { + return imageartifact.ResolvedDockerSource{}, err + } + return imageartifact.ResolvedDockerSource{ + Reference: reference, + ImmutableReference: "ghcr.io/catthehacker/ubuntu@sha256:" + strings.Repeat("a", 64), + IndexDigest: "sha256:" + strings.Repeat("a", 64), + PlatformDigest: "sha256:" + strings.Repeat("b", 64), + Platform: platform, + CompressedLayerBytes: 8 << 30, + }, nil + } stubInitRunnerGroupClient(t) t.Cleanup(func() { initHostname = oldHostname initRandomRead = oldRandomRead + initResolveDockerSandboxesSource = oldResolver }) } @@ -1869,6 +2119,9 @@ func stubInitDockerSandboxesSetup(t *testing.T, platform sandboxpromotion.Platfo oldDiscovery := initDiscoverDockerSandboxes oldReadiness := initDockerSandboxesReadiness oldCapacityCheck := initDockerSandboxesCapacityCheck + oldResolver := initResolveDockerSandboxesSource + oldPolicyFingerprint := initDockerSandboxesPolicyFingerprint + oldEnsureTemplate := initEnsureDockerSandboxesTemplate initSandboxPromotionPlatform = func() sandboxpromotion.Platform { return platform } initSandboxPromotionLookup = func(actual sandboxpromotion.Platform) (sandboxpromotion.Record, bool) { if actual != platform { @@ -1892,6 +2145,33 @@ func stubInitDockerSandboxesSetup(t *testing.T, platform sandboxpromotion.Platfo initDockerSandboxesReadiness = func(context.Context) (dockersandboxes.HostReadiness, error) { return dockersandboxes.HostReadiness{ChecksPassed: 8, ChecksWarned: 1}, nil } + initResolveDockerSandboxesSource = func(ctx context.Context, input, guestPlatform string) (imageartifact.ResolvedDockerSource, error) { + current, err := initDiscoverDockerSandboxes(ctx, "test-project", guestPlatform) + if err != nil { + return imageartifact.ResolvedDockerSource{}, err + } + reference, err := imageartifact.NormalizeCatthehackerSource(input) + if err != nil { + return imageartifact.ResolvedDockerSource{}, err + } + compressed := uint64(8 << 30) + if len(current.Templates) > 0 && current.Templates[0].Size > 0 { + compressed = uint64(current.Templates[0].Size) + } + return imageartifact.ResolvedDockerSource{ + Reference: reference, + ImmutableReference: "ghcr.io/catthehacker/ubuntu@sha256:" + strings.Repeat("a", 64), + IndexDigest: "sha256:" + strings.Repeat("a", 64), + PlatformDigest: "sha256:" + strings.Repeat("b", 64), + Platform: guestPlatform, + CompressedLayerBytes: compressed, + }, nil + } + initDockerSandboxesPolicyFingerprint = func(ctx context.Context) (string, error) { + current, err := initDiscoverDockerSandboxes(ctx, "test-project", expectedDockerSandboxesGuestPlatform(t, platform)) + return current.PolicyFingerprint, err + } + initEnsureDockerSandboxesTemplate = func(context.Context, string, string) error { return nil } initDockerSandboxesCapacityCheck = func(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { return initDockerSandboxesCapacityResult{ StorageRoot: `C:\stub\DockerSandboxes`, @@ -1909,9 +2189,21 @@ func stubInitDockerSandboxesSetup(t *testing.T, platform sandboxpromotion.Platfo initDiscoverDockerSandboxes = oldDiscovery initDockerSandboxesReadiness = oldReadiness initDockerSandboxesCapacityCheck = oldCapacityCheck + initResolveDockerSandboxesSource = oldResolver + initDockerSandboxesPolicyFingerprint = oldPolicyFingerprint + initEnsureDockerSandboxesTemplate = oldEnsureTemplate }) } +func expectedDockerSandboxesGuestPlatform(t *testing.T, platform sandboxpromotion.Platform) string { + t.Helper() + guestPlatform, _, err := dockerSandboxesPlatform(platform) + if err != nil { + t.Fatal(err) + } + return guestPlatform +} + func stubInitSandboxPromotion(t *testing.T, record sandboxpromotion.Record, result sandboxpromotion.PreflightResult) { t.Helper() oldPlatform := initSandboxPromotionPlatform @@ -1919,6 +2211,9 @@ func stubInitSandboxPromotion(t *testing.T, record sandboxpromotion.Record, resu oldPreflight := initDockerSandboxesPreflight oldReadiness := initDockerSandboxesReadiness oldCapacityCheck := initDockerSandboxesCapacityCheck + oldResolver := initResolveDockerSandboxesSource + oldPolicyFingerprint := initDockerSandboxesPolicyFingerprint + oldEnsureTemplate := initEnsureDockerSandboxesTemplate initSandboxPromotionPlatform = func() sandboxpromotion.Platform { return record.Platform } initSandboxPromotionLookup = func(platform sandboxpromotion.Platform) (sandboxpromotion.Record, bool) { if platform != record.Platform { @@ -1932,6 +2227,22 @@ func stubInitSandboxPromotion(t *testing.T, record sandboxpromotion.Record, resu initDockerSandboxesReadiness = func(context.Context) (dockersandboxes.HostReadiness, error) { return dockersandboxes.HostReadiness{ChecksPassed: 8}, nil } + initResolveDockerSandboxesSource = func(_ context.Context, input, guestPlatform string) (imageartifact.ResolvedDockerSource, error) { + reference, err := imageartifact.NormalizeCatthehackerSource(input) + if err != nil { + return imageartifact.ResolvedDockerSource{}, err + } + return imageartifact.ResolvedDockerSource{ + Reference: reference, + ImmutableReference: "ghcr.io/catthehacker/ubuntu@" + record.TemplateDigest, + IndexDigest: record.TemplateDigest, + PlatformDigest: record.TemplateDigest, + Platform: guestPlatform, + CompressedLayerBytes: 8 << 30, + }, nil + } + initDockerSandboxesPolicyFingerprint = func(context.Context) (string, error) { return record.PolicyFingerprint, nil } + initEnsureDockerSandboxesTemplate = func(context.Context, string, string) error { return nil } initDockerSandboxesCapacityCheck = func(rootDisk, dockerDisk, minHostFreeSpace uint64) (initDockerSandboxesCapacityResult, error) { return initDockerSandboxesCapacityResult{ StorageRoot: `C:\stub\DockerSandboxes`, @@ -1949,6 +2260,9 @@ func stubInitSandboxPromotion(t *testing.T, record sandboxpromotion.Record, resu initDockerSandboxesPreflight = oldPreflight initDockerSandboxesReadiness = oldReadiness initDockerSandboxesCapacityCheck = oldCapacityCheck + initResolveDockerSandboxesSource = oldResolver + initDockerSandboxesPolicyFingerprint = oldPolicyFingerprint + initEnsureDockerSandboxesTemplate = oldEnsureTemplate }) } diff --git a/cmd/ephemeral-action-runner/main.go b/cmd/ephemeral-action-runner/main.go index 1fc3c2c..3cf5917 100644 --- a/cmd/ephemeral-action-runner/main.go +++ b/cmd/ephemeral-action-runner/main.go @@ -227,6 +227,7 @@ func runImage(args []string) error { case "update-upstream": fs := flag.NewFlagSet("image update-upstream", flag.ExitOnError) common := addCommonFlags(fs) + allowInsufficientStorage := fs.Bool("allow-insufficient-storage", false, "continue this invocation after storage-only admission warnings") if err := fs.Parse(args[1:]); err != nil { return err } @@ -235,6 +236,7 @@ func runImage(args []string) error { return err } defer m.Close() + m.ConfigureStorageAdmissionOverride(*allowInsufficientStorage, invocation.Command(append([]string{"image", "update-upstream"}, appendStorageOverride(args[1:])...)...)) if err := rejectDockerSandboxesImageCommand(m, "image update-upstream"); err != nil { return err } @@ -250,6 +252,7 @@ func runImage(args []string) error { replace := fs.Bool("replace", false, "delete an existing output image before building") update := fs.Bool("update-upstream", false, "refresh runner-images before building") skipUpstream := fs.Bool("skip-upstream-check", false, "skip checking the runner-images checkout") + allowInsufficientStorage := fs.Bool("allow-insufficient-storage", false, "continue this invocation after storage-only admission warnings") if err := fs.Parse(args[1:]); err != nil { return err } @@ -258,9 +261,7 @@ func runImage(args []string) error { return err } defer m.Close() - if err := rejectDockerSandboxesImageCommand(m, "image build"); err != nil { - return err - } + m.ConfigureStorageAdmissionOverride(*allowInsufficientStorage, invocation.Command(append([]string{"image", "build"}, appendStorageOverride(args[1:])...)...)) ctx := interruptContext() poolControllerLock, err := m.AcquirePoolControllerLock() if err != nil { @@ -316,6 +317,7 @@ func runPool(args []string) error { instances := fs.Int("instances", 0, "number of concurrent instances to verify; overrides pool.instances") registerOnly := fs.Bool("register-only", false, "register runners and verify online/idle without dispatching a job") cleanup := fs.Bool("cleanup", false, "clean up verification resources; legacy providers use the configured pool prefix, while Docker Sandboxes uses exact owned records") + allowInsufficientStorage := fs.Bool("allow-insufficient-storage", false, "continue this invocation after storage-only admission warnings") if err := fs.Parse(args[1:]); err != nil { return err } @@ -327,6 +329,7 @@ func runPool(args []string) error { return err } defer m.Close() + m.ConfigureStorageAdmissionOverride(*allowInsufficientStorage, invocation.Command(append([]string{"pool", "verify"}, appendStorageOverride(args[1:])...)...)) return m.Verify(interruptContext(), pool.VerifyOptions{Instances: *instances, RegisterOnly: *registerOnly, Cleanup: *cleanup}) case "up": fs := flag.NewFlagSet("pool up", flag.ExitOnError) @@ -336,6 +339,7 @@ func runPool(args []string) error { keepOnExit := fs.Bool("keep-on-exit", false, "leave prefixed instances and GitHub runners running when interrupted") replaceCompleted := fs.Bool("replace-completed", true, "replace an instance when its ephemeral runner exits after a job") monitorInterval := fs.Duration("monitor-interval", 15*time.Second, "interval for runner liveness checks") + allowInsufficientStorage := fs.Bool("allow-insufficient-storage", false, "continue this invocation after storage-only admission warnings") if err := fs.Parse(args[1:]); err != nil { return err } @@ -346,6 +350,7 @@ func runPool(args []string) error { if err != nil { return err } + m.ConfigureStorageAdmissionOverride(*allowInsufficientStorage, invocation.Command(append([]string{"pool", "up"}, appendStorageOverride(args[1:])...)...)) return m.RunPool(interruptContext(), pool.RunOptions{ Instances: *instances, Register: *register, @@ -423,6 +428,14 @@ func flagPassed(fs *flag.FlagSet, name string) bool { } func newManager(configPath, projectRoot string, dryRun bool, githubEnabled bool) (*pool.Manager, error) { + return newManagerWithLifecycleState(configPath, projectRoot, dryRun, githubEnabled, true) +} + +func newImageProvisioningManager(configPath, projectRoot string) (*pool.Manager, error) { + return newManagerWithLifecycleState(configPath, projectRoot, false, false, false) +} + +func newManagerWithLifecycleState(configPath, projectRoot string, dryRun bool, githubEnabled bool, openLifecycleState bool) (*pool.Manager, error) { projectRoot, err := filepath.Abs(projectRoot) if err != nil { return nil, err @@ -449,9 +462,6 @@ func newManager(configPath, projectRoot string, dryRun bool, githubEnabled bool) if providerRuntime.Lifecycle == nil || providerRuntime.Storage == nil { return nil, fmt.Errorf("provider %q registry entry is missing required lifecycle or storage behavior", cfg.Provider.Type) } - if err := preflightControllerStorage(projectRoot, cfg, providerRuntime.Storage); err != nil { - return nil, err - } var client pool.GitHubClient if githubEnabled && !dryRun { if err := config.ValidateGitHub(cfg); err != nil { @@ -460,7 +470,7 @@ func newManager(configPath, projectRoot string, dryRun bool, githubEnabled bool) client = gh.New(cfg.GitHub) } var lifecycleState *poolstate.Store - if !dryRun { + if !dryRun && openLifecycleState { lifecycleState, err = pool.OpenLifecycleState(projectRoot, resolvedConfigPath) if err != nil { return nil, err @@ -575,7 +585,7 @@ func rejectDockerSandboxesImageCommand(manager *pool.Manager, command string) er if manager.Config.Provider.Type != "docker-sandboxes" { return nil } - return fmt.Errorf("%s is not supported by docker-sandboxes; build and load the pinned template with scripts/docker-sandboxes before admission", command) + return fmt.Errorf("%s is not applicable to docker-sandboxes; edit image.sourceImage or image.customInstallScripts, then run %s", command, invocation.Command("image", "build")) } func loggingSinks(values []string) logging.Sinks { diff --git a/cmd/ephemeral-action-runner/provider_test.go b/cmd/ephemeral-action-runner/provider_test.go index 5aee9de..1e21637 100644 --- a/cmd/ephemeral-action-runner/provider_test.go +++ b/cmd/ephemeral-action-runner/provider_test.go @@ -8,10 +8,10 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/pool" ) -func TestDockerSandboxesImageCommandsAreRejectedClearly(t *testing.T) { +func TestDockerSandboxesSourceMaintenanceCommandsAreRejectedClearly(t *testing.T) { manager := &pool.Manager{Config: config.Config{Provider: config.ProviderConfig{Type: "docker-sandboxes"}}} - err := rejectDockerSandboxesImageCommand(manager, "image build") - if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "scripts/docker-sandboxes") { + err := rejectDockerSandboxesImageCommand(manager, "image update-upstream") + if err == nil || !strings.Contains(err.Error(), "not applicable") || !strings.Contains(err.Error(), "image build") { t.Fatalf("image command rejection = %v", err) } } diff --git a/cmd/ephemeral-action-runner/start.go b/cmd/ephemeral-action-runner/start.go index 87a264b..49fab05 100644 --- a/cmd/ephemeral-action-runner/start.go +++ b/cmd/ephemeral-action-runner/start.go @@ -39,6 +39,10 @@ type closingStarterManager interface { Close() error } +type storageAdmissionConfiguringStarterManager interface { + ConfigureStorageAdmissionOverride(bool, string) +} + type starterManagerFactory func(configPath, projectRoot string, dryRun bool, githubEnabled bool) (starterManager, error) var newStarterManager starterManagerFactory = func(configPath, projectRoot string, dryRun bool, githubEnabled bool) (starterManager, error) { @@ -46,18 +50,20 @@ var newStarterManager starterManagerFactory = func(configPath, projectRoot strin } type startOptions struct { - Context context.Context - ProjectRoot string - ConfigPath string - DryRun bool - Instances int - Register bool - KeepOnExit bool - ReplaceCompleted bool - MonitorInterval time.Duration - In io.Reader - Out io.Writer - ManagerFactory starterManagerFactory + Context context.Context + ProjectRoot string + ConfigPath string + DryRun bool + Instances int + Register bool + KeepOnExit bool + ReplaceCompleted bool + MonitorInterval time.Duration + AllowInsufficientStorage bool + StorageOverrideCommand string + In io.Reader + Out io.Writer + ManagerFactory starterManagerFactory } func runStart(args []string) error { @@ -68,6 +74,7 @@ func runStart(args []string) error { keepOnExit := fs.Bool("keep-on-exit", false, "leave prefixed instances and GitHub runners running when interrupted") replaceCompleted := fs.Bool("replace-completed", true, "replace an instance when its ephemeral runner exits after a job") monitorInterval := fs.Duration("monitor-interval", 15*time.Second, "interval for runner liveness checks") + allowInsufficientStorage := fs.Bool("allow-insufficient-storage", false, "continue this invocation after storage-only admission warnings") if err := fs.Parse(args); err != nil { return err } @@ -75,18 +82,20 @@ func runStart(args []string) error { return fmt.Errorf("--instances must be 1 or greater") } return runStartWithOptions(startOptions{ - Context: interruptContext(), - ProjectRoot: *common.projectRoot, - ConfigPath: *common.configPath, - DryRun: *common.dryRun, - Instances: *instances, - Register: *register, - KeepOnExit: *keepOnExit, - ReplaceCompleted: *replaceCompleted, - MonitorInterval: *monitorInterval, - In: os.Stdin, - Out: os.Stdout, - ManagerFactory: newStarterManager, + Context: interruptContext(), + ProjectRoot: *common.projectRoot, + ConfigPath: *common.configPath, + DryRun: *common.dryRun, + Instances: *instances, + Register: *register, + KeepOnExit: *keepOnExit, + ReplaceCompleted: *replaceCompleted, + MonitorInterval: *monitorInterval, + AllowInsufficientStorage: *allowInsufficientStorage, + StorageOverrideCommand: matchingStartCommand(appendStorageOverride(args)), + In: os.Stdin, + Out: os.Stdout, + ManagerFactory: newStarterManager, }) } @@ -130,6 +139,13 @@ func runStartWithOptions(opts startOptions) (err error) { if closingManager, ok := manager.(closingStarterManager); ok { defer closingManager.Close() } + if configuringManager, ok := manager.(storageAdmissionConfiguringStarterManager); ok { + overrideCommand := opts.StorageOverrideCommand + if overrideCommand == "" { + overrideCommand = matchingStartCommand([]string{"--allow-insufficient-storage"}) + } + configuringManager.ConfigureStorageAdmissionOverride(opts.AllowInsufficientStorage, overrideCommand) + } if timingManager, ok := manager.(startupTimingStarterManager); ok { if _, err := timingManager.StartStartupTiming(); err != nil { return fmt.Errorf("start startup timing log: %w", err) @@ -191,6 +207,23 @@ func runStartWithOptions(opts startOptions) (err error) { return err } +func appendStorageOverride(args []string) []string { + result := append([]string(nil), args...) + for _, arg := range result { + if arg == "--allow-insufficient-storage" || arg == "--allow-insufficient-storage=true" { + return result + } + } + return append(result, "--allow-insufficient-storage") +} + +func matchingStartCommand(args []string) string { + if os.Getenv(invocation.Environment) == "start" { + return invocation.Command(args...) + } + return invocation.Command(append([]string{"start"}, args...)...) +} + func ensureConfigForStart(opts startOptions) (string, bool, error) { path, exists, err := resolveStartConfigPath(opts.ProjectRoot, opts.ConfigPath) if err != nil { diff --git a/cmd/ephemeral-action-runner/start_test.go b/cmd/ephemeral-action-runner/start_test.go index 5a2ab1c..d53715b 100644 --- a/cmd/ephemeral-action-runner/start_test.go +++ b/cmd/ephemeral-action-runner/start_test.go @@ -86,10 +86,43 @@ func TestStartPropagatesConfigAndInstances(t *testing.T) { } } +func TestStartConfiguresOneInvocationStorageOverride(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yml") + if err := os.WriteFile(configPath, []byte("config"), 0600); err != nil { + t.Fatal(err) + } + fake := &fakeStarterManager{} + err := runStartWithOptions(startOptions{ + Context: context.Background(), + ProjectRoot: dir, + ConfigPath: configPath, + AllowInsufficientStorage: true, + StorageOverrideCommand: "./start --allow-insufficient-storage", + Out: &bytes.Buffer{}, + ManagerFactory: func(string, string, bool, bool) (starterManager, error) { + return fake, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if !fake.allowStorage || fake.overrideHint != "./start --allow-insufficient-storage" { + t.Fatalf("storage override = allow %t hint %q", fake.allowStorage, fake.overrideHint) + } +} + +func TestMatchingStartCommandPreservesWrapperEntryPoint(t *testing.T) { + t.Setenv("EPAR_INVOCATION", "start") + if got, want := matchingStartCommand([]string{"--allow-insufficient-storage"}), "./start --allow-insufficient-storage"; got != want { + t.Fatalf("matchingStartCommand() = %q, want %q", got, want) + } +} + func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { dir := t.TempDir() stubNoWSL2(t) - stubInitRunnerGroupClient(t) + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) oldInteractive := stdinIsInteractive oldDocker := dockerAvailable oldResolveHostTrust := initResolveHostTrust @@ -109,7 +142,7 @@ func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n\n\n\n\n"), Out: &out, ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { if path != filepath.Join(dir, ".local", "config.yml") { @@ -137,7 +170,7 @@ func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { func TestStartInteractiveMissingConfigCanExitToReview(t *testing.T) { dir := t.TempDir() stubNoWSL2(t) - stubInitRunnerGroupClient(t) + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) oldInteractive := stdinIsInteractive oldDocker := dockerAvailable oldResolveHostTrust := initResolveHostTrust @@ -156,7 +189,7 @@ func TestStartInteractiveMissingConfigCanExitToReview(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\n\n\nn\nn\n"), Out: &out, ManagerFactory: func(string, string, bool, bool) (starterManager, error) { t.Fatal("manager factory should not run after choosing to review the new config") @@ -259,7 +292,7 @@ func TestStartInteractiveMissingConfigCanSelectDockerSandboxes(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\n\n\n\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\n\n\n\n\n\n"), Out: &out, ManagerFactory: func(path, _ string, _ bool, _ bool) (starterManager, error) { if path != filepath.Join(dir, ".local", "config.yml") { @@ -391,11 +424,12 @@ func TestStartPreflightsBeforeImageAndPool(t *testing.T) { fake = &fakeStarterManager{preflightErr: errors.New("unsafe group")} err = runStartWithOptions(startOptions{ - Context: context.Background(), - ProjectRoot: dir, - ConfigPath: configPath, - Register: true, - Out: &bytes.Buffer{}, + Context: context.Background(), + ProjectRoot: dir, + ConfigPath: configPath, + Register: true, + AllowInsufficientStorage: true, + Out: &bytes.Buffer{}, ManagerFactory: func(string, string, bool, bool) (starterManager, error) { return fake, nil }, @@ -406,6 +440,9 @@ func TestStartPreflightsBeforeImageAndPool(t *testing.T) { if got := strings.Join(fake.calls, ","); got != "preflight" { t.Fatalf("call order after rejection = %q, want preflight only", got) } + if !fake.allowStorage { + t.Fatal("storage override was not configured before the non-storage safety check") + } } type fakeStarterManager struct { @@ -414,6 +451,13 @@ type fakeStarterManager struct { runCalls int runOptions pool.RunOptions calls []string + allowStorage bool + overrideHint string +} + +func (m *fakeStarterManager) ConfigureStorageAdmissionOverride(allow bool, command string) { + m.allowStorage = allow + m.overrideHint = command } func (m *fakeStarterManager) PreflightRunnerGroup(context.Context) error { diff --git a/cmd/ephemeral-action-runner/storage.go b/cmd/ephemeral-action-runner/storage.go index 4a0044a..98f684b 100644 --- a/cmd/ephemeral-action-runner/storage.go +++ b/cmd/ephemeral-action-runner/storage.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "os" @@ -69,13 +70,21 @@ func runStorage(args []string) error { } now := time.Now().UTC() var selections []inventory.TemplateSelection - if cfg.DockerSandboxes.Template != "" && cfg.DockerSandboxes.TemplateDigest != "" { - selections = append(selections, inventory.TemplateSelection{ - Platform: cfg.Provider.Platform, - Tag: cfg.DockerSandboxes.Template, - TemplateDigest: cfg.DockerSandboxes.TemplateDigest, - ActivatedAt: configTime, - }) + activeTemplateRootDisk := "" + if cfg.Provider.Type == "docker-sandboxes" { + artifact, metadataSHA256, activatedAt, receiptErr := artifactimage.LoadDockerSandboxesReceipt(projectRoot) + if receiptErr == nil { + activeTemplateRootDisk = artifact.RootDisk + selections = append(selections, inventory.TemplateSelection{ + Platform: artifact.Platform, + Tag: artifact.Reference, + TemplateDigest: artifact.Digest, + MetadataSHA256: metadataSHA256, + ActivatedAt: activatedAt, + }) + } else if !errors.Is(receiptErr, os.ErrNotExist) { + return fmt.Errorf("read Docker Sandboxes active artifact receipt: %w", receiptErr) + } } currentExecutable, _ := os.Executable() configuredFiles := configuredStorageFiles(cfg, projectRoot, configTime) @@ -142,6 +151,44 @@ func runStorage(args []string) error { requirements = append(requirements, providerSnapshot.Requirements...) } } + if storageProvider == "docker-sandboxes" { + rootDisk := cfg.DockerSandboxes.RootDisk + if rootDisk == config.DockerSandboxesAutomaticRootDisk { + rootDisk = activeTemplateRootDisk + } + appendLogicalSurface := func(id, configured string) { + if configured == "" && id == "docker-sandboxes-root-logical" { + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ + ID: id, + Provider: "docker-sandboxes", + Kind: storage.SurfaceExternal, + Classification: "logical", + Sparse: true, + Confidence: "pending-artifact-resolution", + Advisory: true, + Capacity: storage.Capacity{ObservedAt: now}, + }) + return + } + parsed, parseErr := config.ParseByteSize(configured) + if parseErr != nil || parsed <= 0 { + return + } + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ + ID: id, + Provider: "docker-sandboxes", + Kind: storage.SurfaceExternal, + Classification: "logical", + Sparse: true, + VirtualMaximumBytes: uint64(parsed), + Confidence: "configured-logical-limit", + Advisory: true, + Capacity: storage.Capacity{ObservedAt: now}, + }) + } + appendLogicalSurface("docker-sandboxes-root-logical", rootDisk) + appendLogicalSurface("docker-sandboxes-inner-docker-logical", cfg.DockerSandboxes.DockerDisk) + } plan, err := storage.Preview(snapshot.PreviewRequest(policy, requirements)) if err != nil { return err @@ -305,7 +352,7 @@ func printStorageReport(subcommand string, report storageCommandReport) { if surface.Capacity.Known { available = formatStorageBytes(surface.Capacity.AvailableBytes) } - fmt.Fprintf(os.Stdout, "Surface %s\tprovider=%s\tkind=%s\tavailable=%s\tlocation=%s\n", surface.ID, valueOrDash(surface.Provider), surface.Kind, available, surface.Location) + fmt.Fprintf(os.Stdout, "Surface %s\tprovider=%s\tkind=%s\tclassification=%s\tsparse=%t\tavailable=%s\tallocated=%s\tvirtualMaximum=%s\tconfidence=%s\tauthoritative=%t\tadvisory=%t\tlocation=%s\n", surface.ID, valueOrDash(surface.Provider), surface.Kind, valueOrDash(surface.Classification), surface.Sparse, available, formatStorageOptionalBytes(surface.AllocatedBytes), formatStorageOptionalBytes(surface.VirtualMaximumBytes), valueOrDash(surface.Confidence), surface.AdmissionAuthoritative, surface.Advisory, surface.Location) } for _, check := range report.Plan.CapacityChecks { fmt.Fprintf(os.Stdout, "Capacity %s\tstatus=%s\tavailable=%s\testimated=%s\treserve=%s\trequired=%s\n", check.Requirement.ID, check.Status, formatStorageBytes(check.Capacity.AvailableBytes), formatStorageBytes(check.Requirement.PeakBytes), formatStorageBytes(check.Requirement.MinimumFreeBytes), formatStorageBytes(check.RequiredAvailableBytes)) @@ -326,6 +373,13 @@ func printStorageReport(subcommand string, report storageCommandReport) { } } +func formatStorageOptionalBytes(value uint64) string { + if value == 0 { + return "-" + } + return formatStorageBytes(value) +} + func formatStorageBytes(value uint64) string { const gib = uint64(1 << 30) const mib = uint64(1 << 20) diff --git a/configs/docker-container.act.example.yml b/configs/docker-container.act.example.yml index 3d5eaee..429076e 100644 --- a/configs/docker-container.act.example.yml +++ b/configs/docker-container.act.example.yml @@ -28,7 +28,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/configs/docker-container.core.example.yml b/configs/docker-container.core.example.yml index 3680882..34fe58f 100644 --- a/configs/docker-container.core.example.yml +++ b/configs/docker-container.core.example.yml @@ -28,7 +28,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/configs/docker-container.example.yml b/configs/docker-container.example.yml index 443993f..528f5dd 100644 --- a/configs/docker-container.example.yml +++ b/configs/docker-container.example.yml @@ -28,7 +28,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/configs/docker-container.web-e2e.example.yml b/configs/docker-container.web-e2e.example.yml index a24ec3d..4e3adcf 100644 --- a/configs/docker-container.web-e2e.example.yml +++ b/configs/docker-container.web-e2e.example.yml @@ -29,7 +29,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/configs/docker-sandboxes.example.yml b/configs/docker-sandboxes.example.yml index 3fb7c76..7180d9e 100644 --- a/configs/docker-sandboxes.example.yml +++ b/configs/docker-sandboxes.example.yml @@ -6,6 +6,12 @@ github: webBaseUrl: https://github.com image: + sourceType: docker-image + sourceImage: ghcr.io/catthehacker/ubuntu:full-latest + sourcePlatform: linux/amd64 + runnerVersion: latest + customInstallScripts: + # - examples/custom-install/install-extra-apt-tools.sh hostTrustMode: overlay hostTrustScopes: [system] @@ -14,7 +20,7 @@ pool: namePrefix: change-me-docker-sandboxes storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative @@ -41,10 +47,8 @@ provider: # Exact host mapping: Windows/Linux amd64 -> linux/amd64; macOS arm64 -> linux/arm64. platform: linux/amd64 -# This sample tag is the repository's current linux/amd64 full-template lock. Replace template, templateDigest, and policyGeneration with the exact platform-specific identities printed by the wizard or approved for your environment. templateDigest is the full verified local template ID printed by build-template.ps1. dockerSandboxes: - template: epar-docker-sandboxes-catthehacker-full:20260723-r2-amd64 - templateDigest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + # The wizard reads and records the exact active host-global policy fingerprint. policyGeneration: sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 networkBaseline: open additionalAllow: @@ -55,11 +59,11 @@ dockerSandboxes: stagingRoot: .local/docker-sandboxes-staging cpus: 4 memory: 8GiB - # Current full-template recommendation: 309.73MiB measured guest root peak + 25% + 20GiB headroom, rounded up to 10GiB. - rootDisk: 30GiB - dockerDisk: 100GiB + # EPAR derives this sparse logical maximum from the selected image and stores the effective size in the active artifact receipt. + rootDisk: auto + # Independent sparse workload capacity for Docker inside the sandbox. + dockerDisk: 50GiB maxConcurrentCreates: 2 - minHostFreeSpace: 50GiB timeouts: bootSeconds: 180 diff --git a/configs/tart.example.yml b/configs/tart.example.yml index 1e305c5..86e5563 100644 --- a/configs/tart.example.yml +++ b/configs/tart.example.yml @@ -26,7 +26,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/configs/tart.web-e2e.example.yml b/configs/tart.web-e2e.example.yml index ffd9ae8..8035588 100644 --- a/configs/tart.web-e2e.example.yml +++ b/configs/tart.web-e2e.example.yml @@ -24,7 +24,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/configs/wsl.example.yml b/configs/wsl.example.yml index cd60777..d43379b 100644 --- a/configs/wsl.example.yml +++ b/configs/wsl.example.yml @@ -26,7 +26,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/configs/wsl.lean.example.yml b/configs/wsl.lean.example.yml index 29dbfb2..eabdcdb 100644 --- a/configs/wsl.lean.example.yml +++ b/configs/wsl.lean.example.yml @@ -25,7 +25,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/configs/wsl.web-e2e.example.yml b/configs/wsl.web-e2e.example.yml index 11532bd..7895255 100644 --- a/configs/wsl.web-e2e.example.yml +++ b/configs/wsl.web-e2e.example.yml @@ -25,7 +25,7 @@ pool: replacementRetryJitterPercent: 20 storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative diff --git a/docs/README.md b/docs/README.md index 4202a86..d927229 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ Use these guides after the short [README quick start](../README.md). Start with ## Choose a provider - [Docker Container](providers/docker-container.md): disposable containers with a private Docker daemon. -- [Docker Sandboxes](providers/docker-sandboxes.md): dedicated microVM runners and the required prebuilt template. +- [Docker Sandboxes](providers/docker-sandboxes.md): dedicated microVM runners and guided template provisioning. - [WSL](providers/wsl.md): disposable Windows WSL2 runners. - [Tart](providers/tart.md): experimental Apple Silicon ARM64 Linux VMs. @@ -22,7 +22,7 @@ Use these guides after the short [README quick start](../README.md). Start with - [Troubleshooting](troubleshooting.md): symptom-first diagnostics. - [Logging](logging.md) and [Storage](storage.md): retention, capacity, and exact cleanup boundaries. - [Image customization](image-build.md): build layers and custom install scripts. -- [Docker Sandboxes templates](advanced/docker-sandboxes-template.md): build, review, load, size, and retain pinned templates. +- [Docker Sandboxes templates](advanced/docker-sandboxes-template.md): build, verify, import, size, and retain exact templates. - [Cross-architecture containers](advanced/cross-architecture-containers.md): image platforms, emulation, labels, and verification. - [Docker registry mirrors](advanced/docker-registry-mirrors.md): an optional pull-time optimization. - [Windows startup](advanced/windows-startup.md), [macOS startup](advanced/macos-startup.md), and [no-Go startup](advanced/no-go-install.md): host-specific launch help. diff --git a/docs/advanced/docker-sandboxes-template.md b/docs/advanced/docker-sandboxes-template.md index 5dd6028..b55ca77 100644 --- a/docs/advanced/docker-sandboxes-template.md +++ b/docs/advanced/docker-sandboxes-template.md @@ -1,6 +1,6 @@ # Docker Sandboxes Template Build And Retention -Docker Sandboxes requires an EPAR Candidate A template that has been built, reviewed, and loaded locally before setup. This is separate from `ephemeral-action-runner image build`: the normal image command does not build or load sandbox templates. +Docker Sandboxes requires an EPAR runner template built from the desired Catthehacker image and imported into the local Docker Sandboxes cache. `./start` performs this provisioning during first-run setup, and `./start image build` uses the same implementation. ## Before You Start @@ -10,35 +10,23 @@ Use a native Docker server that matches the template platform. An amd64 server b sbx diagnose --output json ``` -EPAR requires at least one diagnostic pass and no diagnostic failures. Warnings and skipped checks are accepted. When a diagnostic fails, review the failed item and its hint in the JSON output. The lock file at [`templates/docker-sandboxes/sources.lock.json`](../../templates/docker-sandboxes/sources.lock.json) identifies the allowed source profiles and exact build inputs. It is an approved snapshot, not an automatic update channel. +EPAR requires at least one diagnostic pass and no diagnostic failures. Warnings and skipped checks are accepted. When a diagnostic fails, review the failed item and its hint in the JSON output. The lock file at [`templates/docker-sandboxes/sources.lock.json`](../../templates/docker-sandboxes/sources.lock.json) pins the build tooling, runner, and platform inputs. The selected Catthehacker source tag is resolved independently to exact OCI index and platform-manifest digests for each build. -## Build And Review +## Build, Import, And Review -Run the build once without `-Execute` to inspect its plan. Build a reviewed full or lean profile only after confirming its source and platform: +Use `./start` for first-run provisioning or the shared image command afterward: ```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/build-template.ps1 -Profile full -Platform linux/amd64 -Execute +./start image build --replace ``` -The lean profile is `act-22.04`. On Apple Silicon, use PowerShell 7 and `-Platform linux/arm64`. A cold full-profile acquisition can take significant time and storage. +EPAR resolves the configured Catthehacker tag for the native platform, checks capacity, builds with the pinned template inputs, generates metadata, provenance, an SPDX SBOM, and a software inventory, exports the archive, imports it with `sbx template load`, and reads back the exact cache identity. The active receipt is updated atomically only after every step succeeds. -The build directory contains the template archive, metadata, SBOM, provenance, software inventory, compatibility record, and checksums. Record the SHA-256 of `template-metadata.json` outside that directory before review. The loader uses that operator-provided value as its trust anchor. - -## Load Exactly Once - -After reviewing the evidence, load the archive: - -```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/docker-sandboxes/load-template.ps1 -ArtifactDirectory work/template-builds/docker-sandboxes/full -ExpectedMetadataSha256 sha256: -Execute -``` - -The loader validates the archive, metadata, provenance, SPDX SBOM, inventory, helper hashes, compatibility record, source lock, exact local Docker image identity, and `sbx diagnose --output json` result before it invokes `sbx template load`. It reads the tag and cache inventory back afterward and never runs `sbx reset`. - -Keep the archive and its evidence while the template is in service or may require independent review. The Docker Sandboxes cache ID is only 12 hexadecimal characters; it is not the full template identity. EPAR records the full local Docker image identity as `dockerSandboxes.templateDigest` and checks it independently. +The compatibility scripts under `scripts/docker-sandboxes` delegate to this command. They no longer maintain a separate build or load implementation. ## Configure And Prewarm -Run `./start` with no configuration, or `ephemeral-action-runner init`. The wizard finds only current lock-selected, locally loaded templates that match their full local identity and platform. It writes the exact tag, full digest, policy fingerprint, and resource reservations; it does not build, load, start, or remove a sandbox during discovery. +Run `./start` with no configuration. The wizard offers `full-latest`, `act-latest`, `dotnet-latest`, `js-latest`, or another `catthehacker/ubuntu` tag; verifies the tag and native platform; validates optional custom install scripts; displays source, platform, size estimates, reserve, and duration; then builds and imports after one confirmation. It does not write the usable config until exact readback passes. After configuration, prewarm the selected template outside the job path: @@ -50,7 +38,7 @@ Do not add `--register-only`. This creates, verifies, and exactly removes one un ## Capacity -Size `rootDisk` from the exact template/workload's measured guest root peak plus 25% margin and at least 20 GiB writable headroom, rounded up to the next 10 GiB. Size `dockerDisk` from representative Docker workload use plus 25% margin and 20 GiB deletion headroom; the minimum is 100 GiB. Keep at least 50 GiB or 10% of the backing volume free, whichever is greater. +Use `rootDisk: auto` unless a deployment requires an explicit larger sparse logical maximum. EPAR derives the effective root from the exact expanded source estimate plus a 5 GiB customization allowance and 20 GiB writable headroom, rounded up to the next 10 GiB. `dockerDisk` is an independent sparse workload limit with a 50 GiB default and 1 GiB minimum. Physical admission uses only the estimated incremental host growth plus `storage.minimumFree`; it never reserves a percentage of the backing volume or adds both virtual maxima to host usage. The template cache and archive are host-cache measurements, not each sandbox's root-disk baseline. EPAR rechecks backing storage, configured reservations, and uncertain cleanup reservations before every create. A failed capacity admission does not silently choose another provider. @@ -62,6 +50,6 @@ EPAR storage maintenance never broadly prunes Docker images, BuildKit state, Doc ## Evidence And Certification -The source lock pins the Candidate A source, Actions runner, Tini, helper inputs, and platform manifests. A rolling upstream channel moving later does not change a loaded template; build, review, and load a new versioned template before it becomes selectable. +The source lock pins build tooling, Actions runner, Tini, helper inputs, and platform-specific inputs. EPAR resolves a mutable source selector on every start and activates a new immutable template only after build, import, and exact readback succeed. The current ARM64 path has pinned inputs and code support but no equivalent recorded native real-host lifecycle or independent-certification evidence. Treat it as capability-ready only after local admission and your own workload validation. An independent certification record, when available, must bind the reviewed native-controller source/build, full template identity, cache ID, metadata/archive digests, and reviewed evidence. diff --git a/docs/advanced/no-go-install.md b/docs/advanced/no-go-install.md index 26d11cc..0edec5f 100644 --- a/docs/advanced/no-go-install.md +++ b/docs/advanced/no-go-install.md @@ -18,7 +18,7 @@ Under the hood, the wrapper calls `scripts/run-with-docker.sh` or `scripts/run-w ### Host trust -When the selected config enables `image.hostTrustMode: overlay`, the native controller reads trust from the real Windows, macOS, or Linux host. The temporary Go toolchain container only compiles the binary and does not supply runtime trust roots. +The wrappers publish a short-lived `EPAR_BUILD_TRUST_FEED` from the real Windows, macOS, or Linux host for build-consuming commands, including when `image.hostTrustMode` is disabled. When runner overlay is enabled, they separately publish `EPAR_HOST_TRUST_FEED`. Native controllers can collect directly; the temporary Go toolchain container only compiles the binary and never substitutes its own trust roots for either host policy. The explicit legacy path `EPAR_LEGACY_CONTROLLER_IN_DOCKER=1` remains available only for compatible providers. That path uses the existing short-lived host-trust bridge and rejects `provider.type: docker-sandboxes`; it is not an automatic fallback. diff --git a/docs/configuration.md b/docs/configuration.md index 23247f8..54b3eef 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,11 +24,11 @@ EPAR chooses the first available configuration path in this order: `--config `; no default | Required with Docker Sandboxes. | Full local OCI configuration digest for the template identity. | +| `image.sourceImage` | `ghcr.io/catthehacker/ubuntu:full-latest` | Required with Docker Sandboxes. | Desired Catthehacker source selector; EPAR builds and imports the runnable template automatically. | | `policyGeneration` | lowercase `sha256:<64-hex>`; no default | Required with Docker Sandboxes. | Fingerprint of the verified host-global Balanced policy. Policy drift blocks admission. | | `networkBaseline` | `open` or `balanced`; `open` | Docker Sandboxes. | `open` adds a sandbox-scoped public-egress rule while denying host aliases; it does not change the host-global policy. | | `additionalAllow` | unique hostname or `*.domain`, optional port; empty | Docker Sandboxes. | Adds sandbox-scoped allow resources. With `open`, it cannot re-allow EPAR's host-alias deny guardrails. | @@ -165,9 +164,8 @@ If the complete subsection is absent, EPAR warns and uses the strict recommended | `stagingRoot` | canonical project-relative `.local/...` path; `.local/docker-sandboxes-staging` | Docker Sandboxes. | Per-create staging root; cannot be absolute, escape `.local`, or overlap `.local/bin` or `.local/state`. | | `cpus` | positive integer; `4` | Docker Sandboxes. | CPU allocation for each sandbox. | | `memory` | positive byte size; `8GiB` | Docker Sandboxes. | Per-sandbox memory allocation written by the wizard. | -| `rootDisk` | byte size at least `20GiB`; no schema default | Docker Sandboxes. | Guest root capacity. Wizard sizing uses measured guest use plus margin/headroom when evidence exists. | -| `dockerDisk` | byte size at least `100GiB`; no schema default | Docker Sandboxes. | Inner Docker disk capacity. | -| `minHostFreeSpace` | byte size at least `50GiB`; no schema default | Docker Sandboxes. | Host admission floor; effective reserve is the larger of this and `storage.minimumFree`. Runtime may require a stricter backing-volume percentage. | +| `rootDisk` | `auto` or byte size at least `20GiB`; `auto` | Docker Sandboxes. | Sparse guest-root logical maximum. `auto` is recalculated for each artifact as the expanded image estimate plus 5 GiB build allowance and 20 GiB writable headroom, rounded up to 10 GiB. An explicit undersized value is rejected before creation. | +| `dockerDisk` | byte size at least `1GiB`; `50GiB` | Docker Sandboxes. | Independent sparse logical maximum for the Docker daemon inside the sandbox; it is workload capacity and is not derived from the base image. | | `maxConcurrentCreates` | positive integer; `2` | Docker Sandboxes. | Limits concurrent sandbox creation to control capacity pressure. | ### `timeouts` @@ -182,7 +180,7 @@ If the complete subsection is absent, EPAR warns and uses the strict recommended - `provider.sourceImage` is required for Tart, WSL, and Docker Container, and forbidden for Docker Sandboxes. - `provider.rosettaTag` is accepted only for Tart. `provider.platform` is accepted only for Docker Container or Docker Sandboxes. Docker Sandboxes accepts only `linux/amd64` and `linux/arm64`. -- Docker Sandboxes requires `runner.ephemeral: true`, `security.runnerGroup.enforcement: enforce`, a valid pinned template/digest/policy generation, resource values, and a lowercase-compatible pool prefix. +- Docker Sandboxes requires `runner.ephemeral: true`, `security.runnerGroup.enforcement: enforce`, a valid desired Catthehacker image, policy generation, resource values, and a lowercase-compatible pool prefix. - `image.sourcePlatform` requires `image.sourceType: docker-image`; all byte-size fields require a positive `B`, `KiB`, `MiB`, `GiB`, or `TiB` value. - Host-trust overlay requires a non-empty, duplicate-free scope list and `runner.ephemeral: true`; `user` is not supported on Linux. - `pool.namePrefix` is an ownership boundary. Tart, WSL, and Docker Container use the configured prefix to select legacy owned resources; Docker Sandboxes uses its durable ledger of exact owned identities. Do not share a prefix between controllers or assume broad prefix cleanup is safe. @@ -195,7 +193,7 @@ The configuration loader starts with Tart defaults, then applies provider-specif | Provider | Source and output | Default labels and prefix | | --- | --- | --- | | Docker Container | Catthehacker full Ubuntu to `epar-docker-container-catthehacker-ubuntu`. | `self-hosted`, `linux`, `epar-docker-container-catthehacker-ubuntu`; prefix `epar-docker-container`. | -| Docker Sandboxes | Pinned Candidate A template, digest, and policy generation; no `provider.sourceImage`. | `self-hosted`, `linux`, matching `X64`/`ARM64`, `epar-docker-sandboxes`; prefix `epar-docker-sandboxes`. | +| Docker Sandboxes | Desired image settings plus policy generation; exact template identities live in the local artifact receipt. | `self-hosted`, `linux`, matching `X64`/`ARM64`, `epar-docker-sandboxes`; prefix `epar-docker-sandboxes`. | | WSL Docker source | Catthehacker full Ubuntu, `linux/amd64`, output `work/images/epar-wsl-catthehacker-ubuntu.tar`. | `self-hosted`, `linux`, `X64`, `epar-wsl-catthehacker-ubuntu`; prefix `epar-wsl`. | | WSL rootfs tar | `work/images/ubuntu-24.04-clean.rootfs.tar`, output `work/images/epar-ubuntu-24-wsl.tar`. | `self-hosted`, `linux`, `X64`, `epar-wsl-ubuntu-24.04-base`; prefix `epar-wsl`. | | Tart | `ghcr.io/cirruslabs/ubuntu:latest` to `epar-ubuntu-24-arm64`. | `self-hosted`, `linux`, `ARM64`, `epar-tart-ubuntu-24.04-base`; prefix `epar`. | @@ -245,4 +243,4 @@ docker: noProxy: localhost,127.0.0.1,.example.test ``` -For Docker Sandboxes, run `./start` and let the wizard write the pinned template, digest, policy fingerprint, and capacity settings. Do not hand-copy a template tag from another host or replace its digest with a mutable tag. +For Docker Sandboxes, run `./start` and let the wizard select the desired image, provision the native-platform runner template, and write the policy fingerprint and capacity settings. Do not hand-copy generated template identities between hosts; EPAR records them in the local artifact receipt. diff --git a/docs/development/principles.md b/docs/development/principles.md index 727cb25..744f40e 100644 --- a/docs/development/principles.md +++ b/docs/development/principles.md @@ -4,7 +4,7 @@ EPAR extensions preserve the existing user flow and controller design. ## First Run -`./start` is the Quick Start and general source entry point. With no command, or with start flags only, it runs the `start` command and opens the missing-configuration wizard when needed; with an explicit command, it must forward that command and all arguments exactly as the binary and `go run ./cmd/ephemeral-action-runner` do. The local-Go and no-Go native-controller paths must behave the same, and user-facing remediation commands must use the entry point the user actually invoked. A selectable provider must appear in the wizard with its prerequisite status. Reject an unavailable selection clearly and never silently switch a configured provider. +`./start` is the Quick Start and general source entry point. With no command, or with start flags only, it runs the `start` command and opens the missing-configuration wizard when needed; with an explicit command, it must forward that command and all arguments exactly as the binary and `go run ./cmd/ephemeral-action-runner` do. The local-Go and no-Go native-controller paths must behave the same, including native-host operational build trust, and user-facing remediation commands must use the entry point the user actually invoked. A selectable provider must appear in the wizard with its tooling and daemon prerequisite status; storage estimates never make provider selection unavailable or prevent configuration creation. Docker Container, Docker Sandboxes, and WSL use the same Catthehacker image and custom-script onboarding flow. The wizard writes the desired configuration first, then an embedded `./start` continues through the ordinary artifact provisioning and storage-admission path. Reject an unavailable platform or invalid image clearly and never silently switch a configured provider or artifact. Builder operational trust and optional runner trust are separate contracts: system roots always support the owned builder, while `image.hostTrustMode` controls only runner inheritance. ## Runner Names diff --git a/docs/image-build.md b/docs/image-build.md index 70c0286..4fe7f9e 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -1,6 +1,6 @@ # Image Customization -EPAR prepares a reusable runner artifact before it creates disposable instances. The artifact differs by provider: Docker Container uses a Docker image, WSL2 uses a rootfs tar, Tart uses a Tart VM image, and Docker Sandboxes uses a separately built and loaded template. +EPAR prepares a reusable runner artifact before it creates disposable instances. The artifact differs by provider: Docker Container uses a Docker image, WSL2 uses a rootfs tar, Tart uses a Tart VM image, and Docker Sandboxes uses a built, imported, and read-back runner template. ```mermaid flowchart LR @@ -19,9 +19,9 @@ flowchart LR | Docker Container | `ghcr.io/catthehacker/ubuntu:full-latest` | Docker image tag | `image build --replace` | | WSL2 | Catthehacker Docker image converted to rootfs | Rootfs tar | `image build --replace` | | Tart | `ghcr.io/cirruslabs/ubuntu:latest` | Tart VM image | `image build --replace` | -| Docker Sandboxes | Lock-selected Catthehacker source | Loaded Candidate A template | [template guide](advanced/docker-sandboxes-template.md) | +| Docker Sandboxes | Selected Catthehacker source | Verified imported runner template | `image build` | -`./start` compares the configured image artifact with its manifest and builds Docker Container, WSL2, or Tart artifacts when they are missing or no longer match. Docker Sandboxes is intentionally different: `start` validates the configured, already loaded template and never builds or loads it. +The first-run wizard gives Docker Container, Docker Sandboxes, and WSL the same ordered Catthehacker choices (`full-latest`, `act-latest`, `dotnet-latest`, `js-latest`, or another validated tag), platform resolution, optional custom-script collection, and storage estimate. `./start` compares the desired image settings with the active artifact receipt and builds a replacement when the source digest, platform, EPAR assets, runner inputs, trust inputs, or custom-script hashes change. Docker Sandboxes imports the replacement into its template cache and activates it only after exact readback succeeds. ## Add Tools @@ -55,9 +55,11 @@ image: - .local/enterprise-root.pem ``` -EPAR validates PEM or DER CA certificates and adds them before networked guest install steps. Keep TLS verification enabled. +EPAR validates PEM or DER CA certificates and incorporates their hashes into artifact freshness. Explicit certificates are available to both the operational image build and the resulting runner artifact. Keep TLS verification enabled. -The wizard can also enable `image.hostTrustMode: overlay` for Docker Container and Docker Sandboxes. It inherits selected host root anchors into fresh runner generations; it is additive to Ubuntu roots and explicit CA paths, not an emulation of every Windows or macOS trust policy. Use `[system, user]` on Windows/macOS or `[system]` on Linux. See [Configuration](configuration.md) and [Security](security.md) before enabling it. +EPAR's project-owned BuildKit builder always receives current host system roots so image acquisition can operate behind authorized HTTPS inspection. This operational trust is independent of `image.hostTrustMode` and is not copied into runners. If runner overlay explicitly includes the `user` scope, those user roots are also available to the builder for the same invocation. + +The wizard can enable `image.hostTrustMode: overlay` for Docker Container and Docker Sandboxes when runners themselves must inherit selected host root anchors. Omitted or `disabled` mode creates a Docker Sandboxes template with an explicit disabled-policy marker and no job-start trust hook. Overlay mode is additive to Ubuntu roots and explicit CA paths, not an emulation of every Windows or macOS trust policy. Use `[system, user]` on Windows/macOS or `[system]` on Linux. See [Configuration](configuration.md) and [Security](security.md) before enabling it. ## Provider Differences @@ -75,7 +77,7 @@ The output is a local Tart VM image. The default is intentionally lean; use a cu ### Docker Sandboxes -Docker Sandboxes has no `image build` path and rejects `provider.sourceImage`. Build and load the reviewed template with [Docker Sandboxes template build and retention](advanced/docker-sandboxes-template.md), then let the wizard write the exact template identity into configuration. +Docker Sandboxes uses `image.sourceImage`, `image.sourcePlatform`, and `image.customInstallScripts` as the desired template inputs. `./start` and `./start image build` share the same build/import implementation. Exact Docker image and Sandbox cache identities are stored in `.local/state/image/docker-sandboxes/active.json`, not user configuration; a failed desired update leaves the previous receipt and artifact intact but does not run it as a fallback. ## Verify A Customized Artifact diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index 3b8f05e..aedaf5f 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -34,54 +34,61 @@ EPAR selects this provider by capability, not by an operating-system allowlist: ## Prerequisites - A working Docker CLI and daemon. -- Docker Sandboxes CLI whose `sbx diagnose --output json` result reports at least one passing check and no failed checks. Warnings and skipped checks do not make the provider unavailable. -- A native `amd64` or `arm64` controller with the matching `linux/amd64` or `linux/arm64` EPAR template. EPAR does not use emulation to admit a mismatched template. -- A locally built and loaded, lock-selected Candidate A template whose full local identity matches configuration. -- Enough Docker Sandboxes backing storage for the configured root disk, Docker disk, existing reservations, and host-free watermark. +- Docker Sandboxes CLI whose `sbx diagnose --output json` result reports at least one passing check and no failed checks. Before the first-run provider assessment, the wizard runs `sbx daemon start --detach` when the `sbx` executable is installed, then runs diagnostics. Warnings and skipped checks do not make the provider unavailable. +- A native `amd64` or `arm64` controller with matching `linux/amd64` or `linux/arm64` image support. EPAR does not use emulation to admit a mismatched template. +- Enough capacity to resolve, build, export, import, and retain the selected runner template. +- Enough physical backing storage for the estimated incremental template and sandbox bootstrap work while retaining `storage.minimumFree`. Sparse root and inner-Docker logical maxima are reported separately and are not counted as immediate host allocation. - A GitHub runner group that meets enforced policy. Docker Sandboxes requires `security.runnerGroup.enforcement: enforce` and `runner.ephemeral: true`. -Build and load the template before running the wizard. See [Docker Sandboxes template build and retention](../advanced/docker-sandboxes-template.md). +The wizard builds and imports the template. The recipes in `templates/docker-sandboxes` are build inputs, not prebuilt images. ## Minimal Configuration -Start with [`configs/docker-sandboxes.example.yml`](../../configs/docker-sandboxes.example.yml). The wizard writes the exact values after it verifies local admission; do not substitute a raw Catthehacker image for the template. +Start with `./start` or [`configs/docker-sandboxes.example.yml`](../../configs/docker-sandboxes.example.yml). Configuration expresses the desired source; EPAR stores immutable build and cache identities in its local receipt. ```yaml provider: type: docker-sandboxes platform: linux/amd64 +image: + sourceType: docker-image + sourceImage: ghcr.io/catthehacker/ubuntu:full-latest + sourcePlatform: linux/amd64 + runnerVersion: latest + customInstallScripts: + # - examples/custom-install/install-extra-apt-tools.sh + dockerSandboxes: - template: epar-docker-sandboxes-catthehacker-full: - templateDigest: sha256: policyGeneration: sha256: networkBaseline: open stagingRoot: .local/docker-sandboxes-staging cpus: 4 memory: 8GiB - rootDisk: 30GiB - dockerDisk: 100GiB + rootDisk: auto + dockerDisk: 50GiB maxConcurrentCreates: 2 - minHostFreeSpace: 50GiB ``` -`provider.sourceImage` is invalid for this provider. `templateDigest` is the full local image identity, not Docker Sandboxes' short cache ID. The `rootDisk`, `dockerDisk`, and host-free settings are reservations: the minimums are 20 GiB, 100 GiB, and 50 GiB respectively, and runtime also enforces at least 10% free on the backing volume. See [Configuration](../configuration.md) for the complete schema. +`provider.sourceImage` is invalid for this provider; use the common `image` section. `rootDisk: auto` derives a sparse logical root maximum from the selected artifact. `dockerDisk` is an independent sparse workload limit whose default is 50 GiB and minimum is 1 GiB. Neither virtual maximum is treated as immediately consumed host space; the only physical reserve is `storage.minimumFree`, whose generated default is 1 GiB. See [Configuration](../configuration.md) for the complete schema. `networkBaseline: open` adds EPAR-owned sandbox-scoped public egress plus deny-wins guardrails for host aliases; it does not change the host-global Docker Sandboxes policy. Use `balanced` with `additionalAllow` for default-deny public egress. Additional allow/deny entries are exact hostnames or `*.domain[:port]`; they cannot override the Open host-alias denies. ## Normal Workflow -1. Build and load a reviewed template using the advanced guide. -2. Run `./start` with no config, or `ephemeral-action-runner init`, and select Docker Sandboxes when its checks pass. The wizard records the exact template, policy fingerprint, architecture, and reservations. +1. Run `./start` with no config and select Docker Sandboxes when its tooling and diagnostics pass. Choose a Catthehacker profile or tag and optional custom install scripts; review the non-blocking physical-growth estimate, sparse logical limits, reserve, confidence, and expected duration. +2. The wizard writes the desired configuration. Embedded `./start` then enters the ordinary provisioning path, performs authoritative storage admission, builds and imports the template, and activates it only after exact readback. 3. Prewarm the selected template without GitHub registration: ```powershell powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-native-controller.ps1 pool verify --config .local/docker-sandboxes.yml --project-root . --instances 1 --cleanup ``` -4. Start the pool with `./start`. EPAR validates the already loaded template; it does not build or load one for you. +4. Start the pool with `./start`. EPAR re-resolves mutable selectors such as `full-latest` and reuses or rebuilds the exact desired template automatically. + +Each allocation receives an empty owner-restricted staging directory, but Actions `_work` stays on the guest filesystem. EPAR verifies the guest, policy, private daemon, and runner trust policy before requesting a short-lived registration token. With `image.hostTrustMode: overlay`, the common pool lifecycle installs the selected roots, verifies the immutable generation, and maintains the job-start lease. With the setting omitted or disabled, the template carries an explicit disabled-policy marker and does not install the trust hook. The token remains on the native host except for registration through `sbx exec` standard input. -Each allocation receives an empty owner-restricted staging directory, but Actions `_work` stays on the guest filesystem. EPAR verifies the guest, policy, private daemon, and host-trust generation before requesting a short-lived registration token. The token remains on the native host except for registration through `sbx exec` standard input. +Template construction uses two independent trust paths. EPAR's project-owned BuildKit builder automatically receives host system roots for Docker Hub, GHCR, and the other pinned registries used by the build. The native controller downloads the locked Actions runner and `tini`, verifies their SHA-256 values, and then supplies them as local build inputs; the Dockerfile does not perform remote HTTPS downloads. ## Limitations diff --git a/docs/storage.md b/docs/storage.md index 724d437..78e80b4 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -4,7 +4,7 @@ EPAR checks the storage surfaces required by the selected provider before bootst ```yaml storage: - minimumFree: 20GiB + minimumFree: 1GiB gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative @@ -23,7 +23,11 @@ ephemeral-action-runner storage prune --execute Conservative housekeeping is limited to expired, unleased, exactly owned local staging artifacts and dedicated recomputable caches. Docker images, named volumes, imported Docker Sandboxes templates, WSL distributions, and Tart images require explicit prune execution. -EPAR image builds use a project-scoped Buildx builder with persisted ownership metadata and BuildKit garbage collection capped by `storage.buildCacheLimit`. EPAR does not select, modify, or prune Docker's shared/default builder. The no-Go native-controller path similarly creates project-scoped, ownership-labelled Go module and build-cache volumes, measures their combined use, and clears only those exact recomputable caches when they exceed `storage.goCacheLimit`; active cache volumes are not touched. Existing unlabelled or explicitly overridden Go cache volumes remain shared or ownership-unknown and report-only. The opt-in legacy controller-in-Docker path uses ephemeral container caches unless the operator explicitly supplies cache volume names. +EPAR image builds use a project-scoped Buildx builder with persisted ownership metadata, an exact registry/trust configuration digest, and BuildKit garbage collection capped by `storage.buildCacheLimit`. When its owned configuration or trust generation changes, EPAR recreates only that builder's control container with Buildx state retention and verifies configuration readback before publishing new metadata. EPAR does not select, modify, or prune Docker's shared/default builder. Active CA material lives under `.local/storage/buildkit-certs/`; obsolete owned generations follow the normal grace policy. The no-Go native-controller path similarly creates project-scoped, ownership-labelled Go module and build-cache volumes, measures their combined use, and clears only those exact recomputable caches when they exceed `storage.goCacheLimit`; active cache volumes are not touched. Existing unlabelled or explicitly overridden Go cache volumes remain shared or ownership-unknown and report-only. The opt-in legacy controller-in-Docker path uses ephemeral container caches unless the operator explicitly supplies cache volume names. + +Physical host growth and logical virtual-disk limits are reported separately. A 300 GiB VHDX or `Docker.raw` maximum/apparent length does not mean 300 GiB of live Docker content or 300 GiB of new host space is required: Docker Desktop, WSL, and Docker Sandboxes use dynamically allocated or sparse backing storage. `docker system df` reports Docker-managed usage, not host free capacity. EPAR probes the physical filesystem that contains the backing file when it is measurable and treats an unexposed Docker Desktop internal-free value as advisory. + +Storage admission remains fail-closed during normal artifact provisioning, creation, and replacement. To accept the storage risk for one invocation while retaining every non-storage safety check, pass `--allow-insufficient-storage` to `start`, `pool up`, `pool verify`, `image build`, or `image update-upstream`. Unknown, shared, prefix-only, custom-path, or identity-drifted resources are report-only. EPAR never turns storage cleanup into a broad Docker prune, Docker Sandboxes reset, Docker Desktop reset, WSL reset, or VHDX compaction. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3f5cfd5..585e912 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -108,9 +108,9 @@ Startup reports a template identity/digest mismatch, policy-generation drift, an ### Diagnosis and remediation -Docker Sandboxes uses an explicit local Candidate A template, full OCI configuration digest, and host-global Balanced-policy fingerprint. A mutable tag, a template copied from another host, a changed template digest, or a changed policy generation is intentionally rejected. Return to the wizard or the documented template promotion procedure to obtain a fresh, locally verified configuration; do not weaken the fingerprint or replace it with a guessed value. +Docker Sandboxes resolves the configured source selector, records the exact OCI identities in a local artifact receipt, and verifies the host-global policy fingerprint. If the desired source, platform, scripts, template inputs, runner inputs, or trust inputs change, rerun `./start`; EPAR builds and imports a replacement and activates it only after exact readback succeeds. -Capacity admission accounts for the configured root disk, inner Docker disk, per-sandbox memory, concurrent creation limit, and host-free reserve. Inspect the storage location reported by the failure and free or expand the relevant backing storage. The minimums are `20GiB` root disk, `100GiB` Docker disk, and `50GiB` host free space; runtime can require more. Avoid broad cleanup commands: they can delete stopped containers and intentionally retained resources. +Capacity admission accounts for estimated incremental physical growth on each measurable backing filesystem plus the fixed `storage.minimumFree` reserve. Docker Sandboxes root and inner-Docker sizes are independent sparse logical maxima and are not added as immediate host usage. Inspect the reported physical surface, run the matching `storage status` and prune-preview commands, or deliberately retry only that invocation with `--allow-insufficient-storage`. Avoid broad cleanup commands: they can delete stopped containers and intentionally retained resources. `networkBaseline: open` is a sandbox-scoped public-egress compatibility rule with EPAR host-alias deny guardrails. It does not alter the host-global policy. If a required service is blocked, use a narrow `additionalAllow` hostname rule; do not allow `host.docker.internal`, `gateway.docker.internal`, `kubernetes.docker.internal`, or `host.containers.internal` through the Open-policy guardrails. @@ -160,7 +160,19 @@ HTTPS access fails with `curl: (60)`, `certificate verification failed`, or an u ### Diagnosis and remediation -Do not disable certificate verification. Configure host trust overlay for an ephemeral runner and rebuild the image: +Do not disable certificate verification. First identify which trust boundary failed. + +For an EPAR Buildx failure, leave `image.hostTrustMode` unchanged. EPAR automatically supplies host system roots to its project-owned builder and prints the full build transcript path before `docker buildx build`. The console and error report include a bounded redacted tail. Inspect the underlying `x509` line together with the registry host, builder identity, and active trust generation: + +```powershell +docker buildx ls +Get-Content .local/storage/buildx.json +Get-Content .local/storage/buildkitd.toml +``` + +The owned metadata records the exact registry set, configuration digest, certificate bundle, and trust generation. Rerunning the same command reconciles that exact builder and preserves its BuildKit state; EPAR never changes Docker's shared/default builder. If the source-image `docker pull` itself fails before Buildx starts, configure the authorized CA in Docker Desktop, OrbStack, or Docker Engine because builder trust cannot repair host-daemon trust. + +Configure runner overlay only when jobs inside an ephemeral runner must inherit host roots: ```yaml image: @@ -168,7 +180,7 @@ image: hostTrustScopes: [system, user] ``` -Use `[system]` on Linux. Overlay mode collects the current host roots, validates them before registration, and combines them with Ubuntu roots and any `image.trustedCaCertificatePaths`; it is root-anchor inheritance rather than exact Windows/macOS TLS-policy emulation. It requires `runner.ephemeral: true`. +Use `[system]` on Linux. Overlay mode collects the current host roots, validates them before registration, and combines them with Ubuntu roots and any `image.trustedCaCertificatePaths`; it is root-anchor inheritance rather than exact Windows/macOS TLS-policy emulation. It requires `runner.ephemeral: true`. Omitted or disabled mode remains valid for Docker Sandboxes and does not install the job-start trust hook. Use the normal host entry point so EPAR can inspect the real Windows certificate stores or macOS Keychain: @@ -177,7 +189,7 @@ Use the normal host entry point so EPAR can inspect the real Windows certificate go run ./cmd/ephemeral-action-runner image build --replace ``` -On no-Go Windows, use `scripts\run-with-docker.ps1 image build --replace`; on macOS/Linux, use `scripts/run-with-docker.sh image build --replace`. A bare Linux toolchain container is not a replacement for the host-trust bridge. If the source-image `docker pull` itself fails, configure the authorized CA in Docker Desktop, OrbStack, or Docker Engine first. +On no-Go Windows, use `scripts\run-with-docker.ps1 image build --replace`; on macOS/Linux, use `scripts/run-with-docker.sh image build --replace`. These wrappers publish the separate native-host build feed for `start`, `image build`, `pool up`, and `pool verify`, even when runner overlay is disabled. A bare Linux toolchain container is not a replacement for that bridge. ## Windows Docker Desktop WSL2 disk is smaller than expected diff --git a/docs/usage.md b/docs/usage.md index 2344a13..640ac31 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,7 +9,7 @@ Use this page for normal EPAR tasks. Start with the host and provider you alread | Run a source archive | Go 1.25 or newer, or Docker for the no-Go controller builder | | Register or inspect GitHub runners | A GitHub App with organization self-hosted runner read/write permission | | Docker Container | Docker Engine, Docker Desktop, or OrbStack with privileged Linux-container support | -| Docker Sandboxes | Docker, the `sbx` CLI with at least one diagnostic pass and zero failures, and an approved EPAR template already built and loaded | +| Docker Sandboxes | Docker and the `sbx` CLI with at least one diagnostic pass and zero failures; the wizard builds and imports the selected EPAR template | | WSL | Native Windows, WSL2, and Docker when preparing the default WSL image | | Tart | Native Apple Silicon macOS and Tart | @@ -35,9 +35,9 @@ The wrapper uses local Go when available, otherwise it uses Docker to build and go run ./cmd/ephemeral-action-runner start ``` -When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group, shows every provider with its current prerequisite result, and refuses unavailable selections. Docker Sandboxes becomes the Enter default only when Docker, `sbx diagnose --output json`, the host platform, and the exact locally built-and-loaded template pass admission. The wizard does not build or load a template, and it never falls back from a selected provider. +When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group, shows every provider with its tooling and daemon prerequisite result, and refuses unavailable selections. Storage does not make a provider unavailable. Docker Container, Docker Sandboxes, and WSL share one Catthehacker image/profile and custom-script flow, followed by an informational physical-growth estimate and confirmation. The wizard writes the desired configuration first; direct `init` then exits, while embedded `./start` continues through the ordinary image/template provisioning and pool startup path. When `sbx` is installed, the wizard runs `sbx daemon start --detach` before Docker Sandboxes diagnostics so a stopped daemon does not require a manual retry. -Before choosing Docker Sandboxes, follow [Docker Sandboxes](providers/docker-sandboxes.md) to build, review, and load the approved template for the host platform. The wizard verifies the template cache entry and its full local image identity before it writes configuration. +See [Docker Sandboxes](providers/docker-sandboxes.md) for source profiles, capacity, local receipts, and platform validation status. ## Create or choose configuration @@ -68,6 +68,8 @@ go run ./cmd/ephemeral-action-runner start --config .local\ci.yml --instances 2 If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.instances` from the selected config. EPAR resolves configuration from `--config`, `EPAR_CONFIG`, `.local/config.yml`, then `~/.config/ephemeral-action-runner/config.yml`. Tracked files in `configs/` are examples; keep App values and key paths in an ignored local file. See [Configuration](configuration.md) for every setting and [Runner Group Security](runner-groups.md) before broadening repository access. +Storage-consuming commands fail before their provider side effects when an authoritative physical surface cannot retain `storage.minimumFree`. The one-invocation `--allow-insufficient-storage` option keeps all probes and warnings but permits only storage admission to continue; provider diagnostics, GitHub policy, ownership, lifecycle, and cleanup protections remain enforced. The option is available on `start`, `pool up`, `pool verify`, `image build`, and `image update-upstream`, including the equivalent `./start ...` wrapper forms. + Press `Ctrl-C` once to stop a foreground pool and wait for cleanup confirmation. Use `--keep-on-exit` only to retain owned resources for deliberate debugging. ## Verify before sending jobs @@ -104,7 +106,7 @@ For a command-construction preview on compatible providers, add `--dry-run`: go run ./cmd/ephemeral-action-runner pool verify --dry-run --instances 1 ``` -Docker Sandboxes intentionally does not support this dry run because EPAR must read back the exact prewarmed template identity. Use its admission and template checks instead. +Docker Sandboxes intentionally does not support dry-run instance creation because EPAR must read back the exact active template-cache identity. Use its admission and template checks instead. ## Target the right runner diff --git a/internal/config/config.go b/internal/config/config.go index c529d41..061e48a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -156,12 +156,11 @@ type DockerConfig struct { NoProxy string } -// DockerSandboxesConfig configures the docker-sandboxes provider. Template, -// TemplateDigest, and PolicyGeneration deliberately have no defaults: a sandbox must -// be created from an explicitly pinned template and policy generation. +// DockerSandboxesConfig configures host/runtime behavior for the +// docker-sandboxes provider. The desired source belongs to ImageConfig, while +// the exact imported template identity is stored in EPAR's local artifact +// receipt rather than user configuration. type DockerSandboxesConfig struct { - Template string - TemplateDigest string PolicyGeneration string NetworkBaseline string AdditionalAllow []string @@ -172,7 +171,6 @@ type DockerSandboxesConfig struct { RootDisk string DockerDisk string MaxConcurrentCreates int - MinHostFreeSpace string } const ( @@ -196,9 +194,10 @@ func DockerSandboxesOpenDefaultDenyResources() []string { } const ( - DockerSandboxesMinimumRootDiskBytes int64 = 20 << 30 - DockerSandboxesMinimumDockerDiskBytes int64 = 100 << 30 - DockerSandboxesMinimumHostFreeSpaceBytes int64 = 50 << 30 + DockerSandboxesAutomaticRootDisk = "auto" + DockerSandboxesMinimumRootDiskBytes int64 = 20 << 30 + DockerSandboxesMinimumDockerDiskBytes int64 = 1 << 30 + DockerSandboxesDefaultDockerDisk = "50GiB" ) type TimeoutConfig struct { @@ -240,7 +239,7 @@ func Default() Config { ReplacementRetryJitterPercent: 20, }, Storage: StorageConfig{ - MinimumFree: "20GiB", + MinimumFree: "1GiB", GracePeriod: "168h", KeepPrevious: 0, AutomaticHousekeeping: StorageHousekeepingConservative, @@ -291,6 +290,8 @@ func Default() Config { StagingRoot: ".local/docker-sandboxes-staging", CPUs: 4, Memory: "8GiB", + RootDisk: DockerSandboxesAutomaticRootDisk, + DockerDisk: DockerSandboxesDefaultDockerDisk, MaxConcurrentCreates: 2, }, Timeouts: TimeoutConfig{ @@ -699,9 +700,9 @@ func apply(cfg *Config, section, key, value string) error { case "dockerSandboxes": switch key { case "template": - cfg.DockerSandboxes.Template = value + return fmt.Errorf("dockerSandboxes.template is no longer supported; remove generated template identities and rerun ./start to provision a Docker Sandboxes runner template") case "templateDigest": - cfg.DockerSandboxes.TemplateDigest = value + return fmt.Errorf("dockerSandboxes.templateDigest is no longer supported; remove generated template identities and rerun ./start to provision a Docker Sandboxes runner template") case "policyGeneration": cfg.DockerSandboxes.PolicyGeneration = value case "networkBaseline": @@ -716,7 +717,7 @@ func apply(cfg *Config, section, key, value string) error { return fmt.Errorf("invalid dockerSandboxes.cpus: %w", err) } cfg.DockerSandboxes.CPUs = v - case "memory", "rootDisk", "dockerDisk", "minHostFreeSpace": + case "memory", "rootDisk", "dockerDisk": switch key { case "memory": cfg.DockerSandboxes.Memory = value @@ -724,9 +725,9 @@ func apply(cfg *Config, section, key, value string) error { cfg.DockerSandboxes.RootDisk = value case "dockerDisk": cfg.DockerSandboxes.DockerDisk = value - case "minHostFreeSpace": - cfg.DockerSandboxes.MinHostFreeSpace = value } + case "minHostFreeSpace": + return fmt.Errorf("dockerSandboxes.minHostFreeSpace is no longer supported; remove it and configure the provider-neutral storage.minimumFree value instead") case "maxConcurrentCreates": v, err := strconv.Atoi(value) if err != nil { @@ -831,14 +832,24 @@ func applyProviderDefaults(cfg *Config, explicit map[string]bool) { cfg.Pool.NamePrefix = "epar-docker-container" } case "docker-sandboxes": - // Provider.SourceImage belongs to image-building providers. Do not carry the - // Tart default into the sandbox provider, where the template is explicit. if !explicit["provider.sourceImage"] { cfg.Provider.SourceImage = "" } if !explicit["provider.platform"] { cfg.Provider.Platform = "linux/amd64" } + if !explicit["image.sourceType"] { + cfg.Image.SourceType = ImageSourceDockerImage + } + if !explicit["image.sourceImage"] { + cfg.Image.SourceImage = "ghcr.io/catthehacker/ubuntu:full-latest" + } + if !explicit["image.sourcePlatform"] { + cfg.Image.SourcePlatform = cfg.Provider.Platform + } + if !explicit["image.outputImage"] { + cfg.Image.OutputImage = "" + } if !explicit["runner.labels"] { architecture := "X64" if cfg.Provider.Platform == "linux/arm64" { @@ -1078,11 +1089,20 @@ func Validate(cfg Config) error { } if cfg.Provider.Type == "docker-sandboxes" { if cfg.Provider.SourceImage != "" { - return fmt.Errorf("provider.sourceImage is not supported with provider.type=docker-sandboxes; use dockerSandboxes.template and dockerSandboxes.templateDigest") + return fmt.Errorf("provider.sourceImage is not supported with provider.type=docker-sandboxes; use image.sourceImage") } if cfg.Provider.Platform != "linux/amd64" && cfg.Provider.Platform != "linux/arm64" { return fmt.Errorf("provider.platform must be linux/amd64 or linux/arm64 with provider.type=docker-sandboxes") } + if cfg.Image.SourceType != "" && cfg.Image.SourceType != ImageSourceDockerImage { + return fmt.Errorf("image.sourceType must be docker-image with provider.type=docker-sandboxes") + } + if cfg.Image.SourceType != "" && !validDockerSandboxesSourceImage(cfg.Image.SourceImage) { + return fmt.Errorf("image.sourceImage with provider.type=docker-sandboxes must be an exact ghcr.io/catthehacker/ubuntu: reference") + } + if cfg.Image.SourcePlatform != "" && cfg.Image.SourcePlatform != cfg.Provider.Platform { + return fmt.Errorf("image.sourcePlatform must match provider.platform with provider.type=docker-sandboxes") + } if !cfg.Runner.Ephemeral { return fmt.Errorf("runner.ephemeral must be true with provider.type=docker-sandboxes") } @@ -1219,12 +1239,6 @@ func ValidateStorage(storage StorageConfig) error { // callers constructing Config values programmatically receive the same checks as // YAML-loaded configuration. func ValidateDockerSandboxes(sandboxes DockerSandboxesConfig) error { - if err := validateDockerSandboxTemplate(sandboxes.Template); err != nil { - return err - } - if err := validateSHA256Fingerprint("dockerSandboxes.templateDigest", sandboxes.TemplateDigest); err != nil { - return err - } if err := validateSHA256Fingerprint("dockerSandboxes.policyGeneration", sandboxes.PolicyGeneration); err != nil { return err } @@ -1259,12 +1273,10 @@ func ValidateDockerSandboxes(sandboxes DockerSandboxesConfig) error { if sandboxes.CPUs <= 0 { return fmt.Errorf("dockerSandboxes.cpus must be greater than zero") } - parsedSizes := make(map[string]int64, 4) + parsedSizes := make(map[string]int64, 2) for key, value := range map[string]string{ - "memory": sandboxes.Memory, - "rootDisk": sandboxes.RootDisk, - "dockerDisk": sandboxes.DockerDisk, - "minHostFreeSpace": sandboxes.MinHostFreeSpace, + "memory": sandboxes.Memory, + "dockerDisk": sandboxes.DockerDisk, } { parsed, err := ParseByteSize(value) if err != nil { @@ -1272,14 +1284,17 @@ func ValidateDockerSandboxes(sandboxes DockerSandboxesConfig) error { } parsedSizes[key] = parsed } - if parsedSizes["rootDisk"] < DockerSandboxesMinimumRootDiskBytes { - return fmt.Errorf("dockerSandboxes.rootDisk must be at least 20GiB") + if sandboxes.RootDisk != DockerSandboxesAutomaticRootDisk { + rootDisk, err := ParseByteSize(sandboxes.RootDisk) + if err != nil { + return fmt.Errorf("invalid dockerSandboxes.rootDisk: %w", err) + } + if rootDisk < DockerSandboxesMinimumRootDiskBytes { + return fmt.Errorf("dockerSandboxes.rootDisk must be auto or at least 20GiB") + } } if parsedSizes["dockerDisk"] < DockerSandboxesMinimumDockerDiskBytes { - return fmt.Errorf("dockerSandboxes.dockerDisk must be at least 100GiB") - } - if parsedSizes["minHostFreeSpace"] < DockerSandboxesMinimumHostFreeSpaceBytes { - return fmt.Errorf("dockerSandboxes.minHostFreeSpace must be at least 50GiB") + return fmt.Errorf("dockerSandboxes.dockerDisk must be at least 1GiB") } if sandboxes.MaxConcurrentCreates <= 0 { return fmt.Errorf("dockerSandboxes.maxConcurrentCreates must be greater than zero") @@ -1287,6 +1302,25 @@ func ValidateDockerSandboxes(sandboxes DockerSandboxesConfig) error { return nil } +func validDockerSandboxesSourceImage(value string) bool { + const prefix = "ghcr.io/catthehacker/ubuntu:" + if !strings.HasPrefix(value, prefix) { + return false + } + tag := strings.TrimPrefix(value, prefix) + if tag == "" || len(tag) > 128 || strings.ContainsAny(tag, "/@\t\r\n ") { + return false + } + for _, character := range tag { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '_' || character == '.' || character == '-' { + continue + } + return false + } + first := tag[0] + return (first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || (first >= '0' && first <= '9') || first == '_' +} + func validateDockerSandboxesStagingRoot(value string) error { if value == "" || value != strings.TrimSpace(value) || strings.ContainsAny(value, "\x00\r\n:") { return fmt.Errorf("dockerSandboxes.stagingRoot must be a canonical project-relative path under .local") @@ -1308,24 +1342,6 @@ func validateDockerSandboxesStagingRoot(value string) error { return nil } -func validateDockerSandboxTemplate(template string) error { - if template == "" || template != strings.TrimSpace(template) { - return fmt.Errorf("dockerSandboxes.template is required") - } - if strings.ContainsAny(template, "@\\") || strings.ContainsAny(template, "\t\r\n") || strings.Contains(template, "..") || strings.Contains(template, "//") { - return fmt.Errorf("dockerSandboxes.template must be a non-empty immutable template identity") - } - for i, r := range template { - if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' || r == '/' || r == ':') { - return fmt.Errorf("dockerSandboxes.template contains invalid character at position %d", i) - } - } - if strings.HasPrefix(template, ".") || strings.HasPrefix(template, "-") || strings.HasPrefix(template, "/") || strings.HasSuffix(template, ".") || strings.HasSuffix(template, ":") || strings.HasSuffix(template, "/") { - return fmt.Errorf("dockerSandboxes.template must be a non-empty immutable template identity") - } - return nil -} - func validateSHA256Fingerprint(key, value string) error { const prefix = "sha256:" if len(value) != len(prefix)+64 || !strings.HasPrefix(value, prefix) { @@ -1430,10 +1446,8 @@ func ParseByteSize(value string) (int64, error) { return 0, fmt.Errorf("must be a positive byte size such as 4GiB") } -// EffectiveMinimumFreeBytes returns the provider-neutral reserve, raised to a -// provider's stricter configured reserve when one exists. Docker Sandboxes -// keeps its established 50 GiB admission watermark while all providers share -// the storage.minimumFree contract. +// EffectiveMinimumFreeBytes returns the single provider-neutral free-space +// reserve used by every storage-consuming provider operation. func EffectiveMinimumFreeBytes(cfg Config) (uint64, error) { value := cfg.Storage.MinimumFree if value == "" { @@ -1443,15 +1457,6 @@ func EffectiveMinimumFreeBytes(cfg Config) (uint64, error) { if err != nil { return 0, fmt.Errorf("parse storage minimum free reserve: %w", err) } - if cfg.Provider.Type == "docker-sandboxes" && cfg.DockerSandboxes.MinHostFreeSpace != "" { - providerMinimum, err := ParseByteSize(cfg.DockerSandboxes.MinHostFreeSpace) - if err != nil { - return 0, fmt.Errorf("parse dockerSandboxes.minHostFreeSpace: %w", err) - } - if providerMinimum > minimum { - minimum = providerMinimum - } - } return uint64(minimum), nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e34838f..2daeeba 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -114,7 +114,7 @@ func TestRunnerRegistrationControlsDefaultToDisabled(t *testing.T) { func TestStorageDefaultsAreBoundedAndConservative(t *testing.T) { storage := Default().Storage - if storage.MinimumFree != "20GiB" || + if storage.MinimumFree != "1GiB" || storage.GracePeriod != "168h" || storage.KeepPrevious != 0 || storage.AutomaticHousekeeping != StorageHousekeepingConservative || @@ -1227,12 +1227,9 @@ func TestDockerSandboxesRejectsNamePrefixOutsideProviderGrammar(t *testing.T) { cfg.Pool.NamePrefix = "EPAR_sandbox" cfg.Runner.Ephemeral = true cfg.Security.RunnerGroup.Enforcement = RunnerGroupEnforcementEnforce - cfg.DockerSandboxes.Template = "epar-template:v1" - cfg.DockerSandboxes.TemplateDigest = "sha256:" + strings.Repeat("a", 64) cfg.DockerSandboxes.PolicyGeneration = "sha256:" + strings.Repeat("b", 64) cfg.DockerSandboxes.RootDisk = "120GiB" - cfg.DockerSandboxes.DockerDisk = "100GiB" - cfg.DockerSandboxes.MinHostFreeSpace = "50GiB" + cfg.DockerSandboxes.DockerDisk = "50GiB" if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "lowercase") { t.Fatalf("Validate() error = %v, want Docker Sandboxes prefix grammar rejection", err) } @@ -1245,14 +1242,16 @@ func TestLoadDockerSandboxesConfig(t *testing.T) { if err := os.WriteFile(path, []byte(` provider: type: docker-sandboxes +image: + sourceType: docker-image + sourceImage: ghcr.io/catthehacker/ubuntu:full-latest + sourcePlatform: linux/amd64 runner: ephemeral: true security: runnerGroup: enforcement: enforce dockerSandboxes: - template: registry.example.invalid/epar/runner-template:preview - templateDigest: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef policyGeneration: sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 networkBaseline: balanced additionalAllow: [api.github.com, '*.githubusercontent.com:443'] @@ -1261,10 +1260,9 @@ dockerSandboxes: stagingRoot: .local/docker-sandboxes cpus: 2 memory: 4GiB - rootDisk: 20GiB - dockerDisk: 100GiB + rootDisk: auto + dockerDisk: 50GiB maxConcurrentCreates: 1 - minHostFreeSpace: 50GiB `), 0644); err != nil { t.Fatal(err) } @@ -1295,6 +1293,18 @@ dockerSandboxes: } } +func TestLoadDockerSandboxesRejectsRemovedHostReserve(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "docker-sandboxes.yml") + if err := os.WriteFile(path, []byte("provider:\n type: docker-sandboxes\ndockerSandboxes:\n minHostFreeSpace: 50GiB\n"), 0644); err != nil { + t.Fatal(err) + } + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "remove it and configure the provider-neutral storage.minimumFree value instead") { + t.Fatalf("Load() error = %v, want exact regeneration guidance", err) + } +} + func TestValidateDockerSandboxesRejectsInvalidPreviewConfiguration(t *testing.T) { tests := []struct { name string @@ -1316,14 +1326,6 @@ func TestValidateDockerSandboxesRejectsInvalidPreviewConfiguration(t *testing.T) name: "runner group enforcement is not fail closed", mutate: func(cfg *Config) { cfg.Security.RunnerGroup.Enforcement = RunnerGroupEnforcementWarn }, }, - { - name: "template is empty", - mutate: func(cfg *Config) { cfg.DockerSandboxes.Template = "" }, - }, - { - name: "template digest is not pinned", - mutate: func(cfg *Config) { cfg.DockerSandboxes.TemplateDigest = "sha256:ABCDEF" }, - }, { name: "policy generation is not content addressed", mutate: func(cfg *Config) { cfg.DockerSandboxes.PolicyGeneration = "verified-balanced-policy-fingerprint" }, @@ -1362,11 +1364,7 @@ func TestValidateDockerSandboxesRejectsInvalidPreviewConfiguration(t *testing.T) }, { name: "docker disk is below the hard minimum", - mutate: func(cfg *Config) { cfg.DockerSandboxes.DockerDisk = "99GiB" }, - }, - { - name: "host free space is below the hard minimum", - mutate: func(cfg *Config) { cfg.DockerSandboxes.MinHostFreeSpace = "49GiB" }, + mutate: func(cfg *Config) { cfg.DockerSandboxes.DockerDisk = "512MiB" }, }, { name: "concurrency is not positive", @@ -1473,17 +1471,16 @@ func TestParseByteSize(t *testing.T) { } } -func TestEffectiveMinimumFreeBytesUsesProviderReserve(t *testing.T) { +func TestEffectiveMinimumFreeBytesUsesCommonReserve(t *testing.T) { cfg := Default() cfg.Provider.Type = "docker-sandboxes" cfg.Storage.MinimumFree = "20GiB" - cfg.DockerSandboxes.MinHostFreeSpace = "50GiB" got, err := EffectiveMinimumFreeBytes(cfg) if err != nil { t.Fatal(err) } - if want := uint64(50 << 30); got != want { + if want := uint64(20 << 30); got != want { t.Fatalf("EffectiveMinimumFreeBytes() = %d, want %d", got, want) } } @@ -1492,7 +1489,6 @@ func TestEffectiveMinimumFreeBytesKeepsStricterCommonReserve(t *testing.T) { cfg := Default() cfg.Provider.Type = "docker-sandboxes" cfg.Storage.MinimumFree = "60GiB" - cfg.DockerSandboxes.MinHostFreeSpace = "50GiB" got, err := EffectiveMinimumFreeBytes(cfg) if err != nil { @@ -1510,12 +1506,9 @@ func validDockerSandboxesConfig() Config { cfg.Provider.Platform = "linux/amd64" cfg.Runner.Ephemeral = true cfg.Security.RunnerGroup.Enforcement = RunnerGroupEnforcementEnforce - cfg.DockerSandboxes.Template = "registry.example.invalid/epar/runner-template:preview" - cfg.DockerSandboxes.TemplateDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" cfg.DockerSandboxes.PolicyGeneration = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" cfg.DockerSandboxes.AdditionalAllow = []string{"api.github.com"} cfg.DockerSandboxes.RootDisk = "120GiB" - cfg.DockerSandboxes.DockerDisk = "100GiB" - cfg.DockerSandboxes.MinHostFreeSpace = "50GiB" + cfg.DockerSandboxes.DockerDisk = "50GiB" return cfg } diff --git a/internal/image/artifact_lifecycle.go b/internal/image/artifact_lifecycle.go index 17c6bb8..ee48cd7 100644 --- a/internal/image/artifact_lifecycle.go +++ b/internal/image/artifact_lifecycle.go @@ -41,8 +41,8 @@ func hostTrustMetadata(snapshot hosttrust.Snapshot) *HostTrustMetadata { } func (m *Coordinator) EnsureImage(ctx context.Context) error { - if err := m.preflightStorage("image-pull", imagePullExpansionBytes); err != nil { - return err + if m.Config.Provider.Type == "docker-sandboxes" { + return m.ensureDockerSandboxesTemplate(ctx, false) } if artifactManager, ok := m.Lifecycle.(provider.ArtifactManager); ok { handled, err := artifactManager.EnsureArtifacts(ctx, m.DryRun) diff --git a/internal/image/build.go b/internal/image/build.go index 1738868..c7ba20e 100644 --- a/internal/image/build.go +++ b/internal/image/build.go @@ -74,12 +74,19 @@ func (m *Coordinator) UpdateUpstream(ctx context.Context) error { } func (m *Coordinator) BuildImage(ctx context.Context, opts ImageBuildOptions) error { - if err := m.preflightStorage("image-build", imageBuildExpansionBytes); err != nil { - return err + if m.Config.Provider.Type == "docker-sandboxes" { + return m.ensureDockerSandboxesTemplate(ctx, true) } if err := m.validateTrustedCACertificates(); err != nil { return err } + plan, err := m.configuredArtifactStoragePlan(ctx, false) + if err != nil { + return err + } + if err := m.preflightStorage("image-build", plan.EstimatedIncrementalPeak); err != nil { + return err + } upstreamDir := config.ProjectPath(m.ProjectRoot, m.Config.Image.UpstreamDir) copyMode := m.runnerImagesCopyMode() if !opts.SkipUpstreamCheck && copyMode != runnerImagesCopyNone { @@ -118,7 +125,7 @@ func (m *Coordinator) buildDockerContainerImageUntimed(ctx context.Context, opts return fmt.Errorf("docker image %s already exists; rerun with --replace", m.Config.Image.OutputImage) } } - builder, err := m.ensureBuildxBuilder(ctx) + builder, err := m.ensureBuildxBuilder(ctx, []string{m.Config.Image.SourceImage, "docker.io/docker/dockerfile:1"}) if err != nil { return err } diff --git a/internal/image/buildx.go b/internal/image/buildx.go index 2223afe..be16675 100644 --- a/internal/image/buildx.go +++ b/internal/image/buildx.go @@ -6,26 +6,37 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" "runtime" + "sort" + "strconv" "strings" "time" "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/filelock" + "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" ) -const buildxMetadataSchemaVersion = 1 +const buildxMetadataSchemaVersion = 2 type BuildxMetadata struct { - SchemaVersion int `json:"schemaVersion"` - Builder string `json:"builder"` - Driver string `json:"driver"` - ProjectRoot string `json:"projectRoot"` - CacheLimit string `json:"cacheLimit"` - ConfigPath string `json:"configPath"` - CreatedAt time.Time `json:"createdAt"` + SchemaVersion int `json:"schemaVersion"` + Builder string `json:"builder"` + Driver string `json:"driver"` + ProjectRoot string `json:"projectRoot"` + CacheLimit string `json:"cacheLimit"` + ConfigPath string `json:"configPath"` + ConfigSHA256 string `json:"configSha256,omitempty"` + TrustGeneration string `json:"trustGeneration,omitempty"` + CertificateBundle string `json:"certificateBundle,omitempty"` + CertificateSHA256 string `json:"certificateSha256,omitempty"` + RegistryHosts []string `json:"registryHosts,omitempty"` + CreatedAt time.Time `json:"createdAt"` + LastReconciledAt time.Time `json:"lastReconciledAt,omitempty"` } func BuildxMetadataPath(projectRoot string) string { @@ -41,7 +52,7 @@ func LoadBuildxMetadata(projectRoot string) (BuildxMetadata, error) { if err := json.Unmarshal(content, &metadata); err != nil { return BuildxMetadata{}, err } - if metadata.SchemaVersion != buildxMetadataSchemaVersion || metadata.Builder == "" || metadata.Driver != "docker-container" || metadata.ProjectRoot == "" || metadata.ConfigPath == "" { + if (metadata.SchemaVersion != 1 && metadata.SchemaVersion != buildxMetadataSchemaVersion) || metadata.Builder == "" || metadata.Driver != "docker-container" || metadata.ProjectRoot == "" || metadata.ConfigPath == "" { return BuildxMetadata{}, fmt.Errorf("invalid EPAR Buildx ownership metadata") } return metadata, nil @@ -56,7 +67,7 @@ func buildxBuilderName(projectRoot string) string { return "epar-" + hex.EncodeToString(sum[:6]) } -func (m *Coordinator) ensureBuildxBuilder(ctx context.Context) (string, error) { +func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReferences []string) (string, error) { builder := buildxBuilderName(m.ProjectRoot) cacheLimit := strings.TrimSpace(m.Config.Storage.BuildCacheLimit) if cacheLimit == "" { @@ -66,19 +77,65 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context) (string, error) { if err != nil { return "", fmt.Errorf("parse storage.buildCacheLimit: %w", err) } - configPath := filepath.Join(m.ProjectRoot, ".local", "storage", "buildkitd.toml") - expected := BuildxMetadata{ - SchemaVersion: buildxMetadataSchemaVersion, - Builder: builder, - Driver: "docker-container", - ProjectRoot: filepath.Clean(m.ProjectRoot), - CacheLimit: cacheLimit, - ConfigPath: configPath, + registryHosts, err := buildRegistryHosts(registryReferences) + if err != nil { + return "", err } if m.DryRun { - m.infof("[dry-run] ensure EPAR-owned Buildx builder %s with cache limit %s\n", builder, cacheLimit) + m.infof("[dry-run] ensure EPAR-owned Buildx builder %s with cache limit %s for registries %s\n", builder, cacheLimit, strings.Join(registryHosts, ", ")) return builder, nil } + trust, err := m.resolveBuildTrust(ctx) + if err != nil { + return "", err + } + if len(trust.Certificates) == 0 { + return "", fmt.Errorf("operational BuildKit trust resolved no system CA certificates") + } + bundle, err := buildTrustBundle(trust) + if err != nil { + return "", err + } + bundleSHA := sha256.Sum256(bundle) + certificatePath := filepath.Join(m.ProjectRoot, ".local", "storage", "buildkit-certs", trust.Generation, "ca.pem") + configPath := filepath.Join(m.ProjectRoot, ".local", "storage", "buildkitd.toml") + configContent := buildkitConfig(uint64(limitBytes), trust.Generation, certificatePath, registryHosts) + configSHA := sha256.Sum256(configContent) + expected := BuildxMetadata{ + SchemaVersion: buildxMetadataSchemaVersion, + Builder: builder, + Driver: "docker-container", + ProjectRoot: filepath.Clean(m.ProjectRoot), + CacheLimit: cacheLimit, + ConfigPath: configPath, + ConfigSHA256: hex.EncodeToString(configSHA[:]), + TrustGeneration: trust.Generation, + CertificateBundle: certificatePath, + CertificateSHA256: hex.EncodeToString(bundleSHA[:]), + RegistryHosts: registryHosts, + } + storageDirectory := filepath.Join(m.ProjectRoot, ".local", "storage") + if err := validateRegularParent(storageDirectory, m.ProjectRoot); err != nil { + return "", fmt.Errorf("validate EPAR storage directory: %w", err) + } + reconcileLock, err := filelock.Acquire(filepath.Join(storageDirectory, "buildx.lock")) + if err != nil { + if errors.Is(err, filelock.ErrLocked) { + return "", fmt.Errorf("another EPAR process is reconciling the project Buildx builder") + } + return "", err + } + defer reconcileLock.Close() + + if err := validateRegularParent(filepath.Dir(certificatePath), storageDirectory); err != nil { + return "", fmt.Errorf("validate BuildKit certificate generation directory: %w", err) + } + if err := writeAtomicFile(certificatePath, bundle, 0o600); err != nil { + return "", fmt.Errorf("write operational BuildKit CA bundle: %w", err) + } + if err := writeAtomicFile(configPath, configContent, 0o600); err != nil { + return "", fmt.Errorf("write EPAR BuildKit configuration: %w", err) + } metadata, metadataErr := LoadBuildxMetadata(m.ProjectRoot) _, inspectErr := m.runHostOutput(ctx, "docker", "buildx", "inspect", builder) @@ -86,53 +143,197 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context) (string, error) { if metadataErr != nil { return "", fmt.Errorf("Buildx builder %q already exists without valid EPAR ownership metadata; refusing to adopt it: %w", builder, metadataErr) } + if !buildxOwnershipMatches(metadata, expected) { + return "", fmt.Errorf("Buildx builder %q ownership metadata does not match this project; refusing to remove or adopt it", builder) + } if !buildxMetadataMatches(metadata, expected) { - return "", fmt.Errorf("Buildx builder %q ownership metadata does not match this project and cache policy", builder) + m.infof("upgrading EPAR-owned Buildx builder %s for operational trust generation %s while preserving its cache state\n", builder, trust.Generation) + if err := m.runHost(ctx, "docker", "buildx", "rm", "--keep-state", "--force", builder); err != nil { + return "", fmt.Errorf("remove outdated EPAR Buildx builder %q while retaining state: %w", builder, err) + } + inspectErr = fmt.Errorf("builder removed for owned upgrade") } - if err := m.runHost(ctx, "docker", "buildx", "inspect", "--bootstrap", builder); err != nil { - return "", fmt.Errorf("bootstrap EPAR Buildx builder %q: %w", builder, err) + } else if metadataErr == nil && !buildxOwnershipMatches(metadata, expected) { + return "", fmt.Errorf("EPAR Buildx metadata does not match this project; refusing to reuse it") + } + + if inspectErr != nil { + if err := m.runHost(ctx, "docker", "buildx", "create", "--name", builder, "--driver", "docker-container", "--buildkitd-config", configPath); err != nil { + return "", fmt.Errorf("create EPAR Buildx builder %q: %w", builder, err) } - return builder, nil } - if metadataErr == nil && !buildxMetadataMatches(metadata, expected) { - return "", fmt.Errorf("EPAR Buildx metadata does not match this project and cache policy") + if err := m.runHost(ctx, "docker", "buildx", "inspect", "--bootstrap", builder); err != nil { + return "", fmt.Errorf("bootstrap EPAR Buildx builder %q: %w", builder, err) } - if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + if err := m.verifyBuildxConfiguration(ctx, builder, expected); err != nil { return "", err } - configContent := fmt.Sprintf("[worker.oci]\n gc = true\n gckeepstorage = %d\n", uint64(limitBytes)) - if err := writeAtomicFile(configPath, []byte(configContent), 0644); err != nil { - return "", fmt.Errorf("write EPAR BuildKit configuration: %w", err) - } - if metadataErr != nil { - expected.CreatedAt = time.Now().UTC() - content, err := json.MarshalIndent(expected, "", " ") - if err != nil { - return "", err - } - content = append(content, '\n') - if err := writeAtomicFile(BuildxMetadataPath(m.ProjectRoot), content, 0644); err != nil { - return "", fmt.Errorf("write EPAR Buildx ownership metadata: %w", err) - } + now := time.Now().UTC() + if metadataErr == nil && !metadata.CreatedAt.IsZero() { + expected.CreatedAt = metadata.CreatedAt + } else { + expected.CreatedAt = now } - if err := m.runHost(ctx, "docker", "buildx", "create", "--name", builder, "--driver", "docker-container", "--buildkitd-config", configPath); err != nil { - return "", fmt.Errorf("create EPAR Buildx builder %q: %w", builder, err) + expected.LastReconciledAt = now + content, err := json.MarshalIndent(expected, "", " ") + if err != nil { + return "", err } - if err := m.runHost(ctx, "docker", "buildx", "inspect", "--bootstrap", builder); err != nil { - return "", fmt.Errorf("bootstrap EPAR Buildx builder %q: %w", builder, err) + if err := writeAtomicFile(BuildxMetadataPath(m.ProjectRoot), append(content, '\n'), 0o600); err != nil { + return "", fmt.Errorf("publish EPAR Buildx ownership metadata: %w", err) } return builder, nil } -func buildxMetadataMatches(actual, expected BuildxMetadata) bool { - return actual.SchemaVersion == expected.SchemaVersion && - actual.Builder == expected.Builder && +func buildxOwnershipMatches(actual, expected BuildxMetadata) bool { + return actual.Builder == expected.Builder && actual.Driver == expected.Driver && filepath.Clean(actual.ProjectRoot) == expected.ProjectRoot && - actual.CacheLimit == expected.CacheLimit && filepath.Clean(actual.ConfigPath) == expected.ConfigPath } +func buildxMetadataMatches(actual, expected BuildxMetadata) bool { + return actual.SchemaVersion == expected.SchemaVersion && + buildxOwnershipMatches(actual, expected) && + actual.CacheLimit == expected.CacheLimit && + actual.ConfigSHA256 == expected.ConfigSHA256 && + actual.TrustGeneration == expected.TrustGeneration && + filepath.Clean(actual.CertificateBundle) == expected.CertificateBundle && + actual.CertificateSHA256 == expected.CertificateSHA256 && + sameOrderedStrings(actual.RegistryHosts, expected.RegistryHosts) +} + +func buildRegistryHosts(references []string) ([]string, error) { + seen := make(map[string]struct{}) + for _, reference := range references { + reference = strings.TrimSpace(reference) + if reference == "" { + continue + } + parts := strings.SplitN(reference, "/", 2) + first := parts[0] + host := "docker.io" + if len(parts) == 2 && (strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost") { + host = strings.ToLower(first) + } + if host == "index.docker.io" || host == "registry-1.docker.io" { + host = "docker.io" + } + if strings.ContainsAny(host, "\"'[] \t\r\n") { + return nil, fmt.Errorf("invalid registry host derived from %q", reference) + } + seen[host] = struct{}{} + } + hosts := make([]string, 0, len(seen)) + for host := range seen { + hosts = append(hosts, host) + } + sort.Strings(hosts) + return hosts, nil +} + +func buildTrustBundle(snapshot hosttrust.Snapshot) ([]byte, error) { + canonical, err := hosttrust.Canonicalize(snapshot) + if err != nil { + return nil, err + } + var bundle bytes.Buffer + for _, certificate := range canonical.Certificates { + bundle.Write(certificate.PEM) + } + return bundle.Bytes(), nil +} + +func buildkitConfig(cacheLimit uint64, generation, certificatePath string, registryHosts []string) []byte { + var content strings.Builder + fmt.Fprintf(&content, "# epar-build-trust-generation=%s\n", generation) + content.WriteString("[worker.oci]\n") + content.WriteString(" gc = true\n") + fmt.Fprintf(&content, " gckeepstorage = %d\n", cacheLimit) + certificatePath = strings.ReplaceAll(filepath.Clean(certificatePath), `\`, "/") + for _, host := range registryHosts { + fmt.Fprintf(&content, "\n[registry.%s]\n", strconv.Quote(host)) + fmt.Fprintf(&content, " ca = [%s]\n", strconv.Quote(certificatePath)) + } + return []byte(content.String()) +} + +func (m *Coordinator) verifyBuildxConfiguration(ctx context.Context, builder string, expected BuildxMetadata) error { + container := buildxControlContainer(builder) + content, err := m.runHostOutput(ctx, "docker", "exec", container, "cat", "/etc/buildkit/buildkitd.toml") + if err != nil { + return fmt.Errorf("verify EPAR Buildx builder %q configuration readback: %w", builder, err) + } + for _, host := range expected.RegistryHosts { + doubleQuoted := "[registry." + strconv.Quote(host) + "]" + singleQuoted := "[registry.'" + host + "']" + if !strings.Contains(content, doubleQuoted) && !strings.Contains(content, singleQuoted) { + return fmt.Errorf("EPAR Buildx builder %q did not install registry trust for %s", builder, host) + } + installedBundle, err := m.runHostOutput(ctx, "docker", "exec", container, "cat", "/etc/buildkit/certs/"+host+"/ca.pem") + if err != nil { + return fmt.Errorf("verify EPAR Buildx builder %q CA readback for %s: %w", builder, host, err) + } + sum := sha256.Sum256([]byte(installedBundle)) + if hex.EncodeToString(sum[:]) != expected.CertificateSHA256 { + return fmt.Errorf("EPAR Buildx builder %q CA readback digest for %s does not match trust generation %s", builder, host, expected.TrustGeneration) + } + } + return nil +} + +func buildxControlContainer(builder string) string { + return "buildx_buildkit_" + builder + "0" +} + +func validateRegularParent(path, allowedRoot string) error { + absoluteRoot, err := filepath.Abs(allowedRoot) + if err != nil { + return err + } + absolutePath, err := filepath.Abs(path) + if err != nil { + return err + } + relative, err := filepath.Rel(absoluteRoot, absolutePath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fmt.Errorf("%s is outside %s", absolutePath, absoluteRoot) + } + current := absoluteRoot + for _, part := range strings.Split(relative, string(filepath.Separator)) { + if part == "" || part == "." { + continue + } + current = filepath.Join(current, part) + info, statErr := os.Lstat(current) + if os.IsNotExist(statErr) { + if err := os.Mkdir(current, 0o700); err != nil { + return err + } + continue + } + if statErr != nil { + return statErr + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%s is not a real directory", current) + } + } + return nil +} + +func sameOrderedStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + func writeAtomicFile(path string, content []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err diff --git a/internal/image/buildx_test.go b/internal/image/buildx_test.go index a69dc68..5ba0122 100644 --- a/internal/image/buildx_test.go +++ b/internal/image/buildx_test.go @@ -63,3 +63,67 @@ func TestLoadBuildxMetadataRequiresExactOwnershipFields(t *testing.T) { t.Fatal("LoadBuildxMetadata accepted a shared Docker driver") } } + +func TestBuildRegistryHostsUsesDockerHubForUnqualifiedImages(t *testing.T) { + got, err := buildRegistryHosts([]string{ + "source:latest", + "docker.io/docker/dockerfile:1", + "ghcr.io/catthehacker/ubuntu@sha256:abc", + }) + if err != nil { + t.Fatal(err) + } + want := []string{"docker.io", "ghcr.io"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("registry hosts = %v, want %v", got, want) + } +} + +func TestBuildkitConfigIsDeterministicAndEscapesPaths(t *testing.T) { + first := buildkitConfig(64<<30, "generation", `C:\repo path\.local\ca.pem`, []string{"docker.io", "ghcr.io"}) + second := buildkitConfig(64<<30, "generation", `C:\repo path\.local\ca.pem`, []string{"docker.io", "ghcr.io"}) + if string(first) != string(second) { + t.Fatal("BuildKit configuration is not deterministic") + } + text := string(first) + for _, want := range []string{ + "# epar-build-trust-generation=generation", + `[registry."docker.io"]`, + `[registry."ghcr.io"]`, + `"C:/repo path/.local/ca.pem"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("BuildKit configuration omitted %q:\n%s", want, text) + } + } +} + +func TestBuildxSchemaOneMetadataIsOwnedButRequiresUpgrade(t *testing.T) { + root := t.TempDir() + expected := BuildxMetadata{ + SchemaVersion: buildxMetadataSchemaVersion, + Builder: buildxBuilderName(root), + Driver: "docker-container", + ProjectRoot: root, + CacheLimit: "64GiB", + ConfigPath: filepath.Join(root, ".local", "storage", "buildkitd.toml"), + ConfigSHA256: strings.Repeat("a", 64), + TrustGeneration: strings.Repeat("b", 64), + CertificateBundle: filepath.Join(root, ".local", "storage", "buildkit-certs", "g", "ca.pem"), + CertificateSHA256: strings.Repeat("c", 64), + RegistryHosts: []string{"docker.io"}, + } + legacy := expected + legacy.SchemaVersion = 1 + legacy.ConfigSHA256 = "" + legacy.TrustGeneration = "" + legacy.CertificateBundle = "" + legacy.CertificateSHA256 = "" + legacy.RegistryHosts = nil + if !buildxOwnershipMatches(legacy, expected) { + t.Fatal("schema-one EPAR metadata lost exact ownership") + } + if buildxMetadataMatches(legacy, expected) { + t.Fatal("schema-one EPAR metadata did not require an owned builder upgrade") + } +} diff --git a/internal/image/coordinator.go b/internal/image/coordinator.go index afeab23..b243d01 100644 --- a/internal/image/coordinator.go +++ b/internal/image/coordinator.go @@ -2,6 +2,7 @@ package image import ( "context" + "fmt" "io" "time" @@ -13,8 +14,6 @@ import ( ) const ( - imagePullExpansionBytes = 20 * storage.GiB - imageBuildExpansionBytes = 30 * storage.GiB sourceUpdateExpansionBytes = 5 * storage.GiB hostTrustGuestDir = "/usr/local/share/ca-certificates/epar-host" hostTrustMarkerGuest = "/opt/epar/host-trust-generation.json" @@ -32,10 +31,12 @@ type Environment interface { RunHostLogged(ctx context.Context, logPath, name string, args ...string) error RunHost(ctx context.Context, name string, args ...string) error RunHostOutput(ctx context.Context, name string, args ...string) (string, error) + RunHostOutputTo(ctx context.Context, output io.Writer, name string, args ...string) error RunHostQuiet(ctx context.Context, name string, args ...string) error TimeStartupStage(stage string, fn func() error) error HostTrustEnabled() bool ResolveHostTrust(ctx context.Context) (hosttrust.Snapshot, error) + ResolveBuildTrust(ctx context.Context) (hosttrust.Snapshot, error) WriteHostTrustBuildInputs(buildContext string, snapshot hosttrust.Snapshot) error ValidateRuntime(ctx context.Context, instance string) error ExecGuest(ctx context.Context, instance string, command []string, opts provider.ExecOptions) (provider.ExecResult, error) @@ -101,6 +102,10 @@ func (m *Coordinator) runHostOutput(ctx context.Context, name string, args ...st return m.environment.RunHostOutput(ctx, name, args...) } +func (m *Coordinator) runHostOutputTo(ctx context.Context, output io.Writer, name string, args ...string) error { + return m.environment.RunHostOutputTo(ctx, output, name, args...) +} + func (m *Coordinator) runHostQuiet(ctx context.Context, name string, args ...string) error { return m.environment.RunHostQuiet(ctx, name, args...) } @@ -117,6 +122,25 @@ func (m *Coordinator) resolveHostTrust(ctx context.Context) (hosttrust.Snapshot, return m.environment.ResolveHostTrust(ctx) } +func (m *Coordinator) resolveBuildTrust(ctx context.Context) (hosttrust.Snapshot, error) { + snapshot, err := m.environment.ResolveBuildTrust(ctx) + if err != nil { + return hosttrust.Snapshot{}, err + } + explicit, err := m.trustedCACertificates() + if err != nil { + return hosttrust.Snapshot{}, err + } + for _, certificate := range explicit { + parsed, err := hosttrust.CertificatesFromBytes(certificate.PEM) + if err != nil { + return hosttrust.Snapshot{}, fmt.Errorf("canonicalize explicit build CA %s: %w", certificate.DestinationName, err) + } + snapshot.Certificates = append(snapshot.Certificates, parsed...) + } + return hosttrust.Canonicalize(snapshot) +} + func (m *Coordinator) writeHostTrustBuildInputs(buildContext string, snapshot hosttrust.Snapshot) error { return m.environment.WriteHostTrustBuildInputs(buildContext, snapshot) } diff --git a/internal/image/docker_sandboxes.go b/internal/image/docker_sandboxes.go new file mode 100644 index 0000000..fb00607 --- /dev/null +++ b/internal/image/docker_sandboxes.go @@ -0,0 +1,1525 @@ +package image + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +const ( + dockerSandboxesReceiptSchema = 2 + dockerSandboxesMetadataSchema = 4 +) + +var dockerTagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`) + +type ResolvedDockerSource struct { + Reference string `json:"reference"` + ImmutableReference string `json:"immutableReference"` + IndexDigest string `json:"indexDigest"` + PlatformDigest string `json:"platformDigest"` + Platform string `json:"platform"` + CompressedLayerBytes uint64 `json:"compressedLayerBytes"` +} + +type dockerManifestDescriptor struct { + Digest string `json:"digest"` + Size uint64 `json:"size"` + Platform struct { + OS string `json:"os"` + Architecture string `json:"architecture"` + } `json:"platform"` +} + +type dockerManifestDocument struct { + MediaType string `json:"mediaType"` + Manifests []dockerManifestDescriptor `json:"manifests"` + Layers []dockerManifestDescriptor `json:"layers"` +} + +type dockerSandboxesReceipt struct { + SchemaVersion int `json:"schemaVersion"` + ManifestHash string `json:"manifestHash"` + Manifest Manifest `json:"manifest"` + Source ResolvedDockerSource `json:"source"` + Artifact provider.TemplateArtifact `json:"artifact"` + MetadataPath string `json:"metadataPath"` + MetadataSHA256 string `json:"metadataSha256"` + ArchivePath string `json:"archivePath"` + ArchiveSHA256 string `json:"archiveSha256"` + ActivatedAt time.Time `json:"activatedAt"` +} + +type dockerSandboxesSourceLock struct { + SchemaVersion int `json:"schemaVersion"` + DockerfileFrontend struct { + Reference string `json:"reference"` + } `json:"dockerfileFrontend"` + SBOMGenerator struct { + InspectionReference string `json:"inspectionReference"` + } `json:"sbomGenerator"` + GoBuilder struct { + Version string `json:"version"` + IndexDigest string `json:"indexDigest"` + } `json:"goBuilder"` + HookLauncher struct { + SHA256 string `json:"sha256"` + } `json:"hookLauncher"` + ActionsRunner struct { + Version string `json:"version"` + } `json:"actionsRunner"` + Tini struct { + Version string `json:"version"` + } `json:"tini"` + Platforms map[string]struct { + GoBuilderReference string `json:"goBuilderReference"` + GoBuilderManifestDigest string `json:"goBuilderManifestDigest"` + SBOMGeneratorReference string `json:"sbomGeneratorReference"` + SBOMGeneratorManifestDigest string `json:"sbomGeneratorManifestDigest"` + DockerfileFrontendManifestDigest string `json:"dockerfileFrontendManifestDigest"` + ActionsRunner struct { + URL string `json:"url"` + SHA256 string `json:"sha256"` + } `json:"actionsRunner"` + Tini struct { + URL string `json:"url"` + SHA256 string `json:"sha256"` + } `json:"tini"` + } `json:"platforms"` +} + +type dockerSandboxesBuildMetadata struct { + ImageDigest string `json:"containerimage.digest"` + Provenance json.RawMessage `json:"buildx.build.provenance"` + BuildRef string `json:"buildx.build.ref"` +} + +type buildxAttachmentDocument struct { + Manifests []struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Annotations map[string]string `json:"annotations"` + } `json:"manifests"` + Layers []struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size uint64 `json:"size"` + Annotations map[string]string `json:"annotations"` + } `json:"layers"` +} + +type buildxHistoryEntry struct { + Ref string `json:"ref"` + Status string `json:"status"` +} + +type buildxHistoryInspection struct { + Ref string `json:"Ref"` + Status string `json:"Status"` + BuildArgs []struct { + Name string `json:"Name"` + Value string `json:"Value"` + } `json:"BuildArgs"` + Attachments []struct { + Digest string `json:"Digest"` + Type string `json:"Type"` + } `json:"Attachments"` +} + +type dockerSandboxesTemplateMetadata struct { + SchemaVersion int `json:"schemaVersion"` + Profile string `json:"profile"` + Platform string `json:"platform"` + ManifestHash string `json:"manifestHash"` + Template struct { + Tag string `json:"tag"` + Digest string `json:"digest"` + CacheID string `json:"cacheID"` + RootDisk string `json:"rootDisk"` + Archive string `json:"archive"` + ArchiveSHA256 string `json:"archiveSha256"` + ArchiveBytes uint64 `json:"archiveBytes"` + } `json:"template"` + Source ResolvedDockerSource `json:"source"` + Compatibility struct { + TemplateSchemaVersion int `json:"templateSchemaVersion"` + RunnerExecution string `json:"runnerExecution"` + DockerDaemonOwner string `json:"dockerDaemonOwner"` + ExpectedDockerDaemonCount int `json:"expectedDockerDaemonCount"` + } `json:"compatibility"` + Artifacts map[string]artifactEvidence `json:"artifacts"` +} + +type artifactEvidence struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + SourceDigest string `json:"sourceDigest,omitempty"` +} + +// NormalizeCatthehackerSource accepts the user-facing family shorthand or one +// exact tag while keeping the supported source repository explicit. +func NormalizeCatthehackerSource(input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + value = "full" + } + switch value { + case "full", "act", "dotnet", "js": + value += "-latest" + } + const repository = "ghcr.io/catthehacker/ubuntu" + if strings.HasPrefix(value, repository+":") { + value = strings.TrimPrefix(value, repository+":") + } + if !dockerTagPattern.MatchString(value) || strings.Contains(value, "/") || strings.Contains(value, "@") { + return "", fmt.Errorf("Docker Sandboxes source must be a catthehacker/ubuntu tag such as full, act, dotnet, js, or go-24.04") + } + return repository + ":" + value, nil +} + +// ResolveCatthehackerSource resolves a mutable tag to the exact OCI index and +// platform manifest identities that will be recorded in the artifact receipt. +func ResolveCatthehackerSource(ctx context.Context, input, platform string) (ResolvedDockerSource, error) { + reference, err := NormalizeCatthehackerSource(input) + if err != nil { + return ResolvedDockerSource{}, err + } + indexRaw, err := exec.CommandContext(ctx, "docker", "buildx", "imagetools", "inspect", "--raw", reference).Output() + if err != nil { + return ResolvedDockerSource{}, fmt.Errorf("resolve source image %s: %w", reference, err) + } + var index dockerManifestDocument + if err := json.Unmarshal(indexRaw, &index); err != nil { + return ResolvedDockerSource{}, fmt.Errorf("parse source image index for %s: %w", reference, err) + } + parts := strings.Split(platform, "/") + var platformDigest string + if len(parts) == 2 { + for _, descriptor := range index.Manifests { + if descriptor.Platform.OS == parts[0] && descriptor.Platform.Architecture == parts[1] { + if platformDigest != "" { + return ResolvedDockerSource{}, fmt.Errorf("source image %s contains multiple %s manifests", reference, platform) + } + platformDigest = descriptor.Digest + } + } + } + if platformDigest == "" { + return ResolvedDockerSource{}, fmt.Errorf("source image %s does not provide %s", reference, platform) + } + tagSeparator := strings.LastIndex(reference, ":") + if tagSeparator < 0 { + return ResolvedDockerSource{}, fmt.Errorf("source image %s has no tag", reference) + } + platformRaw, err := exec.CommandContext(ctx, "docker", "buildx", "imagetools", "inspect", "--raw", reference[:tagSeparator]+"@"+platformDigest).Output() + if err != nil { + return ResolvedDockerSource{}, fmt.Errorf("resolve source image %s for %s: %w", reference, platform, err) + } + return parseResolvedDockerSource(reference, platform, indexRaw, platformRaw) +} + +func sourceProfile(reference string) string { + _, tag, found := strings.Cut(reference, ":") + if !found || tag == "" { + return "custom" + } + return strings.ToLower(tag) +} + +func parseResolvedDockerSource(reference, platform string, indexRaw, platformRaw []byte) (ResolvedDockerSource, error) { + indexRaw = trimManifestCommandNewline(indexRaw) + platformRaw = trimManifestCommandNewline(platformRaw) + var index dockerManifestDocument + if err := json.Unmarshal(indexRaw, &index); err != nil { + return ResolvedDockerSource{}, fmt.Errorf("parse source image manifest index: %w", err) + } + parts := strings.Split(platform, "/") + if len(parts) != 2 || parts[0] != "linux" || (parts[1] != "amd64" && parts[1] != "arm64") { + return ResolvedDockerSource{}, fmt.Errorf("unsupported Docker Sandboxes source platform %q", platform) + } + var matching []dockerManifestDescriptor + for _, descriptor := range index.Manifests { + if descriptor.Platform.OS == parts[0] && descriptor.Platform.Architecture == parts[1] { + matching = append(matching, descriptor) + } + } + if len(matching) != 1 { + return ResolvedDockerSource{}, fmt.Errorf("source image %s must contain exactly one %s manifest; found %d", reference, platform, len(matching)) + } + indexSum := sha256.Sum256(indexRaw) + indexDigest := "sha256:" + hex.EncodeToString(indexSum[:]) + if !validSHA256(matching[0].Digest) { + return ResolvedDockerSource{}, fmt.Errorf("source image %s omitted a valid %s manifest digest", reference, platform) + } + var manifest dockerManifestDocument + if err := json.Unmarshal(platformRaw, &manifest); err != nil { + return ResolvedDockerSource{}, fmt.Errorf("parse source image %s manifest: %w", platform, err) + } + var compressed uint64 + for _, layer := range manifest.Layers { + if layer.Size > ^uint64(0)-compressed { + return ResolvedDockerSource{}, fmt.Errorf("source image compressed layer size overflows") + } + compressed += layer.Size + } + tagSeparator := strings.LastIndex(reference, ":") + if tagSeparator < 0 { + return ResolvedDockerSource{}, fmt.Errorf("source image %s has no tag", reference) + } + return ResolvedDockerSource{ + Reference: reference, + ImmutableReference: reference[:tagSeparator] + "@" + indexDigest, + IndexDigest: indexDigest, + PlatformDigest: matching[0].Digest, + Platform: platform, + CompressedLayerBytes: compressed, + }, nil +} + +func trimManifestCommandNewline(content []byte) []byte { + if len(content) != 0 && content[len(content)-1] == '\n' { + content = content[:len(content)-1] + if len(content) != 0 && content[len(content)-1] == '\r' { + content = content[:len(content)-1] + } + } + return content +} + +func (m *Coordinator) resolveDockerSandboxesSource(ctx context.Context) (ResolvedDockerSource, error) { + reference := strings.TrimSpace(m.Config.Image.SourceImage) + if m.Config.Provider.Type == "docker-sandboxes" { + var err error + reference, err = NormalizeCatthehackerSource(reference) + if err != nil { + return ResolvedDockerSource{}, err + } + } else if reference == "" { + return ResolvedDockerSource{}, errors.New("image.sourceImage is required") + } + platform := strings.TrimSpace(m.Config.Image.SourcePlatform) + if platform == "" { + platform = strings.TrimSpace(m.Config.Provider.Platform) + } + indexRaw, err := m.runHostOutput(ctx, "docker", "buildx", "imagetools", "inspect", "--raw", reference) + if err != nil { + return ResolvedDockerSource{}, fmt.Errorf("resolve source image %s: %w", reference, err) + } + var index dockerManifestDocument + if err := json.Unmarshal([]byte(indexRaw), &index); err != nil { + return ResolvedDockerSource{}, fmt.Errorf("parse source image index for %s: %w", reference, err) + } + parts := strings.Split(platform, "/") + var platformDigest string + if len(parts) == 2 { + for _, descriptor := range index.Manifests { + if descriptor.Platform.OS == parts[0] && descriptor.Platform.Architecture == parts[1] { + if platformDigest != "" { + return ResolvedDockerSource{}, fmt.Errorf("source image %s contains multiple %s manifests", reference, platform) + } + platformDigest = descriptor.Digest + } + } + } + if platformDigest == "" { + return ResolvedDockerSource{}, fmt.Errorf("source image %s does not provide %s", reference, platform) + } + tagSeparator := strings.LastIndex(reference, ":") + if tagSeparator <= strings.LastIndex(reference, "/") { + return ResolvedDockerSource{}, fmt.Errorf("source image %s must include a tag", reference) + } + repository := reference[:tagSeparator] + platformRaw, err := m.runHostOutput(ctx, "docker", "buildx", "imagetools", "inspect", "--raw", repository+"@"+platformDigest) + if err != nil { + return ResolvedDockerSource{}, fmt.Errorf("resolve source platform manifest %s: %w", platform, err) + } + return parseResolvedDockerSource(reference, platform, []byte(indexRaw), []byte(platformRaw)) +} + +func (m *Coordinator) dockerSandboxesDesiredManifest(ctx context.Context) (Manifest, ResolvedDockerSource, error) { + source, err := m.resolveDockerSandboxesSource(ctx) + if err != nil { + return Manifest{}, ResolvedDockerSource{}, err + } + lock, err := loadDockerSandboxesSourceLock(m.ProjectRoot, source.Platform) + if err != nil { + return Manifest{}, ResolvedDockerSource{}, err + } + configuredRunnerVersion := strings.TrimSpace(m.Config.Image.RunnerVersion) + if configuredRunnerVersion != "" && configuredRunnerVersion != "latest" && configuredRunnerVersion != lock.ActionsRunner.Version { + return Manifest{}, ResolvedDockerSource{}, fmt.Errorf("Docker Sandboxes build inputs pin Actions runner %s; image.runnerVersion %q is not available", lock.ActionsRunner.Version, configuredRunnerVersion) + } + snapshot, err := m.resolveHostTrust(ctx) + if err != nil { + return Manifest{}, ResolvedDockerSource{}, err + } + customScripts, err := m.customInstallScriptDigests() + if err != nil { + return Manifest{}, ResolvedDockerSource{}, err + } + trustedCertificates, err := m.trustedCACertificateDigests() + if err != nil { + return Manifest{}, ResolvedDockerSource{}, err + } + templateInputs, err := fileDigestsRecursive(filepath.Join(m.ProjectRoot, "templates", "docker-sandboxes")) + if err != nil { + return Manifest{}, ResolvedDockerSource{}, err + } + manifest := Manifest{ + SchemaVersion: ManifestSchemaVersion, + ProviderType: "docker-sandboxes", + ProviderPlatform: source.Platform, + SourceType: config.ImageSourceDockerImage, + SourceImage: source.Reference, + SourcePlatform: source.Platform, + SourceDigest: source.IndexDigest, + SourcePlatformDigest: source.PlatformDigest, + OutputImage: "docker-sandboxes-template", + RunnerVersion: lock.ActionsRunner.Version, + TemplateInputs: templateInputs, + CustomInstallScripts: customScripts, + TrustedCACertificates: trustedCertificates, + HostTrust: hostTrustMetadata(snapshot), + } + return manifest, source, nil +} + +func fileDigestsRecursive(root string) ([]FileDigest, error) { + var result []FileDigest + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("template input %s is not a regular file", path) + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + sum := sha256.Sum256(content) + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + result = append(result, FileDigest{Path: filepath.ToSlash(relative), SHA256: hex.EncodeToString(sum[:])}) + return nil + }) + sort.Slice(result, func(i, j int) bool { return result[i].Path < result[j].Path }) + return result, err +} + +func DockerSandboxesReceiptPath(projectRoot string) string { + return filepath.Join(projectRoot, ".local", "state", "image", "docker-sandboxes", "active.json") +} + +func LoadDockerSandboxesReceipt(projectRoot string) (provider.TemplateArtifact, string, time.Time, error) { + receipt, err := readDockerSandboxesReceipt(projectRoot) + if err != nil { + return provider.TemplateArtifact{}, "", time.Time{}, err + } + return receipt.Artifact, receipt.MetadataSHA256, receipt.ActivatedAt, nil +} + +func readDockerSandboxesReceipt(projectRoot string) (dockerSandboxesReceipt, error) { + content, err := os.ReadFile(DockerSandboxesReceiptPath(projectRoot)) + if err != nil { + return dockerSandboxesReceipt{}, err + } + var receipt dockerSandboxesReceipt + if err := json.Unmarshal(content, &receipt); err != nil { + return dockerSandboxesReceipt{}, err + } + if receipt.SchemaVersion != dockerSandboxesReceiptSchema || receipt.ManifestHash == "" || receipt.Artifact.Reference == "" || receipt.Artifact.Digest == "" || receipt.Artifact.RootDisk == "" || receipt.MetadataSHA256 == "" || receipt.ArchiveSHA256 == "" || receipt.ActivatedAt.IsZero() { + return dockerSandboxesReceipt{}, fmt.Errorf("invalid Docker Sandboxes active artifact receipt") + } + return receipt, nil +} + +func (m *Coordinator) ensureDockerSandboxesTemplate(ctx context.Context, force bool) error { + runtime, ok := m.Lifecycle.(provider.TemplateArtifactRuntime) + if !ok { + return fmt.Errorf("docker-sandboxes provider is missing required template artifact integration") + } + manifest, source, err := m.dockerSandboxesDesiredManifest(ctx) + if err != nil { + return err + } + manifestHash, err := ManifestHash(manifest) + if err != nil { + return err + } + rootDisk, err := m.effectiveDockerSandboxesRootDisk(source) + if err != nil { + return err + } + if !force { + receipt, receiptErr := readDockerSandboxesReceipt(m.ProjectRoot) + if receiptErr == nil && receipt.ManifestHash == manifestHash && receipt.Artifact.RootDisk == rootDisk { + if err := runtime.VerifyTemplate(ctx, receipt.Artifact); err != nil { + return fmt.Errorf("configured Docker Sandboxes artifact is not available exactly as recorded: %w", err) + } + if err := runtime.ActivateTemplate(receipt.Artifact); err != nil { + return err + } + m.infof("Docker Sandboxes runner template is current: %s@%s\n", receipt.Artifact.Reference, receipt.Artifact.Digest) + return nil + } + if receiptErr != nil && !errors.Is(receiptErr, os.ErrNotExist) { + return fmt.Errorf("read Docker Sandboxes active artifact receipt: %w", receiptErr) + } + } + if m.DryRun { + m.infof("[dry-run] would build and import Docker Sandboxes runner template from %s for %s\n", source.Reference, source.Platform) + return nil + } + return m.buildDockerSandboxesTemplate(ctx, manifest, source, manifestHash, rootDisk, runtime) +} + +func estimatedDockerSandboxesExpansion(source ResolvedDockerSource) uint64 { + estimate, err := EstimateSourceSize(source.CompressedLayerBytes, 0) + if err != nil { + return ^uint64(0) + } + dockerDisk, _ := config.ParseByteSize(config.DockerSandboxesDefaultDockerDisk) + plan, err := PlanArtifactStorage("docker-sandboxes", estimate, false, uint64(dockerDisk)) + if err != nil { + return ^uint64(0) + } + return plan.EstimatedIncrementalPeak +} + +func (m *Coordinator) effectiveDockerSandboxesRootDisk(source ResolvedDockerSource) (string, error) { + estimate, err := EstimateSourceSize(source.CompressedLayerBytes, 0) + if err != nil { + return "", err + } + required, err := AutomaticDockerSandboxesRootBytes(estimate.ExpandedBytes) + if err != nil { + return "", err + } + if m.Config.DockerSandboxes.RootDisk != config.DockerSandboxesAutomaticRootDisk { + configured, err := config.ParseByteSize(m.Config.DockerSandboxes.RootDisk) + if err != nil { + return "", err + } + if uint64(configured) < required { + return "", fmt.Errorf("dockerSandboxes.rootDisk %s is too small for %s; use rootDisk: auto or at least %dGiB", m.Config.DockerSandboxes.RootDisk, source.Reference, required/storage.GiB) + } + return m.Config.DockerSandboxes.RootDisk, nil + } + return fmt.Sprintf("%dGiB", required/storage.GiB), nil +} + +func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest Manifest, source ResolvedDockerSource, manifestHash, rootDisk string, runtime provider.TemplateArtifactRuntime) error { + if err := m.preflightStorage("template-build", estimatedDockerSandboxesExpansion(source)); err != nil { + return err + } + if err := m.verifyDockerSandboxesNativeBuilder(ctx, source.Platform); err != nil { + return err + } + lock, err := loadDockerSandboxesSourceLock(m.ProjectRoot, source.Platform) + if err != nil { + return err + } + profile := sourceProfile(source.Reference) + architecture := strings.TrimPrefix(source.Platform, "linux/") + tagProfile := sanitizeTemplateTag(profile) + templateTag := fmt.Sprintf("epar-docker-sandboxes-catthehacker-%s:%s-%s", tagProfile, manifestHash[:16], architecture) + artifactRoot := filepath.Join(m.ProjectRoot, "work", "template-builds", "docker-sandboxes", manifestHash) + if err := os.MkdirAll(artifactRoot, 0o755); err != nil { + return err + } + platformLock := lock.Platforms[source.Platform] + builder, err := m.ensureBuildxBuilder(ctx, []string{ + source.ImmutableReference, + lock.DockerfileFrontend.Reference, + platformLock.GoBuilderReference, + platformLock.SBOMGeneratorReference, + }) + if err != nil { + return err + } + buildTrust, err := m.resolveBuildTrust(ctx) + if err != nil { + return err + } + downloadClient, err := buildTrustHTTPClient(buildTrust) + if err != nil { + return err + } + inputRoot := filepath.Join(artifactRoot, "inputs") + actionsRunnerPath := filepath.Join(inputRoot, "actions-runner.tar.gz") + tiniPath := filepath.Join(inputRoot, "tini") + if err := verifiedDownload(ctx, downloadClient, platformLock.ActionsRunner.URL, actionsRunnerPath, platformLock.ActionsRunner.SHA256, 0o600); err != nil { + return fmt.Errorf("acquire locked Actions runner: %w", err) + } + if err := verifiedDownload(ctx, downloadClient, platformLock.Tini.URL, tiniPath, platformLock.Tini.SHA256, 0o700); err != nil { + return fmt.Errorf("acquire locked tini: %w", err) + } + buildMetadataPath := filepath.Join(artifactRoot, "build-metadata.json") + provenancePath := filepath.Join(artifactRoot, "provenance.json") + sbomPath := filepath.Join(artifactRoot, "sbom.intoto.json") + inventoryPath := filepath.Join(artifactRoot, "software-inventory.txt") + compatibilityEvidencePath := filepath.Join(artifactRoot, "compatibility.json") + archivePath := filepath.Join(artifactRoot, "runner-template.tar") + metadataPath := filepath.Join(artifactRoot, "template-metadata.json") + resumed, err := m.resumeDockerSandboxesTemplate(ctx, manifest, source, manifestHash, rootDisk, artifactRoot, metadataPath, archivePath, runtime) + if err != nil { + return err + } + if resumed { + return nil + } + buildMetadata, localDigest, recoveredBuild, err := m.recoverDockerSandboxesBuild(ctx, builder, templateTag, source, manifestHash, architecture, lock, compatibilityEvidencePath) + if err != nil { + return err + } + if recoveredBuild { + m.infof("reusing completed exact Docker Sandboxes Buildx result %s\n", localDigest) + if err := writeJSONFile(buildMetadataPath, buildMetadata); err != nil { + return err + } + if err := writeAtomicFile(provenancePath, append(buildMetadata.Provenance, '\n'), 0o644); err != nil { + return err + } + } else { + contextRoot, err := os.MkdirTemp(filepath.Join(m.ProjectRoot, ".local"), "docker-sandboxes-context-") + if err != nil { + return err + } + defer os.RemoveAll(contextRoot) + if err := copyDirectory(filepath.Join(m.ProjectRoot, "templates", "docker-sandboxes"), contextRoot); err != nil { + return err + } + compatibilityPath := filepath.Join(contextRoot, "profiles", "generated.compatibility.json") + compatibility := map[string]any{ + "schemaVersion": 2, + "templateSchemaVersion": 1, + "profile": profile, + "platform": source.Platform, + "runnerExecution": "direct-actions-listener", + "dockerDaemonOwner": "docker-sandboxes-runtime", + "expectedDockerDaemonCount": 1, + } + if err := writeJSONFile(compatibilityPath, compatibility); err != nil { + return err + } + if err := copyFile(compatibilityPath, compatibilityEvidencePath, 0o644); err != nil { + return err + } + if err := m.prepareDockerSandboxesCustomScripts(contextRoot); err != nil { + return err + } + runnerTrust, err := m.resolveHostTrust(ctx) + if err != nil { + return err + } + if err := m.prepareDockerSandboxesTrustPolicy(contextRoot, runnerTrust); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(contextRoot, "inputs"), 0o755); err != nil { + return err + } + if err := copyFile(actionsRunnerPath, filepath.Join(contextRoot, "inputs", "actions-runner.tar.gz"), 0o600); err != nil { + return err + } + if err := copyFile(tiniPath, filepath.Join(contextRoot, "inputs", "tini"), 0o755); err != nil { + return err + } + for _, path := range []string{buildMetadataPath, provenancePath, sbomPath, inventoryPath, archivePath, metadataPath} { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + args := []string{ + "buildx", "build", "--builder", builder, "--platform", source.Platform, "--pull", "--progress", "plain", "--load", + "--provenance", "mode=max", "--sbom", "generator=" + platformLock.SBOMGeneratorReference, + "--metadata-file", buildMetadataPath, "--tag", templateTag, + } + for _, buildArg := range []string{ + "TEMPLATE_PLATFORM=" + source.Platform, + "SOURCE_IMAGE=" + source.ImmutableReference, + "GO_BUILDER_IMAGE=" + platformLock.GoBuilderReference, + "HOOK_LAUNCHER_SHA256=" + lock.HookLauncher.SHA256, + "SOURCE_PROFILE=" + profile, + "SOURCE_INDEX_DIGEST=" + source.IndexDigest, + "SOURCE_MANIFEST_DIGEST=" + source.PlatformDigest, + "SOURCE_REVISION=" + source.IndexDigest, + "TEMPLATE_VERSION=" + manifestHash[:16] + "-" + architecture, + "COMPATIBILITY_FILE=generated.compatibility.json", + "ACTIONS_RUNNER_SHA256=sha256:" + strings.TrimPrefix(platformLock.ActionsRunner.SHA256, "sha256:"), + "TINI_SHA256=sha256:" + strings.TrimPrefix(platformLock.Tini.SHA256, "sha256:"), + } { + args = append(args, "--build-arg", buildArg) + } + args = append(args, "--file", filepath.Join(contextRoot, "Dockerfile"), contextRoot) + buildLogPath := m.buildLogPath("docker-sandboxes-" + manifestHash[:16] + ".docker-build.log") + defer m.releaseTranscript(buildLogPath) + if err := resetLogs(buildLogPath); err != nil { + return err + } + m.infof("building Docker Sandboxes runner template from %s for %s\n", source.Reference, source.Platform) + m.infof("full Docker Sandboxes Buildx progress: %s\n", buildLogPath) + if err := m.runHostLogged(ctx, buildLogPath, "docker", args...); err != nil { + return fmt.Errorf("build Docker Sandboxes runner template: %w%s", err, boundedRedactedLogTail(buildLogPath, 32*1024)) + } + if err := readJSONFile(buildMetadataPath, &buildMetadata); err != nil { + return fmt.Errorf("read Docker Sandboxes Buildx metadata: %w", err) + } + localDigest, err = m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", templateTag) + if err != nil { + return err + } + localDigest = strings.TrimSpace(localDigest) + if !validSHA256(localDigest) || buildMetadata.ImageDigest != localDigest { + return fmt.Errorf("Docker Sandboxes template Buildx digest does not match the full local Docker image identity") + } + if len(buildMetadata.Provenance) == 0 || string(buildMetadata.Provenance) == "null" { + return fmt.Errorf("Docker Sandboxes template Buildx metadata omitted max-mode provenance") + } + if err := writeAtomicFile(provenancePath, append(buildMetadata.Provenance, '\n'), 0o644); err != nil { + return err + } + } + sbomSourceDigest, err := m.persistBuildxSBOM(ctx, builder, buildMetadata, sbomPath) + if err != nil { + return fmt.Errorf("persist Docker Sandboxes template SBOM attestation: %w", err) + } + inventory, err := m.runHostOutput(ctx, "docker", "run", "--rm", "--pull", "never", "--platform", source.Platform, "--entrypoint", "/opt/epar/collect-software-inventory.sh", templateTag) + if err != nil { + return fmt.Errorf("collect Docker Sandboxes template software inventory: %w", err) + } + if err := writeAtomicFile(inventoryPath, []byte(strings.TrimSpace(inventory)+"\n"), 0o644); err != nil { + return err + } + if err := m.runHost(ctx, "docker", "image", "save", "--output", archivePath, templateTag); err != nil { + return fmt.Errorf("save Docker Sandboxes template archive: %w", err) + } + archiveSHA, archiveBytes, err := hashFile(archivePath) + if err != nil { + return err + } + artifact := provider.TemplateArtifact{ + Reference: "docker.io/library/" + templateTag, + Digest: localDigest, + CacheID: strings.TrimPrefix(localDigest, "sha256:")[:12], + Platform: source.Platform, + RootDisk: rootDisk, + } + metadata := dockerSandboxesTemplateMetadata{ + SchemaVersion: dockerSandboxesMetadataSchema, + Profile: profile, + Platform: source.Platform, + ManifestHash: manifestHash, + Source: source, + Artifacts: make(map[string]artifactEvidence), + } + metadata.Template.Tag = artifact.Reference + metadata.Template.Digest = localDigest + metadata.Template.CacheID = artifact.CacheID + metadata.Template.RootDisk = artifact.RootDisk + metadata.Template.Archive = filepath.Base(archivePath) + metadata.Template.ArchiveSHA256 = archiveSHA + metadata.Template.ArchiveBytes = archiveBytes + metadata.Compatibility.TemplateSchemaVersion = 1 + metadata.Compatibility.RunnerExecution = "direct-actions-listener" + metadata.Compatibility.DockerDaemonOwner = "docker-sandboxes-runtime" + metadata.Compatibility.ExpectedDockerDaemonCount = 1 + for name, path := range map[string]string{ + "buildMetadata": buildMetadataPath, + "provenance": provenancePath, + "sbom": sbomPath, + "softwareInventory": inventoryPath, + "compatibility": compatibilityEvidencePath, + } { + digest, _, err := hashFile(path) + if err != nil { + return err + } + evidence := artifactEvidence{Path: filepath.Base(path), SHA256: digest} + if name == "sbom" { + evidence.SourceDigest = sbomSourceDigest + } + metadata.Artifacts[name] = evidence + } + if err := writeJSONFile(metadataPath, metadata); err != nil { + return err + } + metadataSHA, _, err := hashFile(metadataPath) + if err != nil { + return err + } + if err := runtime.VerifyTemplate(ctx, artifact); err != nil { + m.infof("importing verified Docker Sandboxes runner template %s\n", artifact.Reference) + if err := runtime.ImportTemplate(ctx, archivePath); err != nil { + return err + } + } + if err := runtime.VerifyTemplate(ctx, artifact); err != nil { + return fmt.Errorf("verify imported Docker Sandboxes runner template: %w", err) + } + return m.activateDockerSandboxesTemplate(manifest, source, manifestHash, artifact, metadataPath, metadataSHA, archivePath, archiveSHA, runtime) +} + +func (m *Coordinator) recoverDockerSandboxesBuild(ctx context.Context, builder, templateTag string, source ResolvedDockerSource, manifestHash, architecture string, lock dockerSandboxesSourceLock, compatibilityPath string) (dockerSandboxesBuildMetadata, string, bool, error) { + compatibilityInfo, err := os.Lstat(compatibilityPath) + if err != nil { + if os.IsNotExist(err) { + return dockerSandboxesBuildMetadata{}, "", false, nil + } + return dockerSandboxesBuildMetadata{}, "", false, err + } + if !compatibilityInfo.Mode().IsRegular() { + return dockerSandboxesBuildMetadata{}, "", false, fmt.Errorf("Docker Sandboxes compatibility evidence is not a regular file: %s", compatibilityPath) + } + localDigest, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", templateTag) + if err != nil { + return dockerSandboxesBuildMetadata{}, "", false, nil + } + localDigest = strings.TrimSpace(localDigest) + if !validSHA256(localDigest) { + return dockerSandboxesBuildMetadata{}, "", false, nil + } + history, err := m.runHostOutput(ctx, "docker", "buildx", "history", "ls", "--builder", builder, "--no-trunc", "--format", "json") + if err != nil { + m.warnf("could not inspect owned Buildx history for interrupted-build recovery; rebuilding: %v\n", err) + return dockerSandboxesBuildMetadata{}, "", false, nil + } + platformLock := lock.Platforms[source.Platform] + expectedArgs := map[string]string{ + "TEMPLATE_PLATFORM": source.Platform, + "SOURCE_IMAGE": source.ImmutableReference, + "GO_BUILDER_IMAGE": platformLock.GoBuilderReference, + "HOOK_LAUNCHER_SHA256": lock.HookLauncher.SHA256, + "SOURCE_PROFILE": sourceProfile(source.Reference), + "SOURCE_INDEX_DIGEST": source.IndexDigest, + "SOURCE_MANIFEST_DIGEST": source.PlatformDigest, + "SOURCE_REVISION": source.IndexDigest, + "TEMPLATE_VERSION": manifestHash[:16] + "-" + architecture, + "COMPATIBILITY_FILE": "generated.compatibility.json", + "ACTIONS_RUNNER_SHA256": "sha256:" + strings.TrimPrefix(platformLock.ActionsRunner.SHA256, "sha256:"), + "TINI_SHA256": "sha256:" + strings.TrimPrefix(platformLock.Tini.SHA256, "sha256:"), + } + for _, line := range strings.Split(strings.TrimSpace(history), "\n") { + var entry buildxHistoryEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil || entry.Status != "Completed" { + continue + } + buildRef := entry.Ref + if separator := strings.LastIndex(buildRef, "/"); separator >= 0 { + buildRef = buildRef[separator+1:] + } + if matched, _ := regexp.MatchString(`^[a-z0-9]{12,128}$`, buildRef); !matched { + continue + } + inspectionJSON, err := m.runHostOutput(ctx, "docker", "buildx", "history", "inspect", "--builder", builder, buildRef, "--format", "json") + if err != nil { + continue + } + var inspection buildxHistoryInspection + if err := json.Unmarshal([]byte(inspectionJSON), &inspection); err != nil || inspection.Status != "completed" { + continue + } + actualArgs := make(map[string]string, len(inspection.BuildArgs)) + for _, argument := range inspection.BuildArgs { + actualArgs[argument.Name] = argument.Value + } + matches := true + for name, expected := range expectedArgs { + if actualArgs[name] != expected { + matches = false + break + } + } + if !matches { + continue + } + exactImage := false + for _, attachment := range inspection.Attachments { + if attachment.Type == "application/vnd.oci.image.index.v1+json" && attachment.Digest == localDigest { + exactImage = true + break + } + } + if !exactImage { + continue + } + provenance, err := m.runHostOutput(ctx, "docker", "buildx", "history", "inspect", "attachment", "--builder", builder, "--type", "provenance", buildRef) + if err != nil || !json.Valid([]byte(provenance)) { + continue + } + return dockerSandboxesBuildMetadata{ + ImageDigest: localDigest, + Provenance: json.RawMessage(provenance), + BuildRef: entry.Ref, + }, localDigest, true, nil + } + return dockerSandboxesBuildMetadata{}, "", false, nil +} + +func (m *Coordinator) persistBuildxSBOM(ctx context.Context, builder string, metadata dockerSandboxesBuildMetadata, destination string) (string, error) { + buildRef := metadata.BuildRef + if separator := strings.LastIndex(buildRef, "/"); separator >= 0 { + buildRef = buildRef[separator+1:] + } + if matched, _ := regexp.MatchString(`^[a-z0-9]{12,128}$`, buildRef); !matched { + return "", fmt.Errorf("Buildx metadata contains invalid build reference %q", metadata.BuildRef) + } + if !validSHA256(metadata.ImageDigest) { + return "", fmt.Errorf("Buildx metadata contains invalid image digest %q", metadata.ImageDigest) + } + indexJSON, err := m.runHostOutput(ctx, "docker", "buildx", "history", "inspect", "attachment", "--builder", builder, buildRef, metadata.ImageDigest) + if err != nil { + return "", fmt.Errorf("inspect Buildx image-index attachment: %w", err) + } + attestationDigest, err := selectBuildxAttestationDigest([]byte(indexJSON)) + if err != nil { + return "", err + } + attestationJSON, err := m.runHostOutput(ctx, "docker", "buildx", "history", "inspect", "attachment", "--builder", builder, buildRef, attestationDigest) + if err != nil { + return "", fmt.Errorf("inspect Buildx attestation manifest: %w", err) + } + sbomDigest, sbomSize, err := selectBuildxSBOMDigest([]byte(attestationJSON)) + if err != nil { + return "", err + } + if sbomSize == 0 || sbomSize > storage.GiB { + return "", fmt.Errorf("Buildx SBOM attachment has invalid size %d bytes", sbomSize) + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return "", err + } + temporary, err := os.CreateTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".partial-") + if err != nil { + return "", err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return "", err + } + if err := m.streamLocalDockerContentBlob(ctx, builder, sbomDigest, temporary); err != nil { + _ = temporary.Close() + return "", fmt.Errorf("extract Buildx SBOM attachment: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return "", err + } + if err := temporary.Close(); err != nil { + return "", err + } + actualDigest, actualSize, err := hashFile(temporaryPath) + if err != nil { + return "", err + } + if actualDigest != sbomDigest || actualSize != sbomSize { + return "", fmt.Errorf("Buildx SBOM attachment readback is %s/%d bytes, expected %s/%d bytes", actualDigest, actualSize, sbomDigest, sbomSize) + } + if err := validateInTotoSPDX(temporaryPath); err != nil { + return "", fmt.Errorf("validate Buildx SBOM attachment: %w", err) + } + if err := os.Rename(temporaryPath, destination); err != nil { + return "", err + } + return sbomDigest, nil +} + +func (m *Coordinator) streamLocalDockerContentBlob(ctx context.Context, builder, digest string, destination *os.File) error { + if !validSHA256(digest) { + return fmt.Errorf("invalid Docker content digest %q", digest) + } + helperImage, err := m.runHostOutput(ctx, "docker", "inspect", "--format", "{{.Image}}", buildxControlContainer(builder)) + if err != nil { + return fmt.Errorf("inspect owned BuildKit control container: %w", err) + } + helperImage = strings.TrimSpace(helperImage) + if !validSHA256(helperImage) { + return fmt.Errorf("owned BuildKit control container reported invalid image identity %q", helperImage) + } + var failures []string + for _, source := range dockerContentBlobCandidates(digest) { + if _, err := destination.Seek(0, io.SeekStart); err != nil { + return err + } + if err := destination.Truncate(0); err != nil { + return err + } + mount := "type=bind,src=" + source + ",dst=/epar-build-attestation,readonly" + err := m.runHostOutputTo( + ctx, + destination, + "docker", + "run", + "--rm", + "--pull=never", + "--network=none", + "--entrypoint", + "cat", + "--mount", + mount, + helperImage, + "/epar-build-attestation", + ) + if err == nil { + return nil + } + failures = append(failures, err.Error()) + } + return fmt.Errorf("exact content blob %s was not readable from Docker's local containerd image store: %s", digest, strings.Join(failures, "; ")) +} + +func dockerContentBlobCandidates(digest string) []string { + hexDigest := strings.TrimPrefix(digest, "sha256:") + return []string{ + "/var/lib/desktop-containerd/daemon/io.containerd.content.v1.content/blobs/sha256/" + hexDigest, + "/var/lib/docker/containerd/daemon/io.containerd.content.v1.content/blobs/sha256/" + hexDigest, + } +} + +func selectBuildxAttestationDigest(content []byte) (string, error) { + var document buildxAttachmentDocument + if err := json.Unmarshal(content, &document); err != nil { + return "", fmt.Errorf("parse Buildx image-index attachment: %w", err) + } + var selected string + for _, manifest := range document.Manifests { + if manifest.Annotations["vnd.docker.reference.type"] != "attestation-manifest" { + continue + } + if !validSHA256(manifest.Digest) || selected != "" { + return "", fmt.Errorf("Buildx image index does not contain exactly one valid attestation manifest") + } + selected = manifest.Digest + } + if selected == "" { + return "", fmt.Errorf("Buildx image index omitted its attestation manifest") + } + return selected, nil +} + +func selectBuildxSBOMDigest(content []byte) (string, uint64, error) { + var document buildxAttachmentDocument + if err := json.Unmarshal(content, &document); err != nil { + return "", 0, fmt.Errorf("parse Buildx attestation manifest: %w", err) + } + var selected string + var size uint64 + for _, layer := range document.Layers { + if layer.MediaType != "application/vnd.in-toto+json" || layer.Annotations["in-toto.io/predicate-type"] != "https://spdx.dev/Document" { + continue + } + if !validSHA256(layer.Digest) || selected != "" { + return "", 0, fmt.Errorf("Buildx attestation manifest does not contain exactly one valid SPDX attachment") + } + selected = layer.Digest + size = layer.Size + } + if selected == "" { + return "", 0, fmt.Errorf("Buildx attestation manifest omitted its SPDX attachment") + } + return selected, size, nil +} + +func validateInTotoSPDX(path string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + decoder := json.NewDecoder(file) + open, err := decoder.Token() + if err != nil { + return err + } + if open != json.Delim('{') { + return fmt.Errorf("SBOM attachment is not an in-toto JSON object") + } + var statementType string + var predicateType string + var spdxID string + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return err + } + switch key { + case "_type": + if err := decoder.Decode(&statementType); err != nil { + return err + } + case "predicateType": + if err := decoder.Decode(&predicateType); err != nil { + return err + } + case "predicate": + spdxID, err = scanSPDXPredicate(decoder) + if err != nil { + return err + } + default: + if err := skipJSONValue(decoder); err != nil { + return err + } + } + } + if _, err := decoder.Token(); err != nil { + return err + } + if statementType != "https://in-toto.io/Statement/v0.1" && statementType != "https://in-toto.io/Statement/v1" { + return fmt.Errorf("unsupported in-toto statement type %q", statementType) + } + if predicateType != "https://spdx.dev/Document" { + return fmt.Errorf("unexpected SBOM predicate type %q", predicateType) + } + if spdxID != "SPDXRef-DOCUMENT" { + return fmt.Errorf("unexpected SPDX document identity %q", spdxID) + } + if token, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("SBOM attachment contains trailing JSON token %v", token) + } + return err + } + return nil +} + +func scanSPDXPredicate(decoder *json.Decoder) (string, error) { + open, err := decoder.Token() + if err != nil { + return "", err + } + if open != json.Delim('{') { + return "", fmt.Errorf("SPDX predicate is not a JSON object") + } + var spdxID string + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return "", err + } + if key == "SPDXID" { + if err := decoder.Decode(&spdxID); err != nil { + return "", err + } + continue + } + if err := skipJSONValue(decoder); err != nil { + return "", err + } + } + _, err = decoder.Token() + return spdxID, err +} + +func skipJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delim, compound := token.(json.Delim) + if !compound || (delim != '{' && delim != '[') { + return nil + } + for decoder.More() { + if delim == '{' { + if _, err := decoder.Token(); err != nil { + return err + } + } + if err := skipJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err +} + +func (m *Coordinator) verifyDockerSandboxesNativeBuilder(ctx context.Context, platform string) error { + reported, err := m.runHostOutput(ctx, "docker", "info", "--format", "{{.OSType}}/{{.Architecture}}") + if err != nil { + return fmt.Errorf("determine Docker server platform for Docker Sandboxes template build: %w", err) + } + native := strings.ToLower(strings.TrimSpace(reported)) + switch native { + case "linux/x86_64": + native = "linux/amd64" + case "linux/aarch64": + native = "linux/arm64" + } + if native != platform { + return fmt.Errorf("Docker Sandboxes template builds require a native %s Docker server; Docker reports %s and EPAR does not use emulation for this artifact", platform, native) + } + return nil +} + +func (m *Coordinator) resumeDockerSandboxesTemplate(ctx context.Context, manifest Manifest, source ResolvedDockerSource, manifestHash, rootDisk, artifactRoot, metadataPath, archivePath string, runtime provider.TemplateArtifactRuntime) (bool, error) { + metadata, artifact, metadataSHA, archiveSHA, valid, err := verifiedDockerSandboxesBuildArtifact(artifactRoot, metadataPath, archivePath, manifestHash, source) + if err != nil { + return false, err + } + if !valid { + return false, nil + } + if artifact.RootDisk != rootDisk { + return false, nil + } + localDigest, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", artifact.Reference) + if inspectErr != nil || strings.TrimSpace(localDigest) != artifact.Digest { + m.infof("restoring verified Docker Sandboxes runner template image from interrupted build evidence\n") + if err := m.runHost(ctx, "docker", "image", "load", "--input", archivePath); err != nil { + return false, fmt.Errorf("restore verified Docker Sandboxes template image: %w", err) + } + localDigest, err = m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", artifact.Reference) + if err != nil || strings.TrimSpace(localDigest) != artifact.Digest { + return false, fmt.Errorf("restored Docker Sandboxes template image does not match verified build evidence") + } + } + if err := runtime.VerifyTemplate(ctx, artifact); err != nil { + m.infof("resuming Docker Sandboxes template import from verified build evidence\n") + if err := runtime.ImportTemplate(ctx, archivePath); err != nil { + return false, err + } + } + if err := runtime.VerifyTemplate(ctx, artifact); err != nil { + return false, fmt.Errorf("verify resumed Docker Sandboxes runner template: %w", err) + } + if metadata.ManifestHash != manifestHash { + return false, fmt.Errorf("verified Docker Sandboxes build evidence changed during resume") + } + if err := m.activateDockerSandboxesTemplate(manifest, source, manifestHash, artifact, metadataPath, metadataSHA, archivePath, archiveSHA, runtime); err != nil { + return false, err + } + m.infof("resumed Docker Sandboxes runner template from verified interrupted build evidence\n") + return true, nil +} + +func verifiedDockerSandboxesBuildArtifact(artifactRoot, metadataPath, archivePath, manifestHash string, source ResolvedDockerSource) (dockerSandboxesTemplateMetadata, provider.TemplateArtifact, string, string, bool, error) { + var metadata dockerSandboxesTemplateMetadata + info, err := os.Lstat(metadataPath) + if errors.Is(err, os.ErrNotExist) { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + if err != nil { + return metadata, provider.TemplateArtifact{}, "", "", false, err + } + if !info.Mode().IsRegular() { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + if err := readJSONFile(metadataPath, &metadata); err != nil { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + if metadata.SchemaVersion != dockerSandboxesMetadataSchema || metadata.ManifestHash != manifestHash || metadata.Source != source || metadata.Platform != source.Platform || metadata.Profile != sourceProfile(source.Reference) { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + if !validSHA256(metadata.Template.Digest) || metadata.Template.CacheID != strings.TrimPrefix(metadata.Template.Digest, "sha256:")[:12] || metadata.Template.Tag == "" || metadata.Template.RootDisk == "" || metadata.Template.Archive != filepath.Base(archivePath) { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + if metadata.Compatibility.TemplateSchemaVersion != 1 || metadata.Compatibility.RunnerExecution != "direct-actions-listener" || metadata.Compatibility.DockerDaemonOwner != "docker-sandboxes-runtime" || metadata.Compatibility.ExpectedDockerDaemonCount != 1 { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + archiveInfo, err := os.Lstat(archivePath) + if err != nil || !archiveInfo.Mode().IsRegular() { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + archiveSHA, archiveBytes, err := hashFile(archivePath) + if err != nil { + return metadata, provider.TemplateArtifact{}, "", "", false, err + } + if archiveSHA != metadata.Template.ArchiveSHA256 || archiveBytes != metadata.Template.ArchiveBytes { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + requiredEvidence := []string{"buildMetadata", "provenance", "sbom", "softwareInventory", "compatibility"} + for _, name := range requiredEvidence { + evidence, found := metadata.Artifacts[name] + if !found || filepath.Base(evidence.Path) != evidence.Path || !validSHA256(evidence.SHA256) { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + evidencePath := filepath.Join(artifactRoot, evidence.Path) + evidenceInfo, err := os.Lstat(evidencePath) + if err != nil || !evidenceInfo.Mode().IsRegular() { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + digest, _, err := hashFile(evidencePath) + if err != nil { + return metadata, provider.TemplateArtifact{}, "", "", false, err + } + if digest != evidence.SHA256 { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + } + metadataSHA, _, err := hashFile(metadataPath) + if err != nil { + return metadata, provider.TemplateArtifact{}, "", "", false, err + } + return metadata, provider.TemplateArtifact{ + Reference: metadata.Template.Tag, + Digest: metadata.Template.Digest, + CacheID: metadata.Template.CacheID, + Platform: metadata.Platform, + RootDisk: metadata.Template.RootDisk, + }, metadataSHA, archiveSHA, true, nil +} + +func (m *Coordinator) activateDockerSandboxesTemplate(manifest Manifest, source ResolvedDockerSource, manifestHash string, artifact provider.TemplateArtifact, metadataPath, metadataSHA, archivePath, archiveSHA string, runtime provider.TemplateArtifactRuntime) error { + if err := runtime.ActivateTemplate(artifact); err != nil { + return err + } + receipt := dockerSandboxesReceipt{ + SchemaVersion: dockerSandboxesReceiptSchema, + ManifestHash: manifestHash, + Manifest: manifest, + Source: source, + Artifact: artifact, + MetadataPath: metadataPath, + MetadataSHA256: metadataSHA, + ArchivePath: archivePath, + ArchiveSHA256: archiveSHA, + ActivatedAt: time.Now().UTC(), + } + if err := writeJSONFile(DockerSandboxesReceiptPath(m.ProjectRoot), receipt); err != nil { + return err + } + m.infof("activated Docker Sandboxes runner template %s@%s\n", artifact.Reference, artifact.Digest) + return nil +} + +func loadDockerSandboxesSourceLock(projectRoot, platform string) (dockerSandboxesSourceLock, error) { + var lock dockerSandboxesSourceLock + path := filepath.Join(projectRoot, "templates", "docker-sandboxes", "sources.lock.json") + if err := readJSONFile(path, &lock); err != nil { + return lock, fmt.Errorf("read Docker Sandboxes source lock: %w", err) + } + if lock.SchemaVersion != 2 { + return lock, fmt.Errorf("unsupported Docker Sandboxes source lock schema %d", lock.SchemaVersion) + } + if lock.DockerfileFrontend.Reference == "" || lock.SBOMGenerator.InspectionReference == "" || lock.GoBuilder.Version == "" || lock.GoBuilder.IndexDigest == "" || lock.HookLauncher.SHA256 == "" || lock.ActionsRunner.Version == "" || lock.Tini.Version == "" { + return lock, errors.New("Docker Sandboxes source lock has incomplete shared build inputs") + } + platformLock, ok := lock.Platforms[platform] + if !ok || platformLock.GoBuilderReference == "" || platformLock.GoBuilderManifestDigest == "" || platformLock.SBOMGeneratorReference == "" || platformLock.SBOMGeneratorManifestDigest == "" || platformLock.DockerfileFrontendManifestDigest == "" || platformLock.ActionsRunner.URL == "" || platformLock.ActionsRunner.SHA256 == "" || platformLock.Tini.URL == "" || platformLock.Tini.SHA256 == "" { + return lock, fmt.Errorf("Docker Sandboxes source lock has incomplete build inputs for %s", platform) + } + return lock, nil +} + +func sanitizeTemplateTag(value string) string { + value = strings.ToLower(value) + var builder strings.Builder + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '.' || character == '_' || character == '-' { + builder.WriteRune(character) + } else { + builder.WriteByte('-') + } + } + result := strings.Trim(builder.String(), "-.") + if result == "" { + return "custom" + } + return result +} + +func (m *Coordinator) prepareDockerSandboxesCustomScripts(contextRoot string) error { + directory := filepath.Join(contextRoot, "custom-install") + if err := os.MkdirAll(directory, 0o755); err != nil { + return err + } + var runner strings.Builder + runner.WriteString("#!/usr/bin/env bash\nset -euo pipefail\n") + for index, configured := range m.Config.Image.CustomInstallScripts { + source, err := m.customInstallScriptHostPath(configured) + if err != nil { + return err + } + name := fmt.Sprintf("%03d-%s", index+1, guestScriptName(filepath.Base(source))) + if err := copyFile(source, filepath.Join(directory, name), 0o755); err != nil { + return err + } + fmt.Fprintf(&runner, "EPAR_CONTAINER_IMAGE_BUILD=true bash /opt/epar/custom-install/%s\n", name) + } + return writeAtomicFile(filepath.Join(directory, "run.sh"), []byte(runner.String()), 0o755) +} + +func (m *Coordinator) prepareDockerSandboxesTrustPolicy(contextRoot string, snapshot hosttrust.Snapshot) error { + hostDirectory := filepath.Join(contextRoot, "host-trust-certificates") + if err := copyHostTrustCertificatesToDir(hostDirectory, snapshot); err != nil { + return err + } + explicitDirectory := filepath.Join(contextRoot, "trusted-ca-certificates") + if err := m.copyTrustedCACertificatesToDir(explicitDirectory); err != nil { + return err + } + metadataDirectory := filepath.Join(contextRoot, "host-trust-metadata") + if err := os.MkdirAll(metadataDirectory, 0o755); err != nil { + return err + } + marker := struct { + SchemaVersion int `json:"schemaVersion"` + Generation string `json:"generation"` + HostOS string `json:"hostOS"` + Mode string `json:"mode"` + Scopes []string `json:"scopes"` + CertificateCount int `json:"certificateCount"` + }{ + SchemaVersion: 1, + Generation: "disabled", + Mode: hosttrust.ModeDisabled, + Scopes: []string{}, + } + if snapshot.Generation != "" { + marker.Generation = snapshot.Generation + marker.HostOS = snapshot.HostOS + marker.Mode = hosttrust.ModeOverlay + marker.Scopes = append([]string(nil), snapshot.Scopes...) + marker.CertificateCount = len(snapshot.Certificates) + } + content, err := json.MarshalIndent(marker, "", " ") + if err != nil { + return err + } + return writeAtomicFile(filepath.Join(metadataDirectory, filepath.Base(hostTrustMarkerGuest)), append(content, '\n'), 0o644) +} + +func copyHostTrustCertificatesToDir(destination string, snapshot hosttrust.Snapshot) error { + if err := os.MkdirAll(destination, 0o755); err != nil { + return err + } + for _, certificate := range snapshot.Certificates { + if err := writeAtomicFile(filepath.Join(destination, certificate.Name), certificate.PEM, 0o644); err != nil { + return err + } + } + return nil +} + +func boundedRedactedLogTail(path string, maximumBytes int64) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return "" + } + start := info.Size() - maximumBytes + if start < 0 { + start = 0 + } + if _, err := file.Seek(start, io.SeekStart); err != nil { + return "" + } + content, err := io.ReadAll(io.LimitReader(file, maximumBytes)) + if err != nil { + return "" + } + text := strings.TrimSpace(provider.RedactText(string(content))) + if text == "" { + return "" + } + if start > 0 { + text = "[earlier Buildx output omitted]\n" + text + } + return "\nBuildx error tail:\n" + text +} + +func copyDirectory(source, destination string) error { + return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + if entry.IsDir() { + return os.MkdirAll(target, 0o755) + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("refusing non-regular Docker Sandboxes template input %s", path) + } + return copyFile(path, target, info.Mode().Perm()) + }) +} + +func writeJSONFile(path string, value any) error { + content, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return writeAtomicFile(path, append(content, '\n'), 0o600) +} + +func readJSONFile(path string, value any) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, 16<<20)) + if err := decoder.Decode(value); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("unexpected trailing JSON value") + } + return err + } + return nil +} + +func hashFile(path string) (string, uint64, error) { + file, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer file.Close() + hash := sha256.New() + size, err := io.Copy(hash, file) + if err != nil { + return "", 0, err + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)), uint64(size), nil +} + +func validSHA256(value string) bool { + if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil && value == strings.ToLower(value) +} diff --git a/internal/image/docker_sandboxes_test.go b/internal/image/docker_sandboxes_test.go new file mode 100644 index 0000000..bb6a15e --- /dev/null +++ b/internal/image/docker_sandboxes_test.go @@ -0,0 +1,313 @@ +package image + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" +) + +func TestNormalizeCatthehackerSourceProfilesAndCustomTag(t *testing.T) { + for _, test := range []struct { + input string + want string + }{ + {"", "ghcr.io/catthehacker/ubuntu:full-latest"}, + {"full", "ghcr.io/catthehacker/ubuntu:full-latest"}, + {"act", "ghcr.io/catthehacker/ubuntu:act-latest"}, + {"dotnet", "ghcr.io/catthehacker/ubuntu:dotnet-latest"}, + {"js", "ghcr.io/catthehacker/ubuntu:js-latest"}, + {"go-24.04", "ghcr.io/catthehacker/ubuntu:go-24.04"}, + {"ghcr.io/catthehacker/ubuntu:go-24.04", "ghcr.io/catthehacker/ubuntu:go-24.04"}, + } { + t.Run(test.input, func(t *testing.T) { + got, err := NormalizeCatthehackerSource(test.input) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("NormalizeCatthehackerSource(%q) = %q, want %q", test.input, got, test.want) + } + }) + } +} + +func TestDockerSandboxesDockerfileUsesVerifiedLocalDownloadsAndInstallsTrustBeforeCustomization(t *testing.T) { + content, err := os.ReadFile(filepath.Join("..", "..", "templates", "docker-sandboxes", "Dockerfile")) + if err != nil { + t.Fatal(err) + } + text := string(content) + if strings.Contains(text, "ADD --") || strings.Contains(text, "ACTIONS_RUNNER_URL") || strings.Contains(text, "TINI_URL") { + t.Fatalf("Docker Sandboxes Dockerfile still delegates remote HTTPS downloads to BuildKit:\n%s", text) + } + for _, want := range []string{ + "COPY --chmod=0755 inputs/tini /usr/local/bin/tini", + "COPY inputs/actions-runner.tar.gz /tmp/actions-runner.tar.gz", + `echo "${TINI_SHA256#sha256:} /usr/local/bin/tini" | sha256sum --check -`, + `echo "${ACTIONS_RUNNER_SHA256#sha256:} /tmp/actions-runner.tar.gz" | sha256sum --check -`, + } { + if !strings.Contains(text, want) { + t.Fatalf("Docker Sandboxes Dockerfile omitted %q", want) + } + } + trustInstall := strings.Index(text, "/opt/epar/install-trusted-ca-certificates.sh") + customInstall := strings.Index(text, "/opt/epar/custom-install/run.sh") + if trustInstall < 0 || customInstall < 0 || trustInstall >= customInstall { + t.Fatalf("runner trust must be installed before custom scripts:\n%s", text) + } +} + +func TestDockerSandboxesDisabledTrustPolicyIsExplicit(t *testing.T) { + root := t.TempDir() + coordinator := &Coordinator{ProjectRoot: root} + if err := coordinator.prepareDockerSandboxesTrustPolicy(root, hosttrust.Snapshot{}); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(root, "host-trust-metadata", "host-trust-generation.json")) + if err != nil { + t.Fatal(err) + } + var marker struct { + SchemaVersion int `json:"schemaVersion"` + Generation string `json:"generation"` + HostOS string `json:"hostOS"` + Mode string `json:"mode"` + Scopes []string `json:"scopes"` + CertificateCount int `json:"certificateCount"` + } + if err := json.Unmarshal(content, &marker); err != nil { + t.Fatal(err) + } + if marker.SchemaVersion != 1 || marker.Generation != "disabled" || marker.HostOS != "" || marker.Mode != hosttrust.ModeDisabled || len(marker.Scopes) != 0 || marker.CertificateCount != 0 { + t.Fatalf("disabled policy marker = %+v", marker) + } +} + +func TestSelectBuildxSBOMAttachmentUsesExactAttestationChain(t *testing.T) { + attestationDigest := "sha256:" + strings.Repeat("a", 64) + sbomDigest := "sha256:" + strings.Repeat("b", 64) + index := []byte(`{ + "manifests": [ + {"digest": "sha256:` + strings.Repeat("c", 64) + `", "annotations": {}}, + {"digest": "` + attestationDigest + `", "annotations": {"vnd.docker.reference.type": "attestation-manifest"}} + ] + }`) + gotAttestation, err := selectBuildxAttestationDigest(index) + if err != nil { + t.Fatal(err) + } + if gotAttestation != attestationDigest { + t.Fatalf("attestation digest = %q, want %q", gotAttestation, attestationDigest) + } + manifest := []byte(`{ + "layers": [ + {"mediaType": "application/vnd.in-toto+json", "digest": "` + sbomDigest + `", "size": 216579021, "annotations": {"in-toto.io/predicate-type": "https://spdx.dev/Document"}}, + {"mediaType": "application/vnd.in-toto+json", "digest": "sha256:` + strings.Repeat("d", 64) + `", "size": 123, "annotations": {"in-toto.io/predicate-type": "https://slsa.dev/provenance/v1"}} + ] + }`) + gotSBOM, gotSize, err := selectBuildxSBOMDigest(manifest) + if err != nil { + t.Fatal(err) + } + if gotSBOM != sbomDigest || gotSize != 216579021 { + t.Fatalf("SBOM attachment = %q/%d, want %q/%d", gotSBOM, gotSize, sbomDigest, 216579021) + } +} + +func TestDockerContentBlobCandidatesAreExactAndContentAddressed(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + candidates := dockerContentBlobCandidates(digest) + if len(candidates) != 2 { + t.Fatalf("candidate count = %d, want 2", len(candidates)) + } + for _, candidate := range candidates { + if !strings.HasPrefix(candidate, "/var/lib/") || !strings.HasSuffix(candidate, "/blobs/sha256/"+strings.Repeat("a", 64)) { + t.Fatalf("unsafe Docker content candidate %q", candidate) + } + } + if candidates[0] == candidates[1] { + t.Fatal("Docker Desktop and native Engine content candidates must differ") + } +} + +func TestValidateInTotoSPDXStreamsAndRejectsMalformedPolicy(t *testing.T) { + path := filepath.Join(t.TempDir(), "sbom.intoto.json") + valid := `{"_type":"https://in-toto.io/Statement/v1","subject":[],"predicateType":"https://spdx.dev/Document","predicate":{"SPDXID":"SPDXRef-DOCUMENT","packages":[{"name":"example"}]}}` + if err := os.WriteFile(path, []byte(valid), 0o600); err != nil { + t.Fatal(err) + } + if err := validateInTotoSPDX(path); err != nil { + t.Fatal(err) + } + invalid := strings.Replace(valid, "https://spdx.dev/Document", "https://example.invalid/Unknown", 1) + if err := os.WriteFile(path, []byte(invalid), 0o600); err != nil { + t.Fatal(err) + } + if err := validateInTotoSPDX(path); err == nil { + t.Fatal("unknown predicate type was accepted") + } +} + +func TestNormalizeCatthehackerSourceRejectsOtherRepositoriesAndInvalidTags(t *testing.T) { + for _, input := range []string{ + "ubuntu:latest", + "ghcr.io/other/ubuntu:full-latest", + "ghcr.io/catthehacker/ubuntu@sha256:" + strings.Repeat("a", 64), + "tag with spaces", + "-leading-dash", + } { + if _, err := NormalizeCatthehackerSource(input); err == nil { + t.Fatalf("NormalizeCatthehackerSource(%q) succeeded", input) + } + } +} + +func TestParseResolvedDockerSourceSelectsExactNativeManifestAndSize(t *testing.T) { + index := dockerManifestDocument{MediaType: "application/vnd.oci.image.index.v1+json"} + amd64 := dockerManifestDescriptor{Digest: "sha256:" + strings.Repeat("a", 64)} + amd64.Platform.OS = "linux" + amd64.Platform.Architecture = "amd64" + arm64 := dockerManifestDescriptor{Digest: "sha256:" + strings.Repeat("b", 64)} + arm64.Platform.OS = "linux" + arm64.Platform.Architecture = "arm64" + index.Manifests = []dockerManifestDescriptor{amd64, arm64} + indexRaw, err := json.Marshal(index) + if err != nil { + t.Fatal(err) + } + manifestRaw := []byte(`{"layers":[{"size":100},{"size":23}]}`) + resolved, err := parseResolvedDockerSource("ghcr.io/catthehacker/ubuntu:full-latest", "linux/arm64", indexRaw, manifestRaw) + if err != nil { + t.Fatal(err) + } + if resolved.PlatformDigest != arm64.Digest || resolved.CompressedLayerBytes != 123 || !strings.HasPrefix(resolved.ImmutableReference, "ghcr.io/catthehacker/ubuntu@sha256:") { + t.Fatalf("resolved source = %+v", resolved) + } + withCommandNewline, err := parseResolvedDockerSource("ghcr.io/catthehacker/ubuntu:full-latest", "linux/arm64", append(append([]byte(nil), indexRaw...), '\n'), append(append([]byte(nil), manifestRaw...), '\n')) + if err != nil { + t.Fatal(err) + } + if withCommandNewline.IndexDigest != resolved.IndexDigest { + t.Fatalf("index digest changed because the CLI appended a newline: %s != %s", withCommandNewline.IndexDigest, resolved.IndexDigest) + } +} + +func TestDockerSandboxesArtifactIdentityChangesWithEveryFreshnessInput(t *testing.T) { + base := Manifest{ + SchemaVersion: ManifestSchemaVersion, + ProviderType: "docker-sandboxes", + ProviderPlatform: "linux/arm64", + SourceType: "docker-image", + SourceImage: "ghcr.io/catthehacker/ubuntu:full-latest", + SourceDigest: "sha256:" + strings.Repeat("a", 64), + SourcePlatformDigest: "sha256:" + strings.Repeat("b", 64), + RunnerVersion: "2.332.0", + TemplateInputs: []FileDigest{{Path: "Dockerfile", SHA256: strings.Repeat("c", 64)}}, + CustomInstallScripts: []FileDigest{{Path: "custom.sh", SHA256: strings.Repeat("d", 64)}}, + } + baseHash, err := ManifestHash(base) + if err != nil { + t.Fatal(err) + } + mutations := []func(*Manifest){ + func(value *Manifest) { value.SourceDigest = "sha256:" + strings.Repeat("e", 64) }, + func(value *Manifest) { value.SourcePlatformDigest = "sha256:" + strings.Repeat("f", 64) }, + func(value *Manifest) { value.RunnerVersion = "2.333.0" }, + func(value *Manifest) { value.TemplateInputs[0].SHA256 = strings.Repeat("1", 64) }, + func(value *Manifest) { value.CustomInstallScripts[0].SHA256 = strings.Repeat("2", 64) }, + func(value *Manifest) { + value.HostTrust = &HostTrustMetadata{Generation: "sha256:" + strings.Repeat("3", 64)} + }, + } + for index, mutate := range mutations { + changed := base + changed.TemplateInputs = append([]FileDigest(nil), base.TemplateInputs...) + changed.CustomInstallScripts = append([]FileDigest(nil), base.CustomInstallScripts...) + mutate(&changed) + hash, err := ManifestHash(changed) + if err != nil { + t.Fatal(err) + } + if hash == baseHash { + t.Fatalf("freshness mutation %d did not change artifact identity", index) + } + } +} + +func TestVerifiedDockerSandboxesBuildArtifactAcceptsOnlyCompleteExactEvidence(t *testing.T) { + root := t.TempDir() + manifestHash := strings.Repeat("a", 64) + source := ResolvedDockerSource{ + Reference: "ghcr.io/catthehacker/ubuntu:full-latest", + ImmutableReference: "ghcr.io/catthehacker/ubuntu@sha256:" + strings.Repeat("b", 64), + IndexDigest: "sha256:" + strings.Repeat("b", 64), + PlatformDigest: "sha256:" + strings.Repeat("c", 64), + Platform: "linux/arm64", + CompressedLayerBytes: 123, + } + archivePath := filepath.Join(root, "runner-template.tar") + if err := os.WriteFile(archivePath, []byte("verified archive"), 0o600); err != nil { + t.Fatal(err) + } + archiveSHA, archiveBytes, err := hashFile(archivePath) + if err != nil { + t.Fatal(err) + } + metadata := dockerSandboxesTemplateMetadata{ + SchemaVersion: dockerSandboxesMetadataSchema, + Profile: "full-latest", + Platform: source.Platform, + ManifestHash: manifestHash, + Source: source, + Artifacts: make(map[string]artifactEvidence), + } + templateDigest := "sha256:" + strings.Repeat("d", 64) + metadata.Template.Tag = "docker.io/library/epar-docker-sandboxes-catthehacker-full-latest:test-arm64" + metadata.Template.Digest = templateDigest + metadata.Template.CacheID = strings.Repeat("d", 12) + metadata.Template.RootDisk = "90GiB" + metadata.Template.Archive = filepath.Base(archivePath) + metadata.Template.ArchiveSHA256 = archiveSHA + metadata.Template.ArchiveBytes = archiveBytes + metadata.Compatibility.TemplateSchemaVersion = 1 + metadata.Compatibility.RunnerExecution = "direct-actions-listener" + metadata.Compatibility.DockerDaemonOwner = "docker-sandboxes-runtime" + metadata.Compatibility.ExpectedDockerDaemonCount = 1 + for _, name := range []string{"buildMetadata", "provenance", "sbom", "softwareInventory", "compatibility"} { + path := filepath.Join(root, name+".json") + if err := os.WriteFile(path, []byte(name), 0o600); err != nil { + t.Fatal(err) + } + digest, _, err := hashFile(path) + if err != nil { + t.Fatal(err) + } + metadata.Artifacts[name] = artifactEvidence{Path: filepath.Base(path), SHA256: digest} + } + metadataPath := filepath.Join(root, "template-metadata.json") + if err := writeJSONFile(metadataPath, metadata); err != nil { + t.Fatal(err) + } + _, artifact, _, _, valid, err := verifiedDockerSandboxesBuildArtifact(root, metadataPath, archivePath, manifestHash, source) + if err != nil { + t.Fatal(err) + } + if !valid || artifact.Digest != templateDigest || artifact.Platform != "linux/arm64" || artifact.RootDisk != "90GiB" { + t.Fatalf("verified artifact = %+v, valid=%t", artifact, valid) + } + + if err := os.WriteFile(filepath.Join(root, "sbom.json"), []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + _, _, _, _, valid, err = verifiedDockerSandboxesBuildArtifact(root, metadataPath, archivePath, manifestHash, source) + if err != nil { + t.Fatal(err) + } + if valid { + t.Fatal("corrupted evidence was accepted for interrupted-build resume") + } +} diff --git a/internal/image/manifest.go b/internal/image/manifest.go index 06d0d7f..4dce9af 100644 --- a/internal/image/manifest.go +++ b/internal/image/manifest.go @@ -37,10 +37,12 @@ type Manifest struct { SourceImage string `json:"sourceImage"` SourcePlatform string `json:"sourcePlatform,omitempty"` SourceDigest string `json:"sourceDigest,omitempty"` + SourcePlatformDigest string `json:"sourcePlatformDigest,omitempty"` OutputImage string `json:"outputImage"` RunnerVersion string `json:"runnerVersion"` UpstreamCommit string `json:"upstreamCommit,omitempty"` EPARScripts []FileDigest `json:"eparScripts,omitempty"` + TemplateInputs []FileDigest `json:"templateInputs,omitempty"` CustomInstallScripts []FileDigest `json:"customInstallScripts,omitempty"` TrustedCACertificates []FileDigest `json:"trustedCaCertificates,omitempty"` HostTrust *HostTrustMetadata `json:"hostTrust,omitempty"` diff --git a/internal/image/storage_plan.go b/internal/image/storage_plan.go new file mode 100644 index 0000000..5afdbb1 --- /dev/null +++ b/internal/image/storage_plan.go @@ -0,0 +1,193 @@ +package image + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +const ( + ExpandedSizeFallbackMultiplier uint64 = 5 + CustomizationAllowanceBytes = 5 * storage.GiB + SandboxWritableHeadroomBytes = 20 * storage.GiB + SandboxRootRoundingBytes = 10 * storage.GiB + SandboxMinimumRootBytes = 20 * storage.GiB +) + +type EstimateConfidence string + +const ( + EstimateExact EstimateConfidence = "exact" + EstimateDerived EstimateConfidence = "derived" + EstimateFallback EstimateConfidence = "conservative-fallback" +) + +type SourceSizeEstimate struct { + CompressedBytes uint64 `json:"compressedBytes"` + ExpandedBytes uint64 `json:"expandedBytes"` + Confidence EstimateConfidence `json:"confidence"` +} + +type ArtifactStoragePlan struct { + Provider string `json:"provider"` + CompressedDownloadBytes uint64 `json:"compressedDownloadBytes"` + ExpandedSourceBytes uint64 `json:"expandedSourceBytes"` + CustomizationBytes uint64 `json:"customizationBytes"` + EstimatedIncrementalPeak uint64 `json:"estimatedIncrementalPeak"` + Confidence EstimateConfidence `json:"confidence"` + LogicalRootMaximumBytes uint64 `json:"logicalRootMaximumBytes,omitempty"` + LogicalDockerMaximumBytes uint64 `json:"logicalDockerMaximumBytes,omitempty"` + LogicalLimitsSparse bool `json:"logicalLimitsSparse,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +func EstimateSourceSize(compressedBytes, exactExpandedBytes uint64) (SourceSizeEstimate, error) { + if compressedBytes == 0 && exactExpandedBytes == 0 { + return SourceSizeEstimate{}, errors.New("source size cannot be estimated without compressed or expanded bytes") + } + if exactExpandedBytes > 0 { + return SourceSizeEstimate{CompressedBytes: compressedBytes, ExpandedBytes: exactExpandedBytes, Confidence: EstimateExact}, nil + } + if compressedBytes > math.MaxUint64/ExpandedSizeFallbackMultiplier { + return SourceSizeEstimate{}, errors.New("expanded source-size estimate overflows uint64") + } + return SourceSizeEstimate{CompressedBytes: compressedBytes, ExpandedBytes: compressedBytes * ExpandedSizeFallbackMultiplier, Confidence: EstimateFallback}, nil +} + +func AutomaticDockerSandboxesRootBytes(expandedSourceBytes uint64) (uint64, error) { + if expandedSourceBytes > math.MaxUint64-CustomizationAllowanceBytes { + return 0, errors.New("Docker Sandboxes customization allowance overflows uint64") + } + required := expandedSourceBytes + CustomizationAllowanceBytes + if required > math.MaxUint64-SandboxWritableHeadroomBytes { + return 0, errors.New("Docker Sandboxes writable headroom overflows uint64") + } + required += SandboxWritableHeadroomBytes + if required < SandboxMinimumRootBytes { + required = SandboxMinimumRootBytes + } + remainder := required % SandboxRootRoundingBytes + if remainder == 0 { + return required, nil + } + increment := SandboxRootRoundingBytes - remainder + if required > math.MaxUint64-increment { + return 0, errors.New("Docker Sandboxes root-disk rounding overflows uint64") + } + return required + increment, nil +} + +func PlanArtifactStorage(providerType string, source SourceSizeEstimate, cached bool, dockerDiskBytes uint64) (ArtifactStoragePlan, error) { + plan := ArtifactStoragePlan{ + Provider: providerType, + CompressedDownloadBytes: source.CompressedBytes, + ExpandedSourceBytes: source.ExpandedBytes, + CustomizationBytes: CustomizationAllowanceBytes, + Confidence: source.Confidence, + } + if cached { + plan.EstimatedIncrementalPeak = 0 + plan.Notes = append(plan.Notes, "A verified current artifact can be reused.") + return plan, nil + } + add := func(value uint64) error { + if plan.EstimatedIncrementalPeak > math.MaxUint64-value { + return errors.New("artifact storage estimate overflows uint64") + } + plan.EstimatedIncrementalPeak += value + return nil + } + switch providerType { + case "docker-container": + if err := add(source.CompressedBytes); err != nil { + return ArtifactStoragePlan{}, err + } + if err := add(source.ExpandedBytes); err != nil { + return ArtifactStoragePlan{}, err + } + if err := add(CustomizationAllowanceBytes); err != nil { + return ArtifactStoragePlan{}, err + } + plan.Notes = append(plan.Notes, "Physical estimate covers Docker Engine source/output growth and customization.") + case "docker-sandboxes": + rootBytes, err := AutomaticDockerSandboxesRootBytes(source.ExpandedBytes) + if err != nil { + return ArtifactStoragePlan{}, err + } + for _, value := range []uint64{source.CompressedBytes, source.ExpandedBytes, source.ExpandedBytes, CustomizationAllowanceBytes} { + if err := add(value); err != nil { + return ArtifactStoragePlan{}, err + } + } + plan.LogicalRootMaximumBytes = rootBytes + plan.LogicalDockerMaximumBytes = dockerDiskBytes + plan.LogicalLimitsSparse = true + plan.Notes = append(plan.Notes, "Physical estimate covers Docker build data, one export archive, Sandbox template-cache import, and customization.", "The root and inner-Docker sizes are independent sparse logical limits and are not added to immediate host growth.") + case "wsl": + for _, value := range []uint64{source.CompressedBytes, source.ExpandedBytes, source.ExpandedBytes, CustomizationAllowanceBytes} { + if err := add(value); err != nil { + return ArtifactStoragePlan{}, err + } + } + plan.Notes = append(plan.Notes, "Physical estimate covers Docker Engine build data, rootfs export, temporary build distribution, and customization.") + default: + return ArtifactStoragePlan{}, fmt.Errorf("provider %q does not use the shared Docker-image storage plan", providerType) + } + return plan, nil +} + +func (m *Coordinator) configuredArtifactStoragePlan(ctx context.Context, cached bool) (ArtifactStoragePlan, error) { + if m.Config.Provider.Type == "tart" { + return ArtifactStoragePlan{ + Provider: m.Config.Provider.Type, + CustomizationBytes: CustomizationAllowanceBytes, + EstimatedIncrementalPeak: CustomizationAllowanceBytes, + Confidence: EstimateDerived, + Notes: []string{"Tart does not use the shared Docker-image plan; only the customization allowance is admitted here."}, + }, nil + } + if m.Config.Provider.Type == "wsl" && m.Config.Image.SourceType == config.ImageSourceRootFSTar { + sourcePath := config.ProjectPath(m.ProjectRoot, m.Config.Image.SourceImage) + info, err := os.Stat(filepath.Clean(sourcePath)) + if err != nil { + return ArtifactStoragePlan{}, fmt.Errorf("measure WSL rootfs source %s: %w", sourcePath, err) + } + if info.Size() < 0 { + return ArtifactStoragePlan{}, fmt.Errorf("measure WSL rootfs source %s: negative size", sourcePath) + } + estimate, err := EstimateSourceSize(uint64(info.Size()), uint64(info.Size())) + if err != nil { + return ArtifactStoragePlan{}, err + } + return PlanArtifactStorage(m.Config.Provider.Type, estimate, cached, 0) + } + source, err := m.resolveDockerSandboxesSource(ctx) + if err != nil { + return ArtifactStoragePlan{}, err + } + var exactExpanded uint64 + if output, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Size}}", source.Reference); inspectErr == nil { + exactExpanded, _ = strconv.ParseUint(strings.TrimSpace(output), 10, 64) + } + estimate, err := EstimateSourceSize(source.CompressedLayerBytes, exactExpanded) + if err != nil { + return ArtifactStoragePlan{}, err + } + dockerDiskBytes := uint64(0) + if m.Config.Provider.Type == "docker-sandboxes" { + parsed, err := config.ParseByteSize(m.Config.DockerSandboxes.DockerDisk) + if err != nil { + return ArtifactStoragePlan{}, err + } + dockerDiskBytes = uint64(parsed) + } + return PlanArtifactStorage(m.Config.Provider.Type, estimate, cached, dockerDiskBytes) +} diff --git a/internal/image/storage_plan_test.go b/internal/image/storage_plan_test.go new file mode 100644 index 0000000..b3bbd8e --- /dev/null +++ b/internal/image/storage_plan_test.go @@ -0,0 +1,76 @@ +package image + +import ( + "math" + "testing" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +func TestEstimateSourceSizeUsesExactOrFiveTimesCompressed(t *testing.T) { + exact, err := EstimateSourceSize(2*storage.GiB, 7*storage.GiB) + if err != nil { + t.Fatal(err) + } + if exact.ExpandedBytes != 7*storage.GiB || exact.Confidence != EstimateExact { + t.Fatalf("exact estimate = %+v", exact) + } + fallback, err := EstimateSourceSize(2*storage.GiB, 0) + if err != nil { + t.Fatal(err) + } + if fallback.ExpandedBytes != 10*storage.GiB || fallback.Confidence != EstimateFallback { + t.Fatalf("fallback estimate = %+v", fallback) + } + if _, err := EstimateSourceSize(math.MaxUint64, 0); err == nil { + t.Fatal("EstimateSourceSize accepted overflowing fallback") + } +} + +func TestAutomaticDockerSandboxesRootTracksSourceSize(t *testing.T) { + act, err := AutomaticDockerSandboxesRootBytes(5 * storage.GiB) + if err != nil { + t.Fatal(err) + } + full, err := AutomaticDockerSandboxesRootBytes(75 * storage.GiB) + if err != nil { + t.Fatal(err) + } + if act != 30*storage.GiB { + t.Fatalf("act root = %d, want 30GiB", act) + } + if full != 100*storage.GiB { + t.Fatalf("full root = %d, want 100GiB", full) + } + if full <= act { + t.Fatalf("full root %d must exceed act root %d", full, act) + } +} + +func TestDockerSandboxesPlanDoesNotAddSparseLogicalLimitsToPhysicalPeak(t *testing.T) { + source := SourceSizeEstimate{CompressedBytes: 16 * storage.GiB, ExpandedBytes: 75 * storage.GiB, Confidence: EstimateExact} + plan, err := PlanArtifactStorage("docker-sandboxes", source, false, 50*storage.GiB) + if err != nil { + t.Fatal(err) + } + wantPhysical := 16*storage.GiB + 75*storage.GiB + 75*storage.GiB + CustomizationAllowanceBytes + if plan.EstimatedIncrementalPeak != wantPhysical { + t.Fatalf("physical peak = %d, want %d", plan.EstimatedIncrementalPeak, wantPhysical) + } + if plan.LogicalRootMaximumBytes != 100*storage.GiB || plan.LogicalDockerMaximumBytes != 50*storage.GiB || !plan.LogicalLimitsSparse { + t.Fatalf("logical limits = %+v", plan) + } +} + +func TestVerifiedCachedArtifactHasZeroIncrementalPeak(t *testing.T) { + source := SourceSizeEstimate{CompressedBytes: storage.GiB, ExpandedBytes: 5 * storage.GiB, Confidence: EstimateFallback} + for _, providerType := range []string{"docker-container", "docker-sandboxes", "wsl"} { + plan, err := PlanArtifactStorage(providerType, source, true, 50*storage.GiB) + if err != nil { + t.Fatal(err) + } + if plan.EstimatedIncrementalPeak != 0 { + t.Fatalf("%s cached peak = %d, want zero", providerType, plan.EstimatedIncrementalPeak) + } + } +} diff --git a/internal/image/terminology_test.go b/internal/image/terminology_test.go new file mode 100644 index 0000000..4d04ff3 --- /dev/null +++ b/internal/image/terminology_test.go @@ -0,0 +1,45 @@ +package image + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestLegacyDockerSandboxesDevelopmentLabelsDoNotReturn(t *testing.T) { + projectRoot := filepath.Clean(filepath.Join("..", "..")) + pattern := regexp.MustCompile(`(?i)\bcandidate[\s_-]*[ab]\b`) + allowedExtensions := map[string]bool{ + ".go": true, ".md": true, ".json": true, ".yml": true, ".yaml": true, ".ps1": true, ".sh": true, + } + err := filepath.WalkDir(projectRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + switch entry.Name() { + case ".git", ".local", "work": + if path != projectRoot { + return filepath.SkipDir + } + } + return nil + } + if !allowedExtensions[strings.ToLower(filepath.Ext(path))] { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + if pattern.Match(content) { + t.Errorf("legacy Docker Sandboxes development label found in %s", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/image/verified_download.go b/internal/image/verified_download.go new file mode 100644 index 0000000..a5f7769 --- /dev/null +++ b/internal/image/verified_download.go @@ -0,0 +1,142 @@ +package image + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" +) + +func verifiedDownload(ctx context.Context, client *http.Client, sourceURL, destination, expectedSHA256 string, mode os.FileMode) error { + expectedSHA256 = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(expectedSHA256), "sha256:")) + if len(expectedSHA256) != sha256.Size*2 { + return fmt.Errorf("invalid locked SHA-256 for %s", sourceURL) + } + if ok, err := fileMatchesSHA256(destination, expectedSHA256); err != nil { + return err + } else if ok { + return nil + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + partial := destination + ".partial" + var offset int64 + if info, err := os.Lstat(partial); err == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf("partial download %s is not a regular file", partial) + } + offset = info.Size() + } else if !os.IsNotExist(err) { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return err + } + if offset > 0 { + request.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) + } + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("download %s: %w", sourceURL, err) + } + defer response.Body.Close() + flags := os.O_CREATE | os.O_WRONLY + if response.StatusCode == http.StatusPartialContent && offset > 0 { + flags |= os.O_APPEND + } else { + offset = 0 + flags |= os.O_TRUNC + } + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusPartialContent { + return fmt.Errorf("download %s: HTTP %s", sourceURL, response.Status) + } + file, err := os.OpenFile(partial, flags, 0o600) + if err != nil { + return err + } + _, copyErr := io.Copy(file, response.Body) + syncErr := file.Sync() + closeErr := file.Close() + if copyErr != nil { + return fmt.Errorf("download %s after %d bytes: %w", sourceURL, offset, copyErr) + } + if syncErr != nil { + return syncErr + } + if closeErr != nil { + return closeErr + } + ok, err := fileMatchesSHA256(partial, expectedSHA256) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("download %s failed locked SHA-256 verification; partial content retained at %s", sourceURL, partial) + } + if err := os.Chmod(partial, mode); err != nil { + return err + } + if runtimeRenameReplace(partial, destination); err != nil { + return err + } + return nil +} + +func fileMatchesSHA256(path, expected string) (bool, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + return false, fmt.Errorf("download target %s is not a regular file", path) + } + file, err := os.Open(path) + if err != nil { + return false, err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return false, err + } + return hex.EncodeToString(hash.Sum(nil)) == expected, nil +} + +func buildTrustHTTPClient(snapshot hosttrust.Snapshot) (*http.Client, error) { + roots, err := x509.SystemCertPool() + if err != nil || roots == nil { + roots = x509.NewCertPool() + } + for _, certificate := range snapshot.Certificates { + if !roots.AppendCertsFromPEM(certificate.PEM) { + return nil, fmt.Errorf("append operational build CA %s", certificate.Name) + } + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots} + return &http.Client{Transport: transport}, nil +} + +func runtimeRenameReplace(source, destination string) error { + if err := os.Rename(source, destination); err == nil { + return nil + } + if err := os.Remove(destination); err != nil && !os.IsNotExist(err) { + return err + } + return os.Rename(source, destination) +} diff --git a/internal/image/verified_download_test.go b/internal/image/verified_download_test.go new file mode 100644 index 0000000..de1a116 --- /dev/null +++ b/internal/image/verified_download_test.go @@ -0,0 +1,79 @@ +package image + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestVerifiedDownloadResumesAndPublishesOnlyLockedContent(t *testing.T) { + content := []byte(strings.Repeat("verified-actions-runner-content\n", 128)) + sum := sha256.Sum256(content) + var requestedRange string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + requestedRange = request.Header.Get("Range") + start := 0 + if requestedRange != "" { + if _, err := fmt.Sscanf(requestedRange, "bytes=%d-", &start); err != nil { + t.Errorf("invalid Range header %q: %v", requestedRange, err) + response.WriteHeader(http.StatusBadRequest) + return + } + response.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(content)-1, len(content))) + response.WriteHeader(http.StatusPartialContent) + } + _, _ = response.Write(content[start:]) + })) + defer server.Close() + + destination := filepath.Join(t.TempDir(), "inputs", "actions-runner.tar.gz") + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + t.Fatal(err) + } + partialBytes := 197 + if err := os.WriteFile(destination+".partial", content[:partialBytes], 0o600); err != nil { + t.Fatal(err) + } + if err := verifiedDownload(context.Background(), server.Client(), server.URL, destination, hex.EncodeToString(sum[:]), 0o600); err != nil { + t.Fatal(err) + } + if requestedRange != "bytes="+strconv.Itoa(partialBytes)+"-" { + t.Fatalf("Range = %q, want resume from %d", requestedRange, partialBytes) + } + got, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(got) != string(content) { + t.Fatal("verified download content does not match") + } + if _, err := os.Stat(destination + ".partial"); !os.IsNotExist(err) { + t.Fatalf("partial download still exists after publication: %v", err) + } +} + +func TestVerifiedDownloadRetainsFailedPartialAndDoesNotPublish(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _, _ = response.Write([]byte("wrong content")) + })) + defer server.Close() + destination := filepath.Join(t.TempDir(), "tini") + err := verifiedDownload(context.Background(), server.Client(), server.URL, destination, strings.Repeat("0", 64), 0o700) + if err == nil || !strings.Contains(err.Error(), "failed locked SHA-256 verification") { + t.Fatalf("checksum failure = %v", err) + } + if _, err := os.Stat(destination); !os.IsNotExist(err) { + t.Fatalf("unverified destination was published: %v", err) + } + if _, err := os.Stat(destination + ".partial"); err != nil { + t.Fatalf("failed partial was not retained for diagnosis/resume: %v", err) + } +} diff --git a/internal/pool/host_trust.go b/internal/pool/host_trust.go index 364978d..92171bb 100644 --- a/internal/pool/host_trust.go +++ b/internal/pool/host_trust.go @@ -134,6 +134,52 @@ func (m *Manager) resolveHostTrust(ctx context.Context) (hosttrust.Snapshot, err return validateHostTrustSnapshot(snapshot, time.Now().UTC()) } +func (m *Manager) resolveBuildTrust(ctx context.Context) (hosttrust.Snapshot, error) { + if m.buildTrustResolver != nil { + snapshot, err := m.buildTrustResolver(ctx) + if err != nil { + return hosttrust.Snapshot{}, err + } + return validateHostTrustSnapshot(snapshot, time.Now().UTC()) + } + if m.hostTrustResolver != nil { + snapshot, err := m.hostTrustResolver(ctx) + if err != nil { + return hosttrust.Snapshot{}, err + } + return validateHostTrustSnapshot(snapshot, time.Now().UTC()) + } + scopes := buildTrustScopes(m.Config.Image.HostTrustMode, m.Config.Image.HostTrustScopes) + feedPath := strings.TrimSpace(os.Getenv("EPAR_BUILD_TRUST_FEED")) + controllerHostOS := strings.TrimSpace(os.Getenv("EPAR_CONTROLLER_HOST_OS")) + if feedPath == "" && hostTrustControllerOS == "linux" && hostTrustControllerInContainer() { + return hosttrust.Snapshot{}, fmt.Errorf("operational BuildKit trust requires EPAR_BUILD_TRUST_FEED when the EPAR controller runs in a container; use an official no-Go wrapper") + } + snapshot, err := hosttrust.Resolve(ctx, hosttrust.Options{ + Mode: hosttrust.ModeOverlay, + Scopes: scopes, + FeedPath: feedPath, + ControllerHostOS: controllerHostOS, + }) + if err != nil { + return hosttrust.Snapshot{}, fmt.Errorf("resolve operational BuildKit trust: %w", err) + } + return validateHostTrustSnapshot(snapshot, time.Now().UTC()) +} + +func buildTrustScopes(runnerMode string, runnerScopes []string) []string { + scopes := []string{hosttrust.ScopeSystem} + if hosttrust.Enabled(runnerMode) { + for _, scope := range runnerScopes { + if strings.EqualFold(strings.TrimSpace(scope), hosttrust.ScopeUser) { + scopes = append(scopes, hosttrust.ScopeUser) + break + } + } + } + return scopes +} + func linuxControllerInContainer() bool { return linuxContainerEvidence( func(path string) bool { _, err := os.Stat(path); return err == nil }, diff --git a/internal/pool/host_trust_test.go b/internal/pool/host_trust_test.go index ca0da84..e7fd8e5 100644 --- a/internal/pool/host_trust_test.go +++ b/internal/pool/host_trust_test.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "sync/atomic" "testing" @@ -350,6 +351,10 @@ func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing. writeTestCACertificate(t, secondPath, "Host Root G2") g1 := hostTrustSnapshotFromFile(t, firstPath, "windows", []string{"system", "user"}) g2 := hostTrustSnapshotFromFile(t, secondPath, "windows", []string{"system", "user"}) + var buildTrustBundleContent strings.Builder + for _, certificate := range g1.Certificates { + buildTrustBundleContent.Write(certificate.PEM) + } sequence := []hosttrust.Snapshot{g1, g2, g2, g2} manager := Manager{ Config: config.Config{ @@ -378,6 +383,11 @@ func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing. value.CollectedAt = time.Now().UTC() return value, nil } + manager.buildTrustResolver = func(context.Context) (hosttrust.Snapshot, error) { + value := g1 + value.CollectedAt = time.Now().UTC() + return value, nil + } oldLogged := runHostLoggedCommand oldOutput := runHostOutputCommand oldQuiet := runHostQuietCommand @@ -402,6 +412,12 @@ func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing. if len(args) > 1 && args[0] == "buildx" && args[1] == "inspect" { return "", errors.New("builder not found") } + if len(args) > 3 && args[0] == "exec" && strings.Contains(args[3], "/certs/") { + return buildTrustBundleContent.String(), nil + } + if len(args) > 1 && args[0] == "exec" { + return "# epar-build-trust-generation=" + g1.Generation + "\n[registry.\"docker.io\"]\n", nil + } return `["source@sha256:1234"]`, nil } runHostQuietCommand = func(context.Context, string, ...string) error { return nil } @@ -423,6 +439,15 @@ func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing. } } +func TestBuildTrustScopesAreIndependentFromDisabledRunnerOverlay(t *testing.T) { + if got := buildTrustScopes(config.HostTrustModeDisabled, []string{hosttrust.ScopeSystem, hosttrust.ScopeUser}); !slices.Equal(got, []string{hosttrust.ScopeSystem}) { + t.Fatalf("disabled runner build scopes = %v, want system only", got) + } + if got := buildTrustScopes(config.HostTrustModeOverlay, []string{hosttrust.ScopeUser}); !slices.Equal(got, []string{hosttrust.ScopeSystem, hosttrust.ScopeUser}) { + t.Fatalf("user-overlay build scopes = %v, want mandatory system plus opted-in user", got) + } +} + func hostTrustSnapshotFromFile(t *testing.T, path, hostOS string, scopes []string) hosttrust.Snapshot { t.Helper() content, err := os.ReadFile(path) diff --git a/internal/pool/image_service.go b/internal/pool/image_service.go index 6a97b14..a9eab8c 100644 --- a/internal/pool/image_service.go +++ b/internal/pool/image_service.go @@ -44,10 +44,11 @@ var dockerPullProgressTerminal = func() bool { var dockerPullProgressConsole io.Writer = os.Stdout var ( - runHostCommand = runHost - runHostLoggedCommand = runHostLogged - runHostOutputCommand = runHostOutput - runHostQuietCommand = runHostQuiet + runHostCommand = runHost + runHostLoggedCommand = runHostLogged + runHostOutputCommand = runHostOutput + runHostOutputToCommand = runHostOutputTo + runHostQuietCommand = runHostQuiet ) func (m *Manager) imageCoordinator() *artifactimage.Coordinator { @@ -224,6 +225,10 @@ func (environment imageEnvironment) RunHostOutput(ctx context.Context, name stri return runHostOutputCommand(ctx, name, args...) } +func (environment imageEnvironment) RunHostOutputTo(ctx context.Context, output io.Writer, name string, args ...string) error { + return runHostOutputToCommand(ctx, output, name, args...) +} + func (environment imageEnvironment) RunHostQuiet(ctx context.Context, name string, args ...string) error { return runHostQuietCommand(ctx, name, args...) } @@ -240,6 +245,10 @@ func (environment imageEnvironment) ResolveHostTrust(ctx context.Context) (hostt return environment.manager.resolveHostTrust(ctx) } +func (environment imageEnvironment) ResolveBuildTrust(ctx context.Context) (hosttrust.Snapshot, error) { + return environment.manager.resolveBuildTrust(ctx) +} + func (environment imageEnvironment) WriteHostTrustBuildInputs(buildContext string, snapshot hosttrust.Snapshot) error { return environment.manager.writeHostTrustBuildInputs(buildContext, snapshot) } @@ -305,6 +314,17 @@ func runHostOutput(ctx context.Context, name string, args ...string) (string, er return string(output), nil } +func runHostOutputTo(ctx context.Context, output io.Writer, name string, args ...string) error { + command := exec.CommandContext(ctx, name, args...) + var stderr strings.Builder + command.Stdout = output + command.Stderr = &stderr + if err := command.Run(); err != nil { + return fmt.Errorf("%s %s failed: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return nil +} + func runHostQuiet(ctx context.Context, name string, args ...string) error { return exec.CommandContext(ctx, name, args...).Run() } diff --git a/internal/pool/manager.go b/internal/pool/manager.go index a6875c9..496e0c4 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -27,17 +27,19 @@ import ( ) type Manager struct { - Config config.Config - Provider provider.Provider - Lifecycle provider.Lifecycle - PolicyManager provider.PolicyManager - Storage provider.StorageContribution - LifecycleState *poolstate.Store - GitHub GitHubClient - ProjectRoot string - ConfigPath string - DryRun bool - Logging *logging.Runtime + Config config.Config + Provider provider.Provider + Lifecycle provider.Lifecycle + PolicyManager provider.PolicyManager + Storage provider.StorageContribution + LifecycleState *poolstate.Store + GitHub GitHubClient + ProjectRoot string + ConfigPath string + DryRun bool + Logging *logging.Runtime + AllowInsufficientStorage bool + StorageOverrideCommand string // AcknowledgeFailedDiagnostics permits the explicit cleanup command to // dispose an exact retained sandbox after the operator has captured the // durable failed-diagnostics evidence. Normal startup and automatic cleanup @@ -48,12 +50,18 @@ type Manager struct { transcripts map[string]*logging.Transcript hostTrustResolver func(context.Context) (hosttrust.Snapshot, error) + buildTrustResolver func(context.Context) (hosttrust.Snapshot, error) hostTrustImageEnsurer func(context.Context) error hostTrustImageMu sync.Mutex now func() time.Time randomFloat64 func() float64 } +func (m *Manager) ConfigureStorageAdmissionOverride(allow bool, command string) { + m.AllowInsufficientStorage = allow + m.StorageOverrideCommand = command +} + type GitHubClient interface { OrganizationURL() string EvaluateRunnerGroupPolicy(ctx context.Context, configuredGroup string, policy config.RunnerGroupSecurityConfig) (gh.RunnerGroupPolicyResult, error) diff --git a/internal/pool/provider_lifecycle.go b/internal/pool/provider_lifecycle.go index ed6e99e..31c701e 100644 --- a/internal/pool/provider_lifecycle.go +++ b/internal/pool/provider_lifecycle.go @@ -28,15 +28,13 @@ func (m *Manager) createProviderInstance(ctx context.Context, name string) (prov return provider.Instance{}, fmt.Errorf("provider lifecycle is required") } return lifecycle.Create(ctx, provider.CreateRequest{ - Name: name, - Source: m.Config.Provider.SourceImage, - Template: m.Config.DockerSandboxes.Template, - TemplateDigest: m.Config.DockerSandboxes.TemplateDigest, - StagingPath: filepath.Join(config.ProjectPath(m.ProjectRoot, m.Config.DockerSandboxes.StagingRoot), name), - CPUs: m.Config.DockerSandboxes.CPUs, - Memory: m.Config.DockerSandboxes.Memory, - RootDisk: m.Config.DockerSandboxes.RootDisk, - DockerDisk: m.Config.DockerSandboxes.DockerDisk, + Name: name, + Source: m.Config.Provider.SourceImage, + StagingPath: filepath.Join(config.ProjectPath(m.ProjectRoot, m.Config.DockerSandboxes.StagingRoot), name), + CPUs: m.Config.DockerSandboxes.CPUs, + Memory: m.Config.DockerSandboxes.Memory, + RootDisk: m.Config.DockerSandboxes.RootDisk, + DockerDisk: m.Config.DockerSandboxes.DockerDisk, }) } diff --git a/internal/pool/runner_script_test.go b/internal/pool/runner_script_test.go index 0fc1f78..cb3b329 100644 --- a/internal/pool/runner_script_test.go +++ b/internal/pool/runner_script_test.go @@ -468,7 +468,7 @@ func TestHostTrustGenerationHookProductionPathsCannotBeRedirectedByWorkflowEnvir } } -func TestDockerSandboxesRunnerAlwaysRequiresAdmissionHook(t *testing.T) { +func TestDockerSandboxesRunnerRequiresExplicitDisabledOrOverlayTrustPolicy(t *testing.T) { path := filepath.Join("..", "..", "templates", "docker-sandboxes", "guest", "run-runner.sh") content, err := os.ReadFile(path) if err != nil { @@ -479,13 +479,17 @@ func TestDockerSandboxesRunnerAlwaysRequiresAdmissionHook(t *testing.T) { "[[ -s /opt/epar/host-trust-generation.json ]]", "ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/epar/check-host-trust-generation.sh", "PATH=/opt/epar/hook-bin:", + `if mode == "disabled":`, + `elif mode == "overlay":`, + `raise SystemExit(f"EPAR runner trust policy: unknown mode {mode!r}")`, + `if [[ "${trust_mode}" == "overlay" ]]; then`, } { if !strings.Contains(text, required) { - t.Fatalf("Docker Sandboxes runner omitted always-on admission invariant %q", required) + t.Fatalf("Docker Sandboxes runner omitted trust-policy invariant %q", required) } } - if strings.Contains(text, "if [[ -s /opt/epar/host-trust-generation.json ]]") { - t.Fatal("Docker Sandboxes runner made its admission hook conditional") + if strings.Contains(text, `if [[ -s /opt/epar/host-trust-generation.json ]]`) { + t.Fatal("Docker Sandboxes runner accepts a missing policy marker") } } diff --git a/internal/pool/storage_preflight.go b/internal/pool/storage_preflight.go index 9c2b95c..dc47662 100644 --- a/internal/pool/storage_preflight.go +++ b/internal/pool/storage_preflight.go @@ -13,9 +13,6 @@ import ( const ( instanceCreateExpansionBytes = 10 * storage.GiB - imagePullExpansionBytes = 20 * storage.GiB - imageBuildExpansionBytes = 30 * storage.GiB - sourceUpdateExpansionBytes = 5 * storage.GiB ) func (m *Manager) preflightStorage(operation string, peakBytes uint64) error { @@ -31,7 +28,12 @@ func (m *Manager) preflightStorage(operation string, peakBytes uint64) error { MinimumFreeBytes: minimumFree, }) if err != nil { - return fmt.Errorf("provider storage surface cannot be measured before %s: %w\n\nInspect storage with:\n %s", operation, err, invocation.Command("storage", "status", "--provider", m.Config.Provider.Type)) + measurementErr := fmt.Errorf("provider storage surface cannot be measured before %s: %w\n\nInspect storage with:\n %s", operation, err, invocation.Command("storage", "status", "--provider", m.Config.Provider.Type)) + if m.AllowInsufficientStorage { + m.warnStorageOverride(operation, measurementErr) + return nil + } + return measurementErr } if len(snapshot.Surfaces) == 0 || len(snapshot.Requirements) == 0 { return fmt.Errorf("provider %q returned no required storage surface for %s", m.Config.Provider.Type, operation) @@ -50,21 +52,34 @@ func (m *Manager) preflightStorage(operation string, peakBytes uint64) error { return fmt.Errorf("evaluate storage capacity before %s: %w", operation, err) } if check.Status != storage.CapacityReady { - return storageAdmissionError(operation, surface, requirement, check, m.Config.Provider.Type) + admissionErr := storageAdmissionError(operation, surface, requirement, check, m.Config.Provider.Type, m.StorageOverrideCommand) + if m.AllowInsufficientStorage { + m.warnStorageOverride(operation, admissionErr) + continue + } + return admissionErr } } return nil } capacity, err := storage.ProbeFilesystemCapacity(m.ProjectRoot, m.currentTime()) if err != nil { - return fmt.Errorf("storage surface %q cannot be measured before %s: %w\n\nInspect storage with:\n %s", m.ProjectRoot, operation, err, invocation.Command("storage", "status", "--provider", m.Config.Provider.Type)) + measurementErr := fmt.Errorf("storage surface %q cannot be measured before %s: %w\n\nInspect storage with:\n %s", m.ProjectRoot, operation, err, invocation.Command("storage", "status", "--provider", m.Config.Provider.Type)) + if m.AllowInsufficientStorage { + m.warnStorageOverride(operation, measurementErr) + return nil + } + return measurementErr } surface := storage.Surface{ - ID: "project", - Provider: m.Config.Provider.Type, - Kind: storage.SurfaceHostFilesystem, - Location: m.ProjectRoot, - Capacity: capacity, + ID: "project", + Provider: m.Config.Provider.Type, + Kind: storage.SurfaceHostFilesystem, + Location: m.ProjectRoot, + Classification: "physical", + Confidence: "authoritative-filesystem-probe", + AdmissionAuthoritative: true, + Capacity: capacity, } requirement := storage.Requirement{ ID: operation, @@ -78,7 +93,12 @@ func (m *Manager) preflightStorage(operation string, peakBytes uint64) error { return fmt.Errorf("evaluate storage capacity before %s: %w", operation, err) } if check.Status != storage.CapacityReady { - return storageAdmissionError(operation, surface, requirement, check, m.Config.Provider.Type) + admissionErr := storageAdmissionError(operation, surface, requirement, check, m.Config.Provider.Type, m.StorageOverrideCommand) + if m.AllowInsufficientStorage { + m.warnStorageOverride(operation, admissionErr) + return nil + } + return admissionErr } return nil } @@ -87,12 +107,20 @@ func (m *Manager) instanceCreateExpansion() uint64 { return uint64(instanceCreateExpansionBytes) } -func storageAdmissionError(operation string, surface storage.Surface, requirement storage.Requirement, check storage.CapacityCheck, providerType string) error { +func storageAdmissionError(operation string, surface storage.Surface, requirement storage.Requirement, check storage.CapacityCheck, providerType, overrideCommand string) error { action := "complete " + strings.ReplaceAll(operation, "-", " ") if operation == "instance-create" { action = "initialize the runner" } - return storage.CapacityAdmissionError(action, surface, requirement, check, invocation.Command("storage", "prune", "--provider", providerType)) + err := storage.CapacityAdmissionError(action, surface, requirement, check, invocation.Command("storage", "prune", "--provider", providerType)) + if overrideCommand != "" { + return fmt.Errorf("%w\n\nContinue this invocation despite the storage risk with:\n %s", err, overrideCommand) + } + return err +} + +func (m *Manager) warnStorageOverride(operation string, err error) { + m.warnf("\n*** STORAGE SAFETY OVERRIDE ACTIVE ***\n%s\nContinuing %s because --allow-insufficient-storage was explicitly supplied for this invocation.\n\n", err, strings.ReplaceAll(operation, "-", " ")) } // PreflightProviderStorage lets provider-side controllers apply the same diff --git a/internal/pool/storage_preflight_test.go b/internal/pool/storage_preflight_test.go index 4accb34..7c1cb9a 100644 --- a/internal/pool/storage_preflight_test.go +++ b/internal/pool/storage_preflight_test.go @@ -91,3 +91,19 @@ func TestRunPoolCapacityRejectionDoesNotCleanupUncreatedInstance(t *testing.T) { t.Fatalf("lifecycle records = %+v, want none before capacity admission", records) } } + +func TestStorageOverrideContinuesOnlyStorageAdmission(t *testing.T) { + manager := Manager{ + Config: config.Config{ + Provider: config.ProviderConfig{Type: "docker-sandboxes"}, + Storage: config.StorageConfig{MinimumFree: "1GiB"}, + }, + Storage: insufficientStorage{}, + AllowInsufficientStorage: true, + StorageOverrideCommand: "./start --allow-insufficient-storage", + ProjectRoot: t.TempDir(), + } + if err := manager.preflightStorage("instance-create", 20*storage.GiB); err != nil { + t.Fatalf("storage override rejected storage-only admission: %v", err) + } +} diff --git a/internal/provider/dockersandboxes/capacity/capacity.go b/internal/provider/dockersandboxes/capacity/capacity.go index dc25f75..87d46bd 100644 --- a/internal/provider/dockersandboxes/capacity/capacity.go +++ b/internal/provider/dockersandboxes/capacity/capacity.go @@ -12,9 +12,10 @@ const ( GiB uint64 = 1 << 30 MinimumRootDisk = 20 * GiB RootWritableHeadroom = 20 * GiB - DockerDeletionHeadroom = 20 * GiB - MinimumDockerDisk = 100 * GiB - MinimumHostFreeSpace = 50 * GiB + CustomizationAllowance = 5 * GiB + DefaultDockerDisk = 50 * GiB + MinimumDockerDisk = 1 * GiB + MinimumHostFreeSpace = 1 * GiB rootRoundingQuantum = 10 * GiB ) @@ -32,21 +33,15 @@ type HostSpace struct { TotalBytes uint64 } -// HostWatermark returns the effective physical free-space floor for the -// backing volume. A configured or promoted floor may strengthen the rule, but -// can never weaken the larger of 50 GiB and ten percent of the current volume. +// HostWatermark returns the configured fixed physical free-space reserve. The +// backing volume size is deliberately not used: a percentage reserve produces +// nonsensical requirements on large volumes. func HostWatermark(configured, backingVolumeSize uint64) (uint64, error) { - if backingVolumeSize == 0 { - return 0, errors.New("backing volume size must be greater than zero") + _ = backingVolumeSize + if configured == 0 { + return MinimumHostFreeSpace, nil } - watermark := ceilDiv(backingVolumeSize, 10) - if watermark < MinimumHostFreeSpace { - watermark = MinimumHostFreeSpace - } - if configured > watermark { - watermark = configured - } - return watermark, nil + return configured, nil } // Derive calculates the resource floors required by the Docker Sandboxes @@ -59,21 +54,12 @@ func Derive(measuredRootPeak, representativeDockerPeak, backingVolumeSize uint64 if representativeDockerPeak == 0 { return Requirements{}, errors.New("representative Docker peak must be greater than zero") } - if backingVolumeSize == 0 { - return Requirements{}, errors.New("backing volume size must be greater than zero") - } - rootDisk, err := DeriveRootDisk(measuredRootPeak, RootWritableHeadroom) if err != nil { return Requirements{}, fmt.Errorf("derive root disk: %w", err) } - dockerDisk, err := addPercentAndHeadroom(representativeDockerPeak, DockerDeletionHeadroom) - if err != nil { - return Requirements{}, fmt.Errorf("derive Docker disk: %w", err) - } - if dockerDisk < MinimumDockerDisk { - dockerDisk = MinimumDockerDisk - } + _ = representativeDockerPeak + dockerDisk := DefaultDockerDisk hostWatermark, err := HostWatermark(0, backingVolumeSize) if err != nil { return Requirements{}, fmt.Errorf("derive host watermark: %w", err) @@ -81,10 +67,8 @@ func Derive(measuredRootPeak, representativeDockerPeak, backingVolumeSize uint64 return Requirements{RootDisk: rootDisk, DockerDisk: dockerDisk, MinHostFreeSpace: hostWatermark}, nil } -// DeriveRootDisk turns a measured guest root-filesystem peak and operator -// headroom into the total root-disk capacity passed to Docker Sandboxes. The -// cached template size is deliberately not an input because it is host-cache -// storage, not measured guest root usage. +// DeriveRootDisk turns the expanded source-image estimate and writable +// headroom into the total root-disk capacity passed to Docker Sandboxes. func DeriveRootDisk(measuredRootPeak, writableHeadroom uint64) (uint64, error) { if measuredRootPeak == 0 { return 0, errors.New("measured root peak must be greater than zero") @@ -92,10 +76,14 @@ func DeriveRootDisk(measuredRootPeak, writableHeadroom uint64) (uint64, error) { if writableHeadroom < RootWritableHeadroom { return 0, fmt.Errorf("writable root headroom must be at least %d", RootWritableHeadroom) } - rootWithMargin, err := addPercentAndHeadroom(measuredRootPeak, writableHeadroom) - if err != nil { - return 0, err + if measuredRootPeak > math.MaxUint64-CustomizationAllowance { + return 0, errors.New("customization allowance overflows uint64") + } + rootWithAllowance := measuredRootPeak + CustomizationAllowance + if rootWithAllowance > math.MaxUint64-writableHeadroom { + return 0, errors.New("writable headroom overflows uint64") } + rootWithMargin := rootWithAllowance + writableHeadroom rootDisk, err := roundUp(rootWithMargin, rootRoundingQuantum) if err != nil { return 0, err diff --git a/internal/provider/dockersandboxes/capacity/capacity_test.go b/internal/provider/dockersandboxes/capacity/capacity_test.go index 00d1f87..85c2bd0 100644 --- a/internal/provider/dockersandboxes/capacity/capacity_test.go +++ b/internal/provider/dockersandboxes/capacity/capacity_test.go @@ -10,13 +10,13 @@ func TestDerivePromotionRequirementsFromMeasuredGuestUsage(t *testing.T) { if err != nil { t.Fatal(err) } - if got, want := requirements.RootDisk, 110*GiB; got != want { + if got, want := requirements.RootDisk, 100*GiB; got != want { t.Fatalf("root disk = %d, want %d", got, want) } - if got, want := requirements.DockerDisk, 120*GiB; got != want { + if got, want := requirements.DockerDisk, DefaultDockerDisk; got != want { t.Fatalf("Docker disk = %d, want %d", got, want) } - if got, want := requirements.MinHostFreeSpace, 200*GiB; got != want { + if got, want := requirements.MinHostFreeSpace, MinimumHostFreeSpace; got != want { t.Fatalf("host watermark = %d, want %d", got, want) } } @@ -54,7 +54,7 @@ func TestDeriveEnforcesAbsoluteFloors(t *testing.T) { if got, want := requirements.RootDisk, 30*GiB; got != want { t.Fatalf("root disk = %d, want %d", got, want) } - if got, want := requirements.DockerDisk, MinimumDockerDisk; got != want { + if got, want := requirements.DockerDisk, DefaultDockerDisk; got != want { t.Fatalf("Docker disk = %d, want %d", got, want) } if got, want := requirements.MinHostFreeSpace, MinimumHostFreeSpace; got != want { @@ -69,7 +69,6 @@ func TestDeriveRejectsMissingAndOverflowingEvidence(t *testing.T) { }{ {name: "template", template: 0, peak: GiB, disk: GiB}, {name: "peak", template: GiB, peak: 0, disk: GiB}, - {name: "volume", template: GiB, peak: GiB, disk: 0}, {name: "overflow", template: math.MaxUint64, peak: GiB, disk: GiB}, } { t.Run(test.name, func(t *testing.T) { @@ -80,15 +79,15 @@ func TestDeriveRejectsMissingAndOverflowingEvidence(t *testing.T) { } } -func TestHostWatermarkUsesStrongestFloor(t *testing.T) { +func TestHostWatermarkUsesFixedConfiguredReserve(t *testing.T) { tests := []struct { name string configured uint64 volume uint64 want uint64 }{ - {name: "absolute floor", volume: 100 * GiB, want: 50 * GiB}, - {name: "volume percentage", volume: 2_000 * GiB, want: 200 * GiB}, + {name: "two terabyte volume uses fixed default", volume: 2_000 * GiB, want: 1 * GiB}, + {name: "twenty terabyte volume uses fixed default", volume: 20_000 * GiB, want: 1 * GiB}, {name: "configured strengthening", configured: 250 * GiB, volume: 2_000 * GiB, want: 250 * GiB}, } for _, test := range tests { @@ -102,8 +101,8 @@ func TestHostWatermarkUsesStrongestFloor(t *testing.T) { } }) } - if _, err := HostWatermark(0, 0); err == nil { - t.Fatal("HostWatermark accepted a zero-sized backing volume") + if got, err := HostWatermark(0, 0); err != nil || got != MinimumHostFreeSpace { + t.Fatalf("HostWatermark(0, 0) = %d, %v; want fixed default", got, err) } } @@ -125,7 +124,7 @@ func TestAdmissionAccountsForReservationsAndWatermark(t *testing.T) { mutate func(*Admission) }{ {name: "concurrency", mutate: func(a *Admission) { a.ActiveCreates = 2 }}, - {name: "weak watermark", mutate: func(a *Admission) { a.MinHostFreeSpace = 49 * GiB }}, + {name: "weak watermark", mutate: func(a *Admission) { a.MinHostFreeSpace = 0 }}, {name: "uncertain reservations", mutate: func(a *Admission) { a.ReservedBytes = 450 * GiB }}, {name: "post-reservation watermark", mutate: func(a *Admission) { a.RequestedBytes = 250 * GiB }}, } diff --git a/internal/provider/dockersandboxes/live_test.go b/internal/provider/dockersandboxes/live_test.go index 279bad5..4b7ca0c 100644 --- a/internal/provider/dockersandboxes/live_test.go +++ b/internal/provider/dockersandboxes/live_test.go @@ -15,7 +15,7 @@ import ( sandboxfs "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/staging" ) -func TestLiveCandidateAIsolation(t *testing.T) { +func TestLiveRunnerTemplateIsolation(t *testing.T) { if os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES") != "1" { t.Skip("set EPAR_LIVE_DOCKER_SANDBOXES=1 to run the destructive live Docker Sandboxes proof") } diff --git a/internal/provider/dockersandboxes/promotion/preflight.go b/internal/provider/dockersandboxes/promotion/preflight.go index d2b457a..24e3741 100644 --- a/internal/provider/dockersandboxes/promotion/preflight.go +++ b/internal/provider/dockersandboxes/promotion/preflight.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "math" "os" "os/exec" "regexp" @@ -126,7 +125,7 @@ func RunPreflight(ctx context.Context, record Record, opts PreflightOptions) Pre case overflow: add("resource availability", "the promoted disk reservation overflows the supported byte range", "Use Docker Container and report the invalid promotion record.") case space.AvailableBytes < required: - add("resource availability", fmt.Sprintf("Docker Sandboxes storage free space is %d bytes; at least %d bytes are required for one promoted root disk, Docker disk, and the stronger of the promoted watermark or 10%% of the %d-byte provider-storage volume", space.AvailableBytes, required, space.TotalBytes), "Free space on the Docker Sandboxes provider-storage volume, then rerun setup.") + add("resource availability", fmt.Sprintf("Docker Sandboxes storage free space is %d bytes; the configured fixed reserve requires at least %d bytes on the %d-byte provider-storage volume", space.AvailableBytes, required, space.TotalBytes), "Free space on the Docker Sandboxes provider-storage volume, then rerun setup.") } } if opts.RunSBX == nil { @@ -193,14 +192,7 @@ func requiredHostFreeBytes(record Record, backingVolumeSize uint64) (uint64, boo if err != nil { return 0, true } - if record.RootDiskBytes > math.MaxUint64-record.DockerDiskBytes { - return 0, true - } - required := record.RootDiskBytes + record.DockerDiskBytes - if required > math.MaxUint64-watermark { - return 0, true - } - return required + watermark, false + return watermark, false } func runSBXCommand(ctx context.Context, args []string) ([]byte, error) { diff --git a/internal/provider/dockersandboxes/promotion/promotion.go b/internal/provider/dockersandboxes/promotion/promotion.go index 2eace1d..ec350e1 100644 --- a/internal/provider/dockersandboxes/promotion/promotion.go +++ b/internal/provider/dockersandboxes/promotion/promotion.go @@ -137,7 +137,7 @@ func Validate(record Record) error { return fmt.Errorf("Docker Sandboxes promotion record %s gate did not pass", gate) } } - if record.RootDiskBytes == 0 || record.DockerDiskBytes < 100<<30 || record.MinHostFreeSpaceBytes < 50<<30 { + if record.RootDiskBytes < 20<<30 || record.DockerDiskBytes < 1<<30 || record.MinHostFreeSpaceBytes < 1<<30 { return fmt.Errorf("Docker Sandboxes promotion record resource floors are incomplete") } if record.ReliabilityJobs < 25 || record.ReliabilityDuration < 2*time.Hour { diff --git a/internal/provider/dockersandboxes/promotion/promotion_test.go b/internal/provider/dockersandboxes/promotion/promotion_test.go index 3df5298..6fb5c76 100644 --- a/internal/provider/dockersandboxes/promotion/promotion_test.go +++ b/internal/provider/dockersandboxes/promotion/promotion_test.go @@ -30,7 +30,7 @@ func TestValidateRejectsEveryNonWaivableGate(t *testing.T) { {name: "unknown EPAR revision", mutate: func(record *Record) { record.EPARRevision = "unknown" }}, {name: "wrong template cache ID", mutate: func(record *Record) { record.TemplateCacheID = "bbbbbbbbbbbb" }}, {name: "unverified", mutate: func(record *Record) { record.Verifier = "" }}, - {name: "weak Docker disk", mutate: func(record *Record) { record.DockerDiskBytes = 99 << 30 }}, + {name: "weak Docker disk", mutate: func(record *Record) { record.DockerDiskBytes = 512 << 20 }}, {name: "too few jobs", mutate: func(record *Record) { record.ReliabilityJobs = 24 }}, {name: "short soak", mutate: func(record *Record) { record.ReliabilityDuration = 119 * time.Minute }}, {name: "slow create", mutate: func(record *Record) { record.CachedCreateP95 = 61 * time.Second }}, diff --git a/internal/provider/dockersandboxes/provider.go b/internal/provider/dockersandboxes/provider.go index f89f046..3ff21f2 100644 --- a/internal/provider/dockersandboxes/provider.go +++ b/internal/provider/dockersandboxes/provider.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "sync/atomic" "time" @@ -55,6 +56,9 @@ type Provider struct { runCommand runCommandFunc inspectImage inspectImageFunc inspectTemplate inspectLocalTemplateFunc + activeMu sync.RWMutex + activeTemplate provider.TemplateArtifact + dryRun bool } type instanceReceipt struct { @@ -106,20 +110,26 @@ type inspectImageFunc func(context.Context, string) (string, error) type inspectLocalTemplateFunc func(context.Context, string) (LocalTemplateImage, error) func New(binary string) *Provider { + return NewWithDryRun(binary, false) +} + +func NewWithDryRun(binary string, dryRun bool) *Provider { if binary == "" { binary = "sbx" } - return &Provider{Binary: binary} + return &Provider{Binary: binary, dryRun: dryRun} } -// EnsureArtifacts records that Docker Sandboxes templates are built and -// imported explicitly. Create verifies the exact configured template digest -// against the provider cache before it allocates an instance. -func (*Provider) EnsureArtifacts(_ context.Context, dryRun bool) (bool, error) { - if dryRun { - return true, fmt.Errorf("docker-sandboxes does not support dry-run because the exact prewarmed template must be read back from sbx") - } - return true, nil +// StartDaemon asks Docker Sandboxes to start its host daemon in the +// background. The command is intentionally exact so onboarding cannot invoke +// other daemon mutations through this path. +func (p *Provider) StartDaemon(ctx context.Context) error { + _, err := p.run(ctx, commandRequest{ + args: []string{"daemon", "start", "--detach"}, + operation: "start docker sandboxes daemon", + outputLimit: diagnosticOutputLimit, + }) + return err } // VerifyHostReadiness requires machine-readable Docker Sandboxes diagnostics @@ -140,6 +150,19 @@ func (p *Provider) VerifyHostReadiness(ctx context.Context) (HostReadiness, erro } func (p *Provider) Create(ctx context.Context, request provider.CreateRequest) (provider.Instance, error) { + if p.dryRun { + return provider.Instance{}, fmt.Errorf("docker-sandboxes does not support dry-run instance creation because exact sandbox and template-cache readback is required") + } + if request.Template == "" && request.TemplateDigest == "" { + p.activeMu.RLock() + active := p.activeTemplate + p.activeMu.RUnlock() + request.Template = active.Reference + request.TemplateDigest = active.Digest + if request.RootDisk == "auto" { + request.RootDisk = active.RootDisk + } + } if err := validateCreateRequest(request); err != nil { return provider.Instance{}, err } @@ -233,6 +256,58 @@ func (p *Provider) Create(ctx context.Context, request provider.CreateRequest) ( return provider.Instance{}, fmt.Errorf("docker sandbox was not present in inventory after create") } +// ImportTemplate performs the one exact provider mutation required after the +// shared image coordinator has built and verified a local template archive. +func (p *Provider) ImportTemplate(ctx context.Context, archivePath string) error { + if archivePath == "" || strings.ContainsRune(archivePath, 0) { + return fmt.Errorf("Docker Sandboxes template archive path is required") + } + info, err := os.Lstat(archivePath) + if err != nil { + return fmt.Errorf("inspect Docker Sandboxes template archive: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("Docker Sandboxes template archive must be a regular file") + } + if _, err := p.run(ctx, commandRequest{ + args: []string{"template", "load", archivePath}, + operation: "load exact Docker Sandboxes runner template", + outputLimit: diagnosticOutputLimit, + }); err != nil { + return err + } + return nil +} + +func (p *Provider) VerifyTemplate(ctx context.Context, artifact provider.TemplateArtifact) error { + if artifact.Reference == "" || !validFullTemplateDigest(artifact.Digest) { + return fmt.Errorf("Docker Sandboxes template reference and digest are required") + } + if artifact.CacheID != strings.TrimPrefix(artifact.Digest, "sha256:")[:12] { + return fmt.Errorf("Docker Sandboxes template cache ID does not match its full digest") + } + return p.verifyCachedTemplate(ctx, artifact.Reference, artifact.Digest) +} + +func (p *Provider) ActivateTemplate(artifact provider.TemplateArtifact) error { + if artifact.Reference == "" || !validFullTemplateDigest(artifact.Digest) { + return fmt.Errorf("cannot activate an invalid Docker Sandboxes template identity") + } + if artifact.CacheID != strings.TrimPrefix(artifact.Digest, "sha256:")[:12] { + return fmt.Errorf("cannot activate a Docker Sandboxes template with a mismatched cache ID") + } + if artifact.Platform != "linux/amd64" && artifact.Platform != "linux/arm64" { + return fmt.Errorf("cannot activate a Docker Sandboxes template for unsupported platform %q", artifact.Platform) + } + if !sizePattern.MatchString(artifact.RootDisk) { + return fmt.Errorf("cannot activate a Docker Sandboxes template without a resolved root-disk size") + } + p.activeMu.Lock() + p.activeTemplate = artifact + p.activeMu.Unlock() + return nil +} + // VerifyAdmission fail-closes on provider-wide channels Docker Sandboxes can // inject into every sandbox. EPAR deliberately does not consume global secrets; // repository and workflow input can never opt out of this check. @@ -868,6 +943,9 @@ func validateCommandRequest(request commandRequest) error { if request.args[0] == "ports" && (len(request.args) != 3 || !sandboxNamePattern.MatchString(request.args[1]) || request.args[2] != "--json") { return fmt.Errorf("only exact machine-readable published-port absence verification is permitted") } + if request.args[0] == "daemon" && (len(request.args) != 3 || !((request.args[1] == "status" && request.args[2] == "--json") || (request.args[1] == "start" && request.args[2] == "--detach"))) { + return fmt.Errorf("only exact daemon status or detached-start operations are permitted") + } for _, arg := range request.args { if strings.ContainsRune(arg, 0) { return fmt.Errorf("docker sandboxes argument contains a null byte") @@ -963,3 +1041,4 @@ var _ provider.Lifecycle = (*Provider)(nil) var _ provider.AdmissionVerifier = (*Provider)(nil) var _ provider.InstanceAdmissionVerifier = (*Provider)(nil) var _ provider.PolicyManager = (*Provider)(nil) +var _ provider.TemplateArtifactRuntime = (*Provider)(nil) diff --git a/internal/provider/dockersandboxes/provider_test.go b/internal/provider/dockersandboxes/provider_test.go index ffc5098..2487edf 100644 --- a/internal/provider/dockersandboxes/provider_test.go +++ b/internal/provider/dockersandboxes/provider_test.go @@ -31,6 +31,32 @@ const ( var testInstance = provider.Instance{Name: testName, ProviderID: testID, Source: "shell", State: "running"} +func TestCreateDryRunFailsBeforeProviderSideEffects(t *testing.T) { + p := NewWithDryRun("sbx", true) + called := false + p.runCommand = func(context.Context, commandRequest) (provider.ExecResult, error) { + called = true + return provider.ExecResult{}, nil + } + _, err := p.Create(context.Background(), provider.CreateRequest{Name: testName}) + if err == nil || !strings.Contains(err.Error(), "does not support dry-run") { + t.Fatalf("Create() error = %v", err) + } + if called { + t.Fatal("dry-run Create invoked a provider command") + } +} + +func TestStartDaemonUsesExactDetachedCommand(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"daemon", "start", "--detach"}}, + ) + if err := p.StartDaemon(context.Background()); err != nil { + t.Fatal(err) + } + done() +} + type commandStep struct { args []string result provider.ExecResult @@ -1001,6 +1027,11 @@ func TestCommandBoundaryRejectsInteractiveAndDestructiveGlobalCommands(t *testin t.Fatalf("non-exact Docker Sandboxes published-port inspection was accepted: %q", arguments) } } + for _, arguments := range [][]string{{"daemon"}, {"daemon", "start"}, {"daemon", "start", "--foreground"}, {"daemon", "stop", "--detach"}, {"daemon", "status"}, {"daemon", "status", "--debug"}} { + if err := validateCommandRequest(commandRequest{args: arguments, operation: "test forbidden daemon command"}); err == nil { + t.Fatalf("non-exact Docker Sandboxes daemon command was accepted: %q", arguments) + } + } } func TestExecRejectsHostPathsAndEnvironmentPassthrough(t *testing.T) { diff --git a/internal/provider/factory.go b/internal/provider/factory.go index a000026..96780b5 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -18,6 +18,13 @@ type Descriptor struct { LifecycleSupported bool StorageSupported bool ImageMode string + GuidedArtifacts bool + WizardImageProfiles []WizardImageProfile +} + +type WizardImageProfile struct { + Name string + Tag string } const ( diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d36e50b..9991329 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -114,6 +114,25 @@ type ArtifactManager interface { EnsureArtifacts(ctx context.Context, dryRun bool) (handled bool, err error) } +// TemplateArtifact is the exact immutable reusable artifact selected by the +// shared image coordinator for a template-backed provider. +type TemplateArtifact struct { + Reference string `json:"reference"` + Digest string `json:"digest"` + CacheID string `json:"cacheId"` + Platform string `json:"platform"` + RootDisk string `json:"rootDisk,omitempty"` +} + +// TemplateArtifactRuntime exposes only provider-specific template-cache +// integration. Source resolution, builds, manifests, receipts, and retention +// remain owned by the shared image and storage packages. +type TemplateArtifactRuntime interface { + ImportTemplate(ctx context.Context, archivePath string) error + VerifyTemplate(ctx context.Context, artifact TemplateArtifact) error + ActivateTemplate(artifact TemplateArtifact) error +} + // StorageContribution is required for every registered provider. It describes // the provider's measurable capacity surface and operation expansion before // the shared pool performs provider side effects. diff --git a/internal/provider/registry/contributions_test.go b/internal/provider/registry/contributions_test.go index f08ecfa..d44af06 100644 --- a/internal/provider/registry/contributions_test.go +++ b/internal/provider/registry/contributions_test.go @@ -36,6 +36,14 @@ func TestEveryProviderRegistersRequiredContributions(t *testing.T) { default: t.Errorf("%s has unsupported image mode %q", descriptor.Type, descriptor.ImageMode) } + if descriptor.ImageMode == provider.ImageModeTemplate { + if !descriptor.GuidedArtifacts || len(descriptor.WizardImageProfiles) == 0 { + t.Errorf("%s has no guided template provisioning contribution", descriptor.Type) + } + if descriptor.WizardImageProfiles[0].Name != "full" || descriptor.WizardImageProfiles[0].Tag != "full-latest" { + t.Errorf("%s does not register its default image profile first", descriptor.Type) + } + } } if len(seen) != len(SupportedTypes()) { t.Fatalf("descriptors=%d supported types=%d", len(seen), len(SupportedTypes())) diff --git a/internal/provider/registry/registry.go b/internal/provider/registry/registry.go index 9d489ca..3efa6b3 100644 --- a/internal/provider/registry/registry.go +++ b/internal/provider/registry/registry.go @@ -34,7 +34,7 @@ type entry struct { var entries = []entry{ { - descriptor: provider.Descriptor{Type: "docker-container", DisplayName: "Docker Container", WizardSupported: true, WizardNumber: "1", WizardLabel: "Docker Container — private daemon", WizardAliases: []string{"docker", "docker-container"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker}, + descriptor: provider.Descriptor{Type: "docker-container", DisplayName: "Docker Container", WizardSupported: true, WizardNumber: "1", WizardLabel: "Docker Container — private daemon", WizardAliases: []string{"docker", "docker-container"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker, GuidedArtifacts: true, WizardImageProfiles: catthehackerProfiles()}, factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { hostGateway := config.DockerConfigNeedsHostGateway(cfg.Docker) environment := map[string]string{ @@ -46,14 +46,29 @@ var entries = []entry{ }, }, { - descriptor: provider.Descriptor{Type: "docker-sandboxes", DisplayName: "Docker Sandboxes", WizardSupported: true, WizardNumber: "2", WizardLabel: "Docker Sandboxes — recommended when ready", WizardAliases: []string{"docker-sandboxes", "sandboxes"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeTemplate}, - factory: func(cfg config.Config, projectRoot string, _ bool) Runtime { - sandboxes := dockersandboxes.New("") + descriptor: provider.Descriptor{ + Type: "docker-sandboxes", + DisplayName: "Docker Sandboxes", + WizardSupported: true, + WizardNumber: "2", + WizardLabel: "Docker Sandboxes — recommended when ready", + WizardAliases: []string{"docker-sandboxes", "sandboxes"}, + ConfigurationDecoder: true, + ConfigurationDefaults: true, + ConfigurationValidator: true, + LifecycleSupported: true, + StorageSupported: true, + ImageMode: provider.ImageModeTemplate, + GuidedArtifacts: true, + WizardImageProfiles: catthehackerProfiles(), + }, + factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { + sandboxes := dockersandboxes.NewWithDryRun("", dryRun) return Runtime{Lifecycle: sandboxes, PolicyManager: sandboxes, Storage: providerStorage(cfg, projectRoot)} }, }, { - descriptor: provider.Descriptor{Type: "wsl", DisplayName: "WSL2", WizardSupported: true, WizardNumber: "3", WizardLabel: "WSL2", WizardAliases: []string{"wsl", "wsl2"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker}, + descriptor: provider.Descriptor{Type: "wsl", DisplayName: "WSL2", WizardSupported: true, WizardNumber: "3", WizardLabel: "WSL2", WizardAliases: []string{"wsl", "wsl2"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker, GuidedArtifacts: true, WizardImageProfiles: catthehackerProfiles()}, factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { installRoot := config.ProjectPath(projectRoot, cfg.Provider.InstallRoot) return adaptLegacy(wsl.New("", installRoot, projectRoot, dryRun), providerStorage(cfg, projectRoot), dryRun) @@ -67,11 +82,21 @@ var entries = []entry{ }, } +func catthehackerProfiles() []provider.WizardImageProfile { + return []provider.WizardImageProfile{ + {Name: "full", Tag: "full-latest"}, + {Name: "act", Tag: "act-latest"}, + {Name: "dotnet", Tag: "dotnet-latest"}, + {Name: "js", Tag: "js-latest"}, + } +} + func Descriptors() []provider.Descriptor { result := make([]provider.Descriptor, 0, len(entries)) for _, registered := range entries { descriptor := registered.descriptor descriptor.WizardAliases = append([]string(nil), descriptor.WizardAliases...) + descriptor.WizardImageProfiles = append([]provider.WizardImageProfile(nil), descriptor.WizardImageProfiles...) result = append(result, descriptor) } return result @@ -82,6 +107,7 @@ func DescriptorFor(providerType string) (provider.Descriptor, bool) { if registered.descriptor.Type == providerType { descriptor := registered.descriptor descriptor.WizardAliases = append([]string(nil), descriptor.WizardAliases...) + descriptor.WizardImageProfiles = append([]provider.WizardImageProfile(nil), descriptor.WizardImageProfiles...) return descriptor, true } } @@ -112,10 +138,18 @@ func New(cfg config.Config, projectRoot string, dryRun bool) (Runtime, error) { if !descriptor.WizardSupported || descriptor.WizardNumber == "" || descriptor.WizardLabel == "" || len(descriptor.WizardAliases) == 0 || !descriptor.ConfigurationDecoder || !descriptor.ConfigurationDefaults || !descriptor.ConfigurationValidator || !descriptor.LifecycleSupported || !descriptor.StorageSupported || descriptor.ImageMode == "" { return Runtime{}, fmt.Errorf("provider %q has an incomplete registry entry", cfg.Provider.Type) } + if (descriptor.ImageMode == provider.ImageModeTemplate || descriptor.Type == "docker-container" || descriptor.Type == "wsl") && (!descriptor.GuidedArtifacts || len(descriptor.WizardImageProfiles) == 0) { + return Runtime{}, fmt.Errorf("Docker-image-capable provider %q has no guided artifact onboarding contribution", cfg.Provider.Type) + } runtime := registered.factory(cfg, projectRoot, dryRun) if runtime.Lifecycle == nil || runtime.Storage == nil { return Runtime{}, fmt.Errorf("provider %q registry entry did not construct required lifecycle and storage behavior", cfg.Provider.Type) } + if descriptor.ImageMode == provider.ImageModeTemplate { + if _, ok := runtime.Lifecycle.(provider.TemplateArtifactRuntime); !ok { + return Runtime{}, fmt.Errorf("template-backed provider %q did not construct required artifact runtime behavior", cfg.Provider.Type) + } + } return runtime, nil } @@ -126,28 +160,23 @@ func providerStorage(cfg config.Config, projectRoot string) provider.StorageCont case "docker-container": roots = append(roots, provider.StorageRoot{ID: "docker-engine-backing", Kind: storage.SurfaceDockerEngine, Location: dockerBackingRoot()}) case "docker-sandboxes": - rootDisk, rootErr := config.ParseByteSize(cfg.DockerSandboxes.RootDisk) - dockerDisk, dockerErr := config.ParseByteSize(cfg.DockerSandboxes.DockerDisk) - sandboxCreateExpansion := uint64(0) - if rootErr == nil && dockerErr == nil && rootDisk > 0 && dockerDisk > 0 { - sandboxCreateExpansion = uint64(rootDisk) + uint64(dockerDisk) - } roots = append(roots, provider.StorageRoot{ ID: "docker-engine-backing", Kind: storage.SurfaceDockerEngine, Location: dockerBackingRoot(), MinimumExpansions: map[string]uint64{ - "image-pull": 0, - "image-build": 0, - "source-update": 0, + "image-pull": 0, + "image-build": 0, + "source-update": 0, + "template-build": 0, }, }, provider.StorageRoot{ ID: "docker-sandboxes-backing", Kind: storage.SurfaceSandboxCache, Location: dockerSandboxesBackingRoot(), - MinimumExpansions: map[string]uint64{"instance-create": sandboxCreateExpansion}, + MinimumExpansions: map[string]uint64{"instance-create": 0, "template-build": 0}, }, provider.StorageRoot{ID: "docker-sandboxes-staging", Location: config.ProjectPath(projectRoot, cfg.DockerSandboxes.StagingRoot)}, ) diff --git a/internal/provider/registry/registry_test.go b/internal/provider/registry/registry_test.go index f960f62..4a78dc1 100644 --- a/internal/provider/registry/registry_test.go +++ b/internal/provider/registry/registry_test.go @@ -130,7 +130,7 @@ func TestDockerSandboxesStorageRoutesOperationsToTheirBackingSurfaces(t *testing for _, requirement := range create.Requirements { createRequirements[requirement.SurfaceID] = requirement.PeakBytes } - if got, want := createRequirements["docker-sandboxes-backing"], uint64(130<<30); got != want { + if got, want := createRequirements["docker-sandboxes-backing"], uint64(10<<30); got != want { t.Fatalf("sandbox backing create expansion = %d, want %d", got, want) } if _, found := createRequirements["docker-engine-backing"]; found { diff --git a/internal/provider/storage.go b/internal/provider/storage.go index d4ecf49..0ad56eb 100644 --- a/internal/provider/storage.go +++ b/internal/provider/storage.go @@ -92,11 +92,14 @@ func (contribution *filesystemStorage) StorageSnapshot(_ context.Context, reques kind = storage.SurfaceHostFilesystem } snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ - ID: specification.ID, - Provider: contribution.providerType, - Kind: kind, - Location: root, - Capacity: capacity, + ID: specification.ID, + Provider: contribution.providerType, + Kind: kind, + Location: root, + Classification: "physical", + Confidence: "authoritative-filesystem-probe", + AdmissionAuthoritative: true, + Capacity: capacity, }) if !requiredForOperation { continue diff --git a/internal/storage/capacity_test.go b/internal/storage/capacity_test.go index 0a8c5e3..d3472cc 100644 --- a/internal/storage/capacity_test.go +++ b/internal/storage/capacity_test.go @@ -22,22 +22,22 @@ func TestEvaluateCapacity(t *testing.T) { capacity: Capacity{}, requirement: Requirement{ID: "full-build", SurfaceID: "host", PeakBytes: 30 * GiB}, wantStatus: CapacityUnknown, - wantRequired: 50 * GiB, + wantRequired: 31 * GiB, }, { name: "insufficient includes deficit", - capacity: Capacity{Known: true, AvailableBytes: 49 * GiB, TotalBytes: 100 * GiB, ObservedAt: now}, + capacity: Capacity{Known: true, AvailableBytes: 30 * GiB, TotalBytes: 100 * GiB, ObservedAt: now}, requirement: Requirement{ID: "full-build", SurfaceID: "host", PeakBytes: 30 * GiB}, wantStatus: CapacityInsufficient, - wantRequired: 50 * GiB, + wantRequired: 31 * GiB, wantDeficit: GiB, }, { name: "ready", - capacity: Capacity{Known: true, AvailableBytes: 50 * GiB, TotalBytes: 100 * GiB, ObservedAt: now}, + capacity: Capacity{Known: true, AvailableBytes: 31 * GiB, TotalBytes: 100 * GiB, ObservedAt: now}, requirement: Requirement{ID: "full-build", SurfaceID: "host", PeakBytes: 30 * GiB}, wantStatus: CapacityReady, - wantRequired: 50 * GiB, + wantRequired: 31 * GiB, }, } for _, test := range tests { diff --git a/internal/storage/inventory/template.go b/internal/storage/inventory/template.go index 2dfa5b2..d34bc7e 100644 --- a/internal/storage/inventory/template.go +++ b/internal/storage/inventory/template.go @@ -12,10 +12,14 @@ import ( "sort" "strings" + "github.com/solutionforest/ephemeral-action-runner/internal/config" "github.com/solutionforest/ephemeral-action-runner/internal/storage" ) -const maximumTemplateMetadataBytes = 4 << 20 +const ( + maximumTemplateMetadataBytes = 4 << 20 + templateMetadataSchema = 4 +) var ( templateDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) @@ -36,16 +40,17 @@ type templateMetadata struct { Profile string `json:"profile"` Platform string `json:"platform"` Template struct { - Tag string `json:"tag"` - Digest string `json:"digest"` - TemplateDigest string `json:"templateDigest"` - CacheID string `json:"cacheID"` - Archive string `json:"archive"` - ArchiveSHA256 string `json:"archiveSha256"` - ArchiveBytes uint64 `json:"archiveBytes"` + Tag string `json:"tag"` + Digest string `json:"digest"` + CacheID string `json:"cacheID"` + RootDisk string `json:"rootDisk"` + Archive string `json:"archive"` + ArchiveSHA256 string `json:"archiveSha256"` + ArchiveBytes uint64 `json:"archiveBytes"` } `json:"template"` Compatibility struct { - Candidate string `json:"candidate"` + TemplateSchemaVersion int `json:"templateSchemaVersion"` + RunnerExecution string `json:"runnerExecution"` DockerDaemonOwner string `json:"dockerDaemonOwner"` ExpectedDockerDaemonCount int `json:"expectedDockerDaemonCount"` } `json:"compatibility"` @@ -189,8 +194,8 @@ func inspectTemplateDirectory(path string) (templateRecord, storage.Artifact, er } func validateTemplateMetadata(metadata templateMetadata) error { - if metadata.SchemaVersion != 2 { - return fmt.Errorf("template metadata schemaVersion must be 2") + if metadata.SchemaVersion != templateMetadataSchema { + return fmt.Errorf("template metadata schemaVersion must be %d", templateMetadataSchema) } if !templateProfilePattern.MatchString(metadata.Profile) { return fmt.Errorf("template metadata profile is invalid") @@ -198,11 +203,15 @@ func validateTemplateMetadata(metadata templateMetadata) error { if metadata.Platform != "linux/amd64" && metadata.Platform != "linux/arm64" { return fmt.Errorf("template metadata platform is invalid") } - if !templateTagPattern.MatchString(metadata.Template.Tag) || !templateDigestPattern.MatchString(metadata.Template.Digest) || !templateDigestPattern.MatchString(metadata.Template.TemplateDigest) || !templateCacheIDPattern.MatchString(metadata.Template.CacheID) { - return fmt.Errorf("template metadata contains an invalid tag, digest, template identity, or cache ID") + if !templateTagPattern.MatchString(metadata.Template.Tag) || !templateDigestPattern.MatchString(metadata.Template.Digest) || !templateCacheIDPattern.MatchString(metadata.Template.CacheID) { + return fmt.Errorf("template metadata contains an invalid tag, digest, or cache ID") + } + if metadata.Template.CacheID != strings.TrimPrefix(metadata.Template.Digest, "sha256:")[:12] { + return fmt.Errorf("template metadata cache ID does not match image digest") } - if metadata.Template.CacheID != strings.TrimPrefix(metadata.Template.TemplateDigest, "sha256:")[:12] { - return fmt.Errorf("template metadata cache ID does not match template identity") + rootDisk, err := config.ParseByteSize(metadata.Template.RootDisk) + if err != nil || rootDisk < int64(20*storage.GiB) { + return fmt.Errorf("template metadata root disk is invalid") } if !templateArchivePattern.MatchString(metadata.Template.Archive) || filepath.Base(metadata.Template.Archive) != metadata.Template.Archive { return fmt.Errorf("template metadata archive is not an exact basename") @@ -210,8 +219,8 @@ func validateTemplateMetadata(metadata templateMetadata) error { if !templateDigestPattern.MatchString(metadata.Template.ArchiveSHA256) || metadata.Template.ArchiveBytes == 0 { return fmt.Errorf("template metadata archive digest or size is invalid") } - if metadata.Compatibility.Candidate != "A" || metadata.Compatibility.DockerDaemonOwner != "docker-sandboxes-runtime" || metadata.Compatibility.ExpectedDockerDaemonCount != 1 { - return fmt.Errorf("template metadata compatibility does not preserve the Candidate A runtime contract") + if metadata.Compatibility.TemplateSchemaVersion != 1 || metadata.Compatibility.RunnerExecution != "direct-actions-listener" || metadata.Compatibility.DockerDaemonOwner != "docker-sandboxes-runtime" || metadata.Compatibility.ExpectedDockerDaemonCount != 1 { + return fmt.Errorf("template metadata compatibility does not preserve the Docker Sandboxes runner runtime contract") } return nil } @@ -244,7 +253,7 @@ func applyTemplateSelections(records []templateRecord, selections []TemplateSele var matches []int for index := range records { record := records[index] - if (selection.Profile == "" || record.metadata.Profile == selection.Profile) && (selection.Platform == "" || record.metadata.Platform == selection.Platform) && normalizedTemplateTag(record.metadata.Template.Tag) == normalizedTemplateTag(selection.Tag) && record.metadata.Template.TemplateDigest == selection.TemplateDigest && (selection.MetadataSHA256 == "" || record.metadataSHA256 == selection.MetadataSHA256) { + if (selection.Profile == "" || record.metadata.Profile == selection.Profile) && (selection.Platform == "" || record.metadata.Platform == selection.Platform) && normalizedTemplateTag(record.metadata.Template.Tag) == normalizedTemplateTag(selection.Tag) && record.metadata.Template.Digest == selection.TemplateDigest && (selection.MetadataSHA256 == "" || record.metadataSHA256 == selection.MetadataSHA256) { matches = append(matches, index) } } diff --git a/internal/storage/inventory/template_test.go b/internal/storage/inventory/template_test.go index 5c7fb45..6aa2010 100644 --- a/internal/storage/inventory/template_test.go +++ b/internal/storage/inventory/template_test.go @@ -178,7 +178,7 @@ func writeTemplateFixture(t *testing.T, root, name, profile, platform, suffix st Profile: profile, Platform: platform, Tag: template["tag"].(string), - TemplateDigest: template["templateDigest"].(string), + TemplateDigest: template["digest"].(string), ArchiveSHA256: archiveSHA, MetadataSHA256: "sha256:" + hex.EncodeToString(metadataSum[:]), Directory: directory, @@ -189,20 +189,21 @@ func writeTemplateFixture(t *testing.T, root, name, profile, platform, suffix st func validTemplateMetadata(profile, platform, suffix, archive string, archiveSHA string, archiveBytes uint64) map[string]any { templateDigest := "sha256:" + strings.Repeat(digestCharacter(suffix), 64) return map[string]any{ - "schemaVersion": 2, + "schemaVersion": templateMetadataSchema, "profile": profile, "platform": platform, "template": map[string]any{ - "tag": "epar-docker-sandboxes-" + profile + ":" + suffix + "-amd64", - "digest": templateDigest, - "templateDigest": templateDigest, - "cacheID": strings.TrimPrefix(templateDigest, "sha256:")[:12], - "archive": archive, - "archiveSha256": archiveSHA, - "archiveBytes": archiveBytes, + "tag": "epar-docker-sandboxes-" + profile + ":" + suffix + "-amd64", + "digest": templateDigest, + "cacheID": strings.TrimPrefix(templateDigest, "sha256:")[:12], + "rootDisk": "30GiB", + "archive": archive, + "archiveSha256": archiveSHA, + "archiveBytes": archiveBytes, }, "compatibility": map[string]any{ - "candidate": "A", + "templateSchemaVersion": 1, + "runnerExecution": "direct-actions-listener", "dockerDaemonOwner": "docker-sandboxes-runtime", "expectedDockerDaemonCount": 1, }, diff --git a/internal/storage/types.go b/internal/storage/types.go index dea04e1..1b3884a 100644 --- a/internal/storage/types.go +++ b/internal/storage/types.go @@ -5,7 +5,7 @@ import "time" const ( GiB uint64 = 1 << 30 - DefaultMinimumFreeBytes = 20 * GiB + DefaultMinimumFreeBytes = 1 * GiB DefaultGracePeriod = 168 * time.Hour DefaultKeepPrevious = 0 DefaultBuildKitMaxBytes = 64 * GiB @@ -34,11 +34,18 @@ type Capacity struct { // Surface identifies one capacity and reclaim domain. type Surface struct { - ID string `json:"id"` - Provider string `json:"provider,omitempty"` - Kind SurfaceKind `json:"kind"` - Location string `json:"location,omitempty"` - Capacity Capacity `json:"capacity"` + ID string `json:"id"` + Provider string `json:"provider,omitempty"` + Kind SurfaceKind `json:"kind"` + Location string `json:"location,omitempty"` + Classification string `json:"classification,omitempty"` + Sparse bool `json:"sparse,omitempty"` + VirtualMaximumBytes uint64 `json:"virtualMaximumBytes,omitempty"` + AllocatedBytes uint64 `json:"allocatedBytes,omitempty"` + Confidence string `json:"confidence,omitempty"` + AdmissionAuthoritative bool `json:"admissionAuthoritative"` + Advisory bool `json:"advisory,omitempty"` + Capacity Capacity `json:"capacity"` } // Requirement is the peak additional capacity needed by an operation on one diff --git a/scripts/build-native-controller.ps1 b/scripts/build-native-controller.ps1 index 0f4b39d..0304fad 100644 --- a/scripts/build-native-controller.ps1 +++ b/scripts/build-native-controller.ps1 @@ -6,6 +6,7 @@ param( $ErrorActionPreference = 'Stop' $RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +. (Join-Path $RepoRoot 'scripts\host-trust\wrapper-lib.ps1') $GoImage = if ($env:GO_DOCKER_IMAGE) { $env:GO_DOCKER_IMAGE } else { 'golang:1.25' } $DevImage = if ($env:EPAR_DEV_IMAGE) { $env:EPAR_DEV_IMAGE } else { 'epar-dev-toolchain' } $repoRootPath = [System.IO.Path]::GetFullPath($RepoRoot) @@ -28,7 +29,7 @@ if ($env:EPAR_GO_CACHE_LIMIT_BYTES) { } $GoCacheLimitBytes = $parsedGoCacheLimit } -$BootstrapMinimumFreeBytes = [uint64](20GB) +$BootstrapMinimumFreeBytes = [uint64](1GB) if ($env:EPAR_BOOTSTRAP_MIN_FREE_BYTES) { $parsedBootstrapMinimum = [uint64] 0 if (-not [uint64]::TryParse($env:EPAR_BOOTSTRAP_MIN_FREE_BYTES, [ref] $parsedBootstrapMinimum) -or $parsedBootstrapMinimum -eq 0) { @@ -52,8 +53,12 @@ try { exit 1 } if ($bootstrapAvailableBytes -lt $BootstrapMinimumFreeBytes) { - Write-Error ("insufficient bootstrap storage on {0}: available={1} required-reserve={2}. Free space or run `ephemeral-action-runner storage status` from an existing controller before retrying." -f $repoVolumeRoot, $bootstrapAvailableBytes, $BootstrapMinimumFreeBytes) - exit 1 + if ($EparArgs -contains '--allow-insufficient-storage') { + Write-Warning ("bootstrap storage on {0} is below the {1}-byte reserve; continuing because --allow-insufficient-storage was explicitly supplied." -f $repoVolumeRoot, $BootstrapMinimumFreeBytes) + } else { + Write-Error ("insufficient bootstrap storage on {0}: available={1} required-reserve={2}. Free space, inspect storage, or retry this invocation with --allow-insufficient-storage." -f $repoVolumeRoot, $bootstrapAvailableBytes, $BootstrapMinimumFreeBytes) + exit 1 + } } function Get-EparGoCacheVolumeIdentity { @@ -491,9 +496,15 @@ $previousNative = $env:EPAR_NATIVE_CONTROLLER $previousControllerOS = $env:EPAR_CONTROLLER_HOST_OS $previousHostName = $env:EPAR_HOST_NAME $previousHints = $env:DOCKER_CLI_HINTS +$previousRunnerTrustFeed = $env:EPAR_HOST_TRUST_FEED +$previousBuildTrustFeed = $env:EPAR_BUILD_TRUST_FEED +$controllerCommand = if ($EparArgs -and $EparArgs.Count -gt 0) { [string]$EparArgs[0] } else { 'start' } +$bridge = Start-EparHostTrustBridge -ProjectRoot $RepoRoot -Command $controllerCommand -Arguments $EparArgs try { $env:EPAR_NATIVE_CONTROLLER = '1' $env:EPAR_CONTROLLER_HOST_OS = 'windows' + if ($bridge.BuildFeedDir) { $env:EPAR_BUILD_TRUST_FEED = Join-Path $bridge.BuildFeedDir 'current.json' } + if ($bridge.RunnerFeedDir) { $env:EPAR_HOST_TRUST_FEED = Join-Path $bridge.RunnerFeedDir 'current.json' } if (-not $env:EPAR_HOST_NAME) { $env:EPAR_HOST_NAME = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { [System.Net.Dns]::GetHostName() } } @@ -501,9 +512,12 @@ try { & $binary @EparArgs exit $LASTEXITCODE } finally { + Stop-EparHostTrustBridge -Bridge $bridge Remove-Item -LiteralPath $leasePath -Force -ErrorAction SilentlyContinue if ($null -eq $previousNative) { Remove-Item Env:EPAR_NATIVE_CONTROLLER -ErrorAction SilentlyContinue } else { $env:EPAR_NATIVE_CONTROLLER = $previousNative } if ($null -eq $previousControllerOS) { Remove-Item Env:EPAR_CONTROLLER_HOST_OS -ErrorAction SilentlyContinue } else { $env:EPAR_CONTROLLER_HOST_OS = $previousControllerOS } if ($null -eq $previousHostName) { Remove-Item Env:EPAR_HOST_NAME -ErrorAction SilentlyContinue } else { $env:EPAR_HOST_NAME = $previousHostName } if ($null -eq $previousHints) { Remove-Item Env:DOCKER_CLI_HINTS -ErrorAction SilentlyContinue } else { $env:DOCKER_CLI_HINTS = $previousHints } + if ($null -eq $previousRunnerTrustFeed) { Remove-Item Env:EPAR_HOST_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_HOST_TRUST_FEED = $previousRunnerTrustFeed } + if ($null -eq $previousBuildTrustFeed) { Remove-Item Env:EPAR_BUILD_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_BUILD_TRUST_FEED = $previousBuildTrustFeed } } diff --git a/scripts/build-native-controller.sh b/scripts/build-native-controller.sh index 0e3ddc6..536532b 100644 --- a/scripts/build-native-controller.sh +++ b/scripts/build-native-controller.sh @@ -2,13 +2,15 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +EPAR_HOST_TRUST_HELPER="${repo_root}/scripts/host-trust/host-trust-feed.sh" +source "${repo_root}/scripts/host-trust/wrapper-lib.sh" go_image="${GO_DOCKER_IMAGE:-golang:1.25}" dev_image="${EPAR_DEV_IMAGE:-epar-dev-toolchain}" native_cache_keep_previous=5 native_cache_max_bytes=$((256 * 1024 * 1024)) native_cache_grace_seconds=$((7 * 24 * 60 * 60)) abandoned_build_grace_seconds=$((24 * 60 * 60)) -bootstrap_minimum_free_bytes="${EPAR_BOOTSTRAP_MIN_FREE_BYTES:-$((20 * 1024 * 1024 * 1024))}" +bootstrap_minimum_free_bytes="${EPAR_BOOTSTRAP_MIN_FREE_BYTES:-$((1 * 1024 * 1024 * 1024))}" go_cache_limit_bytes="${EPAR_GO_CACHE_LIMIT_BYTES:-$((10 * 1024 * 1024 * 1024))}" command -v docker >/dev/null 2>&1 || { echo "docker command not found. Install Docker Desktop, Docker Engine, or a compatible Docker host." >&2; exit 1; } @@ -24,8 +26,12 @@ bootstrap_available_kib="$(df -Pk "$repo_root" | awk 'NR == 2 { print $4 }')" [[ "$bootstrap_available_kib" =~ ^[0-9]+$ ]] || { echo "cannot measure bootstrap storage for ${repo_root}" >&2; exit 1; } bootstrap_available_bytes=$((bootstrap_available_kib * 1024)) if ((bootstrap_available_bytes < bootstrap_minimum_free_bytes)); then - echo "insufficient bootstrap storage for ${repo_root}: available=${bootstrap_available_bytes} required-reserve=${bootstrap_minimum_free_bytes}. Free space or run 'ephemeral-action-runner storage status' from an existing controller before retrying." >&2 - exit 1 + if [[ " $* " == *" --allow-insufficient-storage "* ]]; then + echo "WARNING: bootstrap storage is below the ${bootstrap_minimum_free_bytes}-byte reserve; continuing because --allow-insufficient-storage was explicitly supplied." >&2 + else + echo "insufficient bootstrap storage for ${repo_root}: available=${bootstrap_available_bytes} required-reserve=${bootstrap_minimum_free_bytes}. Free space, inspect storage, or retry this invocation with --allow-insufficient-storage." >&2 + exit 1 + fi fi epar_ensure_go_cache_volume() { @@ -314,8 +320,13 @@ export EPAR_NATIVE_CONTROLLER=1 export EPAR_CONTROLLER_HOST_OS="$goos" export DOCKER_CLI_HINTS="${DOCKER_CLI_HINTS:-false}" export EPAR_HOST_NAME="${EPAR_HOST_NAME:-$(hostname 2>/dev/null || true)}" -"$binary" "$@" -status=$? +controller_command="${1:-start}" +epar_host_trust_prepare "$repo_root" "$controller_command" "$@" +if [[ -n "${EPAR_BUILD_TRUST_FEED_DIR}" ]]; then export EPAR_BUILD_TRUST_FEED="${EPAR_BUILD_TRUST_FEED_DIR}/current.json"; fi +if [[ -n "${EPAR_RUNNER_TRUST_FEED_DIR}" ]]; then export EPAR_HOST_TRUST_FEED="${EPAR_RUNNER_TRUST_FEED_DIR}/current.json"; fi +status=0 +"$binary" "$@" || status=$? +epar_host_trust_cleanup cleanup_build trap - EXIT INT TERM exit "$status" diff --git a/scripts/docker-sandboxes/build-template.ps1 b/scripts/docker-sandboxes/build-template.ps1 index 8617683..2d8591e 100644 --- a/scripts/docker-sandboxes/build-template.ps1 +++ b/scripts/docker-sandboxes/build-template.ps1 @@ -1,409 +1,25 @@ [CmdletBinding()] param( - [ValidateSet('act-22.04', 'full')] - [string]$Profile = 'act-22.04', - [ValidateSet('linux/amd64', 'linux/arm64')] - [string]$Platform = 'linux/amd64', - [string]$OutputDirectory, - [switch]$Execute, - [switch]$ReplaceArtifacts + [string] $Config = '.local/config.yml', + [string] $ProjectRoot, + [switch] $Execute, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $RemainingArguments ) $ErrorActionPreference = 'Stop' -$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path -$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $scriptDirectory '..\..')) -$templateDirectory = Join-Path $repositoryRoot 'templates\docker-sandboxes' -$lockPath = Join-Path $templateDirectory 'sources.lock.json' -$storageStateDirectory = Join-Path $repositoryRoot '.local\state\storage' -$ownerIDPath = Join-Path $storageStateDirectory 'owner-id' -$builderMetadataPath = Join-Path $storageStateDirectory 'buildx-builder.json' -$buildKitConfigPath = Join-Path $storageStateDirectory 'buildkitd.toml' -$lock = Get-Content -Raw -LiteralPath $lockPath | ConvertFrom-Json -$profileLock = $lock.profiles.PSObject.Properties[$Profile].Value -if ($null -eq $profileLock) { - throw "Profile $Profile is not present in $lockPath" +$repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +if ([string]::IsNullOrWhiteSpace($ProjectRoot)) { + $ProjectRoot = $repositoryRoot } -$platformLock = $lock.platforms.PSObject.Properties[$Platform].Value -if ($null -eq $platformLock) { - throw "Platform $Platform is not present in $lockPath" -} -$profilePlatformLock = $profileLock.platforms.PSObject.Properties[$Platform].Value -if ($null -eq $profilePlatformLock) { - throw "Profile $Profile does not define platform $Platform in $lockPath" -} -if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { - $outputName = if ($Platform -eq 'linux/amd64') { $Profile } else { "{0}-{1}" -f $Profile, $platformLock.architecture } - $OutputDirectory = Join-Path $repositoryRoot ("work\template-builds\docker-sandboxes\{0}" -f $outputName) -} -$OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) - -function Get-Sha256Text { - param([Parameter(Mandatory = $true)][string]$Text) - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text) - return 'sha256:' + ([System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()) - } - finally { - $sha.Dispose() - } -} - -function Get-TemplateContextDigest { - param([Parameter(Mandatory = $true)][string]$Directory) - $material = [System.Text.StringBuilder]::new() - $files = @(Get-ChildItem -LiteralPath $Directory -File -Recurse | Sort-Object FullName) - foreach ($file in $files) { - $relative = $file.FullName.Substring($Directory.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/') - $digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant() - [void]$material.AppendLine($relative) - [void]$material.AppendLine($digest) - } - return Get-Sha256Text -Text $material.ToString() -} - -function Test-RemoteIndex { - param( - [Parameter(Mandatory = $true)][string]$Name, - [Parameter(Mandatory = $true)][string]$Reference, - [Parameter(Mandatory = $true)][string]$ExpectedIndexDigest, - [Parameter(Mandatory = $true)][string]$ExpectedManifestDigest, - [Parameter(Mandatory = $true)][string]$Platform - ) - Write-Host "Verifying $Name index and $Platform manifest without pulling layers..." - $rawLines = @(& docker buildx imagetools inspect --raw $Reference) - if ($LASTEXITCODE -ne 0) { - throw "docker buildx imagetools inspect failed for $Reference" - } - # OCI manifests are UTF-8 JSON with LF separators. Reconstructing them with - # the host newline would turn LF into CRLF on Windows and change the digest. - $raw = [string]::Join("`n", $rawLines) - $actualIndexDigest = Get-Sha256Text -Text $raw - if ($actualIndexDigest -ne $ExpectedIndexDigest) { - throw "$Name index digest mismatch: expected $ExpectedIndexDigest, got $actualIndexDigest" - } - $index = $raw | ConvertFrom-Json - $platformParts = $Platform -split '/', 2 - $matching = @($index.manifests | Where-Object { $_.platform.os -eq $platformParts[0] -and $_.platform.architecture -eq $platformParts[1] }) - if ($matching.Count -ne 1) { - throw "$Name must contain exactly one $Platform manifest; found $($matching.Count)" - } - if ($matching[0].digest -ne $ExpectedManifestDigest) { - throw "$Name $Platform manifest mismatch: expected $ExpectedManifestDigest, got $($matching[0].digest)" - } -} - -function Write-Utf8Json { - param( - [Parameter(Mandatory = $true)]$Value, - [Parameter(Mandatory = $true)][string]$Path - ) - $json = $Value | ConvertTo-Json -Depth 100 - [System.IO.File]::WriteAllText($Path, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) -} - -$minimumFreeBytes = [uint64](50GB) -$estimatedExpansionBytes = if ($Profile -eq 'full') { [uint64](30GB) } else { [uint64](10GB) } -$requiredAvailableBytes = $minimumFreeBytes + $estimatedExpansionBytes - -$buildPlan = [ordered]@{ - schemaVersion = 1 - execute = [bool]$Execute - profile = $Profile - status = $profilePlatformLock.validationStatus - platform = $Platform - source = $profileLock.immutableReference - sourceIndexDigest = $profileLock.indexDigest - sourceManifestDigest = $profilePlatformLock.manifestDigest - templateTag = $profilePlatformLock.templateTag - outputDirectory = $OutputDirectory - storage = [ordered]@{ - surface = [System.IO.Path]::GetPathRoot($OutputDirectory) - estimatedExpansionBytes = $estimatedExpansionBytes - minimumFreeBytes = $minimumFreeBytes - requiredAvailableBytes = $requiredAvailableBytes - cleanupPreview = 'ephemeral-action-runner storage prune --provider docker-sandboxes' - } - operations = @( - "verify pinned OCI indexes and $Platform manifests", - "build one $Platform image and load it once into the local Docker image store", - 'save one docker-archive for operator-controlled sbx template load', - 'generate SPDX SBOM, max-mode provenance JSON, software inventory, helper hashes, and compatibility metadata' - ) -} -$buildPlan | ConvertTo-Json -Depth 10 - +$startScript = Join-Path $repositoryRoot 'start.ps1' +$arguments = @('image', 'build', '--config', $Config, '--project-root', $ProjectRoot) if (-not $Execute) { - Write-Host 'Plan only. Re-run with -Execute to acquire image layers and mutate the local Docker image store. A cold full-profile acquisition may take up to 60 minutes.' - exit 0 -} - -if ($lock.schemaVersion -ne 2 -or $lock.defaultPlatform -ne 'linux/amd64' -or -not @($lock.supportedPlatforms).Contains($Platform)) { - throw 'Unsupported source lock schema or platform' -} -if ($profilePlatformLock.templateTag -notmatch '^epar-docker-sandboxes-[a-z0-9._-]+:[a-z0-9._-]+$') { - throw "Template tag does not satisfy the epar-docker-sandboxes-* naming contract: $($profilePlatformLock.templateTag)" -} - -& docker buildx version *> $null -if ($LASTEXITCODE -ne 0) { - throw 'Docker Buildx is required' -} -& docker scout version *> $null -if ($LASTEXITCODE -ne 0) { - throw 'Docker Scout is required to produce the SPDX SBOM' -} -& docker version --format '{{.Server.Version}}' *> $null -if ($LASTEXITCODE -ne 0) { - throw 'A reachable Docker daemon is required for an executed build' -} -$dockerServerPlatformRaw = ((& docker info --format '{{.OSType}}/{{.Architecture}}') -join '').Trim().ToLowerInvariant() -if ($LASTEXITCODE -ne 0) { - throw 'Could not determine the Docker server platform for an executed build' -} -$dockerServerPlatform = switch -Regex ($dockerServerPlatformRaw) { - '^linux/(amd64|x86_64)$' { 'linux/amd64'; break } - '^linux/(arm64|aarch64)$' { 'linux/arm64'; break } - default { throw "Unsupported Docker server platform for a Docker Sandboxes template build: $dockerServerPlatformRaw" } -} -if ($dockerServerPlatform -ne $Platform) { - throw "Cross-architecture Docker Sandboxes template builds are unsupported because pinned build stages require BUILDPLATFORM=$Platform; Docker reports $dockerServerPlatform. Run this executed build on a native $Platform Docker server." -} -$outputRoot = [System.IO.Path]::GetPathRoot($OutputDirectory) -if ([string]::IsNullOrWhiteSpace($outputRoot)) { - throw "Could not determine the storage surface for $OutputDirectory. Run 'ephemeral-action-runner storage status --provider docker-sandboxes' for a capacity report." -} -$drive = [System.IO.DriveInfo]::new($outputRoot) -if (-not $drive.IsReady) { - throw "Storage surface $outputRoot cannot be measured. Run 'ephemeral-action-runner storage status --provider docker-sandboxes' for a capacity report." -} -$availableBytes = [uint64]$drive.AvailableFreeSpace -if ($availableBytes -lt $requiredAvailableBytes) { - throw ("Insufficient storage on {0}: available={1} bytes, estimated expansion={2} bytes, minimum free reserve={3} bytes, required={4} bytes. Preview exact cleanup with 'ephemeral-action-runner storage prune --provider docker-sandboxes'." -f $outputRoot, $availableBytes, $estimatedExpansionBytes, $minimumFreeBytes, $requiredAvailableBytes) -} -Write-Host ("Storage preflight passed for {0}: available={1:N1} GiB, estimated expansion={2:N1} GiB, reserve={3:N1} GiB." -f $outputRoot, ($availableBytes / 1GB), ($estimatedExpansionBytes / 1GB), ($minimumFreeBytes / 1GB)) - -if (-not (Test-Path -LiteralPath $storageStateDirectory)) { - New-Item -ItemType Directory -Path $storageStateDirectory | Out-Null -} -if (Test-Path -LiteralPath $ownerIDPath) { - $ownerID = (Get-Content -Raw -LiteralPath $ownerIDPath).Trim() -} -else { - $ownerID = [guid]::NewGuid().ToString('N') - [System.IO.File]::WriteAllText($ownerIDPath, $ownerID + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) -} -if ($ownerID -notmatch '^[0-9a-f]{32}$') { - throw "Invalid EPAR storage owner identity in $ownerIDPath" + $arguments += '--dry-run' } -$builderName = 'epar-' + $ownerID.Substring(0, 12) -$buildKitConfig = @" -[worker.oci] - gc = true - - [[worker.oci.gcpolicy]] - keepBytes = 68719476736 - keepDuration = 604800 - - [[worker.oci.gcpolicy]] - all = true - keepBytes = 68719476736 -"@ -[System.IO.File]::WriteAllText($buildKitConfigPath, $buildKitConfig, [System.Text.UTF8Encoding]::new($false)) - -$builderExists = $false -$previousErrorActionPreference = $ErrorActionPreference -try { - $ErrorActionPreference = 'Continue' - & docker buildx inspect $builderName *> $null - $builderExists = $LASTEXITCODE -eq 0 -} -finally { - $ErrorActionPreference = $previousErrorActionPreference -} -if ($builderExists) { - if (-not (Test-Path -LiteralPath $builderMetadataPath)) { - throw "Buildx builder $builderName already exists without EPAR ownership metadata; refusing to use or modify it." - } - $builderMetadata = Get-Content -Raw -LiteralPath $builderMetadataPath | ConvertFrom-Json - if ($builderMetadata.ownerID -ne $ownerID -or $builderMetadata.builderName -ne $builderName -or $builderMetadata.driver -ne 'docker-container') { - throw "Buildx builder ownership metadata does not match $builderName; refusing to use or modify it." - } -} -else { - $createdBuilderName = ((& docker buildx create --name $builderName --driver docker-container --buildkitd-config $buildKitConfigPath) -join '').Trim() - if ($LASTEXITCODE -ne 0 -or $createdBuilderName -ne $builderName) { - throw "Could not create the dedicated EPAR Buildx builder $builderName" - } - Write-Utf8Json -Value ([ordered]@{ schemaVersion = 1; ownerID = $ownerID; builderName = $builderName; driver = 'docker-container'; cacheLimitBytes = [uint64](64GB) }) -Path $builderMetadataPath -} - -Test-RemoteIndex -Name 'Dockerfile frontend' -Reference $lock.dockerfileFrontend.inspectionReference -ExpectedIndexDigest $lock.dockerfileFrontend.indexDigest -ExpectedManifestDigest $platformLock.dockerfileFrontendManifestDigest -Platform $Platform -Test-RemoteIndex -Name 'SBOM generator' -Reference $lock.sbomGenerator.inspectionReference -ExpectedIndexDigest $lock.sbomGenerator.indexDigest -ExpectedManifestDigest $platformLock.sbomGeneratorManifestDigest -Platform $Platform -Test-RemoteIndex -Name 'Go hook-launcher builder' -Reference $lock.goBuilder.inspectionReference -ExpectedIndexDigest $lock.goBuilder.indexDigest -ExpectedManifestDigest $platformLock.goBuilderManifestDigest -Platform $Platform -Test-RemoteIndex -Name "Catthehacker $Profile source" -Reference $profileLock.inspectionReference -ExpectedIndexDigest $profileLock.indexDigest -ExpectedManifestDigest $profilePlatformLock.manifestDigest -Platform $Platform - -if (-not (Test-Path -LiteralPath $OutputDirectory)) { - New-Item -ItemType Directory -Path $OutputDirectory | Out-Null -} -$archiveName = if ($Platform -eq 'linux/amd64') { "epar-docker-sandboxes-{0}.tar" -f $Profile } else { "epar-docker-sandboxes-{0}-{1}.tar" -f $Profile, $platformLock.architecture } -$artifactPaths = [ordered]@{ - buildMetadata = Join-Path $OutputDirectory 'build-metadata.json' - provenance = Join-Path $OutputDirectory 'provenance.json' - sbom = Join-Path $OutputDirectory 'sbom.spdx.json' - inventory = Join-Path $OutputDirectory 'software-inventory.txt' - helpers = Join-Path $OutputDirectory 'helpers.sha256' - compatibility = Join-Path $OutputDirectory 'compatibility.json' - templateMetadata = Join-Path $OutputDirectory 'template-metadata.json' - archive = Join-Path $OutputDirectory $archiveName -} -$existingArtifacts = @($artifactPaths.Values | Where-Object { Test-Path -LiteralPath $_ }) -if ($existingArtifacts.Count -gt 0 -and -not $ReplaceArtifacts) { - throw "Refusing to overwrite existing artifacts. Use a new output directory or pass -ReplaceArtifacts: $($existingArtifacts -join ', ')" -} -$localImageExists = $false -$previousErrorActionPreference = $ErrorActionPreference -try { - $ErrorActionPreference = 'Continue' - & docker image inspect $profilePlatformLock.templateTag *> $null - $imageInspectExitCode = $LASTEXITCODE -} -finally { - $ErrorActionPreference = $previousErrorActionPreference -} -if ($imageInspectExitCode -eq 0) { - $localImageExists = $true -} -if ($localImageExists -and -not $ReplaceArtifacts) { - throw "Local image tag already exists: $($profilePlatformLock.templateTag). Pass -ReplaceArtifacts to replace it intentionally." -} - -$helperManifestPath = Join-Path $templateDirectory 'helpers.sha256' -$compatibilityInputPath = Join-Path (Join-Path $templateDirectory 'profiles') $profilePlatformLock.compatibilityFile -$helperManifestBytes = [System.IO.File]::ReadAllBytes($helperManifestPath) -$compatibilityInputBytes = [System.IO.File]::ReadAllBytes($compatibilityInputPath) -$templateContextDigest = Get-TemplateContextDigest -Directory $templateDirectory - -Write-Host '[1/7] Building the pinned template with plain progress. Cold acquisition may take up to 60 minutes.' -$previousMetadataProvenance = $env:BUILDX_METADATA_PROVENANCE -$env:BUILDX_METADATA_PROVENANCE = 'max' -try { - & docker buildx build --builder $builderName --platform $Platform --pull --progress plain --load --provenance mode=max --sbom ("generator={0}" -f $platformLock.sbomGeneratorReference) --metadata-file $artifactPaths.buildMetadata --tag $profilePlatformLock.templateTag --build-arg ("TEMPLATE_PLATFORM={0}" -f $Platform) --build-arg ("SOURCE_IMAGE={0}" -f $profileLock.immutableReference) --build-arg ("GO_BUILDER_IMAGE={0}" -f $platformLock.goBuilderReference) --build-arg ("HOOK_LAUNCHER_SHA256={0}" -f $lock.hookLauncher.sha256) --build-arg ("SOURCE_PROFILE={0}" -f $Profile) --build-arg ("SOURCE_INDEX_DIGEST={0}" -f $profileLock.indexDigest) --build-arg ("SOURCE_MANIFEST_DIGEST={0}" -f $profilePlatformLock.manifestDigest) --build-arg ("SOURCE_REVISION={0}" -f $profileLock.sourceRevision) --build-arg ("TEMPLATE_VERSION={0}" -f (($profilePlatformLock.templateTag -split ':', 2)[1])) --build-arg ("COMPATIBILITY_FILE={0}" -f $profilePlatformLock.compatibilityFile) --build-arg ("ACTIONS_RUNNER_URL={0}" -f $platformLock.actionsRunner.url) --build-arg ("ACTIONS_RUNNER_SHA256=sha256:{0}" -f $platformLock.actionsRunner.sha256) --build-arg ("TINI_URL={0}" -f $platformLock.tini.url) --build-arg ("TINI_SHA256=sha256:{0}" -f $platformLock.tini.sha256) --file (Join-Path $templateDirectory 'Dockerfile') $templateDirectory - if ($LASTEXITCODE -ne 0) { - throw 'docker buildx build failed' - } -} -finally { - $env:BUILDX_METADATA_PROVENANCE = $previousMetadataProvenance -} -if ((Get-TemplateContextDigest -Directory $templateDirectory) -ne $templateContextDigest) { - throw 'Template source lock or build context changed during the build; refusing mixed-source artifacts' -} - -Write-Host '[2/7] Extracting max-mode provenance from Buildx metadata.' -$buildMetadata = Get-Content -Raw -LiteralPath $artifactPaths.buildMetadata | ConvertFrom-Json -$imageDigest = $buildMetadata.'containerimage.digest' -$provenance = $buildMetadata.'buildx.build.provenance' -if ($imageDigest -notmatch '^sha256:[0-9a-f]{64}$' -or $null -eq $provenance) { - throw 'Buildx metadata omitted the immutable image digest or max-mode provenance' -} -$templateDigest = ((& docker image inspect --format '{{.Id}}' $profilePlatformLock.templateTag) -join '').Trim() -if ($LASTEXITCODE -ne 0 -or $templateDigest -notmatch '^sha256:[0-9a-f]{64}$') { - throw 'Docker image inspection omitted the full local template identity' -} -if ($imageDigest -ne $templateDigest) { - throw "Buildx image digest $imageDigest does not match the full local image identity $templateDigest" -} -Write-Utf8Json -Value $provenance -Path $artifactPaths.provenance - -Write-Host '[3/7] Generating SPDX SBOM from the local image without a registry fallback.' -& docker scout sbom --format spdx --output $artifactPaths.sbom ("local://{0}" -f $profilePlatformLock.templateTag) -if ($LASTEXITCODE -ne 0) { - throw 'docker scout sbom failed' -} - -Write-Host '[4/7] Collecting deterministic software inventory without starting the template entrypoint.' -$inventoryLines = @(& docker run --rm --pull never --platform $Platform --entrypoint /opt/epar/collect-software-inventory.sh $profilePlatformLock.templateTag) -if ($LASTEXITCODE -ne 0) { - throw 'software inventory collection failed' -} -[System.IO.File]::WriteAllText($artifactPaths.inventory, [string]::Join([Environment]::NewLine, $inventoryLines) + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) - -Write-Host '[5/7] Saving one Docker archive for an explicit sbx template load.' -& docker image save --output $artifactPaths.archive $profilePlatformLock.templateTag -if ($LASTEXITCODE -ne 0) { - throw 'docker image save failed' -} - -Write-Host '[6/7] Copying helper hashes and compatibility metadata.' -[System.IO.File]::WriteAllBytes($artifactPaths.helpers, $helperManifestBytes) -[System.IO.File]::WriteAllBytes($artifactPaths.compatibility, $compatibilityInputBytes) - -Write-Host '[7/7] Hashing artifacts and writing immutable template metadata.' -$dockerVersion = ((& docker version --format '{{.Client.Version}}') -join '').Trim() -$buildxVersion = ((& docker buildx version) -join ' ').Trim() -$scoutVersionLine = ((& docker scout version | Select-String -Pattern '^version:' | Select-Object -First 1) -as [string]).Trim() -$templateMetadata = [ordered]@{ - schemaVersion = 2 - profile = $Profile - validationStatus = $profilePlatformLock.validationStatus - platform = $Platform - template = [ordered]@{ - tag = $profilePlatformLock.templateTag - digest = $imageDigest - templateDigest = $templateDigest - cacheID = $templateDigest.Substring(7, 12) - archive = [System.IO.Path]::GetFileName($artifactPaths.archive) - archiveSha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.archive).Hash.ToLowerInvariant() - archiveBytes = (Get-Item -LiteralPath $artifactPaths.archive).Length - } - source = [ordered]@{ - reference = $profileLock.immutableReference - indexDigest = $profileLock.indexDigest - manifestDigest = $profilePlatformLock.manifestDigest - revision = $profileLock.sourceRevision - } - inputs = [ordered]@{ - actionsRunnerVersion = $lock.actionsRunner.version - actionsRunnerUrl = $platformLock.actionsRunner.url - actionsRunnerSha256 = $platformLock.actionsRunner.sha256 - tiniVersion = $lock.tini.version - tiniUrl = $platformLock.tini.url - tiniSha256 = $platformLock.tini.sha256 - dockerfileFrontend = $lock.dockerfileFrontend.reference - dockerfileFrontendManifestDigest = $platformLock.dockerfileFrontendManifestDigest - goBuilderVersion = $lock.goBuilder.version - goBuilder = $platformLock.goBuilderReference - goBuilderIndexDigest = $lock.goBuilder.indexDigest - goBuilderManifestDigest = $platformLock.goBuilderManifestDigest - hookLauncherSha256 = $lock.hookLauncher.sha256 - sbomGenerator = $platformLock.sbomGeneratorReference - sourceLockSha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $lockPath).Hash.ToLowerInvariant() - templateContextDigest = $templateContextDigest - } - compatibility = [ordered]@{ - candidate = 'A' - dockerDaemonOwner = 'docker-sandboxes-runtime' - expectedDockerDaemonCount = 1 - } - artifacts = [ordered]@{ - buildMetadata = [ordered]@{ path = 'build-metadata.json'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.buildMetadata).Hash.ToLowerInvariant() } - sbom = [ordered]@{ path = 'sbom.spdx.json'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.sbom).Hash.ToLowerInvariant() } - provenance = [ordered]@{ path = 'provenance.json'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.provenance).Hash.ToLowerInvariant() } - softwareInventory = [ordered]@{ path = 'software-inventory.txt'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.inventory).Hash.ToLowerInvariant() } - helperHashes = [ordered]@{ path = 'helpers.sha256'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.helpers).Hash.ToLowerInvariant() } - compatibility = [ordered]@{ path = 'compatibility.json'; sha256 = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.compatibility).Hash.ToLowerInvariant() } - } - hostTools = [ordered]@{ - dockerClient = $dockerVersion - buildx = $buildxVersion - scout = $scoutVersionLine - } +if ($RemainingArguments) { + $arguments += $RemainingArguments } -Write-Utf8Json -Value $templateMetadata -Path $artifactPaths.templateMetadata -$templateMetadataDigest = 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPaths.templateMetadata).Hash.ToLowerInvariant() -Write-Host "Template artifacts are ready in $OutputDirectory" -Write-Host "Immutable template identity: $($profilePlatformLock.templateTag)@$imageDigest" -Write-Host "EPAR dockerSandboxes.templateDigest (verified local template ID): $templateDigest" -Write-Host "Operator trust anchor for load-template.ps1 -ExpectedMetadataSha256: $templateMetadataDigest" -Write-Host 'No sbx command was invoked. Record the metadata digest outside the artifact directory, review the evidence, then use load-template.ps1 -Execute for one explicit template load.' +Write-Warning 'This compatibility wrapper delegates to the common EPAR image build path. Prefer ./start image build.' +& $startScript @arguments +exit $LASTEXITCODE diff --git a/scripts/docker-sandboxes/load-template.ps1 b/scripts/docker-sandboxes/load-template.ps1 index e845994..0a395f2 100644 --- a/scripts/docker-sandboxes/load-template.ps1 +++ b/scripts/docker-sandboxes/load-template.ps1 @@ -1,303 +1,25 @@ [CmdletBinding()] param( - [Parameter(Mandatory = $true)] - [string]$ArtifactDirectory, - [Parameter(Mandatory = $true)] - [ValidatePattern('^sha256:[0-9a-f]{64}$')] - [string]$ExpectedMetadataSha256, - [switch]$Execute + [string] $Config = '.local/config.yml', + [string] $ProjectRoot, + [switch] $Execute, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $RemainingArguments ) $ErrorActionPreference = 'Stop' -$ArtifactDirectory = [System.IO.Path]::GetFullPath($ArtifactDirectory) -$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path -$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $scriptDirectory '..\..')) -$templateDirectory = Join-Path $repositoryRoot 'templates\docker-sandboxes' -$lockPath = Join-Path $templateDirectory 'sources.lock.json' - -function Get-Sha256File { - param([Parameter(Mandatory = $true)][string]$Path) - return 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() -} - -function Get-Sha256Text { - param([Parameter(Mandatory = $true)][string]$Text) - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text) - return 'sha256:' + ([System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()) - } - finally { - $sha.Dispose() - } -} - -function Get-TemplateContextDigest { - param([Parameter(Mandatory = $true)][string]$Directory) - $material = [System.Text.StringBuilder]::new() - $files = @(Get-ChildItem -LiteralPath $Directory -File -Recurse | Sort-Object FullName) - foreach ($file in $files) { - $relative = $file.FullName.Substring($Directory.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/') - $digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant() - [void]$material.AppendLine($relative) - [void]$material.AppendLine($digest) - } - return Get-Sha256Text -Text $material.ToString() -} - -function Assert-ExactArtifact { - param( - [Parameter(Mandatory = $true)][string]$Name, - [Parameter(Mandatory = $true)][string]$ExpectedFileName, - [Parameter(Mandatory = $true)]$Record - ) - if ($Record.path -ne $ExpectedFileName -or $Record.sha256 -notmatch '^sha256:[0-9a-f]{64}$') { - throw "Artifact metadata for $Name must name exactly $ExpectedFileName and contain a full lowercase SHA-256 digest" - } - if ([System.IO.Path]::IsPathRooted($Record.path) -or [System.IO.Path]::GetFileName($Record.path) -ne $Record.path) { - throw "Artifact metadata for $Name contains an unsafe path" - } - $path = Join-Path $ArtifactDirectory $Record.path - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "Required evidence artifact is missing: $path" - } - $actual = Get-Sha256File -Path $path - if ($actual -ne $Record.sha256) { - throw "Evidence artifact checksum mismatch for ${Name}: expected $($Record.sha256), got $actual" - } - return $path -} - -$metadataPath = Join-Path $ArtifactDirectory 'template-metadata.json' -if (-not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { - throw "Template metadata is missing: $metadataPath" -} -$actualMetadataSha256 = Get-Sha256File -Path $metadataPath -if ($actualMetadataSha256 -ne $ExpectedMetadataSha256) { - throw "Template metadata trust-anchor mismatch: operator expected $ExpectedMetadataSha256, got $actualMetadataSha256" -} -$metadata = Get-Content -Raw -LiteralPath $metadataPath | ConvertFrom-Json -if ($metadata.schemaVersion -ne 2) { - throw 'Artifact metadata must use schema 2' -} -if ($metadata.platform -ne 'linux/amd64' -and $metadata.platform -ne 'linux/arm64') { - throw "Artifact metadata contains an unsupported platform: $($metadata.platform)" -} -if ($metadata.template.tag -notmatch '^epar-docker-sandboxes-[a-z0-9._-]+:[a-z0-9._-]+$' -or $metadata.template.digest -notmatch '^sha256:[0-9a-f]{64}$' -or $metadata.template.templateDigest -notmatch '^sha256:[0-9a-f]{64}$' -or $metadata.template.cacheID -notmatch '^[0-9a-f]{12}$') { - throw 'Artifact metadata contains an invalid template tag, OCI digest, full local image identity, or cache ID' -} -if ($metadata.template.cacheID -ne $metadata.template.templateDigest.Substring(7, 12)) { - throw 'Artifact metadata cache ID does not match the first 12 hexadecimal characters of the full local image identity' -} -if ($metadata.compatibility.candidate -ne 'A' -or $metadata.compatibility.dockerDaemonOwner -ne 'docker-sandboxes-runtime' -or $metadata.compatibility.expectedDockerDaemonCount -ne 1) { - throw 'Artifact compatibility metadata is not Candidate A' -} - -$lock = Get-Content -Raw -LiteralPath $lockPath | ConvertFrom-Json -if ($lock.schemaVersion -ne 2 -or -not @($lock.supportedPlatforms).Contains($metadata.platform)) { - throw 'Repository source lock does not support the artifact platform' -} -$profileLock = $lock.profiles.PSObject.Properties[$metadata.profile].Value -$platformLock = $lock.platforms.PSObject.Properties[$metadata.platform].Value -$profilePlatformLock = if ($null -ne $profileLock) { $profileLock.platforms.PSObject.Properties[$metadata.platform].Value } else { $null } -if ($null -eq $profileLock -or $null -eq $platformLock -or $null -eq $profilePlatformLock) { - throw 'Artifact profile and platform are not present in the repository source lock' -} -$sourceLockSha256 = Get-Sha256File -Path $lockPath -$templateContextDigest = Get-TemplateContextDigest -Directory $templateDirectory -if ($metadata.inputs.sourceLockSha256 -ne $sourceLockSha256 -or $metadata.inputs.templateContextDigest -ne $templateContextDigest) { - throw 'Artifact metadata is not anchored to the current repository source lock and complete template build context' -} -if ($metadata.template.tag -ne $profilePlatformLock.templateTag -or $metadata.source.reference -ne $profileLock.immutableReference -or $metadata.source.indexDigest -ne $profileLock.indexDigest -or $metadata.source.manifestDigest -ne $profilePlatformLock.manifestDigest -or $metadata.source.revision -ne $profileLock.sourceRevision) { - throw 'Artifact template or source identity differs from the authoritative repository lock' -} -if ($metadata.inputs.actionsRunnerVersion -ne $lock.actionsRunner.version -or $metadata.inputs.actionsRunnerUrl -ne $platformLock.actionsRunner.url -or $metadata.inputs.actionsRunnerSha256 -ne $platformLock.actionsRunner.sha256 -or $metadata.inputs.tiniVersion -ne $lock.tini.version -or $metadata.inputs.tiniUrl -ne $platformLock.tini.url -or $metadata.inputs.tiniSha256 -ne $platformLock.tini.sha256 -or $metadata.inputs.dockerfileFrontend -ne $lock.dockerfileFrontend.reference -or $metadata.inputs.dockerfileFrontendManifestDigest -ne $platformLock.dockerfileFrontendManifestDigest -or $metadata.inputs.sbomGenerator -ne $platformLock.sbomGeneratorReference) { - throw 'Artifact build inputs differ from the authoritative repository lock' -} - -$expectedArtifacts = [ordered]@{ - buildMetadata = 'build-metadata.json' - sbom = 'sbom.spdx.json' - provenance = 'provenance.json' - softwareInventory = 'software-inventory.txt' - helperHashes = 'helpers.sha256' - compatibility = 'compatibility.json' -} -$actualArtifactNames = @($metadata.artifacts.PSObject.Properties.Name | Sort-Object) -$expectedArtifactNames = @($expectedArtifacts.Keys | Sort-Object) -if (Compare-Object -ReferenceObject $expectedArtifactNames -DifferenceObject $actualArtifactNames) { - throw 'Template metadata must enumerate exactly every required evidence artifact' -} -$verifiedArtifacts = @{} -foreach ($name in $expectedArtifacts.Keys) { - $verifiedArtifacts[$name] = Assert-ExactArtifact -Name $name -ExpectedFileName $expectedArtifacts[$name] -Record $metadata.artifacts.$name -} - -$archiveName = $metadata.template.archive -if ([System.IO.Path]::IsPathRooted($archiveName) -or [System.IO.Path]::GetFileName($archiveName) -ne $archiveName -or $metadata.template.archiveSha256 -notmatch '^sha256:[0-9a-f]{64}$' -or $metadata.template.archiveBytes -le 0) { - throw 'Artifact metadata contains an unsafe archive record' -} -$archivePath = Join-Path $ArtifactDirectory $archiveName -if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { - throw "Template archive is missing: $archivePath" -} -$actualArchiveHash = Get-Sha256File -Path $archivePath -$actualArchiveBytes = (Get-Item -LiteralPath $archivePath).Length -if ($actualArchiveHash -ne $metadata.template.archiveSha256 -or $actualArchiveBytes -ne $metadata.template.archiveBytes) { - throw "Template archive evidence mismatch: expected $($metadata.template.archiveSha256) and $($metadata.template.archiveBytes) bytes, got $actualArchiveHash and $actualArchiveBytes bytes" -} - -$buildMetadata = Get-Content -Raw -LiteralPath $verifiedArtifacts.buildMetadata | ConvertFrom-Json -if ($buildMetadata.'containerimage.digest' -ne $metadata.template.digest -or $metadata.template.digest -ne $metadata.template.templateDigest -or [string]::IsNullOrWhiteSpace($buildMetadata.'buildx.build.ref') -or $null -eq $buildMetadata.'buildx.build.provenance') { - throw 'Buildx metadata does not bind the recorded OCI digest, full local image identity, build reference, and provenance' -} -$provenance = Get-Content -Raw -LiteralPath $verifiedArtifacts.provenance | ConvertFrom-Json -if ($null -eq $provenance.buildType -or $null -eq $provenance.invocation -or $null -eq $provenance.metadata) { - throw 'Provenance artifact does not contain the required max-mode predicate fields' -} -$sbom = Get-Content -Raw -LiteralPath $verifiedArtifacts.sbom | ConvertFrom-Json -if ($sbom.SPDXID -ne 'SPDXRef-DOCUMENT' -or $sbom.spdxVersion -notmatch '^SPDX-2\.' -or $null -eq $sbom.packages) { - throw 'SBOM artifact is not a valid SPDX JSON document' +$repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +if ([string]::IsNullOrWhiteSpace($ProjectRoot)) { + $ProjectRoot = $repositoryRoot } -if ((Get-Item -LiteralPath $verifiedArtifacts.softwareInventory).Length -le 0) { - throw 'Software inventory evidence is empty' -} -$repositoryHelpers = Join-Path $templateDirectory 'helpers.sha256' -$repositoryCompatibility = Join-Path (Join-Path $templateDirectory 'profiles') $profilePlatformLock.compatibilityFile -if ((Get-Sha256File -Path $verifiedArtifacts.helperHashes) -ne (Get-Sha256File -Path $repositoryHelpers) -or (Get-Sha256File -Path $verifiedArtifacts.compatibility) -ne (Get-Sha256File -Path $repositoryCompatibility)) { - throw 'Copied helper or compatibility evidence differs from the repository-anchored source' -} - -$localTemplateDigest = ((& docker image inspect --format '{{.Id}}' $metadata.template.tag) -join '').Trim() -if ($LASTEXITCODE -ne 0 -or $localTemplateDigest -ne $metadata.template.templateDigest) { - throw "Local Docker image identity does not match the anchored full template identity $($metadata.template.templateDigest)" -} - -Write-Host "Verified operator-anchored metadata: $actualMetadataSha256" -Write-Host "Verified archive: $archivePath" -Write-Host "Full local Docker image identity: $($metadata.template.tag)@$($metadata.template.templateDigest)" -Write-Host "Expected Docker Sandboxes template cache ID: $($metadata.template.cacheID)" +$startScript = Join-Path $repositoryRoot 'start.ps1' +$arguments = @('image', 'build', '--config', $Config, '--project-root', $ProjectRoot) if (-not $Execute) { - Write-Host 'Plan only. All evidence was verified without invoking sbx. Re-run with -Execute and the same expected metadata digest to invoke sbx template load at most once.' - exit 0 -} - -$runtimeArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() -$hostArchitecture = switch ($runtimeArchitecture) { - 'x64' { 'amd64' } - 'arm64' { 'arm64' } - default { $runtimeArchitecture } -} -if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { - $hostOS = 'windows' -} -elseif ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) { - $hostOS = 'linux' -} -elseif ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) { - $hostOS = 'darwin' -} -else { - throw 'Docker Sandboxes does not support this controller host operating system' -} -$expectedPlatform = switch ($hostArchitecture) { - 'amd64' { 'linux/amd64' } - 'arm64' { 'linux/arm64' } - default { throw "Docker Sandboxes has no EPAR template for controller architecture $hostArchitecture on $hostOS" } -} -if ($metadata.platform -ne $expectedPlatform) { - throw "Template platform $($metadata.platform) cannot be loaded for Docker Sandboxes on $hostOS/$hostArchitecture; expected $expectedPlatform" -} - -$diagnosticText = ((& sbx diagnose --output json) -join [Environment]::NewLine).Trim() -if ($LASTEXITCODE -ne 0) { - throw "Docker Sandboxes diagnostics failed. Run 'sbx diagnose --output json' and review the hints for each failed check." -} -try { - $diagnostics = $diagnosticText | ConvertFrom-Json -} -catch { - throw "Docker Sandboxes diagnostics returned invalid JSON. Run 'sbx diagnose --output json' and review its output." -} -$diagnosticChecks = @($diagnostics.checks) -$knownStatuses = @('pass', 'warn', 'fail', 'skip') -$actualCounts = @{ pass = 0; warn = 0; fail = 0; skip = 0 } -foreach ($check in $diagnosticChecks) { - $status = ([string]$check.status).ToLowerInvariant() - if ([string]::IsNullOrWhiteSpace([string]$check.name) -or -not $knownStatuses.Contains($status)) { - throw "Docker Sandboxes diagnostics returned an unsupported check. Run 'sbx diagnose --output json' and review its output." - } - $actualCounts[$status]++ -} -$summaryValid = $diagnosticChecks.Count -gt 0 -and $null -ne $diagnostics.summary -foreach ($status in $knownStatuses) { - $property = if ($summaryValid) { $diagnostics.summary.PSObject.Properties[$status] } else { $null } - $summaryCount = 0 - if ($null -eq $property -or -not [int]::TryParse([string]$property.Value, [ref]$summaryCount) -or $summaryCount -ne $actualCounts[$status]) { - $summaryValid = $false - break - } -} -if (-not $summaryValid) { - throw "Docker Sandboxes diagnostics returned an unsupported summary. Run 'sbx diagnose --output json' and review its output." -} -if ($actualCounts['fail'] -ne 0 -or $actualCounts['pass'] -lt 1) { - throw "Docker Sandboxes diagnostics reported $($actualCounts['pass']) passing and $($actualCounts['fail']) failed checks. Run 'sbx diagnose --output json' and review the hints for each failed check." -} - -function Get-TemplateInventory { - $text = ((& sbx template ls --json) -join [Environment]::NewLine).Trim() - if ($LASTEXITCODE -ne 0) { - throw 'sbx template inventory readback failed' - } - try { - $inventory = $text | ConvertFrom-Json - } - catch { - throw 'sbx template inventory returned invalid JSON' - } - if ($null -eq $inventory.images) { - throw 'sbx template inventory omitted the images array' - } - return $inventory -} - -$separator = $metadata.template.tag.LastIndexOf(':') -$expectedRepository = $metadata.template.tag.Substring(0, $separator) -$expectedTag = $metadata.template.tag.Substring($separator + 1) -$firstRepositoryComponent = ($expectedRepository -split '/', 2)[0] -if (-not $expectedRepository.Contains('/')) { - $expectedRepository = "docker.io/library/$expectedRepository" -} -elseif ($firstRepositoryComponent -ne 'localhost' -and $firstRepositoryComponent -notmatch '[\.:]') { - $expectedRepository = "docker.io/$expectedRepository" -} -$templateInventory = Get-TemplateInventory -$matchingTemplates = @($templateInventory.images | Where-Object { $_.repository -eq $expectedRepository -and $_.tag -eq $expectedTag }) -if ($matchingTemplates.Count -gt 1) { - throw "Expected at most one loaded template named $($metadata.template.tag); found $($matchingTemplates.Count)" -} -if ($matchingTemplates.Count -eq 1 -and $matchingTemplates[0].id -ne $metadata.template.cacheID) { - throw "Loaded template cache ID mismatch: expected $($metadata.template.cacheID), got $($matchingTemplates[0].id)" -} -if ($matchingTemplates.Count -eq 1) { - Write-Host "Template is already loaded with the exact expected cache ID: $($metadata.template.cacheID)" - exit 0 -} - -Write-Host 'Loading the verified prewarmed template archive into Docker Sandboxes once...' -& sbx template load $archivePath -if ($LASTEXITCODE -ne 0) { - throw 'sbx template load failed' -} -$templateInventory = Get-TemplateInventory -$matchingTemplates = @($templateInventory.images | Where-Object { $_.repository -eq $expectedRepository -and $_.tag -eq $expectedTag }) -if ($matchingTemplates.Count -ne 1) { - throw "Expected exactly one loaded template named $($metadata.template.tag); found $($matchingTemplates.Count)" + $arguments += '--dry-run' } -if ($matchingTemplates[0].id -ne $metadata.template.cacheID) { - throw "Loaded template cache ID mismatch: expected $($metadata.template.cacheID), got $($matchingTemplates[0].id)" +if ($RemainingArguments) { + $arguments += $RemainingArguments } -Write-Host "Template load readback completed with cache ID $($metadata.template.cacheID)." -Write-Host 'The 12-hex cache ID is the complete identity exposed by the local template inventory; it is not a full digest. Full identity is anchored independently by the operator-verified metadata and local Docker image readback.' -Write-Host 'The script does not preload /var/lib/docker and will not invoke sbx template load again.' +Write-Warning 'Template import is now part of the common EPAR image build. This compatibility wrapper delegates to ./start image build.' +& $startScript @arguments +exit $LASTEXITCODE diff --git a/scripts/docker-sandboxes/validate-assets.ps1 b/scripts/docker-sandboxes/validate-assets.ps1 index 00851bf..f795853 100644 --- a/scripts/docker-sandboxes/validate-assets.ps1 +++ b/scripts/docker-sandboxes/validate-assets.ps1 @@ -3,7 +3,8 @@ param( [ValidateSet('linux/amd64', 'linux/arm64')] [string]$Platform = 'linux/amd64', [switch]$VerifyRemote, - [switch]$DockerfileCheck + [switch]$DockerfileCheck, + [string]$Builder ) $ErrorActionPreference = 'Stop' @@ -134,7 +135,7 @@ foreach ($profileName in $expectedProfiles.Keys) { Assert-Equal "$profileName superseded record authority" $supersededRecord.authoritative $false Assert-Equal "$profileName superseded amd64 manifest" $supersededRecord.manifestDigest $expectedProfile.legacyManifest Assert-Equal "$profileName superseded template tag" $supersededRecord.templateTag $expectedProfile.legacyTag - Assert-Equal "$profileName superseded reason" $supersededRecord.reason 'Predates current Candidate A helper and architecture changes' + Assert-Equal "$profileName superseded reason" $supersededRecord.reason 'Predates the current runner-template helper and architecture changes' if ($supersededRecord.PSObject.Properties.Name -contains 'validationStatus') { throw "$profileName superseded record must not carry a current validation status" } @@ -197,7 +198,7 @@ foreach ($required in @( if ($dockerfile -match '(?im)apt-get\s+update|(?im)\blatest\b|(?im)COPY\s+.*var/lib/docker|(?im)--privileged|(?im)--secret') { throw 'Dockerfile contains an unpinned, privileged, secret, or /var/lib/docker preload pattern' } -foreach ($requiredContextEntry in @('!Dockerfile', '!helpers.sha256', '!guest/*.sh', '!hook-launcher/*.go', '!profiles/*.compatibility.json')) { +foreach ($requiredContextEntry in @('!Dockerfile', '!helpers.sha256', '!guest/*.sh', '!hook-launcher/*.go', '!custom-install/run.sh', '!profiles/*.compatibility.json')) { if (-not ($dockerignore -split "`r?`n").Contains($requiredContextEntry)) { throw ".dockerignore is missing deterministic context entry: $requiredContextEntry" } @@ -225,8 +226,8 @@ foreach ($profileName in $expectedProfiles.Keys) { $profilePlatform = $profile.platforms.PSObject.Properties[$platformName].Value $compatibilityPath = Join-Path (Join-Path $templateDirectory 'profiles') $profilePlatform.compatibilityFile $compatibility = Get-Content -Raw -LiteralPath $compatibilityPath | ConvertFrom-Json - Assert-Equal "$profileName $platformName compatibility schema" $compatibility.schemaVersion 1 - Assert-Equal "$profileName $platformName compatibility candidate" $compatibility.candidate 'A' + Assert-Equal "$profileName $platformName compatibility schema" $compatibility.schemaVersion 2 + Assert-Equal "$profileName $platformName template schema" $compatibility.templateSchemaVersion 1 Assert-Equal "$profileName $platformName compatibility profile" $compatibility.profile $profileName Assert-Equal "$profileName $platformName compatibility status" $compatibility.validationStatus $profilePlatform.validationStatus Assert-Equal "$profileName $platformName compatibility platform" $compatibility.platform $platformName @@ -281,14 +282,28 @@ if ($VerifyRemote) { } } if ($DockerfileCheck) { + if ([string]::IsNullOrWhiteSpace($Builder)) { + $buildxMetadataPath = Join-Path $repositoryRoot '.local\storage\buildx.json' + if (Test-Path -LiteralPath $buildxMetadataPath -PathType Leaf) { + $buildxMetadata = Get-Content -Raw -LiteralPath $buildxMetadataPath | ConvertFrom-Json + $Builder = [string]$buildxMetadata.builder + } + } + if ([string]::IsNullOrWhiteSpace($Builder)) { + throw 'DockerfileCheck requires the exact EPAR-owned Buildx builder. Run ./start image build first or pass -Builder with that owned builder identity; the validation script will not use Docker''s current/default builder.' + } + & docker buildx inspect $Builder *> $null + if ($LASTEXITCODE -ne 0) { + throw "EPAR-owned Buildx builder '$Builder' is unavailable; the validation script will not fall back to Docker's current/default builder." + } $platformLock = $lock.platforms.PSObject.Properties[$Platform].Value foreach ($profileName in $expectedProfiles.Keys) { $profile = $lock.profiles.PSObject.Properties[$profileName].Value $profilePlatform = $profile.platforms.PSObject.Properties[$Platform].Value - & docker buildx build --call check --platform $Platform --build-arg ("TEMPLATE_PLATFORM={0}" -f $Platform) --build-arg ("SOURCE_IMAGE={0}" -f $profile.immutableReference) --build-arg ("GO_BUILDER_IMAGE={0}" -f $platformLock.goBuilderReference) --build-arg ("HOOK_LAUNCHER_SHA256={0}" -f $lock.hookLauncher.sha256) --build-arg ("SOURCE_PROFILE={0}" -f $profileName) --build-arg ("SOURCE_INDEX_DIGEST={0}" -f $profile.indexDigest) --build-arg ("SOURCE_MANIFEST_DIGEST={0}" -f $profilePlatform.manifestDigest) --build-arg ("SOURCE_REVISION={0}" -f $profile.sourceRevision) --build-arg ("TEMPLATE_VERSION={0}" -f (($profilePlatform.templateTag -split ':', 2)[1])) --build-arg ("COMPATIBILITY_FILE={0}" -f $profilePlatform.compatibilityFile) --build-arg ("ACTIONS_RUNNER_URL={0}" -f $platformLock.actionsRunner.url) --build-arg ("ACTIONS_RUNNER_SHA256=sha256:{0}" -f $platformLock.actionsRunner.sha256) --build-arg ("TINI_URL={0}" -f $platformLock.tini.url) --build-arg ("TINI_SHA256=sha256:{0}" -f $platformLock.tini.sha256) --file $dockerfilePath $templateDirectory + & docker buildx build --builder $Builder --call check --platform $Platform --build-arg ("TEMPLATE_PLATFORM={0}" -f $Platform) --build-arg ("SOURCE_IMAGE={0}" -f $profile.immutableReference) --build-arg ("GO_BUILDER_IMAGE={0}" -f $platformLock.goBuilderReference) --build-arg ("HOOK_LAUNCHER_SHA256={0}" -f $lock.hookLauncher.sha256) --build-arg ("SOURCE_PROFILE={0}" -f $profileName) --build-arg ("SOURCE_INDEX_DIGEST={0}" -f $profile.indexDigest) --build-arg ("SOURCE_MANIFEST_DIGEST={0}" -f $profilePlatform.manifestDigest) --build-arg ("SOURCE_REVISION={0}" -f $profile.sourceRevision) --build-arg ("TEMPLATE_VERSION={0}" -f (($profilePlatform.templateTag -split ':', 2)[1])) --build-arg ("COMPATIBILITY_FILE={0}" -f $profilePlatform.compatibilityFile) --build-arg ("ACTIONS_RUNNER_URL={0}" -f $platformLock.actionsRunner.url) --build-arg ("ACTIONS_RUNNER_SHA256=sha256:{0}" -f $platformLock.actionsRunner.sha256) --build-arg ("TINI_URL={0}" -f $platformLock.tini.url) --build-arg ("TINI_SHA256=sha256:{0}" -f $platformLock.tini.sha256) --file $dockerfilePath $templateDirectory if ($LASTEXITCODE -ne 0) { throw "Dockerfile frontend check failed for $profileName" } } } -Write-Host 'Docker Sandboxes Candidate A template assets passed validation.' +Write-Host 'Docker Sandboxes runner-template assets passed validation.' diff --git a/scripts/host-trust/host-trust-feed.ps1 b/scripts/host-trust/host-trust-feed.ps1 index ad494a4..b31d311 100644 --- a/scripts/host-trust/host-trust-feed.ps1 +++ b/scripts/host-trust/host-trust-feed.ps1 @@ -7,6 +7,8 @@ param( [string] $ProjectRoot, [Parameter(Mandatory = $true)] [string] $Config, + [ValidateSet('runner', 'build')] + [string] $Purpose = 'runner', [ValidateRange(1, 3600)] [int] $Interval = 10 ) @@ -14,8 +16,11 @@ param( $ErrorActionPreference = 'Stop' function Get-OverlayConfiguration { - param([string] $Path) - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + param([string] $Path, [string] $FeedPurpose) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + if ($FeedPurpose -eq 'build') { return [pscustomobject]@{ Scopes = [string[]]@('system') } } + return $null + } $inImage = $false $listMode = $false $mode = '' @@ -52,6 +57,12 @@ function Get-OverlayConfiguration { } $listMode = $false } + if ($FeedPurpose -eq 'build') { + $buildScopes = [System.Collections.Generic.List[string]]::new() + [void]$buildScopes.Add('system') + if ($mode -eq 'overlay' -and $scopes -contains 'user') { [void]$buildScopes.Add('user') } + return [pscustomobject]@{ Scopes = $buildScopes } + } if ($mode -ne 'overlay') { return $null } if ($scopes.Count -eq 0) { [void]$scopes.Add('system') } foreach ($scope in $scopes) { @@ -219,11 +230,11 @@ if (Test-Path -LiteralPath $Config -PathType Leaf) { } } $Config = $Config.ToLowerInvariant() -$settings = Get-OverlayConfiguration $Config +$settings = Get-OverlayConfiguration $Config $Purpose if ($null -eq $settings) { exit 0 } $cacheBase = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'ephemeral-action-runner\host-trust' } else { Join-Path $env:TEMP 'ephemeral-action-runner\host-trust' } -$configId = Get-ConfigId $Config +$configId = Get-ConfigId ($Purpose + [char]0 + $Config) $feedRoot = Join-Path $cacheBase $configId $lockDir = $feedRoot + '.lock' function Acquire-EparSharedLock { diff --git a/scripts/host-trust/host-trust-feed.sh b/scripts/host-trust/host-trust-feed.sh index 10ccc4d..5bace19 100755 --- a/scripts/host-trust/host-trust-feed.sh +++ b/scripts/host-trust/host-trust-feed.sh @@ -7,7 +7,7 @@ set -euo pipefail usage() { cat >&2 <<'EOF' -Usage: host-trust-feed.sh sync|watch --project-root --config [--interval ] +Usage: host-trust-feed.sh sync|watch --project-root --config [--purpose runner|build] [--interval ] The config must opt in with: image: @@ -20,6 +20,7 @@ shift || true project_root="" config_path="" interval=10 +purpose="runner" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" while (($#)); do @@ -27,6 +28,7 @@ while (($#)); do --project-root) project_root="${2:?missing value for --project-root}"; shift 2 ;; --config) config_path="${2:?missing value for --config}"; shift 2 ;; --interval) interval="${2:?missing value for --interval}"; shift 2 ;; + --purpose) purpose="${2:?missing value for --purpose}"; shift 2 ;; *) usage; exit 2 ;; esac done @@ -35,6 +37,10 @@ if [[ "$command_name" != "sync" && "$command_name" != "watch" ]] || [[ -z "$proj usage exit 2 fi +if [[ "$purpose" != "runner" && "$purpose" != "build" ]]; then + echo "trust feed purpose must be runner or build" >&2 + exit 2 +fi if [[ ! "$interval" =~ ^[1-9][0-9]*$ ]]; then echo "host trust interval must be a positive integer" >&2 exit 2 @@ -98,7 +104,13 @@ while IFS= read -r value; do scope=*) scopes+=("$(printf '%s' "${value#scope=}" | tr '[:upper:]' '[:lower:]')") ;; esac done < <(config_values) -if [[ "$mode" != "overlay" ]]; then +if [[ "$purpose" == "build" ]]; then + build_scopes=(system) + if [[ "$mode" == "overlay" ]] && printf '%s\n' "${scopes[@]}" | grep -Fxq user; then + build_scopes+=(user) + fi + scopes=("${build_scopes[@]}") +elif [[ "$mode" != "overlay" ]]; then exit 0 fi @@ -123,7 +135,7 @@ for scope in "${scopes[@]}"; do fi done -config_id="$(printf '%s' "$config_path" | sha256_text | cut -c1-32)" +config_id="$(printf '%s\0%s' "$purpose" "$config_path" | sha256_text | cut -c1-32)" feed_root="$cache_root/$config_id" lock_dir="$feed_root.lock" diff --git a/scripts/host-trust/wrapper-lib.ps1 b/scripts/host-trust/wrapper-lib.ps1 index 8707e5d..a75a617 100644 --- a/scripts/host-trust/wrapper-lib.ps1 +++ b/scripts/host-trust/wrapper-lib.ps1 @@ -94,42 +94,57 @@ function Start-EparHostTrustBridge { $config = Get-EparHostTrustConfigPath -ProjectRoot $ProjectRoot -Arguments $Arguments if ($Command -eq "init") { - return [pscustomobject]@{ FeedDir = $null; WatchProcess = $null; Config = $config; PostInit = $true } + return [pscustomobject]@{ FeedDir = $null; BuildFeedDir = $null; RunnerFeedDir = $null; WatchProcess = $null; WatchProcesses = @(); Config = $config; PostInit = $true } } $subcommand = if ($Arguments -and $Arguments.Count -gt 1) { [string]$Arguments[1] } else { "" } $needsBridge = $Command -eq "start" -or ($Command -eq "image" -and $subcommand -eq "build") -or ($Command -eq "pool" -and $subcommand -in @("up", "verify")) if (-not $needsBridge) { - return [pscustomobject]@{ FeedDir = $null; WatchProcess = $null; Config = $config; PostInit = $false } + return [pscustomobject]@{ FeedDir = $null; BuildFeedDir = $null; RunnerFeedDir = $null; WatchProcess = $null; WatchProcesses = @(); Config = $config; PostInit = $false } } $helper = Join-Path $ProjectRoot "scripts\host-trust\host-trust-feed.ps1" - $feedLines = @(& $helper sync -ProjectRoot $ProjectRoot -Config $config 2>&1) - if ($LASTEXITCODE -ne 0) { - throw "Host-trust preflight failed: $($feedLines -join [Environment]::NewLine)" - } - $feedDir = ($feedLines | Where-Object { $_ -is [string] -and $_.Trim() } | Select-Object -Last 1) - if (-not $feedDir) { - return [pscustomobject]@{ FeedDir = $null; WatchProcess = $null; Config = $config; PostInit = $false } - } - $feedDir = Split-Path -Parent $feedDir.Trim() - $watchOut = Join-Path $feedDir "watcher.log" - $watchErr = Join-Path $feedDir "watcher-error.log" $powershell = (Get-Process -Id $PID).Path - $watchCommand = '& ' + (ConvertTo-EparPowerShellLiteral $helper) + - ' watch -ProjectRoot ' + (ConvertTo-EparPowerShellLiteral $ProjectRoot) + - ' -Config ' + (ConvertTo-EparPowerShellLiteral $config) + - ' -Interval 10' - $encodedWatchCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($watchCommand)) - $watch = Start-Process -FilePath $powershell -WindowStyle Hidden -PassThru ` - -ArgumentList @("-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", $encodedWatchCommand) ` - -RedirectStandardOutput $watchOut -RedirectStandardError $watchErr - Start-Sleep -Milliseconds 150 - if ($watch.HasExited) { - throw "Host-trust watcher exited during startup. See $watchErr" + $watchers = [System.Collections.Generic.List[object]]::new() + $feedDirectories = @{} + foreach ($purpose in @('build', 'runner')) { + $feedLines = @(& $helper sync -ProjectRoot $ProjectRoot -Config $config -Purpose $purpose 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "$purpose trust preflight failed: $($feedLines -join [Environment]::NewLine)" + } + $feedPath = ($feedLines | Where-Object { $_ -is [string] -and $_.Trim() } | Select-Object -Last 1) + if (-not $feedPath) { + $feedDirectories[$purpose] = $null + continue + } + $feedDir = Split-Path -Parent $feedPath.Trim() + $feedDirectories[$purpose] = $feedDir + $watchOut = Join-Path $feedDir "watcher.log" + $watchErr = Join-Path $feedDir "watcher-error.log" + $watchCommand = '& ' + (ConvertTo-EparPowerShellLiteral $helper) + + ' watch -ProjectRoot ' + (ConvertTo-EparPowerShellLiteral $ProjectRoot) + + ' -Config ' + (ConvertTo-EparPowerShellLiteral $config) + + ' -Purpose ' + (ConvertTo-EparPowerShellLiteral $purpose) + + ' -Interval 10 >> ' + (ConvertTo-EparPowerShellLiteral $watchOut) + + ' 2>> ' + (ConvertTo-EparPowerShellLiteral $watchErr) + $encodedWatchCommand = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($watchCommand)) + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $powershell + $startInfo.Arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encodedWatchCommand" + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $watch = [System.Diagnostics.Process]::Start($startInfo) + Start-Sleep -Milliseconds 150 + if ($watch.HasExited) { + throw "$purpose trust watcher exited during startup. See $watchErr" + } + [void]$watchers.Add([pscustomobject]@{ Process = $watch; FeedDir = $feedDir }) } - return [pscustomobject]@{ FeedDir = $feedDir; WatchProcess = $watch; Config = $config; PostInit = $false } + $runnerFeedDir = $feedDirectories['runner'] + $buildFeedDir = $feedDirectories['build'] + $firstWatcher = if ($watchers.Count -gt 0) { $watchers[0].Process } else { $null } + return [pscustomobject]@{ FeedDir = $runnerFeedDir; BuildFeedDir = $buildFeedDir; RunnerFeedDir = $runnerFeedDir; WatchProcess = $firstWatcher; WatchProcesses = @($watchers); Config = $config; PostInit = $false } } function Complete-EparHostTrustInit { @@ -150,21 +165,26 @@ function Complete-EparHostTrustInit { function Stop-EparHostTrustBridge { param($Bridge) - if ($null -eq $Bridge -or $null -eq $Bridge.WatchProcess) { return } - try { - if (-not $Bridge.WatchProcess.HasExited) { - Stop-Process -Id $Bridge.WatchProcess.Id -ErrorAction SilentlyContinue - $Bridge.WatchProcess.WaitForExit(3000) | Out-Null + if ($null -eq $Bridge) { return } + $entries = @($Bridge.WatchProcesses) + if ($entries.Count -eq 0 -and $null -ne $Bridge.WatchProcess) { + $entries = @([pscustomobject]@{ Process = $Bridge.WatchProcess; FeedDir = $Bridge.FeedDir }) + } + foreach ($entry in $entries) { + try { + if (-not $entry.Process.HasExited) { + Stop-Process -Id $entry.Process.Id -ErrorAction SilentlyContinue + $entry.Process.WaitForExit(3000) | Out-Null + } + } catch { + Write-Warning "Could not stop trust-feed watcher: $($_.Exception.Message)" } - } catch { - Write-Warning "Could not stop host-trust watcher: $($_.Exception.Message)" - } - if ($Bridge.FeedDir -and $Bridge.WatchProcess.HasExited) { - $lockDir = $Bridge.FeedDir + '.lock' + if (-not $entry.FeedDir -or -not $entry.Process.HasExited) { continue } + $lockDir = $entry.FeedDir + '.lock' $ownerPath = Join-Path $lockDir 'pid' $owner = 0 [void][int]::TryParse((Get-Content -LiteralPath $ownerPath -ErrorAction SilentlyContinue | Select-Object -First 1), [ref]$owner) - if ($owner -eq $Bridge.WatchProcess.Id) { + if ($owner -eq $entry.Process.Id) { Remove-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $lockDir -Force -ErrorAction SilentlyContinue } diff --git a/scripts/host-trust/wrapper-lib.sh b/scripts/host-trust/wrapper-lib.sh index d689aa5..a20b47d 100644 --- a/scripts/host-trust/wrapper-lib.sh +++ b/scripts/host-trust/wrapper-lib.sh @@ -4,7 +4,10 @@ # set EPAR_HOST_TRUST_HELPER to the real-host helper script before sourcing. EPAR_HOST_TRUST_FEED_DIR="" +EPAR_BUILD_TRUST_FEED_DIR="" +EPAR_RUNNER_TRUST_FEED_DIR="" EPAR_HOST_TRUST_WATCH_PID="" +EPAR_TRUST_WATCH_PIDS=() EPAR_HOST_TRUST_POST_INIT_CONFIG="" epar_host_trust_config_path() { @@ -72,9 +75,12 @@ epar_host_trust_prepare() { local project_root="$1" command="$2" shift 2 EPAR_HOST_TRUST_FEED_DIR="" + EPAR_BUILD_TRUST_FEED_DIR="" + EPAR_RUNNER_TRUST_FEED_DIR="" EPAR_HOST_TRUST_WATCH_PID="" + EPAR_TRUST_WATCH_PIDS=() EPAR_HOST_TRUST_POST_INIT_CONFIG="" - local config_path feed_path watcher_log subcommand="" + local config_path feed_path feed_dir watcher_log subcommand="" purpose watcher_pid if (($# >= 2)); then subcommand="$2"; fi config_path="$(epar_host_trust_config_path "$project_root" "$@")" case "$command" in @@ -89,21 +95,24 @@ epar_host_trust_prepare() { pool) [[ "$subcommand" == up || "$subcommand" == verify ]] || return 0 ;; *) return 0 ;; esac - feed_path="$("$EPAR_HOST_TRUST_HELPER" sync --project-root "$project_root" --config "$config_path")" || return $? - [[ -n "$feed_path" ]] || return 0 - EPAR_HOST_TRUST_FEED_DIR="$(dirname "$feed_path")" - watcher_log="${EPAR_HOST_TRUST_FEED_DIR}/watcher.log" - "$EPAR_HOST_TRUST_HELPER" watch --project-root "$project_root" --config "$config_path" --interval 10 >>"$watcher_log" 2>&1 & - EPAR_HOST_TRUST_WATCH_PID="$!" - # Fail closed when the singleton watcher rejects the lock or exits before - # the controller receives its first feed generation. - sleep 0.1 - if ! kill -0 "$EPAR_HOST_TRUST_WATCH_PID" 2>/dev/null; then - wait "$EPAR_HOST_TRUST_WATCH_PID" || true - EPAR_HOST_TRUST_WATCH_PID="" - echo "host trust watcher failed to start; see $watcher_log" >&2 - return 1 - fi + for purpose in build runner; do + feed_path="$("$EPAR_HOST_TRUST_HELPER" sync --project-root "$project_root" --config "$config_path" --purpose "$purpose")" || return $? + [[ -n "$feed_path" ]] || continue + feed_dir="$(dirname "$feed_path")" + if [[ "$purpose" == build ]]; then EPAR_BUILD_TRUST_FEED_DIR="$feed_dir"; else EPAR_RUNNER_TRUST_FEED_DIR="$feed_dir"; fi + watcher_log="${feed_dir}/watcher.log" + "$EPAR_HOST_TRUST_HELPER" watch --project-root "$project_root" --config "$config_path" --purpose "$purpose" --interval 10 >>"$watcher_log" 2>&1 & + watcher_pid="$!" + EPAR_TRUST_WATCH_PIDS+=("$watcher_pid") + sleep 0.1 + if ! kill -0 "$watcher_pid" 2>/dev/null; then + wait "$watcher_pid" || true + echo "$purpose trust watcher failed to start; see $watcher_log" >&2 + return 1 + fi + done + EPAR_HOST_TRUST_FEED_DIR="$EPAR_RUNNER_TRUST_FEED_DIR" + if ((${#EPAR_TRUST_WATCH_PIDS[@]} > 0)); then EPAR_HOST_TRUST_WATCH_PID="${EPAR_TRUST_WATCH_PIDS[0]}"; fi } epar_host_trust_post_init() { @@ -125,9 +134,12 @@ epar_host_trust_post_init() { } epar_host_trust_cleanup() { - if [[ -n "${EPAR_HOST_TRUST_WATCH_PID:-}" ]]; then - kill "$EPAR_HOST_TRUST_WATCH_PID" 2>/dev/null || true - wait "$EPAR_HOST_TRUST_WATCH_PID" 2>/dev/null || true - EPAR_HOST_TRUST_WATCH_PID="" - fi + local watcher_pid + for watcher_pid in "${EPAR_TRUST_WATCH_PIDS[@]:-}"; do + [[ -n "$watcher_pid" ]] || continue + kill "$watcher_pid" 2>/dev/null || true + wait "$watcher_pid" 2>/dev/null || true + done + EPAR_TRUST_WATCH_PIDS=() + EPAR_HOST_TRUST_WATCH_PID="" } diff --git a/scripts/run-with-docker.ps1 b/scripts/run-with-docker.ps1 index 1d0865c..0e10cf4 100644 --- a/scripts/run-with-docker.ps1 +++ b/scripts/run-with-docker.ps1 @@ -138,17 +138,23 @@ try { go run ./cmd/ephemeral-action-runner @InitArgs $ExitCode = $LASTEXITCODE if ($ExitCode -eq 0) { - $initBridge = [pscustomobject]@{ FeedDir = $null; WatchProcess = $null; Config = $ConfigPath; PostInit = $true } + $initBridge = [pscustomobject]@{ FeedDir = $null; BuildFeedDir = $null; RunnerFeedDir = $null; WatchProcess = $null; WatchProcesses = @(); Config = $ConfigPath; PostInit = $true } Complete-EparHostTrustInit -ProjectRoot $RepoRoot -Bridge $initBridge } } if ($ExitCode -eq 0) { $bridge = Start-EparHostTrustBridge -ProjectRoot $RepoRoot -Command $EparCommand -Arguments $EparArgs $HostTrustFlags = @() - if ($bridge.FeedDir) { + if ($bridge.BuildFeedDir -or $bridge.RunnerFeedDir) { $HostTrustFlags += @("-e", "EPAR_CONTROLLER_HOST_OS=$(Get-EparHostTrustHostOS)") + } + if ($bridge.BuildFeedDir) { + $HostTrustFlags += @("-e", "EPAR_BUILD_TRUST_FEED=/run/epar-build-trust/current.json") + $HostTrustFlags += @("-v", "$($bridge.BuildFeedDir):/run/epar-build-trust:ro") + } + if ($bridge.RunnerFeedDir) { $HostTrustFlags += @("-e", "EPAR_HOST_TRUST_FEED=/run/epar-host-trust/current.json") - $HostTrustFlags += @("-v", "$($bridge.FeedDir):/run/epar-host-trust:ro") + $HostTrustFlags += @("-v", "$($bridge.RunnerFeedDir):/run/epar-host-trust:ro") } docker run @DockerRunFlags ` @DockerEnvFlags ` diff --git a/scripts/run-with-docker.sh b/scripts/run-with-docker.sh index fecf64b..e0b32ee 100755 --- a/scripts/run-with-docker.sh +++ b/scripts/run-with-docker.sh @@ -111,11 +111,19 @@ fi if [[ "$status" == 0 ]]; then epar_host_trust_prepare "${repo_root}" "${controller_command:-start}" "$@" || status=$? fi -if [[ "$status" == 0 && -n "${EPAR_HOST_TRUST_FEED_DIR}" ]]; then +if [[ "$status" == 0 && ( -n "${EPAR_BUILD_TRUST_FEED_DIR}" || -n "${EPAR_RUNNER_TRUST_FEED_DIR}" ) ]]; then + host_trust_docker_flags+=(-e "EPAR_CONTROLLER_HOST_OS=$(epar_host_trust_host_os)") +fi +if [[ "$status" == 0 && -n "${EPAR_BUILD_TRUST_FEED_DIR}" ]]; then + host_trust_docker_flags+=( + -e "EPAR_BUILD_TRUST_FEED=/run/epar-build-trust/current.json" + -v "${EPAR_BUILD_TRUST_FEED_DIR}:/run/epar-build-trust:ro" + ) +fi +if [[ "$status" == 0 && -n "${EPAR_RUNNER_TRUST_FEED_DIR}" ]]; then host_trust_docker_flags+=( - -e "EPAR_CONTROLLER_HOST_OS=$(epar_host_trust_host_os)" -e "EPAR_HOST_TRUST_FEED=/run/epar-host-trust/current.json" - -v "${EPAR_HOST_TRUST_FEED_DIR}:/run/epar-host-trust:ro" + -v "${EPAR_RUNNER_TRUST_FEED_DIR}:/run/epar-host-trust:ro" ) fi diff --git a/scripts/test/docker-sandboxes-plan-smoke.ps1 b/scripts/test/docker-sandboxes-plan-smoke.ps1 index 4b69d75..f890a6f 100644 --- a/scripts/test/docker-sandboxes-plan-smoke.ps1 +++ b/scripts/test/docker-sandboxes-plan-smoke.ps1 @@ -2,155 +2,28 @@ param() $ErrorActionPreference = 'Stop' -$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\..')) -$templateDirectory = Join-Path $repositoryRoot 'templates\docker-sandboxes' -$lockPath = Join-Path $templateDirectory 'sources.lock.json' -$powerShell = (Get-Process -Id $PID).Path -$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('epar-docker-sandboxes-plan-' + [guid]::NewGuid().ToString('N')) - -function Get-Sha256File { - param([Parameter(Mandatory = $true)][string]$Path) - return 'sha256:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() -} - -function Get-Sha256Text { - param([Parameter(Mandatory = $true)][string]$Text) - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - return 'sha256:' + ([System.BitConverter]::ToString($sha.ComputeHash([System.Text.UTF8Encoding]::new($false).GetBytes($Text))).Replace('-', '').ToLowerInvariant()) - } - finally { - $sha.Dispose() - } -} - -function Get-TemplateContextDigest { - $material = [System.Text.StringBuilder]::new() - foreach ($file in @(Get-ChildItem -LiteralPath $templateDirectory -File -Recurse | Sort-Object FullName)) { - $relative = $file.FullName.Substring($templateDirectory.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/') - [void]$material.AppendLine($relative) - [void]$material.AppendLine((Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant()) - } - return Get-Sha256Text -Text $material.ToString() -} - -function Write-Utf8 { - param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Text) - [System.IO.File]::WriteAllText($Path, $Text, [System.Text.UTF8Encoding]::new($false)) -} - -function Write-Json { - param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)]$Value) - Write-Utf8 -Path $Path -Text (($Value | ConvertTo-Json -Depth 100) + [Environment]::NewLine) -} - -try { - $artifactDirectory = Join-Path $temporaryRoot 'artifacts' - $fakeBin = Join-Path $temporaryRoot 'bin' - New-Item -ItemType Directory -Path $artifactDirectory, $fakeBin | Out-Null - - $buildPlan = @(& $powerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'scripts\docker-sandboxes\build-template.ps1') -Profile act-22.04 -Platform linux/amd64) - if ($LASTEXITCODE -ne 0 -or ($buildPlan -join "`n") -notmatch '"execute"\s*:\s*false' -or ($buildPlan -join "`n") -notmatch 'Plan only') { - throw 'build-template.ps1 plan-only smoke test failed' - } - - $lock = Get-Content -Raw -LiteralPath $lockPath | ConvertFrom-Json - $profile = $lock.profiles.'act-22.04' - $platform = $lock.platforms.'linux/amd64' - $profilePlatform = $profile.platforms.'linux/amd64' - $fullIdentity = 'sha256:' + ('a' * 64) - $paths = [ordered]@{ - buildMetadata = Join-Path $artifactDirectory 'build-metadata.json' - sbom = Join-Path $artifactDirectory 'sbom.spdx.json' - provenance = Join-Path $artifactDirectory 'provenance.json' - softwareInventory = Join-Path $artifactDirectory 'software-inventory.txt' - helperHashes = Join-Path $artifactDirectory 'helpers.sha256' - compatibility = Join-Path $artifactDirectory 'compatibility.json' - archive = Join-Path $artifactDirectory 'template.tar' - metadata = Join-Path $artifactDirectory 'template-metadata.json' - } - Write-Json -Path $paths.buildMetadata -Value ([ordered]@{ - 'containerimage.digest' = $fullIdentity - 'buildx.build.ref' = 'test-builder/test-builder/test-build' - 'buildx.build.provenance' = [ordered]@{ buildType = 'https://mobyproject.org/buildkit@v1'; invocation = @{}; metadata = @{} } - }) - Write-Json -Path $paths.sbom -Value ([ordered]@{ spdxVersion = 'SPDX-2.3'; SPDXID = 'SPDXRef-DOCUMENT'; packages = @() }) - Write-Json -Path $paths.provenance -Value ([ordered]@{ buildType = 'https://mobyproject.org/buildkit@v1'; invocation = @{}; metadata = @{} }) - Write-Utf8 -Path $paths.softwareInventory -Text "test-package 1.0`n" - [System.IO.File]::Copy((Join-Path $templateDirectory 'helpers.sha256'), $paths.helperHashes) - [System.IO.File]::Copy((Join-Path (Join-Path $templateDirectory 'profiles') $profilePlatform.compatibilityFile), $paths.compatibility) - Write-Utf8 -Path $paths.archive -Text 'archive' - - $artifactRecords = [ordered]@{} - foreach ($name in @('buildMetadata', 'sbom', 'provenance', 'softwareInventory', 'helperHashes', 'compatibility')) { - $artifactRecords[$name] = [ordered]@{ path = [System.IO.Path]::GetFileName($paths[$name]); sha256 = Get-Sha256File -Path $paths[$name] } - } - $metadata = [ordered]@{ - schemaVersion = 2 - profile = 'act-22.04' - validationStatus = $profilePlatform.validationStatus - platform = 'linux/amd64' - template = [ordered]@{ - tag = $profilePlatform.templateTag - digest = $fullIdentity - templateDigest = $fullIdentity - cacheID = 'aaaaaaaaaaaa' - archive = 'template.tar' - archiveSha256 = Get-Sha256File -Path $paths.archive - archiveBytes = (Get-Item -LiteralPath $paths.archive).Length - } - source = [ordered]@{ reference = $profile.immutableReference; indexDigest = $profile.indexDigest; manifestDigest = $profilePlatform.manifestDigest; revision = $profile.sourceRevision } - inputs = [ordered]@{ - actionsRunnerVersion = $lock.actionsRunner.version - actionsRunnerUrl = $platform.actionsRunner.url - actionsRunnerSha256 = $platform.actionsRunner.sha256 - tiniVersion = $lock.tini.version - tiniUrl = $platform.tini.url - tiniSha256 = $platform.tini.sha256 - dockerfileFrontend = $lock.dockerfileFrontend.reference - dockerfileFrontendManifestDigest = $platform.dockerfileFrontendManifestDigest - sbomGenerator = $platform.sbomGeneratorReference - sourceLockSha256 = Get-Sha256File -Path $lockPath - templateContextDigest = Get-TemplateContextDigest +$repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$wrappers = @( + (Join-Path $repositoryRoot 'scripts\docker-sandboxes\build-template.ps1'), + (Join-Path $repositoryRoot 'scripts\docker-sandboxes\load-template.ps1') +) + +foreach ($wrapper in $wrappers) { + $tokens = $null + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($wrapper, [ref] $tokens, [ref] $parseErrors) + if (@($parseErrors).Count -ne 0) { + throw "$wrapper has PowerShell parse errors: $(@($parseErrors).Message -join '; ')" + } + $source = Get-Content -Raw -LiteralPath $wrapper + foreach ($required in @("'start.ps1'", "@('image', 'build'", "'--dry-run'")) { + if (-not $source.Contains($required)) { + throw "$wrapper does not delegate to the common image build path: missing $required" } - compatibility = [ordered]@{ candidate = 'A'; dockerDaemonOwner = 'docker-sandboxes-runtime'; expectedDockerDaemonCount = 1 } - artifacts = $artifactRecords - } - Write-Json -Path $paths.metadata -Value $metadata - $expectedMetadataSha256 = Get-Sha256File -Path $paths.metadata - - $sbxMarker = Join-Path $temporaryRoot 'sbx-invoked' - if ($IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop') { - Write-Utf8 -Path (Join-Path $fakeBin 'docker.cmd') -Text "@echo $fullIdentity`r`n" - Write-Utf8 -Path (Join-Path $fakeBin 'sbx.cmd') -Text "@echo invoked>`"$sbxMarker`"`r`n@exit /b 1`r`n" } - else { - $dockerPath = Join-Path $fakeBin 'docker' - $sbxPath = Join-Path $fakeBin 'sbx' - Write-Utf8 -Path $dockerPath -Text "#!/usr/bin/env sh`nprintf '%s\n' '$fullIdentity'`n" - Write-Utf8 -Path $sbxPath -Text "#!/usr/bin/env sh`nprintf invoked > '$sbxMarker'`nexit 1`n" - & chmod 0755 $dockerPath $sbxPath - if ($LASTEXITCODE -ne 0) { throw 'could not make fake plan-only commands executable' } - } - $previousPath = $env:PATH - try { - $env:PATH = $fakeBin + [System.IO.Path]::PathSeparator + $env:PATH - $loadPlan = @(& $powerShell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'scripts\docker-sandboxes\load-template.ps1') -ArtifactDirectory $artifactDirectory -ExpectedMetadataSha256 $expectedMetadataSha256) - } - finally { - $env:PATH = $previousPath - } - if ($LASTEXITCODE -ne 0 -or ($loadPlan -join "`n") -notmatch 'All evidence was verified without invoking sbx' -or (Test-Path -LiteralPath $sbxMarker)) { - throw 'load-template.ps1 plan-only smoke test failed or invoked sbx' - } - $loaderSource = Get-Content -Raw -LiteralPath (Join-Path $repositoryRoot 'scripts\docker-sandboxes\load-template.ps1') - if ($loaderSource -match '&\s+sbx\s+version' -or $loaderSource -notmatch 'sbx diagnose --output json' -or $loaderSource -notmatch 'hints for each failed check') { - throw 'load-template.ps1 must use diagnostic readiness without an installed-version gate and explain how to inspect failed-check hints' - } - Write-Host 'Docker Sandboxes build/load plan-only smoke tests passed.' -} -finally { - if (Test-Path -LiteralPath $temporaryRoot) { - Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + if ($source -match '(?i)\bsbx\b|\bdocker\s+(?:build|image|template)\b') { + throw "$wrapper contains provider build/import operations instead of delegating" } } + +Write-Host 'Docker Sandboxes compatibility wrappers passed delegation and syntax checks.' diff --git a/scripts/test/host-trust-wrapper-smoke.ps1 b/scripts/test/host-trust-wrapper-smoke.ps1 index 71d6672..582e9e4 100644 --- a/scripts/test/host-trust-wrapper-smoke.ps1 +++ b/scripts/test/host-trust-wrapper-smoke.ps1 @@ -109,6 +109,16 @@ image: throw 'Windows wrapper quoted mode/block-scope parsing failed' } + $disabledConfig = Join-Path $temporary 'disabled.yml' + [System.IO.File]::WriteAllText($disabledConfig, "image:`n hostTrustMode: disabled`n hostTrustScopes: [system, user]`n", [System.Text.UTF8Encoding]::new($false)) + $disabledRunnerFeed = [string](& $helper sync -ProjectRoot $ProjectRoot -Config $disabledConfig -Purpose runner) + if ($LASTEXITCODE -ne 0 -or $disabledRunnerFeed) { throw 'disabled runner trust unexpectedly published a feed' } + $disabledBuildCurrent = [string](& $helper sync -ProjectRoot $ProjectRoot -Config $disabledConfig -Purpose build) + $disabledBuildFeed = Get-Content -LiteralPath $disabledBuildCurrent -Raw | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or @($disabledBuildFeed.scopes).Count -ne 1 -or $disabledBuildFeed.scopes[0] -ne 'system') { + throw 'disabled runner trust did not retain automatic system-only build trust' + } + Write-Output 'Windows host-trust wrapper lifecycle smoke passed' } finally { diff --git a/start b/start index 1c3a572..51ebd71 100755 --- a/start +++ b/start @@ -53,10 +53,11 @@ fi epar_host_trust_prepare "${script_dir}" "${controller_command}" "${epar_args[@]}" trap epar_host_trust_cleanup EXIT INT TERM -if [[ -n "${EPAR_HOST_TRUST_FEED_DIR}" ]]; then +if [[ -n "${EPAR_BUILD_TRUST_FEED_DIR}" || -n "${EPAR_RUNNER_TRUST_FEED_DIR}" ]]; then export EPAR_CONTROLLER_HOST_OS="$(epar_host_trust_host_os)" - export EPAR_HOST_TRUST_FEED="${EPAR_HOST_TRUST_FEED_DIR}/current.json" fi +if [[ -n "${EPAR_BUILD_TRUST_FEED_DIR}" ]]; then export EPAR_BUILD_TRUST_FEED="${EPAR_BUILD_TRUST_FEED_DIR}/current.json"; fi +if [[ -n "${EPAR_RUNNER_TRUST_FEED_DIR}" ]]; then export EPAR_HOST_TRUST_FEED="${EPAR_RUNNER_TRUST_FEED_DIR}/current.json"; fi status=0 "${GO_BIN}" run ./cmd/ephemeral-action-runner "${epar_args[@]}" || status=$? if [[ "$status" == "0" && "$controller_command" == "init" ]]; then diff --git a/start.ps1 b/start.ps1 index 8a07994..e55bfa1 100644 --- a/start.ps1 +++ b/start.ps1 @@ -63,10 +63,16 @@ if (-not $goUsable) { $bridge = Start-EparHostTrustBridge -ProjectRoot $Root -Command $ControllerCommand -Arguments $ControllerArgs $previousHostOS = $env:EPAR_CONTROLLER_HOST_OS $previousFeed = $env:EPAR_HOST_TRUST_FEED +$previousBuildFeed = $env:EPAR_BUILD_TRUST_FEED try { - if ($bridge.FeedDir) { + if ($bridge.BuildFeedDir -or $bridge.RunnerFeedDir) { $env:EPAR_CONTROLLER_HOST_OS = Get-EparHostTrustHostOS - $env:EPAR_HOST_TRUST_FEED = Join-Path $bridge.FeedDir "current.json" + } + if ($bridge.BuildFeedDir) { + $env:EPAR_BUILD_TRUST_FEED = Join-Path $bridge.BuildFeedDir "current.json" + } + if ($bridge.RunnerFeedDir) { + $env:EPAR_HOST_TRUST_FEED = Join-Path $bridge.RunnerFeedDir "current.json" } & $GoBin run ./cmd/ephemeral-action-runner @ControllerArgs $exitCode = $LASTEXITCODE @@ -77,6 +83,7 @@ try { Stop-EparHostTrustBridge -Bridge $bridge if ($null -eq $previousHostOS) { Remove-Item Env:EPAR_CONTROLLER_HOST_OS -ErrorAction SilentlyContinue } else { $env:EPAR_CONTROLLER_HOST_OS = $previousHostOS } if ($null -eq $previousFeed) { Remove-Item Env:EPAR_HOST_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_HOST_TRUST_FEED = $previousFeed } + if ($null -eq $previousBuildFeed) { Remove-Item Env:EPAR_BUILD_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_BUILD_TRUST_FEED = $previousBuildFeed } if ($OriginalInvocationExists) { $env:EPAR_INVOCATION = $OriginalInvocation } else { Remove-Item Env:EPAR_INVOCATION -ErrorAction SilentlyContinue } } exit $exitCode diff --git a/templates/docker-sandboxes/.dockerignore b/templates/docker-sandboxes/.dockerignore index 680099d..7fda218 100644 --- a/templates/docker-sandboxes/.dockerignore +++ b/templates/docker-sandboxes/.dockerignore @@ -5,5 +5,16 @@ !guest/*.sh !hook-launcher/ !hook-launcher/*.go +!custom-install/ +!custom-install/run.sh +!inputs/ +!inputs/actions-runner.tar.gz +!inputs/tini +!host-trust-certificates/ +!host-trust-certificates/*.crt +!trusted-ca-certificates/ +!trusted-ca-certificates/*.crt +!host-trust-metadata/ +!host-trust-metadata/host-trust-generation.json !profiles/ !profiles/*.compatibility.json diff --git a/templates/docker-sandboxes/Dockerfile b/templates/docker-sandboxes/Dockerfile index 224e2bc..8a57d48 100644 --- a/templates/docker-sandboxes/Dockerfile +++ b/templates/docker-sandboxes/Dockerfile @@ -27,34 +27,40 @@ ARG SOURCE_MANIFEST_DIGEST=sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8 ARG SOURCE_REVISION=e2f8efe464c82732f78e967ee709c00b6af53643 ARG TEMPLATE_VERSION=20260723-r4-amd64 ARG COMPATIBILITY_FILE=act-22.04.amd64.compatibility.json -ARG ACTIONS_RUNNER_URL=https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-x64-2.332.0.tar.gz ARG ACTIONS_RUNNER_SHA256=sha256:f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a -ARG TINI_URL=https://github.com/krallin/tini/releases/download/v0.19.0/tini-amd64 ARG TINI_SHA256=sha256:93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c USER root SHELL ["/bin/bash", "-euo", "pipefail", "-c"] COPY guest/ /opt/epar/ +COPY custom-install/ /opt/epar/custom-install/ +COPY host-trust-certificates/ /usr/local/share/ca-certificates/epar-host/ +COPY trusted-ca-certificates/ /usr/local/share/ca-certificates/epar/ +COPY host-trust-metadata/ /opt/epar/ COPY helpers.sha256 /opt/epar/helpers.sha256 COPY profiles/${COMPATIBILITY_FILE} /opt/epar/template-compatibility.json COPY --from=hook-builder --chmod=0555 /out/epar-hook-bash /opt/epar/hook-bin/bash - -ADD --chmod=0755 --checksum=${TINI_SHA256} ${TINI_URL} /usr/local/bin/tini -ADD --checksum=${ACTIONS_RUNNER_SHA256} ${ACTIONS_RUNNER_URL} /tmp/actions-runner.tar.gz +COPY --chmod=0755 inputs/tini /usr/local/bin/tini +COPY inputs/actions-runner.tar.gz /tmp/actions-runner.tar.gz RUN [[ "${TARGETPLATFORM}" == "${TEMPLATE_PLATFORM}" ]] \ && [[ "${TARGETOS}" == "linux" ]] \ && [[ "${TARGETARCH}" == "amd64" || "${TARGETARCH}" == "arm64" ]] \ + && echo "${TINI_SHA256#sha256:} /usr/local/bin/tini" | sha256sum --check - \ + && echo "${ACTIONS_RUNNER_SHA256#sha256:} /tmp/actions-runner.tar.gz" | sha256sum --check - \ && cd /opt/epar \ && sha256sum --check helpers.sha256 \ && chmod 0555 ./*.sh \ && /opt/epar/prepare-template.sh \ + && /opt/epar/install-trusted-ca-certificates.sh \ && rm -rf /opt/actions-runner \ && install -d -m 0755 -o agent -g agent /opt/actions-runner /var/log/actions-runner \ && tar -xzf /tmp/actions-runner.tar.gz -C /opt/actions-runner \ && rm -f /tmp/actions-runner.tar.gz \ && chown -R agent:agent /opt/actions-runner /var/log/actions-runner \ + && chmod 0555 /opt/epar/custom-install/run.sh \ + && /opt/epar/custom-install/run.sh \ && sudo -u agent -H /opt/actions-runner/bin/Runner.Listener --version | grep -Fx '2.332.0' \ && /usr/local/bin/tini --version 2>&1 | grep -F 'version 0.19.0' @@ -67,7 +73,10 @@ ENV HOME=/home/agent \ DEBIAN_FRONTEND=dialog LABEL com.docker.sandboxes.start-docker=true \ - io.solutionforest.epar.template.candidate="A" \ + io.solutionforest.epar.template.schema-version="1" \ + io.solutionforest.epar.template.runner-execution="direct-actions-listener" \ + io.solutionforest.epar.template.docker-daemon-owner="docker-sandboxes-runtime" \ + io.solutionforest.epar.template.expected-docker-daemon-count="1" \ io.solutionforest.epar.template.profile="${SOURCE_PROFILE}" \ io.solutionforest.epar.template.platform="${TEMPLATE_PLATFORM}" \ io.solutionforest.epar.template.runner-version="2.332.0" \ diff --git a/templates/docker-sandboxes/custom-install/run.sh b/templates/docker-sandboxes/custom-install/run.sh new file mode 100644 index 0000000..9a5ee9a --- /dev/null +++ b/templates/docker-sandboxes/custom-install/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Generated builds replace this no-op file with the selected project scripts. diff --git a/templates/docker-sandboxes/guest/check-host-trust-generation.sh b/templates/docker-sandboxes/guest/check-host-trust-generation.sh index 5262b9b..f0b6845 100644 --- a/templates/docker-sandboxes/guest/check-host-trust-generation.sh +++ b/templates/docker-sandboxes/guest/check-host-trust-generation.sh @@ -52,10 +52,8 @@ for key in ("generation", "hostOS", "mode", "scopes"): f"(image={marker.get(key)!r}, lease={lease.get(key)!r})" ) -if marker.get("mode") not in ("overlay", "disabled") or not marker.get("generation"): - raise SystemExit("EPAR host-trust gate: invalid image trust policy") -if marker.get("mode") == "disabled" and marker.get("scopes") != []: - raise SystemExit("EPAR host-trust gate: disabled trust mode must not carry scopes") +if marker.get("mode") != "overlay" or not marker.get("generation") or not marker.get("scopes"): + raise SystemExit("EPAR host-trust gate: image trust policy is not an enabled overlay") expires = lease.get("expiresAt") if not isinstance(expires, str) or not expires: diff --git a/templates/docker-sandboxes/guest/install-trusted-ca-certificates.sh b/templates/docker-sandboxes/guest/install-trusted-ca-certificates.sh new file mode 100644 index 0000000..fe6ded3 --- /dev/null +++ b/templates/docker-sandboxes/guest/install-trusted-ca-certificates.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +trust_dirs=( + "/usr/local/share/ca-certificates/epar" + "/usr/local/share/ca-certificates/epar-host" +) +has_certificates=false +for trust_dir in "${trust_dirs[@]}"; do + if [[ -d "${trust_dir}" ]] && find "${trust_dir}" -type f -name '*.crt' -print -quit | grep -q .; then + has_certificates=true + break + fi +done +if [[ "${has_certificates}" != "true" ]]; then + exit 0 +fi +if ! command -v update-ca-certificates >/dev/null 2>&1; then + echo "update-ca-certificates is required to install EPAR trusted CA certificates" >&2 + exit 1 +fi +update-ca-certificates diff --git a/templates/docker-sandboxes/guest/run-runner.sh b/templates/docker-sandboxes/guest/run-runner.sh index 28eb0fb..2821723 100644 --- a/templates/docker-sandboxes/guest/run-runner.sh +++ b/templates/docker-sandboxes/guest/run-runner.sh @@ -29,11 +29,40 @@ rm -f "${pid_file}" "${pid_start_file}" [[ -s /opt/epar/host-trust-generation.json ]] [[ -x /opt/epar/check-host-trust-generation.sh ]] +if [[ ! -x /usr/bin/python3 ]]; then + echo "EPAR runner trust policy: python3 is required" >&2 + exit 1 +fi +trust_mode="$(/usr/bin/env -i PATH=/usr/bin:/bin LANG=C.UTF-8 /usr/bin/python3 -I -S - /opt/epar/host-trust-generation.json <<'PY' +import json +import sys + +try: + with open(sys.argv[1], "r", encoding="utf-8") as handle: + marker = json.load(handle) +except Exception as exc: + raise SystemExit(f"EPAR runner trust policy: invalid image marker: {exc}") +if not isinstance(marker, dict) or marker.get("schemaVersion") != 1: + raise SystemExit("EPAR runner trust policy: unsupported image marker schema") +mode = marker.get("mode") +if mode == "disabled": + if marker.get("generation") != "disabled" or marker.get("hostOS") not in ("", None) or marker.get("scopes") != [] or marker.get("certificateCount") != 0: + raise SystemExit("EPAR runner trust policy: malformed disabled policy") +elif mode == "overlay": + if not isinstance(marker.get("generation"), str) or not marker["generation"] or not isinstance(marker.get("hostOS"), str) or not marker["hostOS"] or not isinstance(marker.get("scopes"), list) or not marker["scopes"] or not isinstance(marker.get("certificateCount"), int) or marker["certificateCount"] < 1: + raise SystemExit("EPAR runner trust policy: malformed overlay policy") +else: + raise SystemExit(f"EPAR runner trust policy: unknown mode {mode!r}") +print(mode) +PY +)" runner_environment=( "EPAR_RUNNER_WORK_DIR=${runner_dir}" "PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - "ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/epar/check-host-trust-generation.sh" ) +if [[ "${trust_mode}" == "overlay" ]]; then + runner_environment+=("ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/epar/check-host-trust-generation.sh") +fi sudo -u agent -H env "${runner_environment[@]}" /bin/bash -c 'cd "$1" || exit 1; nohup ./run.sh >>"$2" 2>&1 "${pid_file}" sleep "${startup_check_seconds}" pid="$(cat "${pid_file}" 2>/dev/null || true)" diff --git a/templates/docker-sandboxes/helpers.sha256 b/templates/docker-sandboxes/helpers.sha256 index 8b74185..c565d0d 100644 --- a/templates/docker-sandboxes/helpers.sha256 +++ b/templates/docker-sandboxes/helpers.sha256 @@ -1,9 +1,10 @@ -269b1431e6eb9ee6803be1ecfae6814a3cb120170466db16fa1b9ededcd4d39c ./check-host-trust-generation.sh +5ce4fad927d8e8a21ad312ec49b0498b51b652cbb89e0c5ca58058b6a00fe91d ./check-host-trust-generation.sh 3f1cee32d05ffad64004377f0276ef8aa46a2848b32bca74ac35efb3d71fd1bf ./check-runner.sh 4fe8e512539f97d00db3c2856f452f017c4de6a04832f1c1af86f1994862df59 ./collect-runner-diagnostics.sh 9d659942a7a0958c07cb0f0f069c699ec865020f0f9302bc005680a88c10c8e6 ./collect-software-inventory.sh 90a13d73f74e7628f1b7c4c768d300d2a4620387061c056dd67ca42692180e2b ./configure-runner.sh +32bc68fe28dbbe9eb6947a1023606c32fc9b20088efcb90b5c543d0fa3db1218 ./install-trusted-ca-certificates.sh a32f6bdb3b883272172e2d7318feb40d88e3ebfaffa909527ca57a3c06773ae1 ./prepare-template.sh -208d6fa96472a53b1ae1a5903da65530f6b8811d556cf04e88b99a2dfd88ec73 ./run-runner.sh +051fe7b5eb3c522da10c2ead6c0a8f4bae0963dc40f432c7dcc562dc07e73969 ./run-runner.sh 0746da2abc0a1033ecdad83ec6e432c7a006c83fefd2ce5424d3932c8f38d9aa ./template-entrypoint.sh d1e3b6808118e4c04bfff8db2b42228cb7a376910874b4a1fb486eaaa55d5d5f ./verify-template.sh diff --git a/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json b/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json index 257b254..ee25861 100644 --- a/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json +++ b/templates/docker-sandboxes/profiles/act-22.04.amd64.compatibility.json @@ -1,6 +1,6 @@ { - "schemaVersion": 1, - "candidate": "A", + "schemaVersion": 2, + "templateSchemaVersion": 1, "profile": "act-22.04", "validationStatus": "planned", "platform": "linux/amd64", diff --git a/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json b/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json index 0d49402..c18bb05 100644 --- a/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json +++ b/templates/docker-sandboxes/profiles/act-22.04.arm64.compatibility.json @@ -1,6 +1,6 @@ { - "schemaVersion": 1, - "candidate": "A", + "schemaVersion": 2, + "templateSchemaVersion": 1, "profile": "act-22.04", "validationStatus": "unvalidated", "platform": "linux/arm64", diff --git a/templates/docker-sandboxes/profiles/full.amd64.compatibility.json b/templates/docker-sandboxes/profiles/full.amd64.compatibility.json index 7fc9468..8fda5da 100644 --- a/templates/docker-sandboxes/profiles/full.amd64.compatibility.json +++ b/templates/docker-sandboxes/profiles/full.amd64.compatibility.json @@ -1,6 +1,6 @@ { - "schemaVersion": 1, - "candidate": "A", + "schemaVersion": 2, + "templateSchemaVersion": 1, "profile": "full", "validationStatus": "planned", "platform": "linux/amd64", diff --git a/templates/docker-sandboxes/profiles/full.arm64.compatibility.json b/templates/docker-sandboxes/profiles/full.arm64.compatibility.json index f9d82ca..f4e13c7 100644 --- a/templates/docker-sandboxes/profiles/full.arm64.compatibility.json +++ b/templates/docker-sandboxes/profiles/full.arm64.compatibility.json @@ -1,6 +1,6 @@ { - "schemaVersion": 1, - "candidate": "A", + "schemaVersion": 2, + "templateSchemaVersion": 1, "profile": "full", "validationStatus": "unvalidated", "platform": "linux/arm64", diff --git a/templates/docker-sandboxes/sources.lock.json b/templates/docker-sandboxes/sources.lock.json index 60dcb98..ea7ad16 100644 --- a/templates/docker-sandboxes/sources.lock.json +++ b/templates/docker-sandboxes/sources.lock.json @@ -63,13 +63,13 @@ "linux/amd64": { "act-22.04": { "authoritative": false, - "reason": "Predates current Candidate A helper and architecture changes", + "reason": "Predates the current runner-template helper and architecture changes", "manifestDigest": "sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8ef546ecb44ca256655", "templateTag": "epar-docker-sandboxes-catthehacker-act-22.04:20260723-r3-amd64" }, "full": { "authoritative": false, - "reason": "Predates current Candidate A helper and architecture changes", + "reason": "Predates the current runner-template helper and architecture changes", "manifestDigest": "sha256:58314fa8cbf0f0e5384a37b3444811033320038816ef7c16f30b3e841ed65e51", "templateTag": "epar-docker-sandboxes-catthehacker-full:20260723-r1-amd64" } From 6b1c8eef59277c1614e0732fcad6e7014fde5526 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 30 Jul 2026 15:32:26 +0800 Subject: [PATCH 08/22] feat: automate artifact lifecycle and storage housekeeping Add a host-wide exact resource catalog and reconcile owned provider artifacts during startup. Stream verified Docker Sandboxes archives directly into the sandbox cache, track WSL and Tart activation, bound BuildKit and no-Go controller storage, and separate operational build trust from runner trust. Improve long-running build progress, lifecycle diagnostics, and supervisor cleanup so temporary provider and GitHub failures do not retire healthy runners. --- README.md | 2 +- cmd/ephemeral-action-runner/init.go | 19 +- cmd/ephemeral-action-runner/init_test.go | 2 +- cmd/ephemeral-action-runner/main.go | 28 +- cmd/ephemeral-action-runner/start.go | 2 +- cmd/ephemeral-action-runner/start_test.go | 2 +- cmd/ephemeral-action-runner/storage.go | 75 +- .../storage_bootstrap.go | 163 +++ .../storage_catalog.go | 497 +++++++ .../storage_catalog_test.go | 174 +++ .../storage_external.go | 23 + configs/docker-container.act.example.yml | 2 +- configs/docker-container.core.example.yml | 2 +- configs/docker-container.example.yml | 2 +- configs/docker-container.web-e2e.example.yml | 2 +- configs/docker-sandboxes.example.yml | 2 +- configs/tart.example.yml | 2 +- configs/tart.web-e2e.example.yml | 2 +- configs/wsl.example.yml | 2 +- configs/wsl.lean.example.yml | 2 +- configs/wsl.web-e2e.example.yml | 2 +- docs/advanced/docker-sandboxes-template.md | 8 +- docs/advanced/no-go-install.md | 8 +- docs/configuration.md | 8 +- docs/development/adding-provider.md | 4 +- docs/development/design.md | 2 +- docs/development/principles.md | 2 +- docs/image-build.md | 4 +- docs/operations.md | 7 +- docs/providers/docker-sandboxes.md | 2 + docs/providers/wsl.md | 2 +- docs/storage.md | 18 +- docs/troubleshooting.md | 34 +- docs/usage.md | 4 +- internal/config/config.go | 2 +- internal/config/config_test.go | 2 +- internal/hosttrust/hosttrust_test.go | 4 +- internal/image/acquisition.go | 6 +- internal/image/artifact_lifecycle.go | 83 +- internal/image/build.go | 280 +++- internal/image/buildx.go | 62 +- internal/image/buildx_progress.go | 242 ++++ internal/image/buildx_progress_test.go | 71 + internal/image/buildx_test.go | 24 +- internal/image/coordinator.go | 14 +- internal/image/docker_archive.go | 351 +++++ internal/image/docker_archive_test.go | 204 +++ internal/image/docker_sandboxes.go | 714 +++++----- internal/image/docker_sandboxes_test.go | 170 ++- internal/image/operation_progress.go | 70 + internal/image/operation_progress_test.go | 89 ++ internal/image/storage_catalog.go | 1207 +++++++++++++++++ internal/image/storage_catalog_test.go | 202 +++ internal/image/storage_plan.go | 2 +- internal/image/tart_activation_test.go | 154 +++ internal/image/wsl_artifact.go | 180 +++ internal/image/wsl_artifact_test.go | 97 ++ internal/pool/host_trust.go | 67 +- internal/pool/host_trust_test.go | 152 ++- internal/pool/image_acquisition_test.go | 126 ++ internal/pool/image_service.go | 36 +- internal/pool/lifecycle_cleanup_test.go | 100 ++ internal/pool/lifecycle_state.go | 17 +- internal/pool/logging.go | 102 +- internal/pool/logging_test.go | 2 +- internal/pool/manager.go | 124 +- internal/pool/manager_test.go | 66 +- internal/pool/storage_catalog_lease.go | 100 ++ internal/pool/storage_preflight.go | 27 + .../provider/dockersandboxes/json_parsing.go | 18 - .../dockersandboxes/promotion/preflight.go | 36 - .../promotion/preflight_test.go | 27 - internal/provider/dockersandboxes/provider.go | 184 ++- .../provider/dockersandboxes/provider_test.go | 144 +- internal/provider/provider.go | 31 +- internal/provider/tart/tart.go | 13 + internal/provider/tart/tart_test.go | 16 + internal/storage/catalog/catalog.go | 518 +++++++ internal/storage/catalog/catalog_test.go | 226 +++ internal/storage/inventory/collect_test.go | 6 +- internal/storage/inventory/native.go | 121 +- internal/storage/inventory/native_test.go | 25 + internal/storage/inventory/template.go | 43 - internal/storage/inventory/template_test.go | 8 +- internal/storage/planner.go | 34 +- internal/storage/types.go | 7 +- scripts/bootstrap-trust/main.go | 296 ++++ scripts/bootstrap-trust/main_test.go | 192 +++ scripts/build-native-controller.ps1 | 500 ++++++- scripts/build-native-controller.sh | 298 +++- scripts/docker/dev.Dockerfile | 2 +- scripts/host-trust/host-trust-feed.sh | 17 +- scripts/run-with-docker.ps1 | 2 +- scripts/run-with-docker.sh | 2 +- scripts/test/host-trust-wrapper-smoke.ps1 | 9 + scripts/test/host-trust-wrapper-smoke.sh | 2 + .../test/native-controller-cache-retention.sh | 14 +- .../windows-native-controller-contract.ps1 | 79 +- start.ps1 | 17 +- templates/docker-sandboxes/Dockerfile | 16 +- .../docker-sandboxes/guest/run-runner.sh | 6 +- templates/docker-sandboxes/helpers.sha256 | 2 +- 102 files changed, 8096 insertions(+), 1074 deletions(-) create mode 100644 cmd/ephemeral-action-runner/storage_bootstrap.go create mode 100644 cmd/ephemeral-action-runner/storage_catalog.go create mode 100644 cmd/ephemeral-action-runner/storage_catalog_test.go create mode 100644 internal/image/buildx_progress.go create mode 100644 internal/image/buildx_progress_test.go create mode 100644 internal/image/docker_archive.go create mode 100644 internal/image/docker_archive_test.go create mode 100644 internal/image/operation_progress.go create mode 100644 internal/image/operation_progress_test.go create mode 100644 internal/image/storage_catalog.go create mode 100644 internal/image/storage_catalog_test.go create mode 100644 internal/image/tart_activation_test.go create mode 100644 internal/image/wsl_artifact.go create mode 100644 internal/image/wsl_artifact_test.go create mode 100644 internal/pool/storage_catalog_lease.go create mode 100644 internal/storage/catalog/catalog.go create mode 100644 internal/storage/catalog/catalog_test.go create mode 100644 scripts/bootstrap-trust/main.go create mode 100644 scripts/bootstrap-trust/main_test.go diff --git a/README.md b/README.md index 4acd834..80ae9c5 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ EPAR uses a GitHub App to obtain short-lived runner registration tokens. Follow In native Windows PowerShell or cmd, use `.\start.ps1` or `start.cmd` if `./start` is not available. The wrapper uses local Go when it works; otherwise it builds a native controller with Docker. If no configuration exists, the interactive wizard asks for the GitHub App, a runner group, and an available provider. The first start can take longer while EPAR prepares the configured runner image or creates the first runner. -Keep the process open while runners should accept work. Press `Ctrl-C` once to stop and wait for cleanup confirmation. For detailed commands, config selection, no-Go startup, verification, and cleanup, read [Usage](docs/usage.md). +Keep the process open while runners should accept work. Press `Ctrl-C` once to stop, then wait for cleanup to finish before closing the terminal. For detailed commands, config selection, no-Go startup, verification, and cleanup, read [Usage](docs/usage.md). ## Choose a provider diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index b83af73..3ca5e57 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -1183,18 +1183,11 @@ func discoverDockerSandboxes(ctx context.Context, projectRoot, guestPlatform str if !found { continue } - identity, inspectErr := adapter.InspectLocalTemplate(ctx, template.Reference) - if inspectErr != nil || identity.Platform != guestPlatform { - continue - } - if !strings.HasPrefix(identity.Digest, "sha256:") || len(identity.Digest) != len("sha256:")+64 || template.CacheID != strings.TrimPrefix(identity.Digest, "sha256:")[:12] { - continue - } templates = append(templates, initDockerSandboxesTemplate{ Reference: template.Reference, - Digest: identity.Digest, + Digest: "", CacheID: template.CacheID, - Platform: identity.Platform, + Platform: guestPlatform, Size: template.SizeBytes, Label: profile.DisplayLabel, SourceChannel: profile.ObservedTag, @@ -1747,7 +1740,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: @@ -1854,7 +1847,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: @@ -1956,7 +1949,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: @@ -2043,7 +2036,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index b828890..e94e0b6 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -163,7 +163,7 @@ func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { if !strings.Contains(string(configText), "replacementRetryInitialSeconds: 15\n replacementRetryMaxSeconds: 1800\n replacementRetryMultiplier: 2\n replacementRetryJitterPercent: 20\n") { t.Fatalf("generated config did not include replacement retry settings:\n%s", configText) } - if !strings.Contains(string(configText), "storage:\n minimumFree: 1GiB\n gracePeriod: 168h\n keepPrevious: 0\n automaticHousekeeping: conservative\n buildCacheLimit: 64GiB\n goCacheLimit: 10GiB\n") { + if !strings.Contains(string(configText), "storage:\n minimumFree: 1GiB\n gracePeriod: 168h\n keepPrevious: 0\n automaticHousekeeping: conservative\n buildCacheLimit: 20GiB\n goCacheLimit: 10GiB\n") { t.Fatalf("generated config did not include bounded storage settings:\n%s", configText) } if got := strings.Join(cfg.Runner.Labels, ","); !strings.Contains(got, "epar-docker-container-catthehacker-ubuntu") { diff --git a/cmd/ephemeral-action-runner/main.go b/cmd/ephemeral-action-runner/main.go index 3cf5917..a7a6703 100644 --- a/cmd/ephemeral-action-runner/main.go +++ b/cmd/ephemeral-action-runner/main.go @@ -455,6 +455,11 @@ func newManagerWithLifecycleState(configPath, projectRoot string, dryRun bool, g if err := config.Validate(cfg); err != nil { return nil, err } + if !dryRun { + if err := importNativeBootstrapAcquisition(projectRoot, resolvedConfigPath, time.Now().UTC()); err != nil { + return nil, err + } + } providerRuntime, err := registry.New(cfg, projectRoot, dryRun) if err != nil { return nil, err @@ -495,17 +500,18 @@ func newManagerWithLifecycleState(configPath, projectRoot string, dryRun bool, g return nil, err } manager := &pool.Manager{ - Config: cfg, - Provider: providerRuntime.Legacy, - Lifecycle: providerRuntime.Lifecycle, - PolicyManager: providerRuntime.PolicyManager, - Storage: providerRuntime.Storage, - LifecycleState: lifecycleState, - GitHub: client, - ProjectRoot: projectRoot, - ConfigPath: resolvedConfigPath, - DryRun: dryRun, - Logging: runtime, + Config: cfg, + Provider: providerRuntime.Legacy, + Lifecycle: providerRuntime.Lifecycle, + PolicyManager: providerRuntime.PolicyManager, + Storage: providerRuntime.Storage, + LifecycleState: lifecycleState, + GitHub: client, + ProjectRoot: projectRoot, + ConfigPath: resolvedConfigPath, + DryRun: dryRun, + Logging: runtime, + AutomaticImageLifecycle: true, } if cfg.Logging.RetentionEnabled { report, pruneErr := manager.PruneLogs(false) diff --git a/cmd/ephemeral-action-runner/start.go b/cmd/ephemeral-action-runner/start.go index 49fab05..9a98905 100644 --- a/cmd/ephemeral-action-runner/start.go +++ b/cmd/ephemeral-action-runner/start.go @@ -186,7 +186,7 @@ func runStartWithOptions(opts startOptions) (err error) { if err = manager.EnsureImage(opts.Context); err != nil { return err } - stopGuidance := "Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window." + stopGuidance := "Press Ctrl-C once to stop, then wait for cleanup to finish before closing this window." if opts.KeepOnExit { stopGuidance = "Press Ctrl-C once to stop; --keep-on-exit will leave owned runner resources running." } diff --git a/cmd/ephemeral-action-runner/start_test.go b/cmd/ephemeral-action-runner/start_test.go index d53715b..5ac63bc 100644 --- a/cmd/ephemeral-action-runner/start_test.go +++ b/cmd/ephemeral-action-runner/start_test.go @@ -78,7 +78,7 @@ func TestStartPropagatesConfigAndInstances(t *testing.T) { if fake.runOptions.Instances != 3 { t.Fatalf("instances = %d, want 3", fake.runOptions.Instances) } - if !strings.Contains(out.String(), "Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.") { + if !strings.Contains(out.String(), "Press Ctrl-C once to stop, then wait for cleanup to finish before closing this window.") { t.Fatalf("start guidance = %q", out.String()) } if strings.Contains(out.String(), "Start runners now?") { diff --git a/cmd/ephemeral-action-runner/storage.go b/cmd/ephemeral-action-runner/storage.go index 98f684b..6dac7ba 100644 --- a/cmd/ephemeral-action-runner/storage.go +++ b/cmd/ephemeral-action-runner/storage.go @@ -13,6 +13,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" artifactimage "github.com/solutionforest/ephemeral-action-runner/internal/image" + "github.com/solutionforest/ephemeral-action-runner/internal/invocation" "github.com/solutionforest/ephemeral-action-runner/internal/provider" providerregistry "github.com/solutionforest/ephemeral-action-runner/internal/provider/registry" "github.com/solutionforest/ephemeral-action-runner/internal/storage" @@ -23,6 +24,7 @@ type storageCommandReport struct { Inventory storageInventorySummary `json:"inventory"` Plan storage.Plan `json:"plan"` Execution *storage.ExecutionReport `json:"execution,omitempty"` + Legacy bool `json:"legacy,omitempty"` } type storageInventorySummary struct { @@ -49,6 +51,8 @@ func runStorage(args []string) error { providerFlag := fs.String("provider", "", "limit provider-specific inventory") jsonOutput := fs.Bool("json", false, "write the complete storage report as JSON") execute := fs.Bool("execute", false, "execute the exact policy-selected prune plan") + legacy := fs.Bool("legacy", false, "include prefix-era EPAR resources in an operator-approved exact preview") + approvedPlan := fs.String("plan", "", "approved legacy preview plan hash") if err := fs.Parse(args[1:]); err != nil { return err } @@ -58,6 +62,15 @@ func runStorage(args []string) error { if subcommand == "status" && *execute { return fmt.Errorf("storage status does not support --execute") } + if subcommand == "status" && (*legacy || strings.TrimSpace(*approvedPlan) != "") { + return fmt.Errorf("storage status does not support --legacy or --plan") + } + if !*legacy && strings.TrimSpace(*approvedPlan) != "" { + return fmt.Errorf("--plan is valid only with storage prune --legacy --execute") + } + if *legacy && *execute && strings.TrimSpace(*approvedPlan) == "" { + return fmt.Errorf("legacy cleanup requires the exact preview hash: storage prune --legacy --execute --plan ") + } if *providerFlag != "" { if _, found := providerregistry.DescriptorFor(*providerFlag); !found { return provider.UnsupportedTypeError(*providerFlag) @@ -69,10 +82,21 @@ func runStorage(args []string) error { return err } now := time.Now().UTC() + if configPath != "" { + if err := importNativeBootstrapAcquisition(projectRoot, configPath, now); err != nil { + return err + } + } var selections []inventory.TemplateSelection activeTemplateRootDisk := "" + legacyTemplateReceiptProtected := false + staleTemplateReceiptWarning := "" if cfg.Provider.Type == "docker-sandboxes" { - artifact, metadataSHA256, activatedAt, receiptErr := artifactimage.LoadDockerSandboxesReceipt(projectRoot) + artifact, metadataSHA256, activatedAt, receiptErr := artifactimage.LoadDockerSandboxesReceiptForConfig(projectRoot, configPath) + if errors.Is(receiptErr, os.ErrNotExist) { + artifact, metadataSHA256, activatedAt, receiptErr = artifactimage.LoadDockerSandboxesReceipt(projectRoot) + legacyTemplateReceiptProtected = receiptErr == nil + } if receiptErr == nil { activeTemplateRootDisk = artifact.RootDisk selections = append(selections, inventory.TemplateSelection{ @@ -83,7 +107,7 @@ func runStorage(args []string) error { ActivatedAt: activatedAt, }) } else if !errors.Is(receiptErr, os.ErrNotExist) { - return fmt.Errorf("read Docker Sandboxes active artifact receipt: %w", receiptErr) + staleTemplateReceiptWarning = fmt.Sprintf("The unpublished Docker Sandboxes receipt is stale and is not treated as active ownership evidence: %v. Normal startup will rebuild and replace it after exact Sandbox-cache readback.", receiptErr) } } currentExecutable, _ := os.Executable() @@ -102,7 +126,22 @@ func runStorage(args []string) error { if err != nil { return err } + if legacyTemplateReceiptProtected { + snapshot.Warnings = append(snapshot.Warnings, "The exact template in the retired shared Docker Sandboxes receipt remains protected for legacy cleanup; normal startup still requires regeneration into per-config state.") + } + if staleTemplateReceiptWarning != "" { + snapshot.Warnings = append(snapshot.Warnings, staleTemplateReceiptWarning) + } collectExternalStorage(&snapshot, *providerFlag) + protectConfiguredSandboxTemplates(&snapshot, selections) + catalogValue, catalogErr := addCatalogStorage(&snapshot, *providerFlag, now) + if catalogErr != nil { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR host resource catalog is unavailable; catalog-owned resources remain report-only: %v", catalogErr)) + } + if *legacy { + selectLegacyStorage(&snapshot, catalogValue, now) + snapshot.Warnings = append(snapshot.Warnings, "Legacy preview is limited to resources visible on this host. Unregistered old EPAR checkouts and their intended references cannot be inferred.") + } if cfg.Storage.AutomaticHousekeeping == config.StorageHousekeepingDisabled { for index := range snapshot.Artifacts { snapshot.Artifacts[index].Protections = append(snapshot.Artifacts[index].Protections, storage.Protection{ @@ -199,7 +238,8 @@ func runStorage(args []string) error { Provider: *providerFlag, Warnings: snapshot.Warnings, }, - Plan: plan, + Plan: plan, + Legacy: *legacy, } if subcommand == "prune" && *execute { @@ -209,15 +249,21 @@ func runStorage(args []string) error { } } if plan.RemovalCount > 0 { - executor, err := storage.NewFilesystemExecutor( - filepath.Join(projectRoot, ".local", "bin"), - filepath.Join(projectRoot, "work", "template-builds", "docker-sandboxes"), - ) + executor, err := newHostStorageExecutor(projectRoot) if err != nil { return err } - execution, err := storage.Execute(context.Background(), plan, plan.Hash, executor) + planApproval := plan.Hash + if *legacy { + planApproval = strings.TrimSpace(*approvedPlan) + } + execution, err := storage.Execute(context.Background(), plan, planApproval, executor) report.Execution = &execution + if catalogErr == nil { + if updateErr := removeExecutedCatalogEntries(execution, time.Now().UTC()); updateErr != nil { + report.Inventory.Warnings = append(report.Inventory.Warnings, fmt.Sprintf("exact removals completed but the host catalog could not be compacted: %v", updateErr)) + } + } if err != nil { if *jsonOutput { _ = writeStorageJSON(report) @@ -252,10 +298,15 @@ func runEffectiveGoCacheLimit(args []string) error { if fs.NArg() != 0 { return fmt.Errorf("storage effective-go-cache-limit does not accept positional arguments") } - _, cfg, _, _, err := loadStorageConfig(*projectRootFlag, *configPathFlag) + projectRoot, cfg, configPath, _, err := loadStorageConfig(*projectRootFlag, *configPathFlag) if err != nil { return err } + if configPath != "" { + if err := importNativeBootstrapAcquisition(projectRoot, configPath, time.Now().UTC()); err != nil { + return err + } + } if err := config.ValidateStorage(cfg.Storage); err != nil { return err } @@ -369,7 +420,11 @@ func printStorageReport(subcommand string, report storageCommandReport) { } fmt.Fprintln(os.Stdout) if subcommand == "prune" && report.Execution == nil { - fmt.Fprintln(os.Stdout, "Preview only. Re-run with --execute to apply only the exact identities marked remove.") + if report.Legacy { + fmt.Fprintf(os.Stdout, "Preview only. Legacy execution requires this exact plan:\n %s\n", invocation.Command("storage", "prune", "--legacy", "--execute", "--plan", report.Plan.Hash)) + } else { + fmt.Fprintln(os.Stdout, "Preview only. Re-run with --execute to apply only the exact identities marked remove.") + } } } diff --git a/cmd/ephemeral-action-runner/storage_bootstrap.go b/cmd/ephemeral-action-runner/storage_bootstrap.go new file mode 100644 index 0000000..962a0b9 --- /dev/null +++ b/cmd/ephemeral-action-runner/storage_bootstrap.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" +) + +type nativeBootstrapAcquisition struct { + SchemaVersion int `json:"schemaVersion"` + ProjectRoot string `json:"projectRoot"` + Phase string `json:"phase"` + GoImage string `json:"goImage"` + DevImage string `json:"devImage"` + PreviousGoImageID string `json:"previousGoImageID"` + ResolvedGoImageID string `json:"resolvedGoImageID"` + PreviousDevImageID string `json:"previousDevImageID,omitempty"` + ResolvedDevImageID string `json:"resolvedDevImageID"` + UpdatedAtUTC string `json:"updatedAtUtc,omitempty"` + UpdatedAtUnix int64 `json:"updatedAtUnix,omitempty"` +} + +func importNativeBootstrapAcquisition(projectRoot, configPath string, now time.Time) error { + path := filepath.Join(projectRoot, ".local", "storage", "bootstrap", "native-controller-acquisition.json") + content, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read native-controller acquisition journal: %w", err) + } + var record nativeBootstrapAcquisition + if err := json.Unmarshal(content, &record); err != nil { + return fmt.Errorf("decode native-controller acquisition journal: %w", err) + } + if record.SchemaVersion != 1 || record.Phase != "toolchain-built" || record.ResolvedDevImageID == "" { + return fmt.Errorf("native-controller acquisition journal is incomplete at phase %q", record.Phase) + } + backendID, err := currentDockerBackendID() + if err != nil { + return fmt.Errorf("identify Docker backend for native-controller acquisition journal: %w", err) + } + store, err := storagecatalog.Open("") + if err != nil { + return err + } + lockContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + backendLock, err := store.AcquireBackendLock(lockContext, backendID) + if err != nil { + return err + } + defer backendLock.Close() + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, err := storagecatalog.RegisterConfig(value, projectRoot, configPath, now) + if err != nil { + return err + } + devTag := normalizeDockerTag(record.DevImage) + dev := storagecatalog.Resource{ + BackendID: backendID, InstallationIDs: []string{configRecord.InstallationID}, Kind: "docker-image", Role: "native-toolchain", Locator: devTag, + Identity: record.ResolvedDevImageID, Custody: storagecatalog.CustodyGenerated, State: storagecatalog.StateCurrent, + IntroducedTags: []string{devTag}, CreatedAt: now, LastSeenAt: now, + } + dev.Key = storagecatalog.ResourceKey(dev.BackendID, dev.Kind, dev.Identity) + var existingReferences []storagecatalog.Reference + for _, resource := range value.Resources { + if resource.Key == dev.Key { + existingReferences = append(existingReferences, resource.References...) + dev.CreatedAt = resource.CreatedAt + dev.InstallationIDs = mergeUniqueStrings(dev.InstallationIDs, resource.InstallationIDs) + } + } + dev.References = existingReferences + if err := storagecatalog.UpsertResource(value, dev); err != nil { + return err + } + storagecatalog.ReplaceConfigRoleReferences(value, configRecord.ID, "native-toolchain", map[string]storagecatalog.Reference{ + dev.Key: {}, + }, now) + if record.PreviousDevImageID != "" && record.PreviousDevImageID != record.ResolvedDevImageID { + old := storagecatalog.Resource{ + BackendID: backendID, InstallationIDs: []string{configRecord.InstallationID}, Kind: "docker-image", Role: "native-toolchain", Locator: record.PreviousDevImageID, + Identity: record.PreviousDevImageID, Custody: storagecatalog.CustodyGenerated, State: storagecatalog.StateSuperseded, + CreatedAt: now, LastSeenAt: now, + } + when := now.UTC() + old.SupersededAt = &when + if err := storagecatalog.UpsertResource(value, old); err != nil { + return err + } + } + if record.ResolvedGoImageID != "" && record.ResolvedGoImageID != record.PreviousGoImageID { + source := storagecatalog.Resource{ + BackendID: backendID, InstallationIDs: []string{configRecord.InstallationID}, Kind: "docker-image", Role: "native-toolchain-source", + Locator: normalizeDockerTag(record.GoImage), Identity: record.ResolvedGoImageID, + Custody: storagecatalog.CustodyAcquired, State: storagecatalog.StateSuperseded, + CreatedAt: now, LastSeenAt: now, + } + if record.PreviousGoImageID == "" { + source.IntroducedTags = []string{normalizeDockerTag(record.GoImage)} + } + when := now.UTC() + source.SupersededAt = &when + if err := storagecatalog.UpsertResource(value, source); err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("retire imported native-controller acquisition journal: %w", err) + } + return nil +} + +func mergeUniqueStrings(values ...[]string) []string { + seen := make(map[string]bool) + var result []string + for _, group := range values { + for _, value := range group { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + } + return result +} + +func normalizeDockerTag(reference string) string { + reference = strings.TrimSpace(reference) + lastSlash := strings.LastIndex(reference, "/") + if strings.LastIndex(reference, ":") <= lastSlash { + return reference + ":latest" + } + return reference +} + +func currentDockerBackendID() (string, error) { + output, err := exec.Command("docker", "info", "--format", "{{.ID}}").CombinedOutput() + if err != nil { + return "", fmt.Errorf("docker info failed: %w: %s", err, strings.TrimSpace(string(output))) + } + id := strings.TrimSpace(string(output)) + if id == "" { + return "", errors.New("Docker Engine returned an empty daemon identity") + } + return "docker:" + id, nil +} diff --git a/cmd/ephemeral-action-runner/storage_catalog.go b/cmd/ephemeral-action-runner/storage_catalog.go new file mode 100644 index 0000000..d9312ac --- /dev/null +++ b/cmd/ephemeral-action-runner/storage_catalog.go @@ -0,0 +1,497 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes" + tartprovider "github.com/solutionforest/ephemeral-action-runner/internal/provider/tart" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" + "github.com/solutionforest/ephemeral-action-runner/internal/storage/inventory" +) + +const legacyOwnershipEvidence = "operator-selected legacy preview; exact identity readback is still required at execution" + +func addCatalogStorage(snapshot *inventory.Snapshot, providerFilter string, now time.Time) (storagecatalog.Catalog, error) { + store, err := storagecatalog.Open("") + if err != nil { + return storagecatalog.Catalog{}, err + } + value, err := store.Load(now) + if err != nil { + return storagecatalog.Catalog{}, err + } + surfaces := make(map[string]bool, len(snapshot.Surfaces)) + for _, surface := range snapshot.Surfaces { + surfaces[surface.ID] = true + } + for _, resource := range value.Resources { + if !storageProviderMatches(providerFilter, resource.Provider) { + continue + } + artifact, ok := catalogStorageArtifact(value, resource, now) + if !ok { + continue + } + if !surfaces[artifact.SurfaceID] { + snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ + ID: artifact.SurfaceID, + Provider: artifact.Provider, + Kind: storage.SurfaceExternal, + Location: resource.BackendID, + Capacity: storage.Capacity{ObservedAt: now}, + }) + surfaces[artifact.SurfaceID] = true + } + mergeCatalogStorageArtifact(snapshot, artifact) + } + return value, nil +} + +func mergeCatalogStorageArtifact(snapshot *inventory.Snapshot, catalogArtifact storage.Artifact) { + artifacts := snapshot.Artifacts[:0] + for _, existing := range snapshot.Artifacts { + if !sameCatalogStorageTarget(existing, catalogArtifact) { + artifacts = append(artifacts, existing) + continue + } + if existing.SizeBytes > catalogArtifact.SizeBytes { + catalogArtifact.SizeBytes = existing.SizeBytes + } + if catalogArtifact.CreatedAt.IsZero() || (!existing.CreatedAt.IsZero() && existing.CreatedAt.Before(catalogArtifact.CreatedAt)) { + catalogArtifact.CreatedAt = existing.CreatedAt + } + if existing.Current { + catalogArtifact.Current = true + for _, protection := range existing.Protections { + if protection.Kind == storage.ProtectionConfiguration { + catalogArtifact.Protections = append(catalogArtifact.Protections, protection) + } + } + } + } + snapshot.Artifacts = append(artifacts, catalogArtifact) +} + +func sameCatalogStorageTarget(left, right storage.Artifact) bool { + if left.Kind != right.Kind || left.Target.Kind != right.Target.Kind || left.Target.Identity == "" || left.Target.Identity != right.Target.Identity { + return false + } + leftLocator := strings.TrimSpace(left.Target.Locator) + rightLocator := strings.TrimSpace(right.Target.Locator) + if left.Target.Kind == storage.TargetSandboxTemplate { + leftLocator = strings.TrimPrefix(strings.ToLower(leftLocator), "docker.io/library/") + rightLocator = strings.TrimPrefix(strings.ToLower(rightLocator), "docker.io/library/") + return leftLocator == rightLocator + } + if left.Target.Kind == storage.TargetFile || left.Target.Kind == storage.TargetDirectory { + leftLocator = filepath.Clean(leftLocator) + rightLocator = filepath.Clean(rightLocator) + if runtime.GOOS == "windows" { + return strings.EqualFold(leftLocator, rightLocator) + } + } + return leftLocator == rightLocator +} + +func catalogStorageArtifact(value storagecatalog.Catalog, resource storagecatalog.Resource, now time.Time) (storage.Artifact, bool) { + ownerID := value.InstallationID + if len(resource.InstallationIDs) != 0 { + ownerID = strings.Join(resource.InstallationIDs, ",") + } + artifact := storage.Artifact{ + ID: "catalog-" + resource.Key, + Provider: resource.Provider, + SurfaceID: catalogSurfaceID(resource), + RetentionGroup: resource.BackendID + "/" + resource.Provider + "/" + resource.Role, + Ownership: storage.Ownership{ + Kind: storage.OwnershipExact, + OwnerID: ownerID, + Evidence: "EPAR host resource catalog " + resource.Key, + }, + CreatedAt: resource.CreatedAt, + LastUsedAt: resource.LastSeenAt, + SupersededAt: resource.SupersededAt, + BackendID: resource.BackendID, + Custody: string(resource.Custody), + LifecycleState: string(resource.State), + CleanupError: resource.CleanupError, + } + for _, reference := range resource.References { + artifact.ConfigRefs = append(artifact.ConfigRefs, reference.ConfigID) + } + sort.Strings(artifact.ConfigRefs) + if resource.LeaseExpiresAt != nil { + artifact.Lease = &storage.Lease{ID: "catalog-resource", ExpiresAt: *resource.LeaseExpiresAt} + } + if len(resource.References) != 0 || resource.State == storagecatalog.StateCurrent { + artifact.Current = true + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionConfiguration, Detail: "referenced by registered EPAR configuration"}) + } + switch resource.Kind { + case "docker-image": + artifact.Kind = storage.ArtifactDockerImage + artifact.Target = storage.Target{Kind: storage.TargetDockerImageTag, Locator: resource.Locator, Identity: resource.Identity, Fingerprint: resource.Fingerprint, Match: storage.MatchExact} + if resource.Custody == storagecatalog.CustodyAcquired && len(resource.IntroducedTags) == 0 { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "the Docker tag existed before EPAR acquisition; it remains shared and report-only"}) + } + case "sandbox-template": + artifact.Kind = storage.ArtifactSandboxTemplate + artifact.Target = storage.Target{Kind: storage.TargetSandboxTemplate, Locator: resource.Locator, Identity: resource.Identity, Fingerprint: resource.Fingerprint, Match: storage.MatchExact} + case "provider-image": + target, err := storage.SnapshotFilesystemTarget(resource.Locator) + if err == nil { + artifact.Kind = storage.ArtifactProviderImage + artifact.Target = target + if info, statErr := os.Lstat(target.Locator); statErr == nil { + artifact.SizeBytes = uint64(maxInt64(info.Size(), 0)) + } + } else { + artifact.Kind = storage.ArtifactProviderImage + artifact.Target = storage.Target{Kind: storage.TargetExternal, Locator: resource.Locator, Identity: resource.Identity, Fingerprint: resource.Fingerprint, Match: storage.MatchExact} + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "catalog filesystem target cannot be read exactly"}) + } + case "template-staging-directory": + target, err := storage.SnapshotFilesystemTarget(resource.Locator) + artifact.Kind = storage.ArtifactOther + if err == nil { + artifact.Target = target + if info, statErr := os.Lstat(target.Locator); statErr == nil { + artifact.SizeBytes = uint64(maxInt64(info.Size(), 0)) + } + } else { + artifact.Target = storage.Target{Kind: storage.TargetExternal, Locator: resource.Locator, Identity: resource.Identity, Fingerprint: resource.Fingerprint, Match: storage.MatchExact} + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "catalog template staging directory cannot be read exactly"}) + } + case "tart-image": + artifact.Kind = storage.ArtifactProviderImage + artifact.Target = storage.Target{Kind: storage.TargetExternal, Locator: "tart-image:" + resource.Locator, Identity: resource.Identity, Fingerprint: resource.Fingerprint, Match: storage.MatchExact} + default: + return storage.Artifact{}, false + } + if artifact.SupersededAt == nil && (resource.State == storagecatalog.StateSuperseded || resource.State == storagecatalog.StateCleanupPending) { + supersededAt := now.UTC() + artifact.SupersededAt = &supersededAt + } + return artifact, true +} + +func catalogSurfaceID(resource storagecatalog.Resource) string { + switch resource.Kind { + case "docker-image": + return "docker-engine" + case "sandbox-template": + return "docker-sandboxes-template-cache" + case "template-staging-directory": + return inventory.ProjectSurfaceID + case "tart-image": + return "tart-images" + default: + return "catalog-" + resource.Key[:12] + } +} + +func selectLegacyStorage(snapshot *inventory.Snapshot, catalogValue storagecatalog.Catalog, now time.Time) { + known := make(map[string]bool) + for _, resource := range catalogValue.Resources { + known[string(resource.Kind)+"\x00"+resource.Identity] = true + known[string(resource.Kind)+"\x00"+resource.Locator] = true + } + for index := range snapshot.Artifacts { + artifact := &snapshot.Artifacts[index] + if artifact.Ownership.Kind != storage.OwnershipUnknown || !legacyEPARArtifact(*artifact) { + continue + } + catalogKind := "" + switch artifact.Kind { + case storage.ArtifactDockerImage: + catalogKind = "docker-image" + case storage.ArtifactSandboxTemplate: + catalogKind = "sandbox-template" + case storage.ArtifactDockerVolume: + catalogKind = "docker-volume" + default: + continue + } + if known[catalogKind+"\x00"+artifact.Target.Identity] || known[catalogKind+"\x00"+artifact.Target.Locator] { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionConfiguration, Detail: "exact identity is present in the host resource catalog"}) + continue + } + artifact.Ownership = storage.Ownership{Kind: storage.OwnershipExact, OwnerID: "legacy-preview", Evidence: legacyOwnershipEvidence} + artifact.LifecycleState = string(storagecatalog.StateSuperseded) + artifact.RetentionGroup = "legacy/" + string(artifact.Kind) + supersededAt := now.UTC() + artifact.SupersededAt = &supersededAt + artifact.Protections = removeUncertainProtections(artifact.Protections) + switch artifact.Kind { + case storage.ArtifactDockerImage: + if blockers, err := dockerImageContainerBlockers(artifact.Target.Identity); err != nil { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "container references could not be checked: " + err.Error()}) + } else if len(blockers) != 0 { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionActive, Detail: "Docker containers reference this image: " + strings.Join(blockers, ",")}) + } + case storage.ArtifactSandboxTemplate: + if active, err := activeSandboxCount(); err != nil { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "active Sandboxes could not be checked: " + err.Error()}) + } else if active != 0 { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionActive, Detail: fmt.Sprintf("%d live Docker Sandbox instance(s) protect template cleanup", active)}) + } + case storage.ArtifactDockerVolume: + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "prefix-era volumes require stronger role evidence than a name"}) + } + } +} + +func legacyEPARArtifact(artifact storage.Artifact) bool { + value := strings.ToLower(artifact.Target.Locator) + if artifact.Kind == storage.ArtifactSandboxTemplate && (value == "docker/sandbox-templates:shell-docker" || value == "docker.io/docker/sandbox-templates:shell-docker") { + return false + } + switch artifact.Kind { + case storage.ArtifactDockerImage: + repository := value + if cut := strings.LastIndex(repository, ":"); cut > strings.LastIndex(repository, "/") { + repository = repository[:cut] + } + repository = strings.TrimPrefix(repository, "docker.io/library/") + return strings.HasPrefix(repository, "epar-") + case storage.ArtifactSandboxTemplate: + return strings.HasPrefix(strings.TrimPrefix(value, "docker.io/library/"), "epar-") + case storage.ArtifactDockerVolume: + return strings.HasPrefix(value, "epar-") + default: + return false + } +} + +func removeUncertainProtections(values []storage.Protection) []storage.Protection { + out := values[:0] + for _, value := range values { + if value.Kind != storage.ProtectionUncertain { + out = append(out, value) + } + } + return out +} + +func dockerImageContainerBlockers(identity string) ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--filter", "ancestor="+identity, "--format", "{{.ID}}").Output() + if err != nil { + return nil, err + } + return strings.Fields(string(output)), nil +} + +func activeSandboxCount() (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + items, err := dockersandboxes.New("").Inventory(ctx) + return len(items), err +} + +type hostStorageExecutor struct { + filesystem storage.ExactExecutor + sandboxes provider.TemplateArtifactCleaner +} + +func newHostStorageExecutor(projectRoot string) (*hostStorageExecutor, error) { + filesystem, err := storage.NewFilesystemExecutor( + filepath.Join(projectRoot, ".local", "bin"), + filepath.Join(projectRoot, "work", "template-builds", "docker-sandboxes"), + ) + if err != nil { + return nil, err + } + return &hostStorageExecutor{filesystem: filesystem, sandboxes: dockersandboxes.New("")}, nil +} + +func (e *hostStorageExecutor) ObserveExact(ctx context.Context, target storage.Target) (storage.Observation, error) { + switch target.Kind { + case storage.TargetFile, storage.TargetDirectory: + return e.filesystem.ObserveExact(ctx, target) + case storage.TargetDockerImageTag: + return observeDockerImageTarget(ctx, target) + case storage.TargetSandboxTemplate: + templates, err := dockersandboxes.New("").CachedTemplates(ctx) + if err != nil { + return storage.Observation{}, err + } + for _, item := range templates { + if item.CacheID == target.Identity && item.Reference == target.Locator { + return storage.Observation{Exists: true, Target: target}, nil + } + } + return storage.Observation{Exists: false, Target: target}, nil + case storage.TargetExternal: + name, ok := strings.CutPrefix(target.Locator, "tart-image:") + if !ok || strings.TrimSpace(name) == "" { + return storage.Observation{}, fmt.Errorf("unsupported exact external storage target %q", target.Locator) + } + instances, err := tartprovider.New("", false).List(ctx) + if err != nil { + return storage.Observation{}, err + } + for _, instance := range instances { + if instance.Name != name { + continue + } + observed := target + observed.Identity = instance.ProviderID + return storage.Observation{Exists: true, Target: observed}, nil + } + return storage.Observation{Exists: false, Target: target}, nil + default: + return storage.Observation{}, fmt.Errorf("exact storage executor does not support %s", target.Kind) + } +} + +func (e *hostStorageExecutor) RemoveExact(ctx context.Context, removal storage.Removal) error { + switch removal.Target.Kind { + case storage.TargetFile, storage.TargetDirectory: + return e.filesystem.RemoveExact(ctx, removal) + case storage.TargetDockerImageTag: + blockers, err := dockerImageContainerBlockers(removal.Target.Identity) + if err != nil { + return err + } + if len(blockers) != 0 { + return fmt.Errorf("Docker containers still reference image %s", removal.Target.Identity) + } + observed, err := observeDockerImageTarget(ctx, removal.Target) + if err != nil { + return err + } + if !observed.Exists { + return nil + } + return runExactStorageCommand(ctx, "docker", "image", "rm", removal.Target.Locator) + case storage.TargetSandboxTemplate: + if count, err := activeSandboxCount(); err != nil { + return err + } else if count != 0 { + return fmt.Errorf("%d live Docker Sandbox instance(s) protect template cleanup", count) + } + return e.sandboxes.RemoveTemplate(ctx, provider.TemplateArtifact{Reference: removal.Target.Locator, CacheID: removal.Target.Identity, Digest: removal.Target.Fingerprint}) + case storage.TargetExternal: + name, ok := strings.CutPrefix(removal.Target.Locator, "tart-image:") + if !ok || strings.TrimSpace(name) == "" { + return fmt.Errorf("unsupported exact external storage target %q", removal.Target.Locator) + } + tart := tartprovider.New("", false) + instances, err := tart.List(ctx) + if err != nil { + return err + } + var exact *provider.Instance + for index := range instances { + instance := instances[index] + if instance.Source == name && instance.Name != name { + return fmt.Errorf("Tart instance %q still references image %q", instance.Name, name) + } + if instance.Name == name { + if instance.ProviderID != removal.Target.Identity { + return errors.New("Tart image identity changed") + } + copy := instance + exact = © + } + } + if exact == nil { + return nil + } + if strings.EqualFold(exact.State, "running") { + return errors.New("Tart image is running") + } + return tart.Delete(ctx, exact.Name) + default: + return fmt.Errorf("exact storage executor does not support %s", removal.Target.Kind) + } +} + +func observeDockerImageTarget(ctx context.Context, target storage.Target) (storage.Observation, error) { + command := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{json .}}", target.Locator) + output, err := command.CombinedOutput() + if err != nil { + var exitErr *exec.ExitError + message := strings.ToLower(string(output)) + if errors.As(err, &exitErr) && (strings.Contains(message, "no such image") || strings.Contains(message, "not found")) { + return storage.Observation{Exists: false, Target: target}, nil + } + return storage.Observation{}, fmt.Errorf("docker image inspect %s failed: %w: %s", target.Locator, err, strings.TrimSpace(string(output))) + } + var record struct { + ID string `json:"Id"` + } + if err := json.Unmarshal(output, &record); err != nil { + return storage.Observation{}, err + } + if record.ID != target.Identity { + drifted := target + drifted.Identity = record.ID + return storage.Observation{Exists: true, Target: drifted}, nil + } + return storage.Observation{Exists: true, Target: target}, nil +} + +func runExactStorageCommand(ctx context.Context, name string, args ...string) error { + command := exec.CommandContext(ctx, name, args...) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s failed: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return nil +} + +func removeExecutedCatalogEntries(report storage.ExecutionReport, now time.Time) error { + if report.RemovedCount == 0 { + return nil + } + removed := make(map[string]bool) + for _, entry := range report.Entries { + if entry.Status == storage.ExecutionRemoved { + removed[string(entry.Removal.Target.Kind)+"\x00"+entry.Removal.Target.Identity] = true + } + } + store, err := storagecatalog.Open("") + if err != nil { + return err + } + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + resources := value.Resources[:0] + for _, resource := range value.Resources { + targetKind := storage.TargetExternal + switch resource.Kind { + case "docker-image": + targetKind = storage.TargetDockerImageTag + case "sandbox-template": + targetKind = storage.TargetSandboxTemplate + case "provider-image": + if runtime.GOOS == "windows" || filepath.IsAbs(resource.Locator) { + targetKind = storage.TargetFile + } + case "template-staging-directory": + targetKind = storage.TargetDirectory + } + if removed[string(targetKind)+"\x00"+resource.Identity] && len(resource.References) == 0 { + continue + } + resources = append(resources, resource) + } + value.Resources = resources + return nil + }) + return err +} diff --git a/cmd/ephemeral-action-runner/storage_catalog_test.go b/cmd/ephemeral-action-runner/storage_catalog_test.go new file mode 100644 index 0000000..445255f --- /dev/null +++ b/cmd/ephemeral-action-runner/storage_catalog_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" + "github.com/solutionforest/ephemeral-action-runner/internal/storage/inventory" +) + +func TestStorageLegacyExecutionRequiresPreviewPlanHash(t *testing.T) { + err := runStorage([]string{"prune", "--legacy", "--execute"}) + if err == nil || !strings.Contains(err.Error(), "--plan ") { + t.Fatalf("runStorage() error = %v, want legacy plan-hash requirement", err) + } + if err := runStorage([]string{"status", "--legacy"}); err == nil || !strings.Contains(err.Error(), "does not support") { + t.Fatalf("storage status --legacy error = %v, want rejection", err) + } +} + +func TestLegacyArtifactSelectionNeverIncludesShellDockerOrUnknownVolumes(t *testing.T) { + now := time.Now().UTC() + snapshot := inventory.Snapshot{CollectedAt: now, Artifacts: []storage.Artifact{ + { + ID: "template", Kind: storage.ArtifactSandboxTemplate, + Target: storage.Target{Kind: storage.TargetSandboxTemplate, Locator: "docker.io/docker/sandbox-templates:shell-docker", Identity: "39cf20eca861", Match: storage.MatchExact}, + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown}, + }, + { + ID: "volume", Kind: storage.ArtifactDockerVolume, + Target: storage.Target{Kind: storage.TargetDockerVolume, Locator: "epar-old-cache", Identity: "epar-old-cache", Match: storage.MatchExact}, + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown}, + Protections: []storage.Protection{{Kind: storage.ProtectionUncertain, Detail: "prefix only"}}, + }, + }} + selectLegacyStorage(&snapshot, storagecatalog.Catalog{}, now) + if snapshot.Artifacts[0].Ownership.Kind != storage.OwnershipUnknown { + t.Fatal("shell-docker became a legacy cleanup candidate") + } + if snapshot.Artifacts[1].Ownership.Kind != storage.OwnershipExact { + t.Fatal("legacy volume was not represented in the approved exact preview") + } + if len(snapshot.Artifacts[1].Protections) == 0 { + t.Fatal("prefix-era volume lost its fail-closed protection") + } +} + +func TestConfiguredSandboxTemplateProtectionMatchesFullReceiptDigest(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + snapshot := inventory.Snapshot{Artifacts: []storage.Artifact{{ + Kind: storage.ArtifactSandboxTemplate, + Target: storage.Target{Kind: storage.TargetSandboxTemplate, Locator: "docker.io/library/epar-docker-sandboxes-test:current", Identity: strings.Repeat("a", 12), Match: storage.MatchExact}, + }}} + + protectConfiguredSandboxTemplates(&snapshot, []inventory.TemplateSelection{{ + Tag: "epar-docker-sandboxes-test:current", + TemplateDigest: digest, + }}) + + if !snapshot.Artifacts[0].Current { + t.Fatal("configured Sandbox template was not marked current") + } + if !hasStorageProtection(snapshot.Artifacts[0].Protections, storage.ProtectionConfiguration) { + t.Fatalf("configured Sandbox template protections = %#v", snapshot.Artifacts[0].Protections) + } +} + +func hasStorageProtection(values []storage.Protection, want storage.ProtectionKind) bool { + for _, value := range values { + if value.Kind == want { + return true + } + } + return false +} + +func TestCatalogArtifactExposesReferencesCustodyAndCleanupState(t *testing.T) { + now := time.Now().UTC() + supersededAt := now.Add(-time.Minute) + value := storagecatalog.Catalog{InstallationID: "installation"} + resource := storagecatalog.Resource{ + Key: "resource", BackendID: "docker:one", Kind: "docker-image", Provider: "docker-container", + Role: "runtime-image", Locator: "epar-image:old", Identity: "sha256:old", + Custody: storagecatalog.CustodyGenerated, State: storagecatalog.StateCleanupPending, + References: []storagecatalog.Reference{{ConfigID: "config-one"}}, SupersededAt: &supersededAt, + CleanupError: "in use", + } + artifact, ok := catalogStorageArtifact(value, resource, now) + if !ok { + t.Fatal("catalog Docker image was not exposed") + } + if artifact.BackendID != "docker:one" || artifact.Custody != "generated" || artifact.LifecycleState != "cleanup-pending" || artifact.CleanupError != "in use" || len(artifact.ConfigRefs) != 1 { + t.Fatalf("catalog artifact omitted lifecycle evidence: %#v", artifact) + } +} + +func TestCatalogSandboxTemplateReplacesMatchingExternalInventory(t *testing.T) { + cacheID := strings.Repeat("a", 12) + snapshot := inventory.Snapshot{Artifacts: []storage.Artifact{{ + ID: "external", + Provider: "docker-sandboxes", + SurfaceID: "docker-sandboxes-template-cache", + Kind: storage.ArtifactSandboxTemplate, + Target: storage.Target{ + Kind: storage.TargetSandboxTemplate, Locator: "epar-docker-sandboxes-test:current", Identity: cacheID, Match: storage.MatchExact, + }, + Ownership: storage.Ownership{Kind: storage.OwnershipUnknown}, + SizeBytes: 1234, + Protections: []storage.Protection{{ + Kind: storage.ProtectionUncertain, Detail: "external inventory", + }}, + }}} + catalogArtifact := storage.Artifact{ + ID: "catalog", + Provider: "docker-sandboxes", + SurfaceID: "docker-sandboxes-template-cache", + Kind: storage.ArtifactSandboxTemplate, + Target: storage.Target{ + Kind: storage.TargetSandboxTemplate, Locator: "docker.io/library/epar-docker-sandboxes-test:current", Identity: cacheID, Match: storage.MatchExact, + }, + Ownership: storage.Ownership{Kind: storage.OwnershipExact}, + Current: true, + } + + mergeCatalogStorageArtifact(&snapshot, catalogArtifact) + + if len(snapshot.Artifacts) != 1 { + t.Fatalf("merged artifacts = %#v, want one authoritative catalog artifact", snapshot.Artifacts) + } + if snapshot.Artifacts[0].ID != "catalog" || snapshot.Artifacts[0].Ownership.Kind != storage.OwnershipExact || snapshot.Artifacts[0].SizeBytes != 1234 { + t.Fatalf("merged catalog artifact = %#v", snapshot.Artifacts[0]) + } + if hasStorageProtection(snapshot.Artifacts[0].Protections, storage.ProtectionUncertain) { + t.Fatalf("merged catalog artifact retained external uncertainty: %#v", snapshot.Artifacts[0].Protections) + } +} + +func TestCatalogPreexistingAcquiredDockerTagRemainsReportOnly(t *testing.T) { + now := time.Now().UTC() + resource := storagecatalog.Resource{ + Key: "source", BackendID: "docker:one", Kind: "docker-image", Role: "build-source", + Locator: "golang:latest", Identity: "sha256:source", Custody: storagecatalog.CustodyAcquired, + State: storagecatalog.StateSuperseded, SupersededAt: &now, + } + artifact, ok := catalogStorageArtifact(storagecatalog.Catalog{InstallationID: "host"}, resource, now) + if !ok { + t.Fatal("catalog Docker source was not exposed") + } + if !hasStorageProtection(artifact.Protections, storage.ProtectionUncertain) { + t.Fatalf("preexisting acquired Docker tag protections = %#v", artifact.Protections) + } +} + +func TestCatalogTartImageUsesExactExternalIdentity(t *testing.T) { + now := time.Now().UTC() + value := storagecatalog.Catalog{InstallationID: "host", Resources: nil} + resource := storagecatalog.Resource{ + Key: "tart-resource", BackendID: "tart:one", InstallationIDs: []string{"installation"}, + Kind: "tart-image", Provider: "tart", Role: "runtime-image", Locator: "epar-current", + Identity: "tart-mac:02:00:00:00:00:01", Custody: storagecatalog.CustodyGenerated, State: storagecatalog.StateCurrent, + } + artifact, ok := catalogStorageArtifact(value, resource, now) + if !ok { + t.Fatal("catalog Tart image was not exposed") + } + if artifact.Target.Kind != storage.TargetExternal || artifact.Target.Locator != "tart-image:epar-current" || artifact.Target.Identity != resource.Identity { + t.Fatalf("Tart artifact lost exact backend identity: %#v", artifact) + } + if artifact.Ownership.OwnerID != "installation" { + t.Fatalf("Tart artifact owner = %q, want installation identity", artifact.Ownership.OwnerID) + } +} diff --git a/cmd/ephemeral-action-runner/storage_external.go b/cmd/ephemeral-action-runner/storage_external.go index 8b156c1..fe63b58 100644 --- a/cmd/ephemeral-action-runner/storage_external.go +++ b/cmd/ephemeral-action-runner/storage_external.go @@ -385,6 +385,29 @@ func collectDockerSandboxesStorage(snapshot *inventory.Snapshot) { } } +func protectConfiguredSandboxTemplates(snapshot *inventory.Snapshot, selections []inventory.TemplateSelection) { + for _, selection := range selections { + digest := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(selection.TemplateDigest)), "sha256:") + if len(digest) != 64 { + continue + } + cacheID := digest[:12] + reference := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(selection.Tag)), "docker.io/library/") + for index := range snapshot.Artifacts { + artifact := &snapshot.Artifacts[index] + if artifact.Kind != storage.ArtifactSandboxTemplate { + continue + } + locator := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(artifact.Target.Locator)), "docker.io/library/") + if locator != reference || strings.ToLower(artifact.Target.Identity) != cacheID { + continue + } + artifact.Current = true + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionConfiguration, Detail: "configured Docker Sandboxes template receipt"}) + } + } +} + func collectWSLStorage(snapshot *inventory.Snapshot) { const surfaceID = "wsl-distributions" snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ID: surfaceID, Provider: "wsl", Kind: storage.SurfaceExternal, Location: "wsl", Capacity: storage.Capacity{ObservedAt: snapshot.CollectedAt}}) diff --git a/configs/docker-container.act.example.yml b/configs/docker-container.act.example.yml index 429076e..8ce9257 100644 --- a/configs/docker-container.act.example.yml +++ b/configs/docker-container.act.example.yml @@ -32,7 +32,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/configs/docker-container.core.example.yml b/configs/docker-container.core.example.yml index 34fe58f..1835aff 100644 --- a/configs/docker-container.core.example.yml +++ b/configs/docker-container.core.example.yml @@ -32,7 +32,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/configs/docker-container.example.yml b/configs/docker-container.example.yml index 528f5dd..40dcc6b 100644 --- a/configs/docker-container.example.yml +++ b/configs/docker-container.example.yml @@ -32,7 +32,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/configs/docker-container.web-e2e.example.yml b/configs/docker-container.web-e2e.example.yml index 4e3adcf..70a3f30 100644 --- a/configs/docker-container.web-e2e.example.yml +++ b/configs/docker-container.web-e2e.example.yml @@ -33,7 +33,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/configs/docker-sandboxes.example.yml b/configs/docker-sandboxes.example.yml index 7180d9e..4b1ea41 100644 --- a/configs/docker-sandboxes.example.yml +++ b/configs/docker-sandboxes.example.yml @@ -24,7 +24,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB runner: diff --git a/configs/tart.example.yml b/configs/tart.example.yml index 86e5563..d49466e 100644 --- a/configs/tart.example.yml +++ b/configs/tart.example.yml @@ -30,7 +30,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/configs/tart.web-e2e.example.yml b/configs/tart.web-e2e.example.yml index 8035588..af15b08 100644 --- a/configs/tart.web-e2e.example.yml +++ b/configs/tart.web-e2e.example.yml @@ -28,7 +28,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/configs/wsl.example.yml b/configs/wsl.example.yml index d43379b..150f73f 100644 --- a/configs/wsl.example.yml +++ b/configs/wsl.example.yml @@ -30,7 +30,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/configs/wsl.lean.example.yml b/configs/wsl.lean.example.yml index eabdcdb..ec90701 100644 --- a/configs/wsl.lean.example.yml +++ b/configs/wsl.lean.example.yml @@ -29,7 +29,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/configs/wsl.web-e2e.example.yml b/configs/wsl.web-e2e.example.yml index 7895255..776a02d 100644 --- a/configs/wsl.web-e2e.example.yml +++ b/configs/wsl.web-e2e.example.yml @@ -29,7 +29,7 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB logging: diff --git a/docs/advanced/docker-sandboxes-template.md b/docs/advanced/docker-sandboxes-template.md index b55ca77..87a6c16 100644 --- a/docs/advanced/docker-sandboxes-template.md +++ b/docs/advanced/docker-sandboxes-template.md @@ -20,13 +20,13 @@ Use `./start` for first-run provisioning or the shared image command afterward: ./start image build --replace ``` -EPAR resolves the configured Catthehacker tag for the native platform, checks capacity, builds with the pinned template inputs, generates metadata, provenance, an SPDX SBOM, and a software inventory, exports the archive, imports it with `sbx template load`, and reads back the exact cache identity. The active receipt is updated atomically only after every step succeeds. +EPAR resolves the configured Catthehacker tag for the native platform, checks capacity, and has BuildKit stream one verified Docker-compatible archive directly to disk. EPAR verifies the archive tag, platform, labels, configuration, layers, and digests without loading it into Docker, imports it with `sbx template load`, and reads back the exact Sandbox-cache identity. Build metadata, provenance, an SPDX SBOM descriptor, compatibility evidence, and a software inventory are retained compactly; the active receipt is updated atomically only after every step succeeds. The compatibility scripts under `scripts/docker-sandboxes` delegate to this command. They no longer maintain a separate build or load implementation. ## Configure And Prewarm -Run `./start` with no configuration. The wizard offers `full-latest`, `act-latest`, `dotnet-latest`, `js-latest`, or another `catthehacker/ubuntu` tag; verifies the tag and native platform; validates optional custom install scripts; displays source, platform, size estimates, reserve, and duration; then builds and imports after one confirmation. It does not write the usable config until exact readback passes. +Run `./start` with no configuration. The wizard offers `full-latest`, `act-latest`, `dotnet-latest`, `js-latest`, or another `catthehacker/ubuntu` tag; verifies the tag and native platform; validates optional custom install scripts; displays source, platform, size estimates, reserve, and duration; then saves the desired configuration after one confirmation. Normal startup performs the build and import, and a provisioning failure leaves the configuration available for a retry. After configuration, prewarm the selected template outside the job path: @@ -44,9 +44,9 @@ The template cache and archive are host-cache measurements, not each sandbox's r ## Retention And Replacement -Docker Sandboxes retains loaded templates after individual sandboxes are deleted. Before deleting an obsolete EPAR template, confirm no active configuration or live sandbox references its exact tag and cache identity, run `sbx template rm `, then verify absence. Do not use `sbx reset`: it removes the whole Docker Sandboxes cache. +Docker Sandboxes retains loaded templates after individual sandboxes are deleted. At startup and after activating a replacement, EPAR removes a superseded template only when its catalog receipt, configurations, leases, and live-sandbox inventory prove it is unreferenced. It verifies absence after `sbx template rm `. Do not use `sbx reset`: it removes the whole Docker Sandboxes cache. -EPAR storage maintenance never broadly prunes Docker images, BuildKit state, Docker Sandboxes templates, WSL distributions, or Tart images. Use `ephemeral-action-runner storage status` and preview `storage prune` before explicit cleanup. Docker Desktop VHDX compaction is separate offline host maintenance. +The direct build does not create a Docker staging image. Once the imported template is authoritative, EPAR removes the transient archive workspace and retains only compact evidence. A completely verified archive left by an interrupted import can resume directly; partial or malformed archives are never activated. Prefix-era or shared templates remain report-only until `storage prune --legacy` produces an approved exact plan. EPAR never broadly prunes Docker images, BuildKit state, Docker Sandboxes templates, WSL distributions, or Tart images. Docker Desktop VHDX compaction is separate offline host maintenance. ## Evidence And Certification diff --git a/docs/advanced/no-go-install.md b/docs/advanced/no-go-install.md index 0edec5f..a0cbee3 100644 --- a/docs/advanced/no-go-install.md +++ b/docs/advanced/no-go-install.md @@ -4,7 +4,7 @@ The standard path is to download GitHub's automatic **Source code (zip)** or **S ## Run With Docker -Run `./start` at the source folder root on macOS, Linux, WSL, or Git Bash, or `./start.ps1` / `start.cmd` in native Windows PowerShell or cmd. The wrapper uses local Go when it is installed and runnable; otherwise a containerized Go toolchain cross-compiles a CGO-disabled binary for the native host, caches it by source, platform, and immutable toolchain-image hash under `.local/bin`, and executes it on the host: +Run `./start` at the source folder root on macOS, Linux, WSL, or Git Bash, or `./start.ps1` / `start.cmd` in native Windows PowerShell or cmd. The wrapper uses local Go when it is installed and runnable; otherwise a containerized Go toolchain cross-compiles a CGO-disabled host-native binary at `.local/bin/ephemeral-action-runner` (or `.exe` on Windows). Its adjacent `ephemeral-action-runner.manifest` records the deterministic source, platform, and toolchain fingerprint: ```bash ./start --config .local/config.yml --instances 2 @@ -18,7 +18,7 @@ Under the hood, the wrapper calls `scripts/run-with-docker.sh` or `scripts/run-w ### Host trust -The wrappers publish a short-lived `EPAR_BUILD_TRUST_FEED` from the real Windows, macOS, or Linux host for build-consuming commands, including when `image.hostTrustMode` is disabled. When runner overlay is enabled, they separately publish `EPAR_HOST_TRUST_FEED`. Native controllers can collect directly; the temporary Go toolchain container only compiles the binary and never substitutes its own trust roots for either host policy. +Before compiling the native controller, the wrapper publishes a short-lived system-trust feed from the real Windows, macOS, or Linux host, validates its freshness, certificate hashes, CA constraints, and distrust entries in an offline container, and mounts the resulting bundle read-only into the Go toolchain container. This operational trust is automatic even when `image.hostTrustMode` is disabled and is not copied into runners. After a native controller is available, it reads the host trust stores directly; only the legacy containerized-controller path uses the separate short-lived `EPAR_BUILD_TRUST_FEED` and optional `EPAR_HOST_TRUST_FEED` bridge. If bootstrap TLS still fails, the wrapper preserves `work/logs/epar-native-controller-build.log` and prints a certificate diagnostic without disabling verification or retrying insecurely. The explicit legacy path `EPAR_LEGACY_CONTROLLER_IN_DOCKER=1` remains available only for compatible providers. That path uses the existing short-lived host-trust bridge and rejects `provider.type: docker-sandboxes`; it is not an automatic fallback. @@ -35,9 +35,9 @@ scripts/run-with-docker.sh version scripts/run-with-docker.sh start --config .local/config.yml ``` -Set `EPAR_USE_DOCKER_RUN=1` to force `./start` to use the containerized compiler even when Go is installed, or `=0` to force local `go run` and error instead of falling back. Docker volumes cache Go modules and build output, while `.local/bin` caches the resulting native binary, so unchanged repeat starts are fast. Native-controller retention never removes active revisions, gives completed revisions a seven-day grace period, then keeps at most five previous completed revisions while the selected cache plus the current revision fits within 256 MiB. Active and grace-protected revisions may temporarily exceed those bounds. Automatic retention requires the ownership manifest written by the current wrapper; older unmanifested revisions remain for explicit review. +Set `EPAR_USE_DOCKER_RUN=1` to force `./start` to use the containerized compiler even when Go is installed, or `=0` to force local `go run` and error instead of falling back. Docker volumes cache Go modules and build output, while unchanged source reuses the one native binary. A changed source or toolchain rebuilds it atomically. If an existing EPAR process is still using the prior binary, stop that process before retrying; the wrapper does not retain another historical binary as a fallback. -Before the first containerized compilation, the wrapper requires 20 GiB free on the source-folder filesystem so bootstrap does not begin when the host is already critically constrained. `EPAR_BOOTSTRAP_MIN_FREE_BYTES` may raise this reserve for managed installations. +Before containerized compilation, the wrapper requires 1 GiB free on the source-folder filesystem so bootstrap does not begin when the host is already critically constrained. `EPAR_BOOTSTRAP_MIN_FREE_BYTES` may raise this reserve for managed installations. The wrapper passes the real host name to the native controller as `EPAR_HOST_NAME` so first-run defaults and generated host labels describe the machine running EPAR. Set `EPAR_HOST_NAME` before launching EPAR to override that identity. diff --git a/docs/configuration.md b/docs/configuration.md index 54b3eef..166377d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,10 +80,10 @@ GitHub `429` and `5xx` responses and transient network failures back off replace | Property | Type and default | Required or applies when | Effect and caution | | --- | --- | --- | --- | | `minimumFree` | positive byte size; `1GiB` | All providers. | Fixed provider-neutral physical free-space reserve. Existing explicit values remain authoritative; the default does not scale with volume size. | -| `gracePeriod` | positive Go duration; `168h` | Conservative housekeeping. | Minimum age before eligible EPAR-owned artifacts can be considered for removal. | -| `keepPrevious` | integer; `0` | Conservative housekeeping. Must be non-negative. | Number of prior reusable artifact generations to retain. | -| `automaticHousekeeping` | `conservative` or `disabled`; `conservative` | All providers. | Conservative mode touches only expired, unleased, exactly owned artifacts; it does not run a broad Docker or WSL prune. | -| `buildCacheLimit` | positive byte size; `64GiB` | Image-building cache. | Bounded EPAR build cache target. | +| `gracePeriod` | positive Go duration; `168h` | Conservative housekeeping. | Minimum age before abandoned or incomplete EPAR temporary work can be removed. It does not delay cleanup of a verified superseded generation. | +| `keepPrevious` | integer; `0` | Conservative housekeeping. Must be non-negative. | `0` allows immediate exact retirement after replacement. A positive value defers automatic artifact retirement to the explicit storage-prune retention preview. | +| `automaticHousekeeping` | `conservative` or `disabled`; `conservative` | All providers. | Conservative mode reconciles interrupted exact-owned work at startup and removes unreferenced superseded resources after readback. It never runs a broad Docker or WSL prune. | +| `buildCacheLimit` | positive byte size; `20GiB` | Image-building cache. | Bounded EPAR BuildKit cache target. Existing explicit values remain authoritative. | | `goCacheLimit` | positive byte size; `10GiB` | Native/no-Go Go build cache. | Bounded EPAR Go cache target. | ### `logging` diff --git a/docs/development/adding-provider.md b/docs/development/adding-provider.md index 5b3c68e..51c13c1 100644 --- a/docs/development/adding-provider.md +++ b/docs/development/adding-provider.md @@ -7,10 +7,10 @@ Put provider commands and host integration in `internal/provider/`. Sh A provider is complete only when it: - Registers its constructor, configuration rules, wizard contribution, reusable-artifact capabilities, and platform status in the provider registry. -- Implements every required lifecycle, exact inventory, artifact-requirement, and storage-surface contract without silent fallback. +- Implements every required lifecycle, exact inventory, artifact-requirement, storage-surface, ownership receipt, and crash-recovery contract without silent fallback. - Uses the wizard’s complete machine-derived prefix and the shared `pool.RunnerName` format. - Preserves strict `pool.instances`, durable exact identities, quarantine on uncertainty, resumable cleanup, diagnostics, and replacement. - Reuses Catthehacker defaults when it consumes Docker runner images, unless an intentional exception is documented and tested. - Adds provider contract tests, configuration and wizard tests, race tests, wrapper checks, and relevant live-platform evidence. -Provider cleanup must target exact identities. Never replace an unavailable exact operation with a prefix deletion, wildcard, broad prune, reset, or deletion of an unknown/shared resource. +Provider cleanup must target exact identities and record enough immutable evidence for common startup housekeeping to distinguish current, superseded, incomplete, and unknown resources. Never replace an unavailable exact operation with a prefix deletion, wildcard, broad prune, reset, or deletion of an unknown/shared resource. diff --git a/docs/development/design.md b/docs/development/design.md index 735a549..49d8896 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -38,6 +38,6 @@ Unknown ownership, unavailable dependencies, failed cleanup, and uncertain remot Each provider reports the storage surfaces and temporary expansion required by bootstrap, artifact builds, instance creation, and replacement. The common preflight requires enough space for the operation plus the configured free-space reserve. -Artifacts are classified as active, current reusable, superseded EPAR-owned, incomplete temporary, or shared/unknown. Conservative housekeeping may remove only expired, unleased, exactly owned local artifacts and bounded dedicated caches. Removing Docker images, volumes, imported templates, WSL distributions, or Tart images requires an explicit previewed storage-prune operation. +Artifacts are classified as active, current reusable, superseded EPAR-owned, incomplete temporary, or shared/unknown. At startup and after activation, conservative housekeeping reconciles interrupted work and removes only unreferenced, exactly owned superseded resources after live readback. It retains resources used by another configuration, lease, container, sandbox, distribution, or builder. Prefix-only and shared resources require an explicit previewed prune; cleanup never expands to a broad prune, reset, or wildcard. See [Adding a Provider](adding-provider.md) for the extension checklist. diff --git a/docs/development/principles.md b/docs/development/principles.md index 744f40e..968499f 100644 --- a/docs/development/principles.md +++ b/docs/development/principles.md @@ -4,7 +4,7 @@ EPAR extensions preserve the existing user flow and controller design. ## First Run -`./start` is the Quick Start and general source entry point. With no command, or with start flags only, it runs the `start` command and opens the missing-configuration wizard when needed; with an explicit command, it must forward that command and all arguments exactly as the binary and `go run ./cmd/ephemeral-action-runner` do. The local-Go and no-Go native-controller paths must behave the same, including native-host operational build trust, and user-facing remediation commands must use the entry point the user actually invoked. A selectable provider must appear in the wizard with its tooling and daemon prerequisite status; storage estimates never make provider selection unavailable or prevent configuration creation. Docker Container, Docker Sandboxes, and WSL use the same Catthehacker image and custom-script onboarding flow. The wizard writes the desired configuration first, then an embedded `./start` continues through the ordinary artifact provisioning and storage-admission path. Reject an unavailable platform or invalid image clearly and never silently switch a configured provider or artifact. Builder operational trust and optional runner trust are separate contracts: system roots always support the owned builder, while `image.hostTrustMode` controls only runner inheritance. +`./start` is the Quick Start and general source entry point. With no command, or with start flags only, it runs the `start` command and opens the missing-configuration wizard when needed; with an explicit command, it must forward that command and all arguments exactly as the binary and `go run ./cmd/ephemeral-action-runner` do. The local-Go and no-Go native-controller paths must behave the same, including native-host operational build trust, startup reconciliation of exactly owned stale work, and user-facing remediation commands that use the entry point the user actually invoked. A no-Go bootstrap TLS failure must preserve the native build transcript and report the requested host and presented certificate metadata without disabling verification or retrying insecurely. A selectable provider must appear in the wizard with its tooling and daemon prerequisite status; storage estimates never make provider selection unavailable or prevent configuration creation. Docker Container, Docker Sandboxes, and WSL use the same Catthehacker image and custom-script onboarding flow. The wizard writes the desired configuration first, then an embedded `./start` continues through the ordinary artifact provisioning and storage-admission path. Reject an unavailable platform or invalid image clearly and never silently switch a configured provider or artifact. Builder operational trust and optional runner trust are separate contracts: system roots always support the owned builder, while `image.hostTrustMode` controls only runner inheritance. ## Runner Names diff --git a/docs/image-build.md b/docs/image-build.md index 4fe7f9e..428b648 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -73,11 +73,11 @@ The default source is converted from a Docker image to an intermediate rootfs ta ### Tart -The output is a local Tart VM image. The default is intentionally lean; use a custom bootable Ubuntu source image or focused install scripts when a workflow needs more tooling. `provider.rosettaTag` is an opt-in, experimental Tart-only layer for selected Linux amd64 user-space workloads. +The output is a local Tart VM image. The default is intentionally lean; use a custom bootable Ubuntu source image or focused install scripts when a workflow needs more tooling. EPAR builds and verifies a content-named candidate, keeps a rollback clone until the configured output passes immutable identity readback, and disables Tart's unrelated automatic cache pruning for its clone operations. `provider.rosettaTag` is an opt-in, experimental Tart-only layer for selected Linux amd64 user-space workloads. ### Docker Sandboxes -Docker Sandboxes uses `image.sourceImage`, `image.sourcePlatform`, and `image.customInstallScripts` as the desired template inputs. `./start` and `./start image build` share the same build/import implementation. Exact Docker image and Sandbox cache identities are stored in `.local/state/image/docker-sandboxes/active.json`, not user configuration; a failed desired update leaves the previous receipt and artifact intact but does not run it as a fallback. +Docker Sandboxes uses `image.sourceImage`, `image.sourcePlatform`, and `image.customInstallScripts` as the desired template inputs. `./start` and `./start image build` share the same build/import implementation. BuildKit writes an attestation-free Docker-compatible archive directly because that archive format cannot carry the provenance/SBOM manifest list; EPAR verifies the archive without creating a Docker staging image, imports it, and records the exact Sandbox cache identity under `.local/state/image//docker-sandboxes/active.json`. A separate cache-backed BuildKit evidence operation produces max-mode provenance and the SBOM without loading the runner image into Docker Engine. The large archive and full SBOM workspace are transient, while compact metadata, provenance, compatibility, inventory, and SBOM descriptor evidence remain. A failed desired update leaves the previous receipt and artifact intact but does not run it as a fallback. ## Verify A Customized Artifact diff --git a/docs/operations.md b/docs/operations.md index 1013df1..465e098 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -17,7 +17,9 @@ flowchart LR ## Start and stop deliberately -Use `./start` for normal operation because it verifies the configured image or sandbox template before starting the pool. Press `Ctrl-C` once to request a clean stop, then wait for its cleanup confirmation before closing the terminal. `--keep-on-exit` is a debugging option that deliberately leaves owned runner resources running after the supervisor exits. +Use `./start` for normal operation because it verifies the configured image or sandbox template before starting the pool. Press `Ctrl-C` once to request a clean stop, then wait for cleanup to finish before closing the terminal. `--keep-on-exit` is a debugging option that deliberately leaves owned runner resources running after the supervisor exits. + +The supervisor reports when GitHub assigns a job and when the ephemeral runner finishes or is released. GitHub Actions remains the source of truth for whether the job succeeded or failed. `pool up` is the lower-level command for a prepared image or template. While no supervisor is running, EPAR cannot retire completed ephemeral runners or create replacements. @@ -58,10 +60,11 @@ Use `cleanup --no-github` only when you intentionally want to leave GitHub runne ephemeral-action-runner storage status ephemeral-action-runner storage prune ephemeral-action-runner storage prune --execute +ephemeral-action-runner storage prune --legacy ephemeral-action-runner logs prune --dry-run ``` -`storage prune` is a preview until `--execute` is supplied. Log pruning is a separate retention operation. EPAR does not run broad Docker prune, Docker Sandboxes reset, WSL reset, Docker Desktop reset, or VHDX compaction. Read [Storage](storage.md) before reclaiming capacity. +Normal `./start` reconciles interrupted exact-owned work and retires unreferenced superseded artifacts. `storage prune` is a preview until `--execute` is supplied. `storage prune --legacy` reports prefix-era resources and requires its displayed plan hash for execution. Log pruning is separate. EPAR does not run broad Docker prune, Docker Sandboxes reset, WSL reset, Docker Desktop reset, or VHDX compaction. Read [Storage](storage.md) before reclaiming capacity. ## Get help from the right page diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index aedaf5f..911b254 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -90,6 +90,8 @@ Each allocation receives an empty owner-restricted staging directory, but Action Template construction uses two independent trust paths. EPAR's project-owned BuildKit builder automatically receives host system roots for Docker Hub, GHCR, and the other pinned registries used by the build. The native controller downloads the locked Actions runner and `tini`, verifies their SHA-256 values, and then supplies them as local build inputs; the Dockerfile does not perform remote HTTPS downloads. +BuildKit streams the runner template directly to an attestation-free, verified archive; Docker Sandboxes does not require or retain a Docker staging image. Separate cache-backed BuildKit targets produce the max-mode provenance, SBOM, and software inventory without loading the runner image into Docker Engine. After `sbx template load` succeeds and EPAR reads back the exact imported template, startup housekeeping removes the transient archive workspace while retaining the active template and compact receipt evidence. Initial creation and every replacement trust the authoritative Sandbox cache readback, so the expected absence of a Docker image does not block a runner. Superseded templates are removed only after no configuration, lease, or live sandbox references them. + ## Limitations - Public egress remains an exfiltration path for workflow code and secrets. Use only trusted repositories and runner groups. diff --git a/docs/providers/wsl.md b/docs/providers/wsl.md index 5e4da47..91e701a 100644 --- a/docs/providers/wsl.md +++ b/docs/providers/wsl.md @@ -39,7 +39,7 @@ Use `configs/wsl.lean.example.yml` with a pre-exported Ubuntu rootfs tar for a s 1. Run `./start`; EPAR converts the default Docker source into a rootfs tar, builds the reusable WSL runner tar, and starts the pool. 2. Target the configured WSL label, normally `epar-wsl-catthehacker-ubuntu`. -3. Stop the supervisor with Ctrl-C and wait for cleanup confirmation. +3. Stop the supervisor with Ctrl-C, then wait for cleanup to finish before closing the terminal. EPAR imports each runner from `provider.sourceImage`, enables systemd in the reusable image, and keeps a quiet host-side WSL process alive while a runner waits for work. That process is intentional: it prevents an otherwise idle systemd distro from stopping automatically. diff --git a/docs/storage.md b/docs/storage.md index 78e80b4..b977ce8 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -8,22 +8,32 @@ storage: gracePeriod: 168h keepPrevious: 0 automaticHousekeeping: conservative - buildCacheLimit: 64GiB + buildCacheLimit: 20GiB goCacheLimit: 10GiB ``` -`storage status` reports capacity and known artifacts. `storage prune` is always a preview unless `--execute` is supplied. +`storage status` reports capacity, exact ownership, references, live blockers, cleanup-pending work, and reclaimable estimates. `storage prune` is always a preview unless `--execute` is supplied. ```text ephemeral-action-runner storage status ephemeral-action-runner storage status --json ephemeral-action-runner storage prune ephemeral-action-runner storage prune --execute +ephemeral-action-runner storage prune --legacy +ephemeral-action-runner storage prune --legacy --execute --plan ``` -Conservative housekeeping is limited to expired, unleased, exactly owned local staging artifacts and dedicated recomputable caches. Docker images, named volumes, imported Docker Sandboxes templates, WSL distributions, and Tart images require explicit prune execution. +With `automaticHousekeeping: conservative`, EPAR reconciles interrupted work at startup and after successful artifact activation. It immediately retires an unreferenced, superseded resource only when its catalog receipt and live readback prove that EPAR created or introduced it. The grace period applies to abandoned or incomplete temporary work, not to a successfully replaced generation. A resource referenced by another configuration, lease, container, sandbox, distribution, or builder remains protected. -EPAR image builds use a project-scoped Buildx builder with persisted ownership metadata, an exact registry/trust configuration digest, and BuildKit garbage collection capped by `storage.buildCacheLimit`. When its owned configuration or trust generation changes, EPAR recreates only that builder's control container with Buildx state retention and verifies configuration readback before publishing new metadata. EPAR does not select, modify, or prune Docker's shared/default builder. Active CA material lives under `.local/storage/buildkit-certs/`; obsolete owned generations follow the normal grace policy. The no-Go native-controller path similarly creates project-scoped, ownership-labelled Go module and build-cache volumes, measures their combined use, and clears only those exact recomputable caches when they exceed `storage.goCacheLimit`; active cache volumes are not touched. Existing unlabelled or explicitly overridden Go cache volumes remain shared or ownership-unknown and report-only. The opt-in legacy controller-in-Docker path uses ephemeral container caches unless the operator explicitly supplies cache volume names. +Setting `automaticHousekeeping: disabled` suppresses controller-managed artifact cleanup. The no-Go wrapper still maintains one correct stable native binary and removes only inactive legacy revision directories with valid ownership metadata. + +The exact host resource catalog lives in the platform's per-user state directory and coordinates all EPAR project directories on that account. References use canonical configuration paths, so separate configs may keep different generations and exact shared artifacts remain protected until the last reference disappears. + +Older prefix-era resources are never adopted automatically. Use `storage prune --legacy` to produce their exact preview; execution requires the preview plan hash. The Docker Sandboxes base template `docker/sandbox-templates:shell-docker` is always protected. + +EPAR image builds use a project-scoped Buildx builder with persisted ownership metadata, an exact registry/trust configuration digest, and BuildKit garbage collection capped by `storage.buildCacheLimit`. Its running BuildKit control container is intentional reusable build infrastructure, not a runner. EPAR prunes only that exact builder; it never selects, modifies, or prunes Docker's shared/default builder. Active CA material lives under `.local/storage/buildkit-certs/`. The no-Go controller similarly uses project-scoped Go module and build-cache volumes, bounded by `storage.goCacheLimit`; shared or explicitly overridden caches are report-only. + +Docker Sandboxes builds directly to one transient archive, so no Docker staging image is expected. After the archive is imported and the exact Sandbox cache identity is read back, EPAR removes the archive workspace while retaining the active imported template and compact receipt evidence. A completely verified interrupted archive can resume at import; partial archives remain inactive and are reclaimed as owned temporary work. Physical host growth and logical virtual-disk limits are reported separately. A 300 GiB VHDX or `Docker.raw` maximum/apparent length does not mean 300 GiB of live Docker content or 300 GiB of new host space is required: Docker Desktop, WSL, and Docker Sandboxes use dynamically allocated or sparse backing storage. `docker system df` reports Docker-managed usage, not host free capacity. EPAR probes the physical filesystem that contains the backing file when it is measurable and treats an unexposed Docker Desktop internal-free value as advisory. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 585e912..c615739 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,8 +9,10 @@ Start with the symptom that most closely matches the failure. EPAR is trusted-jo - [A Docker workload fails with an architecture error](#a-docker-workload-fails-with-an-architecture-error) - [Docker Sandboxes is unavailable or its preflight fails](#docker-sandboxes-is-unavailable-or-its-preflight-fails) - [Docker Sandboxes rejects template, policy, or capacity](#docker-sandboxes-rejects-template-policy-or-capacity) +- [An idle runner reports GitHub or Sandbox health warnings](#an-idle-runner-reports-github-or-sandbox-health-warnings) - [A runner is held for diagnostics or an acknowledgement](#a-runner-is-held-for-diagnostics-or-an-acknowledgement) - [Docker image build runs out of space](#docker-image-build-runs-out-of-space) +- [Storage keeps growing after updates](#storage-keeps-growing-after-updates) - [Docker image build fails with TLS certificate errors](#docker-image-build-fails-with-tls-certificate-errors) - [Windows Docker Desktop WSL2 disk is smaller than expected](#windows-docker-desktop-wsl2-disk-is-smaller-than-expected) - [Docker Container startup fails](#docker-container-startup-fails) @@ -21,6 +23,8 @@ Start with the symptom that most closely matches the failure. EPAR is trusted-jo EPAR writes logs under `work/logs` by default. Start with `work/logs/epar-last-error.log`, then inspect the matching build log in `work/logs/builds/` or instance transcript in `work/logs/instances/`. Manager events are console-only by default; raw transcripts are file-only unless `logging.transcriptSinks` includes `console`. +Long Buildx operations show a bounded console summary with downloaded bytes, completed layers, the active BuildKit step, elapsed time, and growing direct-archive bytes when an export is in progress. The complete raw progress remains in the printed build-log path. + ```bash ./start --help go run ./cmd/ephemeral-action-runner version @@ -110,8 +114,14 @@ Startup reports a template identity/digest mismatch, policy-generation drift, an Docker Sandboxes resolves the configured source selector, records the exact OCI identities in a local artifact receipt, and verifies the host-global policy fingerprint. If the desired source, platform, scripts, template inputs, runner inputs, or trust inputs change, rerun `./start`; EPAR builds and imports a replacement and activates it only after exact readback succeeds. +An imported Docker Sandboxes template does not require a matching Docker image. EPAR builds directly to a verified archive, imports that archive, and then removes the transient workspace. If startup reports a missing Docker staging image, the controller is stale; rebuild the native controller and rerun `./start`. If direct archive verification or `sbx template load` fails, use the printed Buildx transcript and archive error; EPAR does not fall back to the memory-heavy Docker load/save path. + Capacity admission accounts for estimated incremental physical growth on each measurable backing filesystem plus the fixed `storage.minimumFree` reserve. Docker Sandboxes root and inner-Docker sizes are independent sparse logical maxima and are not added as immediate host usage. Inspect the reported physical surface, run the matching `storage status` and prune-preview commands, or deliberately retry only that invocation with `--allow-insufficient-storage`. Avoid broad cleanup commands: they can delete stopped containers and intentionally retained resources. +## An idle runner reports GitHub or Sandbox health warnings + +A GitHub 429/5xx response or an `sbx` command timeout makes runner health temporarily unknown; it does not prove that the Actions listener stopped. EPAR keeps the exact runner, lets a trust lease expire closed when it cannot refresh it, and retries. Cleanup for an inactive listener requires two consecutive guest probes that successfully execute and explicitly report the process stopped. Review the instance guest transcript when warnings repeat; do not delete the runner merely because one API or Sandbox inspection failed. + `networkBaseline: open` is a sandbox-scoped public-egress compatibility rule with EPAR host-alias deny guardrails. It does not alter the host-global policy. If a required service is blocked, use a narrow `additionalAllow` hostname rule; do not allow `host.docker.internal`, `gateway.docker.internal`, `kubernetes.docker.internal`, or `host.containers.internal` through the Open-policy guardrails. ## A runner is held for diagnostics or an acknowledgement @@ -152,6 +162,26 @@ docker system df -v Increase the relevant Docker/VM data-disk allocation or deliberately remove unneeded data after reviewing it. Docker prune commands can remove stopped containers, unused images, build cache, networks, and volumes; they are not a safe generic fix. +## Storage keeps growing after updates + +### Symptom + +Old EPAR images, Docker Sandboxes templates, staging archives, or no-Go controller files remain after an update or an interrupted start. + +### Diagnosis and remediation + +Run `./start` once more. Startup reconciles incomplete exact-owned work and retires an unreferenced superseded generation after its replacement passes readback. It does not delete shared images, prefix-only historical resources, active containers, active sandboxes, or resources referenced by another configuration. + +Inspect the exact classification before removing anything manually: + +```bash +./start storage status +./start storage prune +./start storage prune --legacy +``` + +Use normal `storage prune --execute` only for exact catalog-owned resources. Legacy prefix-era entries require the plan hash printed by `storage prune --legacy`; they are not removed automatically. Do not use broad Docker prune/reset commands or VHDX compaction as a substitute for this review. + ## Docker image build fails with TLS certificate errors ### Symptom @@ -162,6 +192,8 @@ HTTPS access fails with `curl: (60)`, `certificate verification failed`, or an u Do not disable certificate verification. First identify which trust boundary failed. +For a no-Go native-controller build, `./start` automatically reads host system roots, excludes explicitly distrusted certificates, validates the short-lived feed in an offline container, and mounts only the resulting CA bundle into the Go compiler container. Runner CA inheritance remains independent. If the build still reports an unknown issuer, inspect `work/logs/epar-native-controller-build.log`: the wrapper prints the requested host, presented certificate subject and issuer, SHA-256 fingerprint, validity, and verification result, and on Windows it lists matching roots from `LocalMachine\Root` and `CurrentUser\Root`. A remaining failure means the expected issuer was absent, distrusted, malformed, expired, or not the certificate actually presented; EPAR never disables TLS verification or retries insecurely. + For an EPAR Buildx failure, leave `image.hostTrustMode` unchanged. EPAR automatically supplies host system roots to its project-owned builder and prints the full build transcript path before `docker buildx build`. The console and error report include a bounded redacted tail. Inspect the underlying `x509` line together with the registry host, builder identity, and active trust generation: ```powershell @@ -189,7 +221,7 @@ Use the normal host entry point so EPAR can inspect the real Windows certificate go run ./cmd/ephemeral-action-runner image build --replace ``` -On no-Go Windows, use `scripts\run-with-docker.ps1 image build --replace`; on macOS/Linux, use `scripts/run-with-docker.sh image build --replace`. These wrappers publish the separate native-host build feed for `start`, `image build`, `pool up`, and `pool verify`, even when runner overlay is disabled. A bare Linux toolchain container is not a replacement for that bridge. +On no-Go Windows, use `scripts\run-with-docker.ps1 image build --replace`; on macOS/Linux, use `scripts/run-with-docker.sh image build --replace`. The wrapper uses a native-host trust feed while compiling the native controller; the resulting native controller reads host trust directly for `start`, `image build`, `pool up`, and `pool verify`, even when runner overlay is disabled. The legacy containerized controller still requires the separate native-host feed bridge. A bare Linux toolchain container is not a replacement for either path. ## Windows Docker Desktop WSL2 disk is smaller than expected diff --git a/docs/usage.md b/docs/usage.md index 640ac31..39b33d6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -70,7 +70,9 @@ If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.ins Storage-consuming commands fail before their provider side effects when an authoritative physical surface cannot retain `storage.minimumFree`. The one-invocation `--allow-insufficient-storage` option keeps all probes and warnings but permits only storage admission to continue; provider diagnostics, GitHub policy, ownership, lifecycle, and cleanup protections remain enforced. The option is available on `start`, `pool up`, `pool verify`, `image build`, and `image update-upstream`, including the equivalent `./start ...` wrapper forms. -Press `Ctrl-C` once to stop a foreground pool and wait for cleanup confirmation. Use `--keep-on-exit` only to retain owned resources for deliberate debugging. +Each normal start also reconciles interrupted exact-owned work and retires unreferenced superseded artifacts after replacement readback. Use `./start storage status` to inspect the result. `./start storage prune --legacy` previews prefix-era resources, which remain manual and require the displayed plan hash before execution. + +Press `Ctrl-C` once to stop a foreground pool, then wait for cleanup to finish before closing the terminal. Use `--keep-on-exit` only to retain owned resources for deliberate debugging. ## Verify before sending jobs diff --git a/internal/config/config.go b/internal/config/config.go index 061e48a..c5cddd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -243,7 +243,7 @@ func Default() Config { GracePeriod: "168h", KeepPrevious: 0, AutomaticHousekeeping: StorageHousekeepingConservative, - BuildCacheLimit: "64GiB", + BuildCacheLimit: "20GiB", GoCacheLimit: "10GiB", }, Logging: LoggingConfig{ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2daeeba..55a472e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -118,7 +118,7 @@ func TestStorageDefaultsAreBoundedAndConservative(t *testing.T) { storage.GracePeriod != "168h" || storage.KeepPrevious != 0 || storage.AutomaticHousekeeping != StorageHousekeepingConservative || - storage.BuildCacheLimit != "64GiB" || + storage.BuildCacheLimit != "20GiB" || storage.GoCacheLimit != "10GiB" { t.Fatalf("unexpected storage defaults: %+v", storage) } diff --git a/internal/hosttrust/hosttrust_test.go b/internal/hosttrust/hosttrust_test.go index 50a3017..e4b1293 100644 --- a/internal/hosttrust/hosttrust_test.go +++ b/internal/hosttrust/hosttrust_test.go @@ -96,7 +96,7 @@ func TestParseFeedVerifiesCertificateHashAndFreshness(t *testing.T) { SchemaVersion: feedSchemaVersion, HostOS: "darwin", Scopes: []string{ScopeSystem, ScopeUser}, - GeneratedAt: now.Add(-29 * time.Second), + GeneratedAt: now.Add(-maxFeedAge + time.Second), ExpiresAt: now.Add(time.Minute), Certificates: []FeedCertificate{{SHA256: certificates[0].SHA256, PEM: string(certificates[0].PEM)}}, } @@ -123,7 +123,7 @@ func TestParseFeedVerifiesCertificateHashAndFreshness(t *testing.T) { t.Fatalf("bad per-certificate hash error = %v", err) } feed.Certificates[0].SHA256 = certificates[0].SHA256 - feed.GeneratedAt = now.Add(-31 * time.Second) + feed.GeneratedAt = now.Add(-maxFeedAge - time.Second) content, err = json.Marshal(feed) if err != nil { t.Fatal(err) diff --git a/internal/image/acquisition.go b/internal/image/acquisition.go index 9aabc17..1f615ff 100644 --- a/internal/image/acquisition.go +++ b/internal/image/acquisition.go @@ -104,7 +104,7 @@ func (m *Coordinator) renderDockerPullProgress(ctx context.Context, response cli if rendered { m.writeDockerPullProgress(logPath, layers) if m.dockerPullProgressIsInteractive() { - fmt.Fprintln(m.environment.DockerPullProgressConsole()) + fmt.Fprintln(m.environment.ProgressConsole()) } } return nil @@ -151,14 +151,14 @@ func writeDockerPullEvent(logFile io.Writer, event DockerPullEvent) { func (m *Coordinator) writeDockerPullProgress(logPath string, layers map[string]DockerPullProgress) { line := DockerPullProgressSummary(layers) if m.dockerPullProgressIsInteractive() { - _, _ = fmt.Fprintf(m.environment.DockerPullProgressConsole(), "\r\033[2K%s", line) + _, _ = fmt.Fprintf(m.environment.ProgressConsole(), "\r\033[2K%s", line) return } m.environment.LogInfo(line, "provider", m.Config.Provider.Type, "operation", "docker-pull", "logPath", logPath) } func (m *Coordinator) dockerPullProgressIsInteractive() bool { - return m.environment.DockerPullProgressTerminal() && containsString(m.Config.Logging.ManagerSinks, "console") && m.Config.Logging.ManagerConsoleFormat == "text" + return m.environment.ProgressTerminal() && containsString(m.Config.Logging.ManagerSinks, "console") && m.Config.Logging.ManagerConsoleFormat == "text" } func containsString(values []string, wanted string) bool { diff --git a/internal/image/artifact_lifecycle.go b/internal/image/artifact_lifecycle.go index ee48cd7..98a60b3 100644 --- a/internal/image/artifact_lifecycle.go +++ b/internal/image/artifact_lifecycle.go @@ -11,10 +11,12 @@ import ( "path/filepath" "sort" "strings" + "time" "github.com/solutionforest/ephemeral-action-runner/internal/config" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" "github.com/solutionforest/ephemeral-action-runner/internal/provider" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" ) const ( @@ -41,6 +43,9 @@ func hostTrustMetadata(snapshot hosttrust.Snapshot) *HostTrustMetadata { } func (m *Coordinator) EnsureImage(ctx context.Context) error { + if err := m.cleanupSupersededCatalog(ctx); err != nil { + return fmt.Errorf("reconcile EPAR storage before image provisioning: %w", err) + } if m.Config.Provider.Type == "docker-sandboxes" { return m.ensureDockerSandboxesTemplate(ctx, false) } @@ -69,13 +74,22 @@ func (m *Coordinator) EnsureImage(ctx context.Context) error { switch state { case imageStateCurrent: m.infof("image is current: %s\n", m.Config.Image.OutputImage) - return nil + if err := m.recordCurrentArtifact(ctx, hash); err != nil { + return fmt.Errorf("record current EPAR artifact ownership: %w", err) + } + return m.cleanupSupersededCatalog(ctx) case imageStateMissing: m.infof("image is missing; building %s\n", m.Config.Image.OutputImage) case imageStateOutdated: m.infof("image is outdated or not aligned with config; rebuilding %s\n", m.Config.Image.OutputImage) } - return m.BuildImage(ctx, ImageBuildOptions{Replace: true, Manifest: &manifest}) + if err := m.BuildImage(ctx, ImageBuildOptions{Replace: true, Manifest: &manifest}); err != nil { + return err + } + if err := m.recordCurrentArtifact(ctx, hash); err != nil { + return fmt.Errorf("record current EPAR artifact ownership: %w", err) + } + return m.cleanupSupersededCatalog(ctx) } type imageState int @@ -113,7 +127,7 @@ func (m *Coordinator) currentImageState(ctx context.Context, wantHash string) (i } return imageStateCurrent, nil case "tart": - return m.currentTartImageState(ctx) + return m.currentTartImageState(ctx, wantHash) default: return imageStateMissing, fmt.Errorf("unsupported provider.type %q", m.Config.Provider.Type) } @@ -156,17 +170,54 @@ func (m *Coordinator) currentWSLManifestHash() (string, bool, error) { return stored.Hash, true, nil } -func (m *Coordinator) currentTartImageState(ctx context.Context) (imageState, error) { +func (m *Coordinator) currentTartImageState(ctx context.Context, wantHash string) (imageState, error) { instances, err := m.Provider.List(ctx) if err != nil { return imageStateMissing, err } + var current provider.Instance for _, instance := range instances { if instance.Name == m.Config.Image.OutputImage || instance.Source == m.Config.Image.OutputImage { - return imageStateCurrent, nil + current = instance + break + } + } + if current.Name == "" { + return imageStateMissing, nil + } + if current.ProviderID == "" { + return imageStateOutdated, nil + } + store, err := m.hostCatalog() + if err != nil { + return imageStateMissing, err + } + value, err := store.Load(time.Now().UTC()) + if err != nil { + return imageStateMissing, err + } + configID, err := storagecatalog.ConfigID(m.ProjectRoot, m.effectiveConfigPath()) + if err != nil { + return imageStateMissing, err + } + if tartCatalogReferenceMatches(value, configID, strings.TrimSpace(m.Config.Image.OutputImage), current.ProviderID, wantHash) { + return imageStateCurrent, nil + } + return imageStateOutdated, nil +} + +func tartCatalogReferenceMatches(value storagecatalog.Catalog, configID, locator, identity, manifestHash string) bool { + for _, resource := range value.Resources { + if resource.Kind != catalogTartImageKind || resource.Locator != locator || resource.Identity != identity { + continue + } + for _, reference := range resource.References { + if reference.ConfigID == configID && reference.Role == "provider-artifact" && reference.ManifestHash == manifestHash { + return true + } } } - return imageStateMissing, nil + return false } func (m *Coordinator) desiredImageManifest(ctx context.Context) (ImageManifest, error) { @@ -265,6 +316,18 @@ func (m *Coordinator) refreshDockerSourceDigestUntimed(ctx context.Context) (str logPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".source.log") defer m.releaseTranscript(logPath) m.infof("refreshing Docker source image %s\n", image) + backendID, releaseBackend, err := m.acquireDockerBackendLock(ctx) + if err != nil { + return "", err + } + defer releaseBackend() + previousID := "" + if value, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", image); inspectErr == nil { + previousID = strings.TrimSpace(value) + } + if err := m.beginDockerRoleAcquisition(backendID, "build-source", image, previousID, time.Now().UTC()); err != nil { + return "", fmt.Errorf("journal Docker source acquisition: %w", err) + } if err := m.pullDockerSource(ctx, DockerSourcePullOptions{ Image: image, Platform: platform, @@ -273,6 +336,14 @@ func (m *Coordinator) refreshDockerSourceDigestUntimed(ctx context.Context) (str }); err != nil { return "", fmt.Errorf("refresh Docker source image %s: %w", image, err) } + currentID, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", image) + if err != nil { + return "", err + } + currentID = strings.TrimSpace(currentID) + if err := m.recordDockerSourceAcquisition(ctx, image, previousID, currentID, time.Now().UTC()); err != nil { + return "", fmt.Errorf("record Docker source acquisition: %w", err) + } digestsJSON, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{json .RepoDigests}}", image) if err != nil { return "", err diff --git a/internal/image/build.go b/internal/image/build.go index c7ba20e..440ddc1 100644 --- a/internal/image/build.go +++ b/internal/image/build.go @@ -3,6 +3,7 @@ package image import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -74,6 +75,9 @@ func (m *Coordinator) UpdateUpstream(ctx context.Context) error { } func (m *Coordinator) BuildImage(ctx context.Context, opts ImageBuildOptions) error { + if err := m.cleanupSupersededCatalog(ctx); err != nil { + return fmt.Errorf("reconcile EPAR storage before image build: %w", err) + } if m.Config.Provider.Type == "docker-sandboxes" { return m.ensureDockerSandboxesTemplate(ctx, true) } @@ -193,10 +197,19 @@ func (m *Coordinator) buildDockerContainerImageAttempt(ctx context.Context, upst m.infof("building Docker Container image %s from %s\n", targetImage, m.Config.Image.SourceImage) m.infof("log: %s\n", buildLogPath) args := []string{"buildx", "build", "--builder", builder, "--load", "-t", targetImage} + installationID, err := m.catalogInstallationID(time.Now().UTC()) + if err != nil { + return fmt.Errorf("resolve EPAR image ownership identity: %w", err) + } if m.Config.Provider.Platform != "" { args = append(args, "--platform", m.Config.Provider.Platform) } args = append(args, + "--label", "io.solutionforest.epar.schema=1", + "--label", "io.solutionforest.epar.installation="+installationID, + "--label", "io.solutionforest.epar.provider=docker-container", + "--label", "io.solutionforest.epar.role=runtime-image", + "--label", "io.solutionforest.epar.manifest="+manifestHash, "--build-arg", "BASE_IMAGE="+m.Config.Image.SourceImage, "--build-arg", "RUNNER_VERSION="+m.Config.Image.RunnerVersion, "--build-arg", "EPAR_IMAGE_MANIFEST_SHA256="+manifestHash, @@ -225,6 +238,12 @@ func temporaryDockerImageTag(output, generation string, attempt int) string { } func (m *Coordinator) buildTartImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { + return m.withTartBackendLock(ctx, func() error { + return m.buildTartImageLocked(ctx, opts, upstreamDir) + }) +} + +func (m *Coordinator) buildTartImageLocked(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { if opts.Manifest == nil { manifest, err := m.desiredImageManifest(ctx) if err != nil { @@ -232,6 +251,23 @@ func (m *Coordinator) buildTartImage(ctx context.Context, opts ImageBuildOptions } opts.Manifest = &manifest } + manifestHash, err := imageManifestHash(*opts.Manifest) + if err != nil { + return err + } + outputName := strings.TrimSpace(m.Config.Image.OutputImage) + buildName := tartBuildName(outputName, manifestHash) + existing, err := m.Provider.List(ctx) + if err != nil { + return err + } + output, outputExists := findTartImage(existing, outputName) + if outputExists && !opts.Replace { + return fmt.Errorf("Tart image %s already exists; rerun with --replace", outputName) + } + if _, exists := findTartImage(existing, buildName); exists { + return fmt.Errorf("Tart build candidate %q already exists from an interrupted operation; inspect it with storage status and remove it through an exact approved cleanup before retrying", buildName) + } buildLogPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".build.log") guestLogPath := m.buildLogPath(imageLogStem(m.Config.Image.OutputImage) + ".guest.log") defer m.releaseTranscript(buildLogPath) @@ -239,79 +275,222 @@ func (m *Coordinator) buildTartImage(ctx context.Context, opts ImageBuildOptions if err := resetLogs(buildLogPath, guestLogPath); err != nil { return err } - m.infof("building Tart image %s from %s\n", m.Config.Image.OutputImage, m.Config.Image.SourceImage) + m.infof("building Tart image candidate %s from %s\n", buildName, m.Config.Image.SourceImage) m.infof("logs: %s, %s\n", buildLogPath, guestLogPath) - if opts.Replace { - m.infof("replacing existing Tart image %s if present\n", m.Config.Image.OutputImage) - _ = m.Provider.Stop(ctx, m.Config.Image.OutputImage) - _ = m.Provider.Delete(ctx, m.Config.Image.OutputImage) - } m.infof("cloning source image\n") - if err := m.Provider.Clone(ctx, m.Config.Image.SourceImage, m.Config.Image.OutputImage); err != nil { + if err := m.Provider.Clone(ctx, m.Config.Image.SourceImage, buildName); err != nil { return err } + if err := m.recordTartStagingImage(ctx, buildName, "build-candidate"); err != nil { + _ = m.Provider.Delete(ctx, buildName) + return fmt.Errorf("record Tart build candidate ownership: %w", err) + } + buildComplete := false + defer func() { + if buildComplete { + return + } + stopContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = m.Provider.Stop(stopContext, buildName) + }() m.infof("starting image with Tart network mode %q\n", m.Config.Provider.Network) - startOptions, err := m.startOptions(buildLogPath, m.Config.Image.OutputImage) + startOptions, err := m.startOptions(buildLogPath, buildName) if err != nil { return err } - if _, err := m.Provider.Start(ctx, m.Config.Image.OutputImage, startOptions); err != nil { + if _, err := m.Provider.Start(ctx, buildName, startOptions); err != nil { return err } - ip, err := m.Provider.IP(ctx, m.Config.Image.OutputImage, m.Config.Timeouts.BootSeconds) + ip, err := m.Provider.IP(ctx, buildName, m.Config.Timeouts.BootSeconds) if err != nil { return err } m.infof("guest reachable at %s\n", ip) m.infof("copying guest scripts\n") - if err := m.installGuestScripts(ctx, m.Config.Image.OutputImage); err != nil { + if err := m.installGuestScripts(ctx, buildName); err != nil { return err } - if err := m.installTrustedCACertificates(ctx, m.Config.Image.OutputImage); err != nil { + if err := m.installTrustedCACertificates(ctx, buildName); err != nil { return err } - if err := m.installImageManifest(ctx, m.Config.Image.OutputImage, *opts.Manifest); err != nil { + if err := m.installImageManifest(ctx, buildName, *opts.Manifest); err != nil { return err } switch m.runnerImagesCopyMode() { case runnerImagesCopySubset: m.infof("copying runner-images script subset\n") - if err := m.copyRunnerImagesSubset(ctx, m.Config.Image.OutputImage, upstreamDir); err != nil { + if err := m.copyRunnerImagesSubset(ctx, buildName, upstreamDir); err != nil { return err } case runnerImagesCopyNone: m.infof("skipping runner-images script subset; no selected install script requires it\n") } m.infof("installing base runner runtime\n") - if _, err := m.execBuildGuest(ctx, m.Config.Image.OutputImage, []string{"sudo", "bash", "/opt/epar/install-base.sh", "/opt/epar/upstream/runner-images"}, provider.ExecOptions{}); err != nil { + if _, err := m.execBuildGuest(ctx, buildName, []string{"sudo", "bash", "/opt/epar/install-base.sh", "/opt/epar/upstream/runner-images"}, provider.ExecOptions{}); err != nil { return err } m.infof("installing GitHub Actions runner\n") - if _, err := m.execBuildGuest(ctx, m.Config.Image.OutputImage, []string{"sudo", "bash", "/opt/epar/install-runner.sh", m.Config.Image.RunnerVersion}, provider.ExecOptions{}); err != nil { + if _, err := m.execBuildGuest(ctx, buildName, []string{"sudo", "bash", "/opt/epar/install-runner.sh", m.Config.Image.RunnerVersion}, provider.ExecOptions{}); err != nil { return err } - if err := m.installRosettaSupport(ctx, m.Config.Image.OutputImage); err != nil { + if err := m.installRosettaSupport(ctx, buildName); err != nil { return err } - if err := m.installCustomInstallScripts(ctx, m.Config.Image.OutputImage); err != nil { + if err := m.installCustomInstallScripts(ctx, buildName); err != nil { return err } m.infof("validating runner runtime inside the instance\n") - if err := m.validateRuntime(ctx, m.Config.Image.OutputImage); err != nil { + if err := m.validateRuntime(ctx, buildName); err != nil { return err } m.infof("finalizing image for clean Tart clones\n") - if _, err := m.execBuildGuest(ctx, m.Config.Image.OutputImage, []string{"sudo", "bash", "/opt/epar/finalize-image.sh"}, provider.ExecOptions{}); err != nil { + if _, err := m.execBuildGuest(ctx, buildName, []string{"sudo", "bash", "/opt/epar/finalize-image.sh"}, provider.ExecOptions{}); err != nil { return err } m.infof("stopping image\n") - if err := m.Provider.Stop(ctx, m.Config.Image.OutputImage); err != nil { + if err := m.Provider.Stop(ctx, buildName); err != nil { + return err + } + if err := m.activateTartImage(ctx, output, outputExists, buildName, outputName); err != nil { + return err + } + buildComplete = true + m.infof("image build complete: %s is available in `tart list`\n", outputName) + return nil +} + +func tartBuildName(outputName, manifestHash string) string { + short := manifestHash + if len(short) > 12 { + short = short[:12] + } + return outputName + "-epar-build-" + short +} + +func tartBackupName(outputName, providerID string) string { + replacer := strings.NewReplacer(":", "", "-", "") + identity := replacer.Replace(strings.ToLower(providerID)) + if len(identity) > 12 { + identity = identity[len(identity)-12:] + } + return outputName + "-epar-previous-" + identity +} + +func findTartImage(instances []provider.Instance, name string) (provider.Instance, bool) { + for _, instance := range instances { + if instance.Name == name { + return instance, true + } + } + return provider.Instance{}, false +} + +func (m *Coordinator) activateTartImage(ctx context.Context, previous provider.Instance, previousExists bool, buildName, outputName string) error { + instances, err := m.Provider.List(ctx) + if err != nil { + return err + } + candidate, candidateExists := findTartImage(instances, buildName) + if !candidateExists || candidate.ProviderID == "" { + return fmt.Errorf("Tart build candidate %q has no exact immutable identity at activation", buildName) + } + if current, exists := findTartImage(instances, outputName); exists { + if !previousExists || previous.ProviderID == "" || current.ProviderID != previous.ProviderID { + return fmt.Errorf("Tart output image %q changed during replacement; refusing to delete it", outputName) + } + } else if previousExists { + return fmt.Errorf("Tart output image %q disappeared during replacement", outputName) + } + + backupName := "" + if previousExists { + backupName = tartBackupName(outputName, previous.ProviderID) + if _, exists := findTartImage(instances, backupName); exists { + return fmt.Errorf("Tart rollback image %q already exists; refusing an ambiguous replacement", backupName) + } + if err := m.Provider.Clone(ctx, outputName, backupName); err != nil { + return fmt.Errorf("create Tart rollback image: %w", err) + } + if err := m.verifyTartImageIdentity(ctx, backupName); err != nil { + return fmt.Errorf("read back Tart rollback image: %w", err) + } + if err := m.recordTartStagingImage(ctx, backupName, "activation-rollback"); err != nil { + _ = m.Provider.Delete(ctx, backupName) + return fmt.Errorf("record Tart rollback image ownership: %w", err) + } + if strings.EqualFold(previous.State, "running") { + if err := m.Provider.Stop(ctx, outputName); err != nil { + return fmt.Errorf("stop previous Tart output image: %w", err) + } + } + if err := m.Provider.Delete(ctx, outputName); err != nil { + return fmt.Errorf("remove previous Tart output name after rollback copy: %w", err) + } + } + + activationErr := m.Provider.Clone(ctx, buildName, outputName) + if activationErr == nil { + activationErr = m.verifyTartImageIdentity(ctx, outputName) + } + if activationErr != nil { + if !previousExists { + return fmt.Errorf("activate Tart image: %w; the verified build candidate remains available as %q", activationErr, buildName) + } + restoreErr := m.restoreTartImage(ctx, outputName, backupName) + if restoreErr != nil { + return fmt.Errorf("activate Tart image: %v; rollback also failed: %w", activationErr, restoreErr) + } + return fmt.Errorf("activate Tart image: %w; previous image was restored", activationErr) + } + + if backupName != "" { + if err := m.Provider.Delete(ctx, backupName); err != nil { + m.warnf("EPAR Tart rollback image cleanup deferred for %s: %v\n", backupName, err) + } + } + if err := m.Provider.Delete(ctx, buildName); err != nil { + m.warnf("EPAR Tart build candidate cleanup deferred for %s: %v\n", buildName, err) + } + return nil +} + +func (m *Coordinator) verifyTartImageIdentity(ctx context.Context, name string) error { + instances, err := m.Provider.List(ctx) + if err != nil { return err } - m.infof("image build complete: %s is available in `tart list`\n", m.Config.Image.OutputImage) + instance, exists := findTartImage(instances, name) + if !exists || instance.ProviderID == "" { + return fmt.Errorf("Tart image %q did not pass immutable identity readback", name) + } return nil } +func (m *Coordinator) restoreTartImage(ctx context.Context, outputName, backupName string) error { + if backupName == "" { + return errors.New("no previous Tart image exists to restore") + } + instances, err := m.Provider.List(ctx) + if err != nil { + return err + } + if current, exists := findTartImage(instances, outputName); exists { + if strings.EqualFold(current.State, "running") { + if err := m.Provider.Stop(ctx, outputName); err != nil { + return err + } + } + if err := m.Provider.Delete(ctx, outputName); err != nil { + return err + } + } + if err := m.Provider.Clone(ctx, backupName, outputName); err != nil { + return err + } + return m.verifyTartImageIdentity(ctx, outputName) +} + func (m *Coordinator) buildWSLImage(ctx context.Context, opts ImageBuildOptions, upstreamDir string) error { return m.timeStartupStage("wsl_image_build", func() error { return m.buildWSLImageUntimed(ctx, opts, upstreamDir) @@ -336,21 +515,6 @@ func (m *Coordinator) buildWSLImageUntimed(ctx context.Context, opts ImageBuildO if err := resetLogs(buildLogPath, guestLogPath); err != nil { return err } - if !m.DryRun { - if _, err := os.Stat(outputPath); err == nil && !opts.Replace { - return fmt.Errorf("wsl output image %s already exists; rerun with --replace", outputPath) - } else if err != nil && !os.IsNotExist(err) { - return err - } - if opts.Replace { - if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) { - return err - } - if err := os.Remove(wslImageManifestSidecarPath(outputPath)); err != nil && !os.IsNotExist(err) { - return err - } - } - } if opts.Manifest == nil { manifest, err := m.desiredImageManifest(ctx) if err != nil { @@ -358,6 +522,31 @@ func (m *Coordinator) buildWSLImageUntimed(ctx context.Context, opts ImageBuildO } opts.Manifest = &manifest } + manifestHash, err := imageManifestHash(*opts.Manifest) + if err != nil { + return err + } + candidateOutputPath := outputPath + ".epar-candidate-" + manifestHash[:16] + if !m.DryRun { + if err := recoverWSLArtifactSwap(outputPath); err != nil { + return fmt.Errorf("recover interrupted WSL artifact activation: %w", err) + } + if err := removeRegularFileIfPresent(candidateOutputPath); err != nil { + return err + } + if err := removeRegularFileIfPresent(wslImageManifestSidecarPath(candidateOutputPath)); err != nil { + return err + } + if _, err := os.Stat(outputPath); err == nil && !opts.Replace { + return fmt.Errorf("wsl output image %s already exists; rerun with --replace", outputPath) + } else if err != nil && !os.IsNotExist(err) { + return err + } + defer func() { + _ = removeRegularFileIfPresent(candidateOutputPath) + _ = removeRegularFileIfPresent(wslImageManifestSidecarPath(candidateOutputPath)) + }() + } sourceForClone := m.Config.Image.SourceImage sourcePath := config.ProjectPath(m.ProjectRoot, sourceForClone) @@ -472,14 +661,25 @@ func (m *Coordinator) buildWSLImageUntimed(ctx context.Context, opts ImageBuildO if err := m.Provider.Stop(ctx, buildName); err != nil { return err } - m.infof("exporting reusable WSL image to %s\n", outputPath) - if err := exporter.Export(ctx, buildName, m.Config.Image.OutputImage); err != nil { + m.infof("exporting reusable WSL image candidate to %s\n", candidateOutputPath) + if err := exporter.Export(ctx, buildName, candidateOutputPath); err != nil { return err } if !m.DryRun { - if err := writeStoredImageManifest(wslImageManifestSidecarPath(outputPath), *opts.Manifest); err != nil { + candidateSidecarPath := wslImageManifestSidecarPath(candidateOutputPath) + if err := writeStoredImageManifest(candidateSidecarPath, *opts.Manifest); err != nil { return err } + stored, err := readStoredImageManifest(candidateSidecarPath) + if err != nil { + return fmt.Errorf("read back WSL image candidate manifest: %w", err) + } + if stored.Hash != manifestHash { + return fmt.Errorf("WSL image candidate manifest mismatch: got %s, want %s", stored.Hash, manifestHash) + } + if err := activateWSLArtifact(candidateOutputPath, outputPath); err != nil { + return fmt.Errorf("activate WSL image %s: %w", outputPath, err) + } } m.infof("image build complete: %s is available for WSL imports\n", outputPath) return nil diff --git a/internal/image/buildx.go b/internal/image/buildx.go index be16675..355df77 100644 --- a/internal/image/buildx.go +++ b/internal/image/buildx.go @@ -21,7 +21,10 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" ) -const buildxMetadataSchemaVersion = 2 +const ( + buildxMetadataSchemaVersion = 3 + buildkitImageReference = "moby/buildkit:buildx-stable-1" +) type BuildxMetadata struct { SchemaVersion int `json:"schemaVersion"` @@ -35,6 +38,7 @@ type BuildxMetadata struct { CertificateBundle string `json:"certificateBundle,omitempty"` CertificateSHA256 string `json:"certificateSha256,omitempty"` RegistryHosts []string `json:"registryHosts,omitempty"` + BuildKitImageID string `json:"buildkitImageId,omitempty"` CreatedAt time.Time `json:"createdAt"` LastReconciledAt time.Time `json:"lastReconciledAt,omitempty"` } @@ -52,7 +56,7 @@ func LoadBuildxMetadata(projectRoot string) (BuildxMetadata, error) { if err := json.Unmarshal(content, &metadata); err != nil { return BuildxMetadata{}, err } - if (metadata.SchemaVersion != 1 && metadata.SchemaVersion != buildxMetadataSchemaVersion) || metadata.Builder == "" || metadata.Driver != "docker-container" || metadata.ProjectRoot == "" || metadata.ConfigPath == "" { + if (metadata.SchemaVersion < 1 || metadata.SchemaVersion > buildxMetadataSchemaVersion) || metadata.Builder == "" || metadata.Driver != "docker-container" || metadata.ProjectRoot == "" || metadata.ConfigPath == "" { return BuildxMetadata{}, fmt.Errorf("invalid EPAR Buildx ownership metadata") } return metadata, nil @@ -71,12 +75,20 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference builder := buildxBuilderName(m.ProjectRoot) cacheLimit := strings.TrimSpace(m.Config.Storage.BuildCacheLimit) if cacheLimit == "" { - cacheLimit = "64GiB" + cacheLimit = "20GiB" } limitBytes, err := config.ParseByteSize(cacheLimit) if err != nil { return "", fmt.Errorf("parse storage.buildCacheLimit: %w", err) } + effectiveLimit, err := m.effectiveProjectBuildCacheLimit() + if err != nil { + return "", fmt.Errorf("resolve shared EPAR BuildKit cache limit: %w", err) + } + if effectiveLimit < uint64(limitBytes) { + limitBytes = int64(effectiveLimit) + cacheLimit = strconv.FormatUint(effectiveLimit, 10) + "B" + } registryHosts, err := buildRegistryHosts(registryReferences) if err != nil { return "", err @@ -85,6 +97,35 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference m.infof("[dry-run] ensure EPAR-owned Buildx builder %s with cache limit %s for registries %s\n", builder, cacheLimit, strings.Join(registryHosts, ", ")) return builder, nil } + var buildKitImageID string + if err := func() error { + backendID, releaseBackend, acquireErr := m.acquireDockerBackendLock(ctx) + if acquireErr != nil { + return acquireErr + } + defer releaseBackend() + previousBuildKitImageID := "" + if output, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", buildkitImageReference); inspectErr == nil { + previousBuildKitImageID = strings.TrimSpace(output) + } + if journalErr := m.beginDockerRoleAcquisition(backendID, "buildkit-image", buildkitImageReference, previousBuildKitImageID, time.Now().UTC()); journalErr != nil { + return fmt.Errorf("journal EPAR BuildKit image acquisition: %w", journalErr) + } + if pullErr := m.runHost(ctx, "docker", "pull", buildkitImageReference); pullErr != nil { + return fmt.Errorf("resolve EPAR BuildKit image %s: %w", buildkitImageReference, pullErr) + } + output, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", buildkitImageReference) + if inspectErr != nil { + return fmt.Errorf("read EPAR BuildKit image identity: %w", inspectErr) + } + buildKitImageID = strings.TrimSpace(output) + if recordErr := m.recordDockerRoleAcquisition(ctx, "buildkit-image", buildkitImageReference, previousBuildKitImageID, buildKitImageID, time.Now().UTC()); recordErr != nil { + return fmt.Errorf("record EPAR BuildKit image acquisition: %w", recordErr) + } + return nil + }(); err != nil { + return "", err + } trust, err := m.resolveBuildTrust(ctx) if err != nil { return "", err @@ -113,6 +154,7 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference CertificateBundle: certificatePath, CertificateSHA256: hex.EncodeToString(bundleSHA[:]), RegistryHosts: registryHosts, + BuildKitImageID: buildKitImageID, } storageDirectory := filepath.Join(m.ProjectRoot, ".local", "storage") if err := validateRegularParent(storageDirectory, m.ProjectRoot); err != nil { @@ -158,7 +200,7 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference } if inspectErr != nil { - if err := m.runHost(ctx, "docker", "buildx", "create", "--name", builder, "--driver", "docker-container", "--buildkitd-config", configPath); err != nil { + if err := m.runHost(ctx, "docker", "buildx", "create", "--name", builder, "--driver", "docker-container", "--driver-opt", "image="+buildkitImageReference, "--buildkitd-config", configPath); err != nil { return "", fmt.Errorf("create EPAR Buildx builder %q: %w", builder, err) } } @@ -200,6 +242,7 @@ func buildxMetadataMatches(actual, expected BuildxMetadata) bool { actual.TrustGeneration == expected.TrustGeneration && filepath.Clean(actual.CertificateBundle) == expected.CertificateBundle && actual.CertificateSHA256 == expected.CertificateSHA256 && + actual.BuildKitImageID == expected.BuildKitImageID && sameOrderedStrings(actual.RegistryHosts, expected.RegistryHosts) } @@ -249,7 +292,9 @@ func buildkitConfig(cacheLimit uint64, generation, certificatePath string, regis fmt.Fprintf(&content, "# epar-build-trust-generation=%s\n", generation) content.WriteString("[worker.oci]\n") content.WriteString(" gc = true\n") - fmt.Fprintf(&content, " gckeepstorage = %d\n", cacheLimit) + fmt.Fprintf(&content, " reservedSpace = %s\n", strconv.Quote("2GiB")) + fmt.Fprintf(&content, " maxUsedSpace = %s\n", strconv.Quote(strconv.FormatUint(cacheLimit, 10)+"B")) + fmt.Fprintf(&content, " minFreeSpace = %s\n", strconv.Quote("1GiB")) certificatePath = strings.ReplaceAll(filepath.Clean(certificatePath), `\`, "/") for _, host := range registryHosts { fmt.Fprintf(&content, "\n[registry.%s]\n", strconv.Quote(host)) @@ -260,6 +305,13 @@ func buildkitConfig(cacheLimit uint64, generation, certificatePath string, regis func (m *Coordinator) verifyBuildxConfiguration(ctx context.Context, builder string, expected BuildxMetadata) error { container := buildxControlContainer(builder) + imageID, err := m.runHostOutput(ctx, "docker", "inspect", "--format", "{{.Image}}", container) + if err != nil { + return fmt.Errorf("verify EPAR Buildx builder %q image identity: %w", builder, err) + } + if strings.TrimSpace(imageID) != expected.BuildKitImageID { + return fmt.Errorf("EPAR Buildx builder %q uses image %s, expected %s", builder, strings.TrimSpace(imageID), expected.BuildKitImageID) + } content, err := m.runHostOutput(ctx, "docker", "exec", container, "cat", "/etc/buildkit/buildkitd.toml") if err != nil { return fmt.Errorf("verify EPAR Buildx builder %q configuration readback: %w", builder, err) diff --git a/internal/image/buildx_progress.go b/internal/image/buildx_progress.go new file mode 100644 index 0000000..b0f06a2 --- /dev/null +++ b/internal/image/buildx_progress.go @@ -0,0 +1,242 @@ +package image + +import ( + "bytes" + "fmt" + "io" + "math" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const buildxProgressReportInterval = 2 * time.Second + +var ( + buildxLayerProgressPattern = regexp.MustCompile(`^#([0-9]+)\s+(sha256:[0-9a-f]{64})\s+([0-9]+(?:\.[0-9]+)?)([kMGTPE]?i?B)\s+/\s+([0-9]+(?:\.[0-9]+)?)([kMGTPE]?i?B)(?:\s+[0-9]+(?:\.[0-9]+)?s)?(?:\s+(done))?\s*$`) + buildxStepPattern = regexp.MustCompile(`^#([0-9]+)(?:\s|$)`) +) + +type buildxLayerProgress struct { + current int64 + total int64 + complete bool +} + +// BuildxProgressSnapshot is a bounded summary of BuildKit plain progress. +type BuildxProgressSnapshot struct { + CurrentBytes int64 + TotalBytes int64 + CompletedLayers int + KnownLayers int + ActiveStep int + Elapsed time.Duration + ObservedProgress bool + ObservedByteTotal bool +} + +// BuildxProgressMonitor consumes one or more BuildKit plain-progress streams. +// Call NewStream separately for stdout and stderr so partial writes cannot +// corrupt each other's line framing. +type BuildxProgressMonitor struct { + mu sync.Mutex + started time.Time + now func() time.Time + interval time.Duration + lastReport time.Time + layers map[string]buildxLayerProgress + activeStep int + observed bool + report func(BuildxProgressSnapshot) +} + +// NewBuildxProgressMonitor creates a monitor suitable for a live Buildx build. +func NewBuildxProgressMonitor(report func(BuildxProgressSnapshot)) *BuildxProgressMonitor { + return newBuildxProgressMonitor(buildxProgressReportInterval, time.Now, report) +} + +func newBuildxProgressMonitor(interval time.Duration, now func() time.Time, report func(BuildxProgressSnapshot)) *BuildxProgressMonitor { + started := now() + return &BuildxProgressMonitor{ + started: started, + now: now, + interval: interval, + layers: make(map[string]buildxLayerProgress), + report: report, + } +} + +// NewStream returns an independent line-framing writer for one process stream. +func (monitor *BuildxProgressMonitor) NewStream() *BuildxProgressStream { + return &BuildxProgressStream{monitor: monitor} +} + +// Snapshot returns the current summary. +func (monitor *BuildxProgressMonitor) Snapshot() BuildxProgressSnapshot { + monitor.mu.Lock() + defer monitor.mu.Unlock() + return monitor.snapshotLocked(monitor.now()) +} + +func (monitor *BuildxProgressMonitor) consumeLine(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + now := monitor.now() + monitor.mu.Lock() + changed := monitor.consumeLineLocked(line) + if !changed || (monitor.observed && !monitor.lastReport.IsZero() && now.Sub(monitor.lastReport) < monitor.interval) { + monitor.mu.Unlock() + return + } + monitor.observed = true + monitor.lastReport = now + snapshot := monitor.snapshotLocked(now) + report := monitor.report + monitor.mu.Unlock() + if report != nil { + report(snapshot) + } +} + +func (monitor *BuildxProgressMonitor) consumeLineLocked(line string) bool { + layerMatch := buildxLayerProgressPattern.FindStringSubmatch(line) + if layerMatch == nil && strings.Contains(line, "sha256:") && strings.Contains(line, " / ") { + return false + } + var current, total int64 + if layerMatch != nil { + var currentOK, totalOK bool + current, currentOK = parseBuildxBytes(layerMatch[3], layerMatch[4]) + total, totalOK = parseBuildxBytes(layerMatch[5], layerMatch[6]) + if !currentOK || !totalOK || total <= 0 { + return false + } + } + stepMatch := buildxStepPattern.FindStringSubmatch(line) + if stepMatch == nil { + return false + } + step, err := strconv.Atoi(stepMatch[1]) + if err != nil { + return false + } + changed := step != monitor.activeStep + monitor.activeStep = step + + if layerMatch == nil { + return changed + } + if current > total { + current = total + } + layer := buildxLayerProgress{ + current: current, + total: total, + complete: layerMatch[7] == "done" || current == total, + } + previous, exists := monitor.layers[layerMatch[2]] + if !exists || previous != layer { + monitor.layers[layerMatch[2]] = layer + changed = true + } + return changed +} + +func (monitor *BuildxProgressMonitor) snapshotLocked(now time.Time) BuildxProgressSnapshot { + snapshot := BuildxProgressSnapshot{ + ActiveStep: monitor.activeStep, + Elapsed: max(now.Sub(monitor.started), 0), + ObservedProgress: monitor.observed || monitor.activeStep > 0 || len(monitor.layers) > 0, + } + for _, layer := range monitor.layers { + snapshot.KnownLayers++ + snapshot.CurrentBytes += layer.current + snapshot.TotalBytes += layer.total + if layer.complete { + snapshot.CompletedLayers++ + } + } + snapshot.ObservedByteTotal = snapshot.TotalBytes > 0 + return snapshot +} + +func parseBuildxBytes(number, unit string) (int64, bool) { + value, err := strconv.ParseFloat(number, 64) + if err != nil || value < 0 { + return 0, false + } + multipliers := map[string]float64{ + "B": 1, + "kB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12, "PB": 1e15, "EB": 1e18, + "KiB": 1 << 10, "MiB": 1 << 20, "GiB": 1 << 30, "TiB": 1 << 40, "PiB": 1 << 50, "EiB": 1 << 60, + } + multiplier, ok := multipliers[unit] + if !ok || value > float64(math.MaxInt64)/multiplier { + return 0, false + } + return int64(value * multiplier), true +} + +// FormatBuildxProgress renders a concise console-safe summary. +func FormatBuildxProgress(prefix string, snapshot BuildxProgressSnapshot) string { + parts := make([]string, 0, 4) + if snapshot.ObservedByteTotal { + percent := float64(snapshot.CurrentBytes) * 100 / float64(snapshot.TotalBytes) + parts = append(parts, fmt.Sprintf("%s/%s (%.0f%%)", FormatDockerPullBytes(snapshot.CurrentBytes), FormatDockerPullBytes(snapshot.TotalBytes), percent)) + parts = append(parts, fmt.Sprintf("%d/%d layer downloads complete", snapshot.CompletedLayers, snapshot.KnownLayers)) + } + if snapshot.ActiveStep > 0 { + parts = append(parts, fmt.Sprintf("BuildKit step #%d", snapshot.ActiveStep)) + } + parts = append(parts, "elapsed "+formatBuildxElapsed(snapshot.Elapsed)) + return prefix + ": " + strings.Join(parts, "; ") +} + +func formatBuildxElapsed(duration time.Duration) string { + duration = duration.Round(time.Second) + if duration < time.Second { + return "0s" + } + return duration.String() +} + +// BuildxProgressStream frames arbitrary process writes into complete lines. +type BuildxProgressStream struct { + mu sync.Mutex + monitor *BuildxProgressMonitor + pending []byte +} + +func (stream *BuildxProgressStream) Write(content []byte) (int, error) { + stream.mu.Lock() + defer stream.mu.Unlock() + stream.pending = append(stream.pending, content...) + for { + index := bytes.IndexByte(stream.pending, '\n') + if index < 0 { + break + } + line := string(bytes.TrimSuffix(stream.pending[:index], []byte{'\r'})) + stream.pending = stream.pending[index+1:] + stream.monitor.consumeLine(line) + } + return len(content), nil +} + +// Flush consumes a final unterminated line. +func (stream *BuildxProgressStream) Flush() { + stream.mu.Lock() + defer stream.mu.Unlock() + if len(stream.pending) == 0 { + return + } + line := string(bytes.TrimSuffix(stream.pending, []byte{'\r'})) + stream.pending = nil + stream.monitor.consumeLine(line) +} + +var _ io.Writer = (*BuildxProgressStream)(nil) diff --git a/internal/image/buildx_progress_test.go b/internal/image/buildx_progress_test.go new file mode 100644 index 0000000..f8cb97f --- /dev/null +++ b/internal/image/buildx_progress_test.go @@ -0,0 +1,71 @@ +package image + +import ( + "strings" + "testing" + "time" +) + +func TestBuildxProgressMonitorFramesStreamsAndAggregatesLayers(t *testing.T) { + now := time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC) + var reports []BuildxProgressSnapshot + monitor := newBuildxProgressMonitor(2*time.Second, func() time.Time { return now }, func(snapshot BuildxProgressSnapshot) { + reports = append(reports, snapshot) + }) + stdout := monitor.NewStream() + stderr := monitor.NewStream() + firstDigest := "sha256:" + strings.Repeat("a", 64) + secondDigest := "sha256:" + strings.Repeat("b", 64) + + if _, err := stdout.Write([]byte("#12 " + firstDigest + " 1.00G")); err != nil { + t.Fatal(err) + } + if _, err := stderr.Write([]byte("#11 [source 1/1] resolve image config\n")); err != nil { + t.Fatal(err) + } + if _, err := stdout.Write([]byte("B / 2.00GB 10.0s\n")); err != nil { + t.Fatal(err) + } + now = now.Add(3 * time.Second) + if _, err := stderr.Write([]byte("#12 " + secondDigest + " 1.00GB / 1.00GB 13.0s done\n")); err != nil { + t.Fatal(err) + } + stdout.Flush() + stderr.Flush() + + if len(reports) != 2 { + t.Fatalf("reports = %d, want 2: %#v", len(reports), reports) + } + snapshot := reports[1] + if snapshot.CurrentBytes != 2_000_000_000 || snapshot.TotalBytes != 3_000_000_000 || snapshot.CompletedLayers != 1 || snapshot.KnownLayers != 2 || snapshot.ActiveStep != 12 { + t.Fatalf("unexpected aggregate snapshot: %+v", snapshot) + } + if got, want := FormatBuildxProgress("Docker Sandboxes template build", snapshot), "Docker Sandboxes template build: 1.9 GiB/2.8 GiB (67%); 1/2 layer downloads complete; BuildKit step #12; elapsed 3s"; got != want { + t.Fatalf("FormatBuildxProgress = %q, want %q", got, want) + } +} + +func TestBuildxProgressMonitorIgnoresUnrecognizedAndOverflowingOutput(t *testing.T) { + now := time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC) + reported := false + monitor := newBuildxProgressMonitor(0, func() time.Time { return now }, func(BuildxProgressSnapshot) { + reported = true + }) + stream := monitor.NewStream() + lines := []string{ + "ordinary command output", + "#x malformed", + "#12 sha256:" + strings.Repeat("c", 64) + " 999999999999999999999EB / 1.00GB 1.0s", + } + for _, line := range lines { + if _, err := stream.Write([]byte(line + "\n")); err != nil { + t.Fatal(err) + } + } + if reported { + t.Fatal("unrecognized Buildx output produced a progress report") + } + if snapshot := monitor.Snapshot(); snapshot.ObservedProgress { + t.Fatalf("unrecognized Buildx output changed snapshot: %+v", snapshot) + } +} diff --git a/internal/image/buildx_test.go b/internal/image/buildx_test.go index 5ba0122..1b24408 100644 --- a/internal/image/buildx_test.go +++ b/internal/image/buildx_test.go @@ -80,8 +80,8 @@ func TestBuildRegistryHostsUsesDockerHubForUnqualifiedImages(t *testing.T) { } func TestBuildkitConfigIsDeterministicAndEscapesPaths(t *testing.T) { - first := buildkitConfig(64<<30, "generation", `C:\repo path\.local\ca.pem`, []string{"docker.io", "ghcr.io"}) - second := buildkitConfig(64<<30, "generation", `C:\repo path\.local\ca.pem`, []string{"docker.io", "ghcr.io"}) + first := buildkitConfig(20<<30, "generation", `C:\repo path\.local\ca.pem`, []string{"docker.io", "ghcr.io"}) + second := buildkitConfig(20<<30, "generation", `C:\repo path\.local\ca.pem`, []string{"docker.io", "ghcr.io"}) if string(first) != string(second) { t.Fatal("BuildKit configuration is not deterministic") } @@ -91,6 +91,7 @@ func TestBuildkitConfigIsDeterministicAndEscapesPaths(t *testing.T) { `[registry."docker.io"]`, `[registry."ghcr.io"]`, `"C:/repo path/.local/ca.pem"`, + `maxUsedSpace = "21474836480B"`, } { if !strings.Contains(text, want) { t.Fatalf("BuildKit configuration omitted %q:\n%s", want, text) @@ -98,6 +99,25 @@ func TestBuildkitConfigIsDeterministicAndEscapesPaths(t *testing.T) { } } +func TestParseBuildxUsageBytesUsesSummaryOrSumsRecords(t *testing.T) { + for _, test := range []struct { + content string + want uint64 + }{ + {content: `[{"ID":"a","Size":1024},{"ID":"b","Size":2048}]`, want: 3072}, + {content: "{\"ID\":\"a\",\"Size\":1024}\n{\"Total\":4096}\n", want: 4096}, + {content: `[{"ID":"a","Size":"4.128kB"},{"ID":"b","Size":"310.8MB"},{"ID":"c","Size":"15.22GB"}]`, want: 15_530_804_128}, + } { + got, err := parseBuildxUsageBytes([]byte(test.content)) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("parseBuildxUsageBytes() = %d, want %d", got, test.want) + } + } +} + func TestBuildxSchemaOneMetadataIsOwnedButRequiresUpgrade(t *testing.T) { root := t.TempDir() expected := BuildxMetadata{ diff --git a/internal/image/coordinator.go b/internal/image/coordinator.go index b243d01..d93418f 100644 --- a/internal/image/coordinator.go +++ b/internal/image/coordinator.go @@ -29,6 +29,7 @@ type Environment interface { Infof(format string, args ...any) Warnf(format string, args ...any) RunHostLogged(ctx context.Context, logPath, name string, args ...string) error + RunHostBuildxLogged(ctx context.Context, logPath, name string, args ...string) error RunHost(ctx context.Context, name string, args ...string) error RunHostOutput(ctx context.Context, name string, args ...string) (string, error) RunHostOutputTo(ctx context.Context, output io.Writer, name string, args ...string) error @@ -44,8 +45,8 @@ type Environment interface { PullDockerSource(ctx context.Context, options DockerSourcePullOptions) error LogInfo(message string, args ...any) LogWarn(message string, args ...any) - DockerPullProgressTerminal() bool - DockerPullProgressConsole() io.Writer + ProgressTerminal() bool + ProgressConsole() io.Writer RunnerName(prefix string, sequence int, now time.Time) string TranscriptComponent(path string) string } @@ -55,6 +56,7 @@ type Coordinator struct { Provider provider.Provider Lifecycle provider.Lifecycle ProjectRoot string + ConfigPath string DryRun bool environment Environment } @@ -90,10 +92,18 @@ func (m *Coordinator) warnf(format string, args ...any) { m.environment.Warnf(format, args...) } +func (m *Coordinator) HousekeepStorage(ctx context.Context) error { + return m.cleanupSupersededCatalog(ctx) +} + func (m *Coordinator) runHostLogged(ctx context.Context, logPath, name string, args ...string) error { return m.environment.RunHostLogged(ctx, logPath, name, args...) } +func (m *Coordinator) runHostBuildxLogged(ctx context.Context, logPath, name string, args ...string) error { + return m.environment.RunHostBuildxLogged(ctx, logPath, name, args...) +} + func (m *Coordinator) runHost(ctx context.Context, name string, args ...string) error { return m.environment.RunHost(ctx, name, args...) } diff --git a/internal/image/docker_archive.go b/internal/image/docker_archive.go new file mode 100644 index 0000000..dab7791 --- /dev/null +++ b/internal/image/docker_archive.go @@ -0,0 +1,351 @@ +package image + +import ( + "archive/tar" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +const ( + maxTemplateArchiveEntries = 100000 + maxTemplateControlFileSize = 16 << 20 + maxTemplateControlBytes = 64 << 20 +) + +type dockerArchiveVerification struct { + ImageDigest string + ArchiveSHA256 string + ArchiveBytes uint64 +} + +type archivedFile struct { + digest string + size uint64 + data []byte +} + +type dockerSaveManifestEntry struct { + Config string `json:"Config"` + RepoTags []string `json:"RepoTags"` + Layers []string `json:"Layers"` +} + +type dockerImageConfig struct { + Architecture string `json:"architecture"` + OS string `json:"os"` + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"config"` + RootFS struct { + DiffIDs []string `json:"diff_ids"` + } `json:"rootfs"` +} + +type ociDescriptor struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size uint64 `json:"size"` + Annotations map[string]string `json:"annotations"` + Platform *struct { + OS string `json:"os"` + Architecture string `json:"architecture"` + } `json:"platform,omitempty"` +} + +type ociIndex struct { + SchemaVersion int `json:"schemaVersion"` + Manifests []ociDescriptor `json:"manifests"` +} + +type ociManifest struct { + SchemaVersion int `json:"schemaVersion"` + Config ociDescriptor `json:"config"` + Layers []ociDescriptor `json:"layers"` +} + +func verifyDockerSandboxesArchive(archivePath, expectedTag, expectedPlatform, expectedBuildDigest string, expectedLabels map[string]string) (dockerArchiveVerification, error) { + target, err := storage.SnapshotFilesystemTarget(archivePath) + if err != nil { + return dockerArchiveVerification{}, fmt.Errorf("inspect Docker Sandboxes template archive: %w", err) + } + if target.Kind != storage.TargetFile { + return dockerArchiveVerification{}, errors.New("Docker Sandboxes template archive is not a regular file") + } + file, err := os.Open(archivePath) + if err != nil { + return dockerArchiveVerification{}, err + } + files, err := scanTemplateArchive(file) + closeErr := file.Close() + if err != nil { + return dockerArchiveVerification{}, err + } + if closeErr != nil { + return dockerArchiveVerification{}, closeErr + } + + var imageDigest string + switch { + case files["index.json"].data != nil: + imageDigest, err = verifyOCIArchive(files, expectedTag, expectedPlatform, expectedLabels) + case files["manifest.json"].data != nil: + imageDigest, err = verifyDockerSaveArchive(files, expectedTag, expectedPlatform, expectedLabels) + default: + err = errors.New("template archive contains neither OCI index.json nor Docker manifest.json") + } + if err != nil { + return dockerArchiveVerification{}, err + } + if imageDigest != expectedBuildDigest { + return dockerArchiveVerification{}, fmt.Errorf("template archive identity %s does not match Buildx metadata identity %s", imageDigest, expectedBuildDigest) + } + archiveSHA, archiveBytes, err := hashFile(archivePath) + if err != nil { + return dockerArchiveVerification{}, err + } + return dockerArchiveVerification{ImageDigest: imageDigest, ArchiveSHA256: archiveSHA, ArchiveBytes: archiveBytes}, nil +} + +func scanTemplateArchive(reader io.Reader) (map[string]archivedFile, error) { + result := make(map[string]archivedFile) + tarReader := tar.NewReader(reader) + var controlBytes uint64 + for entries := 0; ; entries++ { + if entries >= maxTemplateArchiveEntries { + return nil, errors.New("template archive contains too many entries") + } + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("read template archive: %w", err) + } + name, err := safeArchiveName(header.Name) + if err != nil { + return nil, err + } + if _, duplicate := result[name]; duplicate { + return nil, fmt.Errorf("template archive contains duplicate entry %q", name) + } + switch header.Typeflag { + case tar.TypeDir: + result[name] = archivedFile{} + continue + case tar.TypeReg, tar.TypeRegA: + default: + return nil, fmt.Errorf("template archive entry %q has unsafe type %d", name, header.Typeflag) + } + if header.Size < 0 { + return nil, fmt.Errorf("template archive entry %q has a negative size", name) + } + hasher := sha256.New() + var capture []byte + if shouldCaptureArchiveControl(name, header.Size) { + if uint64(header.Size) > maxTemplateControlFileSize || controlBytes > maxTemplateControlBytes-uint64(header.Size) { + return nil, errors.New("template archive control data exceeds the bounded verification limit") + } + capture = make([]byte, header.Size) + if _, err := io.ReadFull(io.TeeReader(tarReader, hasher), capture); err != nil { + return nil, fmt.Errorf("read template archive entry %q: %w", name, err) + } + controlBytes += uint64(header.Size) + } else { + if _, err := io.Copy(hasher, tarReader); err != nil { + return nil, fmt.Errorf("hash template archive entry %q: %w", name, err) + } + } + result[name] = archivedFile{ + digest: "sha256:" + hex.EncodeToString(hasher.Sum(nil)), + size: uint64(header.Size), + data: capture, + } + } + return result, nil +} + +func safeArchiveName(value string) (string, error) { + if value == "" || strings.ContainsRune(value, 0) || strings.Contains(value, "\\") || strings.HasPrefix(value, "/") { + return "", fmt.Errorf("template archive contains unsafe path %q", value) + } + normalized := strings.TrimSuffix(value, "/") + clean := path.Clean(normalized) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || clean != normalized { + return "", fmt.Errorf("template archive contains unsafe path %q", value) + } + return clean, nil +} + +func shouldCaptureArchiveControl(name string, size int64) bool { + if size > maxTemplateControlFileSize { + return false + } + return name == "manifest.json" || name == "index.json" || name == "oci-layout" || strings.HasSuffix(name, ".json") || strings.HasPrefix(name, "blobs/sha256/") +} + +func verifyDockerSaveArchive(files map[string]archivedFile, expectedTag, expectedPlatform string, expectedLabels map[string]string) (string, error) { + var entries []dockerSaveManifestEntry + if err := json.Unmarshal(files["manifest.json"].data, &entries); err != nil { + return "", fmt.Errorf("decode Docker archive manifest: %w", err) + } + if len(entries) != 1 { + return "", fmt.Errorf("Docker archive must contain exactly one image, found %d", len(entries)) + } + entry := entries[0] + if len(entry.RepoTags) != 1 || !sameDockerReference(entry.RepoTags[0], expectedTag) { + return "", fmt.Errorf("Docker archive tag %q does not match expected tag %q", strings.Join(entry.RepoTags, ", "), expectedTag) + } + configFile, ok := files[entry.Config] + if !ok || configFile.data == nil { + return "", fmt.Errorf("Docker archive is missing configuration %q", entry.Config) + } + configDigest := configFile.digest + configName := strings.TrimSuffix(filepath.Base(entry.Config), ".json") + if len(configName) != 64 || configDigest != "sha256:"+configName { + return "", errors.New("Docker archive configuration filename does not match its recomputed digest") + } + var config dockerImageConfig + if err := json.Unmarshal(configFile.data, &config); err != nil { + return "", fmt.Errorf("decode Docker archive configuration: %w", err) + } + if err := verifyArchivePlatformAndLabels(config.OS, config.Architecture, config.Config.Labels, expectedPlatform, expectedLabels); err != nil { + return "", err + } + if len(entry.Layers) == 0 || len(entry.Layers) != len(config.RootFS.DiffIDs) { + return "", errors.New("Docker archive layer list does not match configuration rootfs") + } + seen := make(map[string]bool, len(entry.Layers)) + for index, layerName := range entry.Layers { + if seen[layerName] { + return "", fmt.Errorf("Docker archive references layer %q more than once", layerName) + } + seen[layerName] = true + layer, ok := files[layerName] + if !ok { + return "", fmt.Errorf("Docker archive is missing layer %q", layerName) + } + if layer.digest != config.RootFS.DiffIDs[index] { + return "", fmt.Errorf("Docker archive layer %q digest does not match configuration diff ID", layerName) + } + } + return configDigest, nil +} + +func verifyOCIArchive(files map[string]archivedFile, expectedTag, expectedPlatform string, expectedLabels map[string]string) (string, error) { + if layout, ok := files["oci-layout"]; !ok || layout.data == nil { + return "", errors.New("OCI archive is missing oci-layout") + } else { + var version struct { + ImageLayoutVersion string `json:"imageLayoutVersion"` + } + if err := json.Unmarshal(layout.data, &version); err != nil || version.ImageLayoutVersion != "1.0.0" { + return "", errors.New("OCI archive has an unsupported image layout version") + } + } + var index ociIndex + if err := json.Unmarshal(files["index.json"].data, &index); err != nil { + return "", fmt.Errorf("decode OCI archive index: %w", err) + } + if index.SchemaVersion != 2 { + return "", errors.New("OCI archive index has an unsupported schema version") + } + var selected *ociDescriptor + for i := range index.Manifests { + descriptor := &index.Manifests[i] + fullName := descriptor.Annotations["io.containerd.image.name"] + shortTag := descriptor.Annotations["org.opencontainers.image.ref.name"] + _, expectedSuffix, _ := strings.Cut(expectedTag, ":") + matches := fullName != "" && sameDockerReference(fullName, expectedTag) + if fullName == "" { + matches = shortTag != "" && shortTag == expectedSuffix + } + if matches { + if selected != nil { + return "", fmt.Errorf("OCI archive contains duplicate tag %q", expectedTag) + } + selected = descriptor + } + } + if selected == nil { + return "", fmt.Errorf("OCI archive does not contain exact tag %q", expectedTag) + } + manifestBytes, err := verifyOCIDescriptor(files, *selected) + if err != nil { + return "", err + } + var manifest ociManifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil || manifest.SchemaVersion != 2 { + return "", errors.New("OCI archive contains an invalid image manifest") + } + configBytes, err := verifyOCIDescriptor(files, manifest.Config) + if err != nil { + return "", err + } + var config dockerImageConfig + if err := json.Unmarshal(configBytes, &config); err != nil { + return "", fmt.Errorf("decode OCI image configuration: %w", err) + } + if err := verifyArchivePlatformAndLabels(config.OS, config.Architecture, config.Config.Labels, expectedPlatform, expectedLabels); err != nil { + return "", err + } + if selected.Platform != nil && expectedPlatform != selected.Platform.OS+"/"+selected.Platform.Architecture { + return "", fmt.Errorf("OCI index platform %s/%s does not match expected platform %s", selected.Platform.OS, selected.Platform.Architecture, expectedPlatform) + } + for _, layer := range manifest.Layers { + if _, err := verifyOCIDescriptor(files, layer); err != nil { + return "", err + } + } + return selected.Digest, nil +} + +func verifyOCIDescriptor(files map[string]archivedFile, descriptor ociDescriptor) ([]byte, error) { + if !validSHA256(descriptor.Digest) { + return nil, fmt.Errorf("OCI archive contains invalid descriptor digest %q", descriptor.Digest) + } + name := "blobs/sha256/" + strings.TrimPrefix(descriptor.Digest, "sha256:") + file, ok := files[name] + if !ok { + return nil, fmt.Errorf("OCI archive is missing blob %s", descriptor.Digest) + } + if file.digest != descriptor.Digest || file.size != descriptor.Size { + return nil, fmt.Errorf("OCI archive blob %s does not match its descriptor", descriptor.Digest) + } + return file.data, nil +} + +func verifyArchivePlatformAndLabels(osName, architecture string, labels map[string]string, expectedPlatform string, expectedLabels map[string]string) error { + if osName+"/"+architecture != expectedPlatform { + return fmt.Errorf("template archive platform %s/%s does not match expected platform %s", osName, architecture, expectedPlatform) + } + for name, expected := range expectedLabels { + if expected == "*" && labels[name] == "" { + return fmt.Errorf("template archive label %s is missing", name) + } + if expected != "*" && labels[name] != expected { + return fmt.Errorf("template archive label %s=%q does not match expected value %q", name, labels[name], expected) + } + } + return nil +} + +func sameDockerReference(left, right string) bool { + normalize := func(value string) string { + value = strings.TrimPrefix(value, "docker.io/") + if !strings.Contains(strings.SplitN(value, ":", 2)[0], "/") { + value = "library/" + value + } + return value + } + return normalize(left) == normalize(right) +} diff --git a/internal/image/docker_archive_test.go b/internal/image/docker_archive_test.go new file mode 100644 index 0000000..f2c5db9 --- /dev/null +++ b/internal/image/docker_archive_test.go @@ -0,0 +1,204 @@ +package image + +import ( + "archive/tar" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestVerifyDockerSandboxesArchiveAcceptsExactDockerExport(t *testing.T) { + path, digest, labels := writeDockerArchiveFixture(t, false, false) + verified, err := verifyDockerSandboxesArchive(path, "docker.io/library/epar-template:test-amd64", "linux/amd64", digest, labels) + if err != nil { + t.Fatal(err) + } + if verified.ImageDigest != digest || verified.ArchiveBytes == 0 || !validSHA256(verified.ArchiveSHA256) { + t.Fatalf("verification = %+v", verified) + } +} + +func TestVerifyDockerSandboxesArchiveAgainstOptionalBuildxFixture(t *testing.T) { + archivePath := os.Getenv("EPAR_TEST_DOCKER_ARCHIVE") + metadataPath := os.Getenv("EPAR_TEST_DOCKER_ARCHIVE_METADATA") + if archivePath == "" || metadataPath == "" { + t.Skip("set EPAR_TEST_DOCKER_ARCHIVE and EPAR_TEST_DOCKER_ARCHIVE_METADATA to validate a real Buildx export") + } + var metadata dockerSandboxesBuildMetadata + if err := readJSONFile(metadataPath, &metadata); err != nil { + t.Fatal(err) + } + if _, err := verifyDockerSandboxesArchive(archivePath, "docker.io/library/epar-template:probe", "linux/amd64", metadata.ImageDigest, map[string]string{ + "io.solutionforest.epar.schema": "1", + "io.solutionforest.epar.installation": "probe", + "io.solutionforest.epar.provider": "docker-sandboxes", + "io.solutionforest.epar.role": "template-staging", + "io.solutionforest.epar.manifest": strings.Repeat("a", 64), + }); err != nil { + t.Fatal(err) + } +} + +func TestVerifyDockerSandboxesArchiveRejectsTamperingAndUnsafeEntries(t *testing.T) { + t.Run("digest tampering", func(t *testing.T) { + path, digest, labels := writeDockerArchiveFixture(t, true, false) + if _, err := verifyDockerSandboxesArchive(path, "epar-template:test-amd64", "linux/amd64", digest, labels); err == nil { + t.Fatal("tampered layer was accepted") + } + }) + t.Run("duplicate control entry", func(t *testing.T) { + path, digest, labels := writeDockerArchiveFixture(t, false, true) + if _, err := verifyDockerSandboxesArchive(path, "epar-template:test-amd64", "linux/amd64", digest, labels); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("err = %v", err) + } + }) + t.Run("unsafe link", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "unsafe.tar") + writeTarFixture(t, path, []tarFixtureEntry{{name: "manifest.json", content: []byte("[]")}, {name: "escape", typeflag: tar.TypeSymlink, linkname: "../outside"}}) + if _, err := verifyDockerSandboxesArchive(path, "epar-template:test-amd64", "linux/amd64", "sha256:"+strings.Repeat("a", 64), nil); err == nil || !strings.Contains(err.Error(), "unsafe type") { + t.Fatalf("err = %v", err) + } + }) + t.Run("truncated", func(t *testing.T) { + path, digest, labels := writeDockerArchiveFixture(t, false, false) + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content[:len(content)/2], 0o600); err != nil { + t.Fatal(err) + } + if _, err := verifyDockerSandboxesArchive(path, "epar-template:test-amd64", "linux/amd64", digest, labels); err == nil { + t.Fatal("truncated archive was accepted") + } + }) +} + +func TestVerifyDockerSandboxesArchiveAcceptsExactOCIIndex(t *testing.T) { + labels := archiveFixtureLabels() + config := dockerImageConfig{Architecture: "arm64", OS: "linux"} + config.Config.Labels = labels + configBytes, _ := json.Marshal(config) + configDigest := digestBytes(configBytes) + layer := []byte("layer") + layerDigest := digestBytes(layer) + manifest := ociManifest{ + SchemaVersion: 2, + Config: ociDescriptor{MediaType: "application/vnd.oci.image.config.v1+json", Digest: configDigest, Size: uint64(len(configBytes))}, + Layers: []ociDescriptor{{MediaType: "application/vnd.oci.image.layer.v1.tar", Digest: layerDigest, Size: uint64(len(layer))}}, + } + manifestBytes, _ := json.Marshal(manifest) + manifestDigest := digestBytes(manifestBytes) + platform := struct { + OS string `json:"os"` + Architecture string `json:"architecture"` + }{OS: "linux", Architecture: "arm64"} + index := ociIndex{SchemaVersion: 2, Manifests: []ociDescriptor{{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Digest: manifestDigest, + Size: uint64(len(manifestBytes)), + Annotations: map[string]string{ + "io.containerd.image.name": "docker.io/library/epar-template:test-arm64", + "org.opencontainers.image.ref.name": "test-arm64", + }, + Platform: &platform, + }}} + indexBytes, _ := json.Marshal(index) + path := filepath.Join(t.TempDir(), "oci.tar") + writeTarFixture(t, path, []tarFixtureEntry{ + {name: "oci-layout", content: []byte(`{"imageLayoutVersion":"1.0.0"}`)}, + {name: "index.json", content: indexBytes}, + {name: "blobs/sha256/" + strings.TrimPrefix(manifestDigest, "sha256:"), content: manifestBytes}, + {name: "blobs/sha256/" + strings.TrimPrefix(configDigest, "sha256:"), content: configBytes}, + {name: "blobs/sha256/" + strings.TrimPrefix(layerDigest, "sha256:"), content: layer}, + }) + if _, err := verifyDockerSandboxesArchive(path, "docker.io/library/epar-template:test-arm64", "linux/arm64", manifestDigest, labels); err != nil { + t.Fatal(err) + } +} + +type tarFixtureEntry struct { + name string + content []byte + typeflag byte + linkname string +} + +func writeDockerArchiveFixture(t *testing.T, tamperLayer, duplicateManifest bool) (string, string, map[string]string) { + t.Helper() + labels := archiveFixtureLabels() + layer := []byte("verified layer content") + layerDigest := digestBytes(layer) + config := dockerImageConfig{Architecture: "amd64", OS: "linux"} + config.Config.Labels = labels + config.RootFS.DiffIDs = []string{layerDigest} + configBytes, _ := json.Marshal(config) + configDigest := digestBytes(configBytes) + configName := strings.TrimPrefix(configDigest, "sha256:") + ".json" + manifestBytes, _ := json.Marshal([]dockerSaveManifestEntry{{ + Config: configName, + RepoTags: []string{"epar-template:test-amd64"}, + Layers: []string{"layer/layer.tar"}, + }}) + if tamperLayer { + layer = []byte("tampered") + } + entries := []tarFixtureEntry{ + {name: "manifest.json", content: manifestBytes}, + {name: configName, content: configBytes}, + {name: "layer/layer.tar", content: layer}, + } + if duplicateManifest { + entries = append(entries, tarFixtureEntry{name: "manifest.json", content: manifestBytes}) + } + path := filepath.Join(t.TempDir(), "template.tar") + writeTarFixture(t, path, entries) + return path, configDigest, labels +} + +func archiveFixtureLabels() map[string]string { + return map[string]string{ + "io.solutionforest.epar.schema": "1", + "io.solutionforest.epar.installation": "installation", + "io.solutionforest.epar.provider": "docker-sandboxes", + "io.solutionforest.epar.role": "template-staging", + "io.solutionforest.epar.manifest": strings.Repeat("a", 64), + } +} + +func writeTarFixture(t *testing.T, path string, entries []tarFixtureEntry) { + t.Helper() + var content bytes.Buffer + writer := tar.NewWriter(&content) + for _, entry := range entries { + typeflag := entry.typeflag + if typeflag == 0 { + typeflag = tar.TypeReg + } + header := &tar.Header{Name: entry.name, Mode: 0o600, Size: int64(len(entry.content)), Typeflag: typeflag, Linkname: entry.linkname} + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if typeflag == tar.TypeReg { + if _, err := writer.Write(entry.content); err != nil { + t.Fatal(err) + } + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content.Bytes(), 0o600); err != nil { + t.Fatal(err) + } +} + +func digestBytes(content []byte) string { + sum := sha256.Sum256(content) + return "sha256:" + hex.EncodeToString(sum[:]) +} diff --git a/internal/image/docker_sandboxes.go b/internal/image/docker_sandboxes.go index fb00607..f9e8c68 100644 --- a/internal/image/docker_sandboxes.go +++ b/internal/image/docker_sandboxes.go @@ -20,11 +20,12 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" "github.com/solutionforest/ephemeral-action-runner/internal/provider" "github.com/solutionforest/ephemeral-action-runner/internal/storage" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" ) const ( - dockerSandboxesReceiptSchema = 2 - dockerSandboxesMetadataSchema = 4 + dockerSandboxesReceiptSchema = 3 + dockerSandboxesMetadataSchema = 5 ) var dockerTagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`) @@ -54,16 +55,16 @@ type dockerManifestDocument struct { } type dockerSandboxesReceipt struct { - SchemaVersion int `json:"schemaVersion"` - ManifestHash string `json:"manifestHash"` - Manifest Manifest `json:"manifest"` - Source ResolvedDockerSource `json:"source"` - Artifact provider.TemplateArtifact `json:"artifact"` - MetadataPath string `json:"metadataPath"` - MetadataSHA256 string `json:"metadataSha256"` - ArchivePath string `json:"archivePath"` - ArchiveSHA256 string `json:"archiveSha256"` - ActivatedAt time.Time `json:"activatedAt"` + SchemaVersion int `json:"schemaVersion"` + ManifestHash string `json:"manifestHash"` + Manifest Manifest `json:"manifest"` + Source ResolvedDockerSource `json:"source"` + Artifact provider.TemplateArtifact `json:"artifact"` + MetadataSHA256 string `json:"metadataSha256"` + ArchiveSHA256 string `json:"archiveSha256"` + ArchiveBytes uint64 `json:"archiveBytes"` + Evidence map[string]artifactEvidence `json:"evidence"` + ActivatedAt time.Time `json:"activatedAt"` } type dockerSandboxesSourceLock struct { @@ -110,38 +111,6 @@ type dockerSandboxesBuildMetadata struct { BuildRef string `json:"buildx.build.ref"` } -type buildxAttachmentDocument struct { - Manifests []struct { - MediaType string `json:"mediaType"` - Digest string `json:"digest"` - Annotations map[string]string `json:"annotations"` - } `json:"manifests"` - Layers []struct { - MediaType string `json:"mediaType"` - Digest string `json:"digest"` - Size uint64 `json:"size"` - Annotations map[string]string `json:"annotations"` - } `json:"layers"` -} - -type buildxHistoryEntry struct { - Ref string `json:"ref"` - Status string `json:"status"` -} - -type buildxHistoryInspection struct { - Ref string `json:"Ref"` - Status string `json:"Status"` - BuildArgs []struct { - Name string `json:"Name"` - Value string `json:"Value"` - } `json:"BuildArgs"` - Attachments []struct { - Digest string `json:"Digest"` - Type string `json:"Type"` - } `json:"Attachments"` -} - type dockerSandboxesTemplateMetadata struct { SchemaVersion int `json:"schemaVersion"` Profile string `json:"profile"` @@ -436,16 +405,51 @@ func DockerSandboxesReceiptPath(projectRoot string) string { return filepath.Join(projectRoot, ".local", "state", "image", "docker-sandboxes", "active.json") } +func DockerSandboxesReceiptPathForConfig(projectRoot, configPath string) (string, error) { + if strings.TrimSpace(configPath) == "" { + configPath = filepath.Join(projectRoot, ".local", "config.yml") + } + configID, err := storagecatalog.ConfigID(projectRoot, configPath) + if err != nil { + return "", err + } + return filepath.Join(projectRoot, ".local", "state", "image", configID, "docker-sandboxes", "active.json"), nil +} + +func (m *Coordinator) dockerSandboxesReceiptPath() (string, error) { + return DockerSandboxesReceiptPathForConfig(m.ProjectRoot, m.ConfigPath) +} + func LoadDockerSandboxesReceipt(projectRoot string) (provider.TemplateArtifact, string, time.Time, error) { - receipt, err := readDockerSandboxesReceipt(projectRoot) + receipt, err := readDockerSandboxesReceiptPath(DockerSandboxesReceiptPath(projectRoot)) + if err != nil { + return provider.TemplateArtifact{}, "", time.Time{}, err + } + return receipt.Artifact, receipt.MetadataSHA256, receipt.ActivatedAt, nil +} + +func LoadDockerSandboxesReceiptForConfig(projectRoot, configPath string) (provider.TemplateArtifact, string, time.Time, error) { + path, err := DockerSandboxesReceiptPathForConfig(projectRoot, configPath) + if err != nil { + return provider.TemplateArtifact{}, "", time.Time{}, err + } + receipt, err := readDockerSandboxesReceiptPath(path) if err != nil { return provider.TemplateArtifact{}, "", time.Time{}, err } return receipt.Artifact, receipt.MetadataSHA256, receipt.ActivatedAt, nil } -func readDockerSandboxesReceipt(projectRoot string) (dockerSandboxesReceipt, error) { - content, err := os.ReadFile(DockerSandboxesReceiptPath(projectRoot)) +func (m *Coordinator) readDockerSandboxesReceipt() (dockerSandboxesReceipt, error) { + path, err := m.dockerSandboxesReceiptPath() + if err != nil { + return dockerSandboxesReceipt{}, err + } + return readDockerSandboxesReceiptPath(path) +} + +func readDockerSandboxesReceiptPath(path string) (dockerSandboxesReceipt, error) { + content, err := os.ReadFile(path) if err != nil { return dockerSandboxesReceipt{}, err } @@ -453,7 +457,7 @@ func readDockerSandboxesReceipt(projectRoot string) (dockerSandboxesReceipt, err if err := json.Unmarshal(content, &receipt); err != nil { return dockerSandboxesReceipt{}, err } - if receipt.SchemaVersion != dockerSandboxesReceiptSchema || receipt.ManifestHash == "" || receipt.Artifact.Reference == "" || receipt.Artifact.Digest == "" || receipt.Artifact.RootDisk == "" || receipt.MetadataSHA256 == "" || receipt.ArchiveSHA256 == "" || receipt.ActivatedAt.IsZero() { + if receipt.SchemaVersion != dockerSandboxesReceiptSchema || receipt.ManifestHash == "" || receipt.Artifact.Reference == "" || receipt.Artifact.Digest == "" || receipt.Artifact.RootDisk == "" || receipt.MetadataSHA256 == "" || receipt.ArchiveSHA256 == "" || receipt.ArchiveBytes == 0 || len(receipt.Evidence) == 0 || receipt.ActivatedAt.IsZero() { return dockerSandboxesReceipt{}, fmt.Errorf("invalid Docker Sandboxes active artifact receipt") } return receipt, nil @@ -477,19 +481,29 @@ func (m *Coordinator) ensureDockerSandboxesTemplate(ctx context.Context, force b return err } if !force { - receipt, receiptErr := readDockerSandboxesReceipt(m.ProjectRoot) + receipt, receiptErr := m.readDockerSandboxesReceipt() if receiptErr == nil && receipt.ManifestHash == manifestHash && receipt.Artifact.RootDisk == rootDisk { - if err := runtime.VerifyTemplate(ctx, receipt.Artifact); err != nil { - return fmt.Errorf("configured Docker Sandboxes artifact is not available exactly as recorded: %w", err) - } - if err := runtime.ActivateTemplate(receipt.Artifact); err != nil { - return err + if err := runtime.VerifyImportedTemplate(ctx, receipt.Artifact); err != nil { + if !errors.Is(err, provider.ErrTemplateNotFound) { + return fmt.Errorf("measure configured Docker Sandboxes artifact availability: %w", err) + } + m.warnf("recorded Docker Sandboxes template is absent from the authoritative Sandbox cache; rebuilding it\n") + } else { + if err := runtime.ActivateTemplate(receipt.Artifact); err != nil { + return err + } + if err := m.recordCurrentSandboxArtifact(ctx, receipt.Artifact, manifestHash, receipt.ActivatedAt); err != nil { + return fmt.Errorf("record current Docker Sandboxes template ownership: %w", err) + } + if err := m.cleanupSupersededCatalog(ctx); err != nil { + return err + } + m.infof("Docker Sandboxes runner template is current: %s@%s\n", receipt.Artifact.Reference, receipt.Artifact.Digest) + return nil } - m.infof("Docker Sandboxes runner template is current: %s@%s\n", receipt.Artifact.Reference, receipt.Artifact.Digest) - return nil } if receiptErr != nil && !errors.Is(receiptErr, os.ErrNotExist) { - return fmt.Errorf("read Docker Sandboxes active artifact receipt: %w", receiptErr) + m.warnf("ignoring stale unpublished Docker Sandboxes receipt and rebuilding: %v\n", receiptErr) } } if m.DryRun { @@ -553,6 +567,9 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest if err := os.MkdirAll(artifactRoot, 0o755); err != nil { return err } + if err := m.recordSandboxWorkspace(ctx, artifactRoot, manifestHash, storagecatalog.StateStaging, time.Now().UTC()); err != nil { + return fmt.Errorf("record Docker Sandboxes archive workspace ownership: %w", err) + } platformLock := lock.Platforms[source.Platform] builder, err := m.ensureBuildxBuilder(ctx, []string{ source.ImmutableReference, @@ -581,11 +598,13 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest return fmt.Errorf("acquire locked tini: %w", err) } buildMetadataPath := filepath.Join(artifactRoot, "build-metadata.json") + attestationMetadataPath := filepath.Join(artifactRoot, "attestation-metadata.json") provenancePath := filepath.Join(artifactRoot, "provenance.json") sbomPath := filepath.Join(artifactRoot, "sbom.intoto.json") inventoryPath := filepath.Join(artifactRoot, "software-inventory.txt") compatibilityEvidencePath := filepath.Join(artifactRoot, "compatibility.json") archivePath := filepath.Join(artifactRoot, "runner-template.tar") + partialArchivePath := archivePath + ".partial" metadataPath := filepath.Join(artifactRoot, "template-metadata.json") resumed, err := m.resumeDockerSandboxesTemplate(ctx, manifest, source, manifestHash, rootDisk, artifactRoot, metadataPath, archivePath, runtime) if err != nil { @@ -594,10 +613,13 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest if resumed { return nil } - buildMetadata, localDigest, recoveredBuild, err := m.recoverDockerSandboxesBuild(ctx, builder, templateTag, source, manifestHash, architecture, lock, compatibilityEvidencePath) - if err != nil { - return err - } + var buildMetadata dockerSandboxesBuildMetadata + var localDigest string + var contextRoot string + var buildArguments []string + var archiveSHA string + var archiveBytes uint64 + recoveredBuild := false if recoveredBuild { m.infof("reusing completed exact Docker Sandboxes Buildx result %s\n", localDigest) if err := writeJSONFile(buildMetadataPath, buildMetadata); err != nil { @@ -607,7 +629,7 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest return err } } else { - contextRoot, err := os.MkdirTemp(filepath.Join(m.ProjectRoot, ".local"), "docker-sandboxes-context-") + contextRoot, err = os.MkdirTemp(filepath.Join(m.ProjectRoot, ".local"), "docker-sandboxes-context-") if err != nil { return err } @@ -650,17 +672,27 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest if err := copyFile(tiniPath, filepath.Join(contextRoot, "inputs", "tini"), 0o755); err != nil { return err } - for _, path := range []string{buildMetadataPath, provenancePath, sbomPath, inventoryPath, archivePath, metadataPath} { + for _, path := range []string{buildMetadataPath, attestationMetadataPath, provenancePath, sbomPath, inventoryPath, archivePath, partialArchivePath, metadataPath} { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } } + installationID, err := m.catalogInstallationID(time.Now().UTC()) + if err != nil { + return fmt.Errorf("resolve EPAR template ownership identity: %w", err) + } args := []string{ - "buildx", "build", "--builder", builder, "--platform", source.Platform, "--pull", "--progress", "plain", "--load", - "--provenance", "mode=max", "--sbom", "generator=" + platformLock.SBOMGeneratorReference, + "buildx", "build", "--builder", builder, "--platform", source.Platform, "--pull", "--progress", "plain", + "--target", "runner-template", "--output", "type=docker,dest=" + partialArchivePath, + "--provenance=false", "--sbom=false", "--metadata-file", buildMetadataPath, "--tag", templateTag, + "--label", "io.solutionforest.epar.schema=1", + "--label", "io.solutionforest.epar.installation=" + installationID, + "--label", "io.solutionforest.epar.provider=docker-sandboxes", + "--label", "io.solutionforest.epar.role=template-staging", + "--label", "io.solutionforest.epar.manifest=" + manifestHash, } - for _, buildArg := range []string{ + buildArguments = []string{ "TEMPLATE_PLATFORM=" + source.Platform, "SOURCE_IMAGE=" + source.ImmutableReference, "GO_BUILDER_IMAGE=" + platformLock.GoBuilderReference, @@ -673,7 +705,8 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest "COMPATIBILITY_FILE=generated.compatibility.json", "ACTIONS_RUNNER_SHA256=sha256:" + strings.TrimPrefix(platformLock.ActionsRunner.SHA256, "sha256:"), "TINI_SHA256=sha256:" + strings.TrimPrefix(platformLock.Tini.SHA256, "sha256:"), - } { + } + for _, buildArg := range buildArguments { args = append(args, "--build-arg", buildArg) } args = append(args, "--file", filepath.Join(contextRoot, "Dockerfile"), contextRoot) @@ -684,45 +717,99 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest } m.infof("building Docker Sandboxes runner template from %s for %s\n", source.Reference, source.Platform) m.infof("full Docker Sandboxes Buildx progress: %s\n", buildLogPath) - if err := m.runHostLogged(ctx, buildLogPath, "docker", args...); err != nil { + if err := m.runHostBuildxLogged(ctx, buildLogPath, "docker", args...); err != nil { return fmt.Errorf("build Docker Sandboxes runner template: %w%s", err, boundedRedactedLogTail(buildLogPath, 32*1024)) } if err := readJSONFile(buildMetadataPath, &buildMetadata); err != nil { return fmt.Errorf("read Docker Sandboxes Buildx metadata: %w", err) } - localDigest, err = m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", templateTag) - if err != nil { - return err - } - localDigest = strings.TrimSpace(localDigest) - if !validSHA256(localDigest) || buildMetadata.ImageDigest != localDigest { - return fmt.Errorf("Docker Sandboxes template Buildx digest does not match the full local Docker image identity") + expectedLabels := map[string]string{ + "io.solutionforest.epar.schema": "1", + "io.solutionforest.epar.installation": installationID, + "io.solutionforest.epar.provider": "docker-sandboxes", + "io.solutionforest.epar.role": "template-staging", + "io.solutionforest.epar.manifest": manifestHash, } - if len(buildMetadata.Provenance) == 0 || string(buildMetadata.Provenance) == "null" { - return fmt.Errorf("Docker Sandboxes template Buildx metadata omitted max-mode provenance") + archiveVerification, err := verifyDockerSandboxesArchive(partialArchivePath, "docker.io/library/"+templateTag, source.Platform, buildMetadata.ImageDigest, expectedLabels) + if err != nil { + return fmt.Errorf("verify directly exported Docker Sandboxes archive: %w", err) } - if err := writeAtomicFile(provenancePath, append(buildMetadata.Provenance, '\n'), 0o644); err != nil { - return err + localDigest = archiveVerification.ImageDigest + archiveSHA = archiveVerification.ArchiveSHA256 + archiveBytes = archiveVerification.ArchiveBytes + if err := os.Rename(partialArchivePath, archivePath); err != nil { + return fmt.Errorf("activate verified Docker Sandboxes archive: %w", err) } } - sbomSourceDigest, err := m.persistBuildxSBOM(ctx, builder, buildMetadata, sbomPath) + evidenceExportRoot := filepath.Join(artifactRoot, "evidence-export") + if err := os.RemoveAll(evidenceExportRoot); err != nil { + return err + } + attestationArgs := []string{ + "buildx", "build", "--builder", builder, "--platform", source.Platform, "--progress", "plain", + "--target", "software-inventory-export", "--output", "type=local,dest=" + evidenceExportRoot, + "--provenance", "mode=max", "--sbom", "generator=" + platformLock.SBOMGeneratorReference, + "--metadata-file", attestationMetadataPath, + } + for _, buildArg := range buildArguments { + attestationArgs = append(attestationArgs, "--build-arg", buildArg) + } + attestationArgs = append(attestationArgs, "--file", filepath.Join(contextRoot, "Dockerfile"), contextRoot) + attestationLogPath := m.buildLogPath("docker-sandboxes-" + manifestHash[:16] + "-attestation.docker-build.log") + defer m.releaseTranscript(attestationLogPath) + if err := resetLogs(attestationLogPath); err != nil { + return err + } + m.infof("full Docker Sandboxes provenance, SBOM, and software-inventory progress: %s\n", attestationLogPath) + if err := m.runHostBuildxLogged(ctx, attestationLogPath, "docker", attestationArgs...); err != nil { + return fmt.Errorf("generate Docker Sandboxes template evidence: %w%s", err, boundedRedactedLogTail(attestationLogPath, 16*1024)) + } + var attestationMetadata dockerSandboxesBuildMetadata + if err := readJSONFile(attestationMetadataPath, &attestationMetadata); err != nil { + return fmt.Errorf("read Docker Sandboxes attestation metadata: %w", err) + } + if len(attestationMetadata.Provenance) == 0 || string(attestationMetadata.Provenance) == "null" { + return fmt.Errorf("Docker Sandboxes attestation build omitted max-mode provenance") + } + if err := validateBuildxMaxProvenance(attestationMetadata.Provenance); err != nil { + return fmt.Errorf("validate Docker Sandboxes Buildx provenance metadata: %w", err) + } + exportedProvenancePath := filepath.Join(evidenceExportRoot, "provenance.json") + exportedProvenance, err := readVerifiedBuildEvidence(exportedProvenancePath, storage.GiB) if err != nil { - return fmt.Errorf("persist Docker Sandboxes template SBOM attestation: %w", err) + return fmt.Errorf("read exported Docker Sandboxes provenance: %w", err) + } + if err := validateInTotoProvenance(exportedProvenance); err != nil { + return fmt.Errorf("validate exported Docker Sandboxes provenance: %w", err) + } + if err := writeAtomicFile(provenancePath, append(exportedProvenance, '\n'), 0o644); err != nil { + return err } - inventory, err := m.runHostOutput(ctx, "docker", "run", "--rm", "--pull", "never", "--platform", source.Platform, "--entrypoint", "/opt/epar/collect-software-inventory.sh", templateTag) + exportedSBOMPath := filepath.Join(evidenceExportRoot, "sbom-runner-template.spdx.json") + exportedSBOM, err := readVerifiedBuildEvidence(exportedSBOMPath, storage.GiB) if err != nil { - return fmt.Errorf("collect Docker Sandboxes template software inventory: %w", err) + return fmt.Errorf("read exported Docker Sandboxes runner-template SBOM: %w", err) } - if err := writeAtomicFile(inventoryPath, []byte(strings.TrimSpace(inventory)+"\n"), 0o644); err != nil { + if err := writeAtomicFile(sbomPath, exportedSBOM, 0o644); err != nil { return err } - if err := m.runHost(ctx, "docker", "image", "save", "--output", archivePath, templateTag); err != nil { - return fmt.Errorf("save Docker Sandboxes template archive: %w", err) + if err := validateInTotoSPDX(sbomPath); err != nil { + return fmt.Errorf("validate exported Docker Sandboxes runner-template SBOM: %w", err) } - archiveSHA, archiveBytes, err := hashFile(archivePath) + sbomSourceDigest, _, err := hashFile(sbomPath) if err != nil { return err } + inventory, err := readVerifiedBuildEvidence(filepath.Join(evidenceExportRoot, "software-inventory.txt"), 64*storage.MiB) + if err != nil { + return fmt.Errorf("read exported Docker Sandboxes software inventory: %w", err) + } + if len(strings.TrimSpace(string(inventory))) == 0 { + return errors.New("exported Docker Sandboxes software inventory is empty") + } + if err := writeAtomicFile(inventoryPath, inventory, 0o644); err != nil { + return err + } artifact := provider.TemplateArtifact{ Reference: "docker.io/library/" + templateTag, Digest: localDigest, @@ -750,11 +837,12 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest metadata.Compatibility.DockerDaemonOwner = "docker-sandboxes-runtime" metadata.Compatibility.ExpectedDockerDaemonCount = 1 for name, path := range map[string]string{ - "buildMetadata": buildMetadataPath, - "provenance": provenancePath, - "sbom": sbomPath, - "softwareInventory": inventoryPath, - "compatibility": compatibilityEvidencePath, + "buildMetadata": buildMetadataPath, + "attestationMetadata": attestationMetadataPath, + "provenance": provenancePath, + "sbom": sbomPath, + "softwareInventory": inventoryPath, + "compatibility": compatibilityEvidencePath, } { digest, _, err := hashFile(path) if err != nil { @@ -773,278 +861,90 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest if err != nil { return err } - if err := runtime.VerifyTemplate(ctx, artifact); err != nil { - m.infof("importing verified Docker Sandboxes runner template %s\n", artifact.Reference) - if err := runtime.ImportTemplate(ctx, archivePath); err != nil { - return err - } - } - if err := runtime.VerifyTemplate(ctx, artifact); err != nil { - return fmt.Errorf("verify imported Docker Sandboxes runner template: %w", err) - } - return m.activateDockerSandboxesTemplate(manifest, source, manifestHash, artifact, metadataPath, metadataSHA, archivePath, archiveSHA, runtime) -} - -func (m *Coordinator) recoverDockerSandboxesBuild(ctx context.Context, builder, templateTag string, source ResolvedDockerSource, manifestHash, architecture string, lock dockerSandboxesSourceLock, compatibilityPath string) (dockerSandboxesBuildMetadata, string, bool, error) { - compatibilityInfo, err := os.Lstat(compatibilityPath) - if err != nil { - if os.IsNotExist(err) { - return dockerSandboxesBuildMetadata{}, "", false, nil - } - return dockerSandboxesBuildMetadata{}, "", false, err - } - if !compatibilityInfo.Mode().IsRegular() { - return dockerSandboxesBuildMetadata{}, "", false, fmt.Errorf("Docker Sandboxes compatibility evidence is not a regular file: %s", compatibilityPath) - } - localDigest, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", templateTag) - if err != nil { - return dockerSandboxesBuildMetadata{}, "", false, nil - } - localDigest = strings.TrimSpace(localDigest) - if !validSHA256(localDigest) { - return dockerSandboxesBuildMetadata{}, "", false, nil - } - history, err := m.runHostOutput(ctx, "docker", "buildx", "history", "ls", "--builder", builder, "--no-trunc", "--format", "json") - if err != nil { - m.warnf("could not inspect owned Buildx history for interrupted-build recovery; rebuilding: %v\n", err) - return dockerSandboxesBuildMetadata{}, "", false, nil - } - platformLock := lock.Platforms[source.Platform] - expectedArgs := map[string]string{ - "TEMPLATE_PLATFORM": source.Platform, - "SOURCE_IMAGE": source.ImmutableReference, - "GO_BUILDER_IMAGE": platformLock.GoBuilderReference, - "HOOK_LAUNCHER_SHA256": lock.HookLauncher.SHA256, - "SOURCE_PROFILE": sourceProfile(source.Reference), - "SOURCE_INDEX_DIGEST": source.IndexDigest, - "SOURCE_MANIFEST_DIGEST": source.PlatformDigest, - "SOURCE_REVISION": source.IndexDigest, - "TEMPLATE_VERSION": manifestHash[:16] + "-" + architecture, - "COMPATIBILITY_FILE": "generated.compatibility.json", - "ACTIONS_RUNNER_SHA256": "sha256:" + strings.TrimPrefix(platformLock.ActionsRunner.SHA256, "sha256:"), - "TINI_SHA256": "sha256:" + strings.TrimPrefix(platformLock.Tini.SHA256, "sha256:"), - } - for _, line := range strings.Split(strings.TrimSpace(history), "\n") { - var entry buildxHistoryEntry - if err := json.Unmarshal([]byte(line), &entry); err != nil || entry.Status != "Completed" { - continue - } - buildRef := entry.Ref - if separator := strings.LastIndex(buildRef, "/"); separator >= 0 { - buildRef = buildRef[separator+1:] - } - if matched, _ := regexp.MatchString(`^[a-z0-9]{12,128}$`, buildRef); !matched { - continue - } - inspectionJSON, err := m.runHostOutput(ctx, "docker", "buildx", "history", "inspect", "--builder", builder, buildRef, "--format", "json") - if err != nil { - continue - } - var inspection buildxHistoryInspection - if err := json.Unmarshal([]byte(inspectionJSON), &inspection); err != nil || inspection.Status != "completed" { - continue - } - actualArgs := make(map[string]string, len(inspection.BuildArgs)) - for _, argument := range inspection.BuildArgs { - actualArgs[argument.Name] = argument.Value - } - matches := true - for name, expected := range expectedArgs { - if actualArgs[name] != expected { - matches = false - break + if err := m.withSandboxBackendLock(ctx, func() error { + if err := runtime.VerifyImportedTemplate(ctx, artifact); err != nil { + if !errors.Is(err, provider.ErrTemplateNotFound) { + return err } - } - if !matches { - continue - } - exactImage := false - for _, attachment := range inspection.Attachments { - if attachment.Type == "application/vnd.oci.image.index.v1+json" && attachment.Digest == localDigest { - exactImage = true - break + label := fmt.Sprintf("Docker Sandboxes template-cache import for %s", artifact.Reference) + if err := m.runProgressOperation(label, nil, func() error { + return runtime.ImportTemplate(ctx, archivePath) + }); err != nil { + return err } } - if !exactImage { - continue - } - provenance, err := m.runHostOutput(ctx, "docker", "buildx", "history", "inspect", "attachment", "--builder", builder, "--type", "provenance", buildRef) - if err != nil || !json.Valid([]byte(provenance)) { - continue + if err := m.runProgressOperation("Docker Sandboxes imported-template verification", nil, func() error { + return runtime.VerifyImportedTemplate(ctx, artifact) + }); err != nil { + return fmt.Errorf("verify imported Docker Sandboxes runner template: %w", err) } - return dockerSandboxesBuildMetadata{ - ImageDigest: localDigest, - Provenance: json.RawMessage(provenance), - BuildRef: entry.Ref, - }, localDigest, true, nil + return nil + }); err != nil { + return err } - return dockerSandboxesBuildMetadata{}, "", false, nil + return m.activateDockerSandboxesTemplate(ctx, manifest, source, manifestHash, artifact, metadataPath, metadataSHA, archivePath, archiveSHA, runtime) } -func (m *Coordinator) persistBuildxSBOM(ctx context.Context, builder string, metadata dockerSandboxesBuildMetadata, destination string) (string, error) { - buildRef := metadata.BuildRef - if separator := strings.LastIndex(buildRef, "/"); separator >= 0 { - buildRef = buildRef[separator+1:] - } - if matched, _ := regexp.MatchString(`^[a-z0-9]{12,128}$`, buildRef); !matched { - return "", fmt.Errorf("Buildx metadata contains invalid build reference %q", metadata.BuildRef) - } - if !validSHA256(metadata.ImageDigest) { - return "", fmt.Errorf("Buildx metadata contains invalid image digest %q", metadata.ImageDigest) +func readVerifiedBuildEvidence(path string, maximumBytes uint64) ([]byte, error) { + if maximumBytes == 0 || maximumBytes > uint64(^uint(0)>>1) { + return nil, fmt.Errorf("invalid build-evidence size limit %d", maximumBytes) } - indexJSON, err := m.runHostOutput(ctx, "docker", "buildx", "history", "inspect", "attachment", "--builder", builder, buildRef, metadata.ImageDigest) + before, err := storage.SnapshotFilesystemTarget(path) if err != nil { - return "", fmt.Errorf("inspect Buildx image-index attachment: %w", err) + return nil, err } - attestationDigest, err := selectBuildxAttestationDigest([]byte(indexJSON)) + info, err := os.Lstat(path) if err != nil { - return "", err + return nil, err } - attestationJSON, err := m.runHostOutput(ctx, "docker", "buildx", "history", "inspect", "attachment", "--builder", builder, buildRef, attestationDigest) - if err != nil { - return "", fmt.Errorf("inspect Buildx attestation manifest: %w", err) + if !info.Mode().IsRegular() || info.Size() <= 0 || uint64(info.Size()) > maximumBytes { + return nil, fmt.Errorf("build evidence %q has invalid size %d", path, info.Size()) } - sbomDigest, sbomSize, err := selectBuildxSBOMDigest([]byte(attestationJSON)) + content, err := os.ReadFile(path) if err != nil { - return "", err - } - if sbomSize == 0 || sbomSize > storage.GiB { - return "", fmt.Errorf("Buildx SBOM attachment has invalid size %d bytes", sbomSize) - } - if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { - return "", err + return nil, err } - temporary, err := os.CreateTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".partial-") + after, err := storage.SnapshotFilesystemTarget(path) if err != nil { - return "", err - } - temporaryPath := temporary.Name() - defer os.Remove(temporaryPath) - if err := temporary.Chmod(0o600); err != nil { - _ = temporary.Close() - return "", err - } - if err := m.streamLocalDockerContentBlob(ctx, builder, sbomDigest, temporary); err != nil { - _ = temporary.Close() - return "", fmt.Errorf("extract Buildx SBOM attachment: %w", err) - } - if err := temporary.Sync(); err != nil { - _ = temporary.Close() - return "", err - } - if err := temporary.Close(); err != nil { - return "", err - } - actualDigest, actualSize, err := hashFile(temporaryPath) - if err != nil { - return "", err - } - if actualDigest != sbomDigest || actualSize != sbomSize { - return "", fmt.Errorf("Buildx SBOM attachment readback is %s/%d bytes, expected %s/%d bytes", actualDigest, actualSize, sbomDigest, sbomSize) + return nil, err } - if err := validateInTotoSPDX(temporaryPath); err != nil { - return "", fmt.Errorf("validate Buildx SBOM attachment: %w", err) - } - if err := os.Rename(temporaryPath, destination); err != nil { - return "", err - } - return sbomDigest, nil -} - -func (m *Coordinator) streamLocalDockerContentBlob(ctx context.Context, builder, digest string, destination *os.File) error { - if !validSHA256(digest) { - return fmt.Errorf("invalid Docker content digest %q", digest) - } - helperImage, err := m.runHostOutput(ctx, "docker", "inspect", "--format", "{{.Image}}", buildxControlContainer(builder)) - if err != nil { - return fmt.Errorf("inspect owned BuildKit control container: %w", err) - } - helperImage = strings.TrimSpace(helperImage) - if !validSHA256(helperImage) { - return fmt.Errorf("owned BuildKit control container reported invalid image identity %q", helperImage) - } - var failures []string - for _, source := range dockerContentBlobCandidates(digest) { - if _, err := destination.Seek(0, io.SeekStart); err != nil { - return err - } - if err := destination.Truncate(0); err != nil { - return err - } - mount := "type=bind,src=" + source + ",dst=/epar-build-attestation,readonly" - err := m.runHostOutputTo( - ctx, - destination, - "docker", - "run", - "--rm", - "--pull=never", - "--network=none", - "--entrypoint", - "cat", - "--mount", - mount, - helperImage, - "/epar-build-attestation", - ) - if err == nil { - return nil - } - failures = append(failures, err.Error()) - } - return fmt.Errorf("exact content blob %s was not readable from Docker's local containerd image store: %s", digest, strings.Join(failures, "; ")) -} - -func dockerContentBlobCandidates(digest string) []string { - hexDigest := strings.TrimPrefix(digest, "sha256:") - return []string{ - "/var/lib/desktop-containerd/daemon/io.containerd.content.v1.content/blobs/sha256/" + hexDigest, - "/var/lib/docker/containerd/daemon/io.containerd.content.v1.content/blobs/sha256/" + hexDigest, + if before.Identity != after.Identity || before.Fingerprint != after.Fingerprint || uint64(len(content)) != uint64(info.Size()) { + return nil, fmt.Errorf("build evidence %q changed during readback", path) } + return content, nil } -func selectBuildxAttestationDigest(content []byte) (string, error) { - var document buildxAttachmentDocument - if err := json.Unmarshal(content, &document); err != nil { - return "", fmt.Errorf("parse Buildx image-index attachment: %w", err) - } - var selected string - for _, manifest := range document.Manifests { - if manifest.Annotations["vnd.docker.reference.type"] != "attestation-manifest" { - continue - } - if !validSHA256(manifest.Digest) || selected != "" { - return "", fmt.Errorf("Buildx image index does not contain exactly one valid attestation manifest") - } - selected = manifest.Digest +func validateBuildxMaxProvenance(content []byte) error { + var provenance struct { + BuildType string `json:"buildType"` + Materials []json.RawMessage `json:"materials"` + Invocation struct { + Parameters json.RawMessage `json:"parameters"` + } `json:"invocation"` + } + if err := json.Unmarshal(content, &provenance); err != nil { + return err } - if selected == "" { - return "", fmt.Errorf("Buildx image index omitted its attestation manifest") + if provenance.BuildType == "" || len(provenance.Materials) == 0 || len(provenance.Invocation.Parameters) == 0 || string(provenance.Invocation.Parameters) == "null" { + return errors.New("max-mode Buildx provenance omitted build type, materials, or invocation parameters") } - return selected, nil + return nil } -func selectBuildxSBOMDigest(content []byte) (string, uint64, error) { - var document buildxAttachmentDocument - if err := json.Unmarshal(content, &document); err != nil { - return "", 0, fmt.Errorf("parse Buildx attestation manifest: %w", err) - } - var selected string - var size uint64 - for _, layer := range document.Layers { - if layer.MediaType != "application/vnd.in-toto+json" || layer.Annotations["in-toto.io/predicate-type"] != "https://spdx.dev/Document" { - continue - } - if !validSHA256(layer.Digest) || selected != "" { - return "", 0, fmt.Errorf("Buildx attestation manifest does not contain exactly one valid SPDX attachment") - } - selected = layer.Digest - size = layer.Size +func validateInTotoProvenance(content []byte) error { + var statement struct { + Type string `json:"_type"` + PredicateType string `json:"predicateType"` + Subject []json.RawMessage `json:"subject"` + Predicate json.RawMessage `json:"predicate"` + } + if err := json.Unmarshal(content, &statement); err != nil { + return err } - if selected == "" { - return "", 0, fmt.Errorf("Buildx attestation manifest omitted its SPDX attachment") + if statement.Type != "https://in-toto.io/Statement/v1" || statement.PredicateType != "https://slsa.dev/provenance/v1" || len(statement.Subject) == 0 || len(statement.Predicate) == 0 || string(statement.Predicate) == "null" { + return errors.New("exported provenance is not a complete in-toto SLSA v1 statement") } - return selected, size, nil + return nil } func validateInTotoSPDX(path string) error { @@ -1190,30 +1090,30 @@ func (m *Coordinator) resumeDockerSandboxesTemplate(ctx context.Context, manifes if artifact.RootDisk != rootDisk { return false, nil } - localDigest, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", artifact.Reference) - if inspectErr != nil || strings.TrimSpace(localDigest) != artifact.Digest { - m.infof("restoring verified Docker Sandboxes runner template image from interrupted build evidence\n") - if err := m.runHost(ctx, "docker", "image", "load", "--input", archivePath); err != nil { - return false, fmt.Errorf("restore verified Docker Sandboxes template image: %w", err) - } - localDigest, err = m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", artifact.Reference) - if err != nil || strings.TrimSpace(localDigest) != artifact.Digest { - return false, fmt.Errorf("restored Docker Sandboxes template image does not match verified build evidence") + if err := m.withSandboxBackendLock(ctx, func() error { + if err := runtime.VerifyImportedTemplate(ctx, artifact); err != nil { + if !errors.Is(err, provider.ErrTemplateNotFound) { + return err + } + if err := m.runProgressOperation("Docker Sandboxes resumed template-cache import", nil, func() error { + return runtime.ImportTemplate(ctx, archivePath) + }); err != nil { + return err + } } - } - if err := runtime.VerifyTemplate(ctx, artifact); err != nil { - m.infof("resuming Docker Sandboxes template import from verified build evidence\n") - if err := runtime.ImportTemplate(ctx, archivePath); err != nil { - return false, err + if err := m.runProgressOperation("Docker Sandboxes resumed imported-template verification", nil, func() error { + return runtime.VerifyImportedTemplate(ctx, artifact) + }); err != nil { + return fmt.Errorf("verify resumed Docker Sandboxes runner template: %w", err) } - } - if err := runtime.VerifyTemplate(ctx, artifact); err != nil { - return false, fmt.Errorf("verify resumed Docker Sandboxes runner template: %w", err) + return nil + }); err != nil { + return false, err } if metadata.ManifestHash != manifestHash { return false, fmt.Errorf("verified Docker Sandboxes build evidence changed during resume") } - if err := m.activateDockerSandboxesTemplate(manifest, source, manifestHash, artifact, metadataPath, metadataSHA, archivePath, archiveSHA, runtime); err != nil { + if err := m.activateDockerSandboxesTemplate(ctx, manifest, source, manifestHash, artifact, metadataPath, metadataSHA, archivePath, archiveSHA, runtime); err != nil { return false, err } m.infof("resumed Docker Sandboxes runner template from verified interrupted build evidence\n") @@ -1255,7 +1155,25 @@ func verifiedDockerSandboxesBuildArtifact(artifactRoot, metadataPath, archivePat if archiveSHA != metadata.Template.ArchiveSHA256 || archiveBytes != metadata.Template.ArchiveBytes { return metadata, provider.TemplateArtifact{}, "", "", false, nil } - requiredEvidence := []string{"buildMetadata", "provenance", "sbom", "softwareInventory", "compatibility"} + var buildMetadata dockerSandboxesBuildMetadata + buildMetadataEvidence, found := metadata.Artifacts["buildMetadata"] + if !found || filepath.Base(buildMetadataEvidence.Path) != buildMetadataEvidence.Path { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + if err := readJSONFile(filepath.Join(artifactRoot, buildMetadataEvidence.Path), &buildMetadata); err != nil { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + archiveVerification, err := verifyDockerSandboxesArchive(archivePath, metadata.Template.Tag, source.Platform, buildMetadata.ImageDigest, map[string]string{ + "io.solutionforest.epar.schema": "1", + "io.solutionforest.epar.installation": "*", + "io.solutionforest.epar.provider": "docker-sandboxes", + "io.solutionforest.epar.role": "template-staging", + "io.solutionforest.epar.manifest": manifestHash, + }) + if err != nil || archiveVerification.ArchiveSHA256 != archiveSHA || archiveVerification.ArchiveBytes != archiveBytes { + return metadata, provider.TemplateArtifact{}, "", "", false, nil + } + requiredEvidence := []string{"buildMetadata", "attestationMetadata", "provenance", "sbom", "softwareInventory", "compatibility"} for _, name := range requiredEvidence { evidence, found := metadata.Artifacts[name] if !found || filepath.Base(evidence.Path) != evidence.Path || !validSHA256(evidence.SHA256) { @@ -1287,29 +1205,103 @@ func verifiedDockerSandboxesBuildArtifact(artifactRoot, metadataPath, archivePat }, metadataSHA, archiveSHA, true, nil } -func (m *Coordinator) activateDockerSandboxesTemplate(manifest Manifest, source ResolvedDockerSource, manifestHash string, artifact provider.TemplateArtifact, metadataPath, metadataSHA, archivePath, archiveSHA string, runtime provider.TemplateArtifactRuntime) error { +func (m *Coordinator) activateDockerSandboxesTemplate(ctx context.Context, manifest Manifest, source ResolvedDockerSource, manifestHash string, artifact provider.TemplateArtifact, metadataPath, metadataSHA, archivePath, archiveSHA string, runtime provider.TemplateArtifactRuntime) error { if err := runtime.ActivateTemplate(artifact); err != nil { return err } + archiveInfo, err := os.Lstat(archivePath) + if err != nil { + return fmt.Errorf("inspect verified Docker Sandboxes archive before activation: %w", err) + } + if !archiveInfo.Mode().IsRegular() { + return errors.New("verified Docker Sandboxes archive is not a regular file") + } + evidence, err := m.persistDockerSandboxesCompactEvidence(manifestHash, filepath.Dir(metadataPath)) + if err != nil { + return err + } receipt := dockerSandboxesReceipt{ SchemaVersion: dockerSandboxesReceiptSchema, ManifestHash: manifestHash, Manifest: manifest, Source: source, Artifact: artifact, - MetadataPath: metadataPath, MetadataSHA256: metadataSHA, - ArchivePath: archivePath, ArchiveSHA256: archiveSHA, + ArchiveBytes: uint64(archiveInfo.Size()), + Evidence: evidence, ActivatedAt: time.Now().UTC(), } - if err := writeJSONFile(DockerSandboxesReceiptPath(m.ProjectRoot), receipt); err != nil { + receiptPath, err := m.dockerSandboxesReceiptPath() + if err != nil { + return err + } + if err := writeJSONFile(receiptPath, receipt); err != nil { + return err + } + if err := m.recordCurrentSandboxArtifact(ctx, artifact, manifestHash, receipt.ActivatedAt); err != nil { + return fmt.Errorf("record current Docker Sandboxes template ownership: %w", err) + } + if err := m.recordSandboxWorkspace(ctx, filepath.Dir(archivePath), manifestHash, storagecatalog.StateSuperseded, receipt.ActivatedAt); err != nil { + return fmt.Errorf("record Docker Sandboxes staging ownership: %w", err) + } + if err := m.cleanupSupersededCatalog(ctx); err != nil { return err } m.infof("activated Docker Sandboxes runner template %s@%s\n", artifact.Reference, artifact.Digest) return nil } +func (m *Coordinator) persistDockerSandboxesCompactEvidence(manifestHash, artifactRoot string) (map[string]artifactEvidence, error) { + receiptPath, err := m.dockerSandboxesReceiptPath() + if err != nil { + return nil, err + } + evidenceRoot := filepath.Join(filepath.Dir(receiptPath), "evidence", manifestHash) + if err := os.MkdirAll(evidenceRoot, 0o700); err != nil { + return nil, err + } + result := make(map[string]artifactEvidence) + for name, filename := range map[string]string{ + "buildMetadata": "build-metadata.json", + "attestationMetadata": "attestation-metadata.json", + "provenance": "provenance.json", + "softwareInventory": "software-inventory.txt", + "compatibility": "compatibility.json", + "templateMetadata": "template-metadata.json", + } { + source := filepath.Join(artifactRoot, filename) + destination := filepath.Join(evidenceRoot, filename) + if err := copyFile(source, destination, 0o600); err != nil { + return nil, fmt.Errorf("retain Docker Sandboxes %s evidence: %w", name, err) + } + digest, _, err := hashFile(destination) + if err != nil { + return nil, err + } + result[name] = artifactEvidence{Path: filepath.ToSlash(filepath.Join("evidence", manifestHash, filename)), SHA256: digest} + } + sbomPath := filepath.Join(artifactRoot, "sbom.intoto.json") + sbomDigest, sbomBytes, err := hashFile(sbomPath) + if err != nil { + return nil, err + } + descriptorPath := filepath.Join(evidenceRoot, "sbom-descriptor.json") + if err := writeJSONFile(descriptorPath, map[string]any{ + "schemaVersion": 1, + "digest": sbomDigest, + "size": sbomBytes, + }); err != nil { + return nil, err + } + descriptorDigest, _, err := hashFile(descriptorPath) + if err != nil { + return nil, err + } + result["sbomDescriptor"] = artifactEvidence{Path: filepath.ToSlash(filepath.Join("evidence", manifestHash, "sbom-descriptor.json")), SHA256: descriptorDigest, SourceDigest: sbomDigest} + return result, nil +} + func loadDockerSandboxesSourceLock(projectRoot, platform string) (dockerSandboxesSourceLock, error) { var lock dockerSandboxesSourceLock path := filepath.Join(projectRoot, "templates", "docker-sandboxes", "sources.lock.json") diff --git a/internal/image/docker_sandboxes_test.go b/internal/image/docker_sandboxes_test.go index bb6a15e..18afb48 100644 --- a/internal/image/docker_sandboxes_test.go +++ b/internal/image/docker_sandboxes_test.go @@ -1,7 +1,9 @@ package image import ( + "crypto/sha256" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -10,6 +12,28 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" ) +func TestDockerSandboxesHelperChecksumsMatchGuestScripts(t *testing.T) { + templateRoot := filepath.Join("..", "..", "templates", "docker-sandboxes") + content, err := os.ReadFile(filepath.Join(templateRoot, "helpers.sha256")) + if err != nil { + t.Fatal(err) + } + for lineNumber, line := range strings.Split(strings.TrimSpace(string(content)), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 || len(fields[0]) != 64 || !strings.HasPrefix(fields[1], "./") { + t.Fatalf("helpers.sha256 line %d is malformed: %q", lineNumber+1, line) + } + guestPath := filepath.Join(templateRoot, "guest", strings.TrimPrefix(fields[1], "./")) + guestContent, err := os.ReadFile(guestPath) + if err != nil { + t.Fatalf("read helper on line %d: %v", lineNumber+1, err) + } + if got := fmt.Sprintf("%x", sha256.Sum256(guestContent)); got != fields[0] { + t.Fatalf("helpers.sha256 line %d digest = %s, want %s for %s", lineNumber+1, fields[0], got, guestPath) + } + } +} + func TestNormalizeCatthehackerSourceProfilesAndCustomTag(t *testing.T) { for _, test := range []struct { input string @@ -49,6 +73,9 @@ func TestDockerSandboxesDockerfileUsesVerifiedLocalDownloadsAndInstallsTrustBefo "COPY inputs/actions-runner.tar.gz /tmp/actions-runner.tar.gz", `echo "${TINI_SHA256#sha256:} /usr/local/bin/tini" | sha256sum --check -`, `echo "${ACTIONS_RUNNER_SHA256#sha256:} /tmp/actions-runner.tar.gz" | sha256sum --check -`, + "RUNNER_TOOL_CACHE=/opt/actions-runner/_work/_tool", + "AGENT_TOOLSDIRECTORY=/opt/actions-runner/_work/_tool", + "DOTNET_INSTALL_DIR=/opt/actions-runner/_work/_tool/dotnet", } { if !strings.Contains(text, want) { t.Fatalf("Docker Sandboxes Dockerfile omitted %q", want) @@ -59,6 +86,20 @@ func TestDockerSandboxesDockerfileUsesVerifiedLocalDownloadsAndInstallsTrustBefo if trustInstall < 0 || customInstall < 0 || trustInstall >= customInstall { t.Fatalf("runner trust must be installed before custom scripts:\n%s", text) } + runnerScript, err := os.ReadFile(filepath.Join("..", "..", "templates", "docker-sandboxes", "guest", "run-runner.sh")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `tool_cache="${EPAR_RUNNER_TOOL_CACHE:-${runner_dir}/_work/_tool}"`, + `"RUNNER_TOOL_CACHE=${tool_cache}"`, + `"AGENT_TOOLSDIRECTORY=${tool_cache}"`, + `"DOTNET_INSTALL_DIR=${tool_cache}/dotnet"`, + } { + if !strings.Contains(string(runnerScript), want) { + t.Fatalf("Docker Sandboxes runner script omitted %q", want) + } + } } func TestDockerSandboxesDisabledTrustPolicyIsExplicit(t *testing.T) { @@ -87,50 +128,44 @@ func TestDockerSandboxesDisabledTrustPolicyIsExplicit(t *testing.T) { } } -func TestSelectBuildxSBOMAttachmentUsesExactAttestationChain(t *testing.T) { - attestationDigest := "sha256:" + strings.Repeat("a", 64) - sbomDigest := "sha256:" + strings.Repeat("b", 64) - index := []byte(`{ - "manifests": [ - {"digest": "sha256:` + strings.Repeat("c", 64) + `", "annotations": {}}, - {"digest": "` + attestationDigest + `", "annotations": {"vnd.docker.reference.type": "attestation-manifest"}} - ] - }`) - gotAttestation, err := selectBuildxAttestationDigest(index) - if err != nil { +func TestReadVerifiedBuildEvidenceRequiresStableBoundedRegularFile(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "evidence.json") + if err := os.WriteFile(path, []byte(`{"ok":true}`), 0o600); err != nil { t.Fatal(err) } - if gotAttestation != attestationDigest { - t.Fatalf("attestation digest = %q, want %q", gotAttestation, attestationDigest) - } - manifest := []byte(`{ - "layers": [ - {"mediaType": "application/vnd.in-toto+json", "digest": "` + sbomDigest + `", "size": 216579021, "annotations": {"in-toto.io/predicate-type": "https://spdx.dev/Document"}}, - {"mediaType": "application/vnd.in-toto+json", "digest": "sha256:` + strings.Repeat("d", 64) + `", "size": 123, "annotations": {"in-toto.io/predicate-type": "https://slsa.dev/provenance/v1"}} - ] - }`) - gotSBOM, gotSize, err := selectBuildxSBOMDigest(manifest) + content, err := readVerifiedBuildEvidence(path, 64) if err != nil { t.Fatal(err) } - if gotSBOM != sbomDigest || gotSize != 216579021 { - t.Fatalf("SBOM attachment = %q/%d, want %q/%d", gotSBOM, gotSize, sbomDigest, 216579021) + if string(content) != `{"ok":true}` { + t.Fatalf("evidence = %q", content) + } + if _, err := readVerifiedBuildEvidence(path, 4); err == nil { + t.Fatal("oversized evidence was accepted") + } + link := filepath.Join(root, "evidence-link.json") + if err := os.Symlink(path, link); err == nil { + if _, err := readVerifiedBuildEvidence(link, 64); err == nil { + t.Fatal("symlinked evidence was accepted") + } } } -func TestDockerContentBlobCandidatesAreExactAndContentAddressed(t *testing.T) { - digest := "sha256:" + strings.Repeat("a", 64) - candidates := dockerContentBlobCandidates(digest) - if len(candidates) != 2 { - t.Fatalf("candidate count = %d, want 2", len(candidates)) - } - for _, candidate := range candidates { - if !strings.HasPrefix(candidate, "/var/lib/") || !strings.HasSuffix(candidate, "/blobs/sha256/"+strings.Repeat("a", 64)) { - t.Fatalf("unsafe Docker content candidate %q", candidate) - } +func TestProvenanceValidatorsRequireMaxBuildxAndInTotoSLSAContracts(t *testing.T) { + buildx := []byte(`{"buildType":"https://mobyproject.org/buildkit@v1","materials":[{"uri":"pkg:docker/example"}],"invocation":{"parameters":{"frontend":"gateway.v0"}}}`) + if err := validateBuildxMaxProvenance(buildx); err != nil { + t.Fatal(err) + } + if err := validateBuildxMaxProvenance([]byte(`{"buildType":"buildkit","materials":[],"invocation":{}}`)); err == nil { + t.Fatal("incomplete Buildx provenance was accepted") } - if candidates[0] == candidates[1] { - t.Fatal("Docker Desktop and native Engine content candidates must differ") + statement := []byte(`{"_type":"https://in-toto.io/Statement/v1","predicateType":"https://slsa.dev/provenance/v1","subject":[{"name":"software-inventory.txt"}],"predicate":{"buildDefinition":{}}}`) + if err := validateInTotoProvenance(statement); err != nil { + t.Fatal(err) + } + if err := validateInTotoProvenance([]byte(`{"_type":"https://in-toto.io/Statement/v1","predicateType":"unknown","subject":[],"predicate":{}}`)); err == nil { + t.Fatal("invalid in-toto provenance was accepted") } } @@ -246,11 +281,16 @@ func TestVerifiedDockerSandboxesBuildArtifactAcceptsOnlyCompleteExactEvidence(t ImmutableReference: "ghcr.io/catthehacker/ubuntu@sha256:" + strings.Repeat("b", 64), IndexDigest: "sha256:" + strings.Repeat("b", 64), PlatformDigest: "sha256:" + strings.Repeat("c", 64), - Platform: "linux/arm64", + Platform: "linux/amd64", CompressedLayerBytes: 123, } + fixturePath, templateDigest, _ := writeDockerArchiveFixture(t, false, false) archivePath := filepath.Join(root, "runner-template.tar") - if err := os.WriteFile(archivePath, []byte("verified archive"), 0o600); err != nil { + fixtureContent, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(archivePath, fixtureContent, 0o600); err != nil { t.Fatal(err) } archiveSHA, archiveBytes, err := hashFile(archivePath) @@ -265,10 +305,9 @@ func TestVerifiedDockerSandboxesBuildArtifactAcceptsOnlyCompleteExactEvidence(t Source: source, Artifacts: make(map[string]artifactEvidence), } - templateDigest := "sha256:" + strings.Repeat("d", 64) - metadata.Template.Tag = "docker.io/library/epar-docker-sandboxes-catthehacker-full-latest:test-arm64" + metadata.Template.Tag = "docker.io/library/epar-template:test-amd64" metadata.Template.Digest = templateDigest - metadata.Template.CacheID = strings.Repeat("d", 12) + metadata.Template.CacheID = strings.TrimPrefix(templateDigest, "sha256:")[:12] metadata.Template.RootDisk = "90GiB" metadata.Template.Archive = filepath.Base(archivePath) metadata.Template.ArchiveSHA256 = archiveSHA @@ -277,10 +316,19 @@ func TestVerifiedDockerSandboxesBuildArtifactAcceptsOnlyCompleteExactEvidence(t metadata.Compatibility.RunnerExecution = "direct-actions-listener" metadata.Compatibility.DockerDaemonOwner = "docker-sandboxes-runtime" metadata.Compatibility.ExpectedDockerDaemonCount = 1 - for _, name := range []string{"buildMetadata", "provenance", "sbom", "softwareInventory", "compatibility"} { + if err := writeJSONFile(filepath.Join(root, "buildMetadata.json"), dockerSandboxesBuildMetadata{ + ImageDigest: templateDigest, + Provenance: json.RawMessage(`{}`), + BuildRef: strings.Repeat("b", 12), + }); err != nil { + t.Fatal(err) + } + for _, name := range []string{"buildMetadata", "attestationMetadata", "provenance", "sbom", "softwareInventory", "compatibility"} { path := filepath.Join(root, name+".json") - if err := os.WriteFile(path, []byte(name), 0o600); err != nil { - t.Fatal(err) + if name != "buildMetadata" { + if err := os.WriteFile(path, []byte(name), 0o600); err != nil { + t.Fatal(err) + } } digest, _, err := hashFile(path) if err != nil { @@ -296,7 +344,7 @@ func TestVerifiedDockerSandboxesBuildArtifactAcceptsOnlyCompleteExactEvidence(t if err != nil { t.Fatal(err) } - if !valid || artifact.Digest != templateDigest || artifact.Platform != "linux/arm64" || artifact.RootDisk != "90GiB" { + if !valid || artifact.Digest != templateDigest || artifact.Platform != "linux/amd64" || artifact.RootDisk != "90GiB" { t.Fatalf("verified artifact = %+v, valid=%t", artifact, valid) } @@ -311,3 +359,37 @@ func TestVerifiedDockerSandboxesBuildArtifactAcceptsOnlyCompleteExactEvidence(t t.Fatal("corrupted evidence was accepted for interrupted-build resume") } } + +func TestDockerSandboxesBuildUsesDirectArchiveAndInventoryTargets(t *testing.T) { + sourcePath := filepath.Join("docker_sandboxes.go") + content, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatal(err) + } + text := string(content) + for _, required := range []string{ + `"--target", "runner-template", "--output", "type=docker,dest=" + partialArchivePath`, + `"--provenance=false", "--sbom=false"`, + `"--target", "software-inventory-export", "--output", "type=local,dest=" + evidenceExportRoot`, + `"--provenance", "mode=max", "--sbom", "generator=" + platformLock.SBOMGeneratorReference`, + `"-attestation.docker-build.log"`, + } { + if !strings.Contains(text, required) { + t.Fatalf("Docker Sandboxes build path omitted %q", required) + } + } + for _, forbidden := range []string{`"--load"`, `"image", "save"`, `"image", "load"`, `"image", "inspect"`, `"type=image,push=false"`} { + if strings.Contains(text, forbidden) { + t.Fatalf("Docker Sandboxes build path retained forbidden Docker staging operation %q", forbidden) + } + } + dockerfile, err := os.ReadFile(filepath.Join("..", "..", "templates", "docker-sandboxes", "Dockerfile")) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"AS runner-template", "ARG BUILDKIT_SBOM_SCAN_STAGE=true", "AS software-inventory-export"} { + if !strings.Contains(string(dockerfile), required) { + t.Fatalf("Dockerfile omitted %q", required) + } + } +} diff --git a/internal/image/operation_progress.go b/internal/image/operation_progress.go new file mode 100644 index 0000000..817ab2d --- /dev/null +++ b/internal/image/operation_progress.go @@ -0,0 +1,70 @@ +package image + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/storage" +) + +var imageOperationHeartbeatInterval = 5 * time.Second + +// runProgressOperation keeps long, otherwise-silent image and storage work +// visible without mixing command output into the manager console. +func (m *Coordinator) runProgressOperation(label string, detail func() string, operation func() error) error { + return runProgressOperation(label, imageOperationHeartbeatInterval, m.infof, detail, operation) +} + +func runProgressOperation(label string, interval time.Duration, logf func(string, ...any), detail func() string, operation func() error) (err error) { + started := time.Now() + logf("%s started\n", label) + + done := make(chan struct{}) + var heartbeat sync.WaitGroup + if interval > 0 { + heartbeat.Add(1) + go func() { + defer heartbeat.Done() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + suffix := "" + if detail != nil { + if current := detail(); current != "" { + suffix = "; " + current + } + } + logf("%s: still working; elapsed %s%s\n", label, time.Since(started).Round(time.Second), suffix) + case <-done: + return + } + } + }() + } + + finished := false + defer func() { + close(done) + heartbeat.Wait() + if finished && err == nil { + logf("%s complete; elapsed %s\n", label, time.Since(started).Round(time.Second)) + } + }() + err = operation() + finished = true + return err +} + +func regularFileSizeDetail(path, description string) func() string { + return func() string { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() < 0 { + return "" + } + return fmt.Sprintf("%s %s", description, storage.FormatBytes(uint64(info.Size()))) + } +} diff --git a/internal/image/operation_progress_test.go b/internal/image/operation_progress_test.go new file mode 100644 index 0000000..9ff423d --- /dev/null +++ b/internal/image/operation_progress_test.go @@ -0,0 +1,89 @@ +package image + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestRunProgressOperationReportsHeartbeatAndCompletion(t *testing.T) { + var mu sync.Mutex + var messages []string + heartbeatObserved := make(chan struct{}) + var heartbeatOnce sync.Once + logf := func(format string, args ...any) { + message := fmt.Sprintf(format, args...) + mu.Lock() + messages = append(messages, message) + mu.Unlock() + if strings.Contains(message, "still working") { + heartbeatOnce.Do(func() { close(heartbeatObserved) }) + } + } + + if err := runProgressOperation("Template archive export", 5*time.Millisecond, logf, func() string { + return "archive written 12.00 GiB" + }, func() error { + <-heartbeatObserved + return nil + }); err != nil { + t.Fatal(err) + } + + mu.Lock() + output := strings.Join(messages, "") + mu.Unlock() + for _, wanted := range []string{ + "Template archive export started", + "Template archive export: still working; elapsed", + "archive written 12.00 GiB", + "Template archive export complete; elapsed", + } { + if !strings.Contains(output, wanted) { + t.Fatalf("progress output omitted %q:\n%s", wanted, output) + } + } +} + +func TestRunProgressOperationDoesNotReportFailedOperationAsComplete(t *testing.T) { + var mu sync.Mutex + var output strings.Builder + logf := func(format string, args ...any) { + mu.Lock() + defer mu.Unlock() + fmt.Fprintf(&output, format, args...) + } + expected := errors.New("failed") + err := runProgressOperation("Template import", 0, logf, nil, func() error { + return expected + }) + if !errors.Is(err, expected) { + t.Fatalf("error = %v, want %v", err, expected) + } + mu.Lock() + defer mu.Unlock() + if strings.Contains(output.String(), "Template import complete") { + t.Fatalf("failed operation was reported complete:\n%s", output.String()) + } +} + +func TestImageOperationHeartbeatDefaultIsFiveSeconds(t *testing.T) { + if got, want := imageOperationHeartbeatInterval, 5*time.Second; got != want { + t.Fatalf("image-operation progress heartbeat = %s, want %s", got, want) + } +} + +func TestRegularFileSizeDetailUsesHumanReadableSize(t *testing.T) { + path := filepath.Join(t.TempDir(), "archive.tar") + if err := os.WriteFile(path, make([]byte, 1536), 0o600); err != nil { + t.Fatal(err) + } + if got, want := regularFileSizeDetail(path, "archive written")(), "archive written 1.50 KiB"; got != want { + t.Fatalf("detail = %q, want %q", got, want) + } +} diff --git a/internal/image/storage_catalog.go b/internal/image/storage_catalog.go new file mode 100644 index 0000000..c43fc89 --- /dev/null +++ b/internal/image/storage_catalog.go @@ -0,0 +1,1207 @@ +package image + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + sandboxcapacity "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/capacity" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" +) + +var buildxUsageSizePattern = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)([kMGTPE]?i?B)$`) + +const ( + catalogDockerImageKind = "docker-image" + catalogSandboxTemplateKind = "sandbox-template" + catalogWSLArtifactKind = "provider-image" + catalogTartImageKind = "tart-image" + catalogTemplateStagingKind = "template-staging-directory" +) + +func (m *Coordinator) effectiveConfigPath() string { + if strings.TrimSpace(m.ConfigPath) != "" { + return m.ConfigPath + } + return filepath.Join(m.ProjectRoot, ".local", "config.yml") +} + +func (m *Coordinator) hostCatalog() (*storagecatalog.Store, error) { + return storagecatalog.Open("") +} + +func (m *Coordinator) catalogInstallationID(now time.Time) (string, error) { + store, err := m.hostCatalog() + if err != nil { + return "", err + } + var installationID string + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + record, err := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if err == nil { + installationID = record.InstallationID + err = m.applyCatalogConfigSettings(value, record.ID) + } + return err + }) + if err != nil { + return "", err + } + if installationID == "" { + return "", errors.New("registered EPAR installation identity disappeared") + } + return installationID, nil +} + +func (m *Coordinator) applyCatalogConfigSettings(value *storagecatalog.Catalog, configID string) error { + configured := strings.TrimSpace(m.Config.Storage.BuildCacheLimit) + if configured == "" { + configured = "20GiB" + } + limit, err := config.ParseByteSize(configured) + if err != nil { + return err + } + for index := range value.Configs { + if value.Configs[index].ID == configID { + value.Configs[index].BuildCacheLimitBytes = uint64(limit) + return nil + } + } + return fmt.Errorf("registered catalog configuration %s disappeared", configID) +} + +func (m *Coordinator) dockerBackendID(ctx context.Context) (string, error) { + id, err := m.runHostOutput(ctx, "docker", "info", "--format", "{{.ID}}") + if err != nil { + return "", err + } + id = strings.TrimSpace(id) + if id == "" { + return "", errors.New("Docker Engine returned an empty daemon identity") + } + return "docker:" + id, nil +} + +func (m *Coordinator) acquireDockerBackendLock(ctx context.Context) (string, func(), error) { + backendID, err := m.dockerBackendID(ctx) + if err != nil { + return "", nil, err + } + store, err := m.hostCatalog() + if err != nil { + return "", nil, err + } + lock, err := store.AcquireBackendLock(ctx, backendID) + if err != nil { + return "", nil, err + } + return backendID, func() { + if closeErr := lock.Close(); closeErr != nil { + m.warnf("EPAR Docker backend lock release warning: %v\n", closeErr) + } + }, nil +} + +func backendPathID(kind, path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + canonical := filepath.Clean(absolute) + if runtime.GOOS == "windows" { + canonical = strings.ToLower(canonical) + } + sum := sha256.Sum256([]byte(canonical)) + return fmt.Sprintf("%s:%x", kind, sum[:12]), nil +} + +func sandboxBackendID() (string, error) { + root, err := sandboxcapacity.DockerSandboxesStorageRoot() + if err != nil { + return "", err + } + return backendPathID("sandbox", root) +} + +func (m *Coordinator) withSandboxBackendLock(ctx context.Context, operation func() error) error { + backendID, err := sandboxBackendID() + if err != nil { + return err + } + store, err := m.hostCatalog() + if err != nil { + return err + } + lock, err := store.AcquireBackendLock(ctx, backendID) + if err != nil { + return err + } + defer func() { + if closeErr := lock.Close(); closeErr != nil { + m.warnf("EPAR Docker Sandboxes backend lock release warning: %v\n", closeErr) + } + }() + return operation() +} + +func tartBackendID() (string, error) { + root := strings.TrimSpace(os.Getenv("TART_HOME")) + if root == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + root = filepath.Join(home, ".tart") + } + return backendPathID("tart", root) +} + +func (m *Coordinator) withTartBackendLock(ctx context.Context, operation func() error) error { + backendID, err := tartBackendID() + if err != nil { + return err + } + store, err := m.hostCatalog() + if err != nil { + return err + } + lock, err := store.AcquireBackendLock(ctx, backendID) + if err != nil { + return err + } + defer func() { + if closeErr := lock.Close(); closeErr != nil { + m.warnf("EPAR Tart backend lock release warning: %v\n", closeErr) + } + }() + return operation() +} + +func (m *Coordinator) recordCurrentArtifact(ctx context.Context, manifestHash string) error { + if m.DryRun { + return nil + } + now := time.Now().UTC() + var resource storagecatalog.Resource + switch m.Config.Provider.Type { + case "docker-container": + reference := strings.TrimSpace(m.Config.Image.OutputImage) + identity, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", reference) + if err != nil { + return fmt.Errorf("read current Docker Container image identity: %w", err) + } + backendID, err := m.dockerBackendID(ctx) + if err != nil { + return err + } + resource = storagecatalog.Resource{ + BackendID: backendID, Kind: catalogDockerImageKind, Provider: "docker-container", Role: "runtime-image", + Locator: reference, Identity: strings.TrimSpace(identity), Custody: storagecatalog.CustodyGenerated, + ManifestHash: manifestHash, IntroducedTags: []string{reference}, State: storagecatalog.StateCurrent, + CreatedAt: now, LastSeenAt: now, + } + case "wsl": + path := filepath.Clean(configPath(m.ProjectRoot, m.Config.Image.OutputImage)) + target, err := storage.SnapshotFilesystemTarget(path) + if err != nil { + return fmt.Errorf("read current WSL artifact identity: %w", err) + } + resource = storagecatalog.Resource{ + BackendID: "filesystem:" + filepath.VolumeName(target.Locator), Kind: catalogWSLArtifactKind, Provider: "wsl", Role: "runtime-rootfs", + Locator: target.Locator, Identity: target.Identity, Fingerprint: target.Fingerprint, Custody: storagecatalog.CustodyGenerated, + ManifestHash: manifestHash, State: storagecatalog.StateCurrent, CreatedAt: now, LastSeenAt: now, + } + case "tart": + output := strings.TrimSpace(m.Config.Image.OutputImage) + items, err := m.Lifecycle.Inventory(ctx) + if err != nil { + return fmt.Errorf("read current Tart image identity: %w", err) + } + var exact provider.Instance + for _, item := range items { + if item.Instance.Name == output { + exact = item.Instance + break + } + } + if exact.ProviderID == "" { + return fmt.Errorf("current Tart image %q has no immutable provider identity", output) + } + backendID, err := tartBackendID() + if err != nil { + return err + } + resource = storagecatalog.Resource{ + BackendID: backendID, Kind: catalogTartImageKind, Provider: "tart", Role: "runtime-image", + Locator: output, Identity: exact.ProviderID, Custody: storagecatalog.CustodyGenerated, + ManifestHash: manifestHash, State: storagecatalog.StateCurrent, CreatedAt: now, LastSeenAt: now, + } + default: + return nil + } + if err := m.registerCurrentCatalogResource(ctx, resource, manifestHash, now); err != nil { + return err + } + return m.releaseCatalogRole("build-source", now) +} + +// recordTartStagingImage records an exact temporary Tart image while the +// caller holds the Tart backend lock. Its per-config staging reference protects +// rollback evidence until startup reconciliation has restored or confirmed the +// configured output image. +func (m *Coordinator) recordTartStagingImage(ctx context.Context, name, role string) error { + items, err := m.Provider.List(ctx) + if err != nil { + return err + } + exact, found := findTartImage(items, name) + if !found || exact.ProviderID == "" { + return fmt.Errorf("Tart image %q has no exact immutable identity", name) + } + backendID, err := tartBackendID() + if err != nil { + return err + } + store, err := m.hostCatalog() + if err != nil { + return err + } + now := time.Now().UTC() + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, err := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if err != nil { + return err + } + if err := m.applyCatalogConfigSettings(value, configRecord.ID); err != nil { + return err + } + resource := storagecatalog.Resource{ + BackendID: backendID, InstallationIDs: []string{configRecord.InstallationID}, Kind: catalogTartImageKind, + Provider: "tart", Role: role, Locator: name, Identity: exact.ProviderID, + Custody: storagecatalog.CustodyGenerated, State: storagecatalog.StateStaging, + CreatedAt: now, LastSeenAt: now, + References: []storagecatalog.Reference{{ + ConfigID: configRecord.ID, Role: "tart-staging", UpdatedAt: now, + }}, + } + return storagecatalog.UpsertResource(value, resource) + }) + return err +} + +func configPath(projectRoot, path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(projectRoot, path) +} + +func (m *Coordinator) recordCurrentSandboxArtifact(ctx context.Context, artifact provider.TemplateArtifact, manifestHash string, now time.Time) error { + backendID, err := sandboxBackendID() + if err != nil { + return err + } + resource := storagecatalog.Resource{ + BackendID: backendID, Kind: catalogSandboxTemplateKind, Provider: "docker-sandboxes", Role: "runtime-template", + Locator: artifact.Reference, Identity: artifact.CacheID, Fingerprint: artifact.Digest, + Custody: storagecatalog.CustodyGenerated, ManifestHash: manifestHash, State: storagecatalog.StateCurrent, + CreatedAt: now, LastSeenAt: now, + } + if err := m.registerCurrentCatalogResource(ctx, resource, manifestHash, now); err != nil { + return err + } + return m.releaseCatalogRole("build-source", now) +} + +func (m *Coordinator) recordSandboxWorkspace(ctx context.Context, workspacePath, manifestHash string, state storagecatalog.State, now time.Time) error { + if state != storagecatalog.StateStaging && state != storagecatalog.StateSuperseded { + return fmt.Errorf("unsupported Docker Sandboxes workspace state %q", state) + } + stagingDirectory, err := storage.SnapshotFilesystemTarget(workspacePath) + if err != nil { + return fmt.Errorf("read Docker Sandboxes staging directory identity: %w", err) + } + resource := storagecatalog.Resource{ + BackendID: "filesystem:" + filepath.VolumeName(stagingDirectory.Locator), Kind: catalogTemplateStagingKind, Provider: "docker-sandboxes", Role: "template-archive-workspace", + Locator: stagingDirectory.Locator, Identity: stagingDirectory.Identity, Fingerprint: stagingDirectory.Fingerprint, Custody: storagecatalog.CustodyGenerated, + ManifestHash: manifestHash, State: state, CreatedAt: now, LastSeenAt: now, + } + if state == storagecatalog.StateSuperseded { + supersededAt := now.UTC() + resource.SupersededAt = &supersededAt + } + store, err := m.hostCatalog() + if err != nil { + return err + } + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, err := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if err != nil { + return err + } + if err := m.applyCatalogConfigSettings(value, configRecord.ID); err != nil { + return err + } + resource.InstallationIDs = unionStrings(resource.InstallationIDs, []string{configRecord.InstallationID}) + resource.Key = storagecatalog.ResourceKey(resource.BackendID, resource.Kind, resource.Identity) + for index := range value.Resources { + candidate := &value.Resources[index] + filtered := candidate.References[:0] + for _, reference := range candidate.References { + if reference.ConfigID == configRecord.ID && reference.Role == "template-staging" { + continue + } + filtered = append(filtered, reference) + } + candidate.References = filtered + if candidate.Key != resource.Key && len(candidate.References) == 0 && candidate.State == storagecatalog.StateStaging { + supersededAt := now.UTC() + candidate.State = storagecatalog.StateSuperseded + candidate.SupersededAt = &supersededAt + } + if candidate.Key == resource.Key { + resource.References = append(resource.References, candidate.References...) + } + } + if state == storagecatalog.StateStaging { + resource.References = append(resource.References, storagecatalog.Reference{ + ConfigID: configRecord.ID, ManifestHash: manifestHash, Role: "template-staging", UpdatedAt: now.UTC(), + }) + resource.SupersededAt = nil + } else if len(resource.References) != 0 { + resource.State = storagecatalog.StateStaging + resource.SupersededAt = nil + } + if err := storagecatalog.UpsertResource(value, resource); err != nil { + return err + } + return nil + }) + return err +} + +func (m *Coordinator) recordDockerSourceAcquisition(ctx context.Context, reference, previousID, currentID string, now time.Time) error { + return m.recordDockerRoleAcquisition(ctx, "build-source", reference, previousID, currentID, now) +} + +func (m *Coordinator) recordDockerRoleAcquisition(ctx context.Context, role, reference, previousID, currentID string, now time.Time) error { + if m.DryRun || currentID == "" { + return nil + } + backendID, err := m.dockerBackendID(ctx) + if err != nil { + return err + } + tag := reference + if strings.LastIndex(tag, ":") <= strings.LastIndex(tag, "/") { + tag += ":latest" + } + resource := storagecatalog.Resource{ + BackendID: backendID, Kind: catalogDockerImageKind, Role: role, Locator: tag, Identity: currentID, + Custody: storagecatalog.CustodyAcquired, State: storagecatalog.StateStaging, + CreatedAt: now, LastSeenAt: now, + } + if previousID == "" { + resource.IntroducedTags = []string{tag} + } + store, err := m.hostCatalog() + if err != nil { + return err + } + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, err := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if err != nil { + return err + } + if err := m.applyCatalogConfigSettings(value, configRecord.ID); err != nil { + return err + } + resource.Key = storagecatalog.ResourceKey(resource.BackendID, resource.Kind, resource.Identity) + if currentID == previousID { + found := false + for _, existing := range value.Resources { + if existing.Key == resource.Key && existing.Custody == storagecatalog.CustodyAcquired { + resource = existing + resource.Role = role + resource.LastSeenAt = now + found = true + break + } + } + if !found { + completeDockerAcquisitionJournal(value, dockerAcquisitionJournalID(configRecord.ID, backendID, role, tag), now) + return nil + } + } + resource.InstallationIDs = unionStrings(resource.InstallationIDs, []string{configRecord.InstallationID}) + if err := storagecatalog.UpsertResource(value, resource); err != nil { + return err + } + storagecatalog.ReplaceConfigRoleReferences(value, configRecord.ID, role, map[string]storagecatalog.Reference{ + resource.Key: {}, + }, now) + completeDockerAcquisitionJournal(value, dockerAcquisitionJournalID(configRecord.ID, backendID, role, tag), now) + return nil + }) + return err +} + +func (m *Coordinator) beginDockerRoleAcquisition(backendID, role, reference, previousID string, now time.Time) error { + tag := reference + if strings.LastIndex(tag, ":") <= strings.LastIndex(tag, "/") { + tag += ":latest" + } + store, err := m.hostCatalog() + if err != nil { + return err + } + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, registerErr := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if registerErr != nil { + return registerErr + } + if settingsErr := m.applyCatalogConfigSettings(value, configRecord.ID); settingsErr != nil { + return settingsErr + } + id := dockerAcquisitionJournalID(configRecord.ID, backendID, role, tag) + for index := range value.Journals { + if value.Journals[index].ID == id { + value.Journals[index].Phase = "acquiring" + value.Journals[index].PreviousIdentity = previousID + value.Journals[index].UpdatedAt = now + value.Journals[index].Error = "" + return nil + } + } + value.Journals = append(value.Journals, storagecatalog.Journal{ + ID: id, Operation: "docker-image-acquisition", BackendID: backendID, ConfigID: configRecord.ID, + Role: role, Locator: tag, PreviousIdentity: previousID, Phase: "acquiring", StartedAt: now, UpdatedAt: now, + }) + return nil + }) + return err +} + +func dockerAcquisitionJournalID(configID, backendID, role, locator string) string { + sum := sha256.Sum256([]byte(configID + "\x00" + backendID + "\x00" + role + "\x00" + locator)) + return fmt.Sprintf("acquire-%x", sum[:12]) +} + +func completeDockerAcquisitionJournal(value *storagecatalog.Catalog, id string, now time.Time) { + for index := range value.Journals { + if value.Journals[index].ID == id { + value.Journals[index].Phase = "complete" + value.Journals[index].UpdatedAt = now + value.Journals[index].Error = "" + return + } + } +} + +func (m *Coordinator) releaseCatalogRole(role string, now time.Time) error { + store, err := m.hostCatalog() + if err != nil { + return err + } + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, err := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if err != nil { + return err + } + if err := m.applyCatalogConfigSettings(value, configRecord.ID); err != nil { + return err + } + storagecatalog.ReplaceConfigRoleReferences(value, configRecord.ID, role, nil, now) + return nil + }) + return err +} + +func (m *Coordinator) registerCurrentCatalogResource(ctx context.Context, resource storagecatalog.Resource, manifestHash string, now time.Time) error { + store, err := m.hostCatalog() + if err != nil { + return err + } + backendLock, err := store.AcquireBackendLock(ctx, resource.BackendID) + if err != nil { + return err + } + defer backendLock.Close() + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, err := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if err != nil { + return err + } + if err := m.applyCatalogConfigSettings(value, configRecord.ID); err != nil { + return err + } + resource.Key = storagecatalog.ResourceKey(resource.BackendID, resource.Kind, resource.Identity) + var existingReferences []storagecatalog.Reference + for _, existing := range value.Resources { + if existing.Key == resource.Key { + existingReferences = append(existingReferences, existing.References...) + if resource.CreatedAt.IsZero() { + resource.CreatedAt = existing.CreatedAt + } + resource.IntroducedTags = unionStrings(existing.IntroducedTags, resource.IntroducedTags) + resource.InstallationIDs = unionStrings(existing.InstallationIDs, resource.InstallationIDs) + break + } + } + resource.InstallationIDs = unionStrings(resource.InstallationIDs, []string{configRecord.InstallationID}) + resource.References = existingReferences + if err := storagecatalog.UpsertResource(value, resource); err != nil { + return err + } + storagecatalog.ReplaceConfigRoleReferences(value, configRecord.ID, "provider-artifact", map[string]storagecatalog.Reference{ + resource.Key: {ManifestHash: manifestHash}, + }, now) + return nil + }) + return err +} + +func unionStrings(left, right []string) []string { + set := make(map[string]struct{}, len(left)+len(right)) + for _, value := range append(append([]string(nil), left...), right...) { + if strings.TrimSpace(value) != "" { + set[value] = struct{}{} + } + } + out := make([]string, 0, len(set)) + for value := range set { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func (m *Coordinator) cleanupSupersededCatalog(ctx context.Context) error { + if m.DryRun || strings.EqualFold(m.Config.Storage.AutomaticHousekeeping, "disabled") { + return nil + } + if err := m.reconcileInterruptedDockerAcquisitions(ctx); err != nil { + m.warnf("EPAR interrupted Docker acquisition reconciliation deferred: %v\n", err) + } + if err := m.reconcileInterruptedTartArtifacts(ctx); err != nil { + return fmt.Errorf("reconcile interrupted Tart artifact activation: %w", err) + } + store, err := m.hostCatalog() + if err != nil { + return err + } + if err := m.enforceDedicatedBuildxCache(ctx); err != nil { + m.warnf("EPAR BuildKit cache housekeeping deferred: %v\n", err) + } + now := time.Now().UTC() + var reconcileWarnings []string + value, err := store.WithLock(now, func(value *storagecatalog.Catalog) error { + reconcileWarnings = storagecatalog.Compact(value, now, func(resource storagecatalog.Resource) (bool, error) { + return m.catalogResourceExists(ctx, resource) + }) + return nil + }) + if err != nil { + return err + } + for _, warning := range reconcileWarnings { + m.warnf("EPAR storage catalog reconciliation: %s\n", warning) + } + if m.Config.Storage.KeepPrevious > 0 { + m.infof("automatic artifact retirement is deferred because storage.keepPrevious=%d; use storage prune to preview the retention policy\n", m.Config.Storage.KeepPrevious) + return nil + } + for _, resource := range value.Resources { + if len(resource.References) != 0 || (resource.State != storagecatalog.StateSuperseded && resource.State != storagecatalog.StateCleanupPending) { + continue + } + backendLock, lockErr := store.AcquireBackendLock(ctx, resource.BackendID) + if lockErr != nil { + m.warnf("EPAR storage cleanup backend lock deferred for %s %s: %v\n", resource.Kind, resource.Identity, lockErr) + continue + } + startedAt := time.Now().UTC() + removeCandidate := resource + shouldRemove := false + if _, journalErr := store.WithLock(startedAt, func(current *storagecatalog.Catalog) error { + for _, candidate := range current.Resources { + if candidate.Key != resource.Key { + continue + } + if len(candidate.References) != 0 || (candidate.State != storagecatalog.StateSuperseded && candidate.State != storagecatalog.StateCleanupPending) { + return nil + } + removeCandidate = candidate + shouldRemove = true + upsertCleanupJournal(current, candidate, "remove-started", "", startedAt) + return nil + } + return nil + }); journalErr != nil { + _ = backendLock.Close() + return journalErr + } + if !shouldRemove { + if closeErr := backendLock.Close(); closeErr != nil { + m.warnf("EPAR storage cleanup backend lock release warning: %v\n", closeErr) + } + continue + } + cleanupLabel := fmt.Sprintf("EPAR superseded %s cleanup for %s", removeCandidate.Kind, removeCandidate.Identity) + removeErr := m.runProgressOperation(cleanupLabel, nil, func() error { + return m.removeCatalogResource(ctx, removeCandidate) + }) + _, updateErr := store.WithLock(time.Now().UTC(), func(current *storagecatalog.Catalog) error { + for index := range current.Resources { + if current.Resources[index].Key != removeCandidate.Key { + continue + } + if len(current.Resources[index].References) != 0 { + return nil + } + if removeErr == nil { + current.Resources = append(current.Resources[:index], current.Resources[index+1:]...) + upsertCleanupJournal(current, removeCandidate, "complete", "", time.Now().UTC()) + } else { + current.Resources[index].State = storagecatalog.StateCleanupPending + current.Resources[index].CleanupError = removeErr.Error() + upsertCleanupJournal(current, removeCandidate, "cleanup-pending", removeErr.Error(), time.Now().UTC()) + } + return nil + } + return nil + }) + closeErr := backendLock.Close() + if updateErr != nil { + return updateErr + } + if closeErr != nil { + m.warnf("EPAR storage cleanup backend lock release warning: %v\n", closeErr) + } + if removeErr != nil { + m.warnf("EPAR storage cleanup deferred for %s %s: %v\n", removeCandidate.Kind, removeCandidate.Identity, removeErr) + } + } + return nil +} + +func (m *Coordinator) reconcileInterruptedTartArtifacts(ctx context.Context) error { + if m.Config.Provider.Type != "tart" { + return nil + } + return m.withTartBackendLock(ctx, func() error { + store, err := m.hostCatalog() + if err != nil { + return err + } + now := time.Now().UTC() + value, err := store.Load(now) + if err != nil { + return err + } + configID, err := storagecatalog.ConfigID(m.ProjectRoot, m.effectiveConfigPath()) + if err != nil { + return err + } + var staging []storagecatalog.Resource + for _, resource := range value.Resources { + if resource.Kind != catalogTartImageKind || resource.State != storagecatalog.StateStaging { + continue + } + for _, reference := range resource.References { + if reference.ConfigID == configID && reference.Role == "tart-staging" { + staging = append(staging, resource) + break + } + } + } + if len(staging) == 0 { + return nil + } + instances, err := m.Provider.List(ctx) + if err != nil { + return err + } + outputName := strings.TrimSpace(m.Config.Image.OutputImage) + if _, outputExists := findTartImage(instances, outputName); !outputExists { + var rollback *storagecatalog.Resource + for index := range staging { + resource := &staging[index] + if resource.Role != "activation-rollback" { + continue + } + instance, exists := findTartImage(instances, resource.Locator) + if exists && instance.ProviderID == resource.Identity { + rollback = resource + break + } + } + if rollback == nil { + return fmt.Errorf("configured Tart output %q is missing and no exact rollback image is available", outputName) + } + if err := m.Provider.Clone(ctx, rollback.Locator, outputName); err != nil { + return fmt.Errorf("restore interrupted Tart activation from %q: %w", rollback.Locator, err) + } + if err := m.verifyTartImageIdentity(ctx, outputName); err != nil { + return err + } + if m.environment != nil { + m.warnf("restored Tart output image %s after an interrupted activation; the desired artifact will be reconciled next\n", outputName) + } + } + _, err = store.WithLock(time.Now().UTC(), func(current *storagecatalog.Catalog) error { + when := time.Now().UTC() + for index := range current.Resources { + resource := ¤t.Resources[index] + if resource.Kind != catalogTartImageKind || resource.State != storagecatalog.StateStaging { + continue + } + filtered := resource.References[:0] + removed := false + for _, reference := range resource.References { + if reference.ConfigID == configID && reference.Role == "tart-staging" { + removed = true + continue + } + filtered = append(filtered, reference) + } + resource.References = filtered + if removed && len(resource.References) == 0 { + resource.State = storagecatalog.StateSuperseded + resource.SupersededAt = &when + } + } + return nil + }) + return err + }) +} + +func (m *Coordinator) StorageCleanupPending() (bool, error) { + store, err := m.hostCatalog() + if err != nil { + return false, err + } + value, err := store.Load(time.Now().UTC()) + if err != nil { + return false, err + } + for _, resource := range value.Resources { + if resource.State == storagecatalog.StateCleanupPending { + return true, nil + } + } + for _, journal := range value.Journals { + if journal.Phase == "cleanup-pending" { + return true, nil + } + } + return false, nil +} + +func (m *Coordinator) reconcileInterruptedDockerAcquisitions(ctx context.Context) error { + store, err := m.hostCatalog() + if err != nil { + return err + } + value, err := store.Load(time.Now().UTC()) + if err != nil { + return err + } + for _, journal := range value.Journals { + if journal.Operation != "docker-image-acquisition" || journal.Phase == "complete" { + continue + } + lock, lockErr := store.AcquireBackendLock(ctx, journal.BackendID) + if lockErr != nil { + return lockErr + } + currentID, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", journal.Locator) + if inspectErr != nil && !dockerInspectMeansMissing(inspectErr) { + _ = lock.Close() + return inspectErr + } + currentID = strings.TrimSpace(currentID) + now := time.Now().UTC() + _, updateErr := store.WithLock(now, func(current *storagecatalog.Catalog) error { + if currentID != "" && currentID != journal.PreviousIdentity { + installationIDs := []string{} + for _, configRecord := range current.Configs { + if configRecord.ID == journal.ConfigID { + installationIDs = append(installationIDs, configRecord.InstallationID) + break + } + } + resource := storagecatalog.Resource{ + BackendID: journal.BackendID, InstallationIDs: installationIDs, Kind: catalogDockerImageKind, + Role: journal.Role, Locator: journal.Locator, Identity: currentID, Custody: storagecatalog.CustodyAcquired, + State: storagecatalog.StateSuperseded, CreatedAt: now, LastSeenAt: now, + } + if journal.PreviousIdentity == "" { + resource.IntroducedTags = []string{journal.Locator} + } + when := now.UTC() + resource.SupersededAt = &when + if upsertErr := storagecatalog.UpsertResource(current, resource); upsertErr != nil { + return upsertErr + } + } + completeDockerAcquisitionJournal(current, journal.ID, now) + return nil + }) + closeErr := lock.Close() + if updateErr != nil { + return updateErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} + +func upsertCleanupJournal(value *storagecatalog.Catalog, resource storagecatalog.Resource, phase, message string, now time.Time) { + id := "cleanup-" + resource.Key + for index := range value.Journals { + if value.Journals[index].ID == id { + value.Journals[index].Phase = phase + value.Journals[index].UpdatedAt = now + value.Journals[index].Error = message + return + } + } + value.Journals = append(value.Journals, storagecatalog.Journal{ + ID: id, Operation: "remove-exact-resource", ResourceKey: resource.Key, Phase: phase, + StartedAt: now, UpdatedAt: now, Error: message, + }) +} + +func (m *Coordinator) catalogResourceExists(ctx context.Context, resource storagecatalog.Resource) (bool, error) { + switch resource.Kind { + case catalogDockerImageKind: + identity, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", resource.Identity) + if err != nil { + if dockerInspectMeansMissing(err) { + return false, nil + } + return false, err + } + return strings.TrimSpace(identity) == resource.Identity, nil + case catalogSandboxTemplateKind: + observer, ok := m.Lifecycle.(provider.TemplateArtifactObserver) + if !ok { + return true, nil + } + return observer.ObserveTemplate(ctx, provider.TemplateArtifact{ + Reference: resource.Locator, + CacheID: resource.Identity, + Digest: resource.Fingerprint, + }) + case catalogWSLArtifactKind: + target, err := storage.SnapshotFilesystemTarget(resource.Locator) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return target.Identity == resource.Identity && target.Fingerprint == resource.Fingerprint, nil + case catalogTemplateStagingKind: + target, err := storage.SnapshotFilesystemTarget(resource.Locator) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return target.Identity == resource.Identity && target.Fingerprint == resource.Fingerprint, nil + case catalogTartImageKind: + if m.Config.Provider.Type != "tart" { + return true, nil + } + items, err := m.Lifecycle.Inventory(ctx) + if err != nil { + return false, err + } + for _, item := range items { + if item.Instance.Name == resource.Locator && item.Instance.ProviderID == resource.Identity { + return true, nil + } + } + return false, nil + default: + return true, nil + } +} + +func (m *Coordinator) enforceDedicatedBuildxCache(ctx context.Context) error { + metadata, err := LoadBuildxMetadata(m.ProjectRoot) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + limitBytes, err := m.effectiveProjectBuildCacheLimit() + if err != nil { + return err + } + if _, err := m.runHostOutput(ctx, "docker", "buildx", "inspect", metadata.Builder); err != nil { + return nil + } + usageOutput, err := m.runHostOutput(ctx, "docker", "buildx", "du", "--builder", metadata.Builder, "--format", "json") + if err != nil { + return err + } + usageBytes, err := parseBuildxUsageBytes([]byte(usageOutput)) + if err != nil { + return err + } + if usageBytes <= limitBytes { + return nil + } + return m.runHostQuiet(ctx, "docker", "buildx", "prune", "--builder", metadata.Builder, "--force", "--max-used-space", strconv.FormatUint(limitBytes, 10)+"B") +} + +func (m *Coordinator) effectiveProjectBuildCacheLimit() (uint64, error) { + configured := strings.TrimSpace(m.Config.Storage.BuildCacheLimit) + if configured == "" { + configured = "20GiB" + } + current, err := config.ParseByteSize(configured) + if err != nil { + return 0, err + } + limit := uint64(current) + store, err := m.hostCatalog() + if err != nil { + return 0, err + } + value, err := store.Load(time.Now().UTC()) + if err != nil { + return 0, err + } + root, err := filepath.Abs(m.ProjectRoot) + if err != nil { + return 0, err + } + for _, configRecord := range value.Configs { + sameRoot := filepath.Clean(configRecord.ProjectRoot) == filepath.Clean(root) + if runtime.GOOS == "windows" { + sameRoot = strings.EqualFold(filepath.Clean(configRecord.ProjectRoot), filepath.Clean(root)) + } + if sameRoot && configRecord.BuildCacheLimitBytes > 0 && configRecord.BuildCacheLimitBytes < limit { + limit = configRecord.BuildCacheLimitBytes + } + } + return limit, nil +} + +func parseBuildxUsageBytes(content []byte) (uint64, error) { + type record struct { + ID string `json:"ID"` + Size json.RawMessage `json:"Size"` + Total json.RawMessage `json:"Total"` + } + trimmed := strings.TrimSpace(string(content)) + if trimmed == "" { + return 0, nil + } + var records []record + if strings.HasPrefix(trimmed, "[") { + if err := json.Unmarshal([]byte(trimmed), &records); err != nil { + return 0, err + } + } else { + for _, line := range strings.Split(trimmed, "\n") { + var value record + if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &value); err != nil { + return 0, err + } + records = append(records, value) + } + } + parse := func(raw json.RawMessage) (uint64, bool) { + var numeric uint64 + if err := json.Unmarshal(raw, &numeric); err == nil { + return numeric, true + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + if parsed, parseErr := config.ParseByteSize(text); parseErr == nil && parsed >= 0 { + return uint64(parsed), true + } + if match := buildxUsageSizePattern.FindStringSubmatch(text); match != nil { + if parsed, ok := parseBuildxBytes(match[1], match[2]); ok && parsed >= 0 { + return uint64(parsed), true + } + } + } + return 0, false + } + var sum uint64 + for _, value := range records { + if total, ok := parse(value.Total); ok { + return total, nil + } + if value.ID == "" { + continue + } + size, ok := parse(value.Size) + if !ok || sum > ^uint64(0)-size { + return 0, errors.New("Buildx disk-usage output is incomplete or overflows") + } + sum += size + } + return sum, nil +} + +func (m *Coordinator) removeCatalogResource(ctx context.Context, resource storagecatalog.Resource) error { + switch resource.Kind { + case catalogDockerImageKind: + containers, err := m.runHostOutput(ctx, "docker", "ps", "-a", "--filter", "ancestor="+resource.Identity, "--format", "{{.ID}}") + if err != nil { + return err + } + if strings.TrimSpace(containers) != "" { + return errors.New("a Docker container still references the image") + } + tagsJSON, err := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{json .RepoTags}}", resource.Identity) + if err != nil { + if dockerInspectMeansMissing(err) { + return nil + } + return err + } + var tags []string + if err := json.Unmarshal([]byte(strings.TrimSpace(tagsJSON)), &tags); err != nil { + return err + } + introduced := make(map[string]bool, len(resource.IntroducedTags)) + for _, tag := range resource.IntroducedTags { + introduced[tag] = true + } + for _, tag := range resource.IntroducedTags { + tagID, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", tag) + if inspectErr == nil && strings.TrimSpace(tagID) == resource.Identity { + if err := m.runHostQuiet(ctx, "docker", "image", "rm", tag); err != nil { + return err + } + } + } + remainingTagsJSON, inspectErr := m.runHostOutput(ctx, "docker", "image", "inspect", "--format", "{{json .RepoTags}}", resource.Identity) + if inspectErr == nil { + var remaining []string + if err := json.Unmarshal([]byte(strings.TrimSpace(remainingTagsJSON)), &remaining); err != nil { + return err + } + for _, tag := range remaining { + if !introduced[tag] { + return nil + } + } + if len(remaining) != 0 { + return fmt.Errorf("EPAR-introduced Docker image tags remain after exact removal: %s", strings.Join(remaining, ", ")) + } + if err := m.runHostQuiet(ctx, "docker", "image", "rm", resource.Identity); err != nil { + return err + } + } + return nil + case catalogSandboxTemplateKind: + if resource.Locator == "docker.io/docker/sandbox-templates:shell-docker" || resource.Locator == "docker/sandbox-templates:shell-docker" { + return errors.New("the Docker Sandboxes shell-docker base template is protected") + } + cleaner, ok := m.Lifecycle.(provider.TemplateArtifactCleaner) + if !ok { + return errors.New("provider does not expose exact template cleanup") + } + return cleaner.RemoveTemplate(ctx, provider.TemplateArtifact{Reference: resource.Locator, CacheID: resource.Identity, Digest: resource.Fingerprint}) + case catalogWSLArtifactKind: + absolute, err := filepath.Abs(resource.Locator) + if err != nil { + return err + } + root, err := filepath.Abs(m.ProjectRoot) + if err != nil { + return err + } + relative, err := filepath.Rel(root, absolute) + if err != nil || relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("filesystem artifact belongs to another project and is deferred to its own controller") + } + target, err := storage.SnapshotFilesystemTarget(absolute) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if target.Identity != resource.Identity || target.Fingerprint != resource.Fingerprint || target.Kind != storage.TargetFile { + return errors.New("filesystem artifact identity changed") + } + return os.Remove(absolute) + case catalogTemplateStagingKind: + executor, err := storage.NewFilesystemExecutor(filepath.Join(m.ProjectRoot, "work", "template-builds", "docker-sandboxes")) + if err != nil { + return err + } + target := storage.Target{Kind: storage.TargetDirectory, Locator: resource.Locator, Identity: resource.Identity, Fingerprint: resource.Fingerprint, Match: storage.MatchExact} + observation, err := executor.ObserveExact(ctx, target) + if err != nil { + return err + } + if !observation.Exists { + return nil + } + if observation.Target != target { + return errors.New("template staging directory exact identity changed") + } + return executor.RemoveExact(ctx, storage.Removal{Target: target}) + case catalogTartImageKind: + if m.Config.Provider.Type != "tart" { + return errors.New("Tart image cleanup is deferred to a Tart controller on macOS") + } + items, err := m.Lifecycle.Inventory(ctx) + if err != nil { + return err + } + var exact *provider.Instance + for index := range items { + instance := items[index].Instance + if instance.Source == resource.Locator && instance.Name != resource.Locator { + return fmt.Errorf("Tart instance %q still references image %q", instance.Name, resource.Locator) + } + if instance.Name == resource.Locator { + if instance.ProviderID != resource.Identity { + return errors.New("Tart image identity changed") + } + copy := instance + exact = © + } + } + if exact == nil { + return nil + } + if strings.EqualFold(exact.State, "running") { + return errors.New("Tart image is running") + } + return m.Lifecycle.Delete(ctx, *exact) + default: + return fmt.Errorf("automatic cleanup is not implemented for catalog resource kind %q", resource.Kind) + } +} diff --git a/internal/image/storage_catalog_test.go b/internal/image/storage_catalog_test.go new file mode 100644 index 0000000..27eda6c --- /dev/null +++ b/internal/image/storage_catalog_test.go @@ -0,0 +1,202 @@ +package image + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" +) + +func TestBeginDockerAcquisitionPersistsPreexistingIdentityBeforePull(t *testing.T) { + stateRoot := filepath.Join(t.TempDir(), "host-state") + t.Setenv("EPAR_STATE_HOME", stateRoot) + projectRoot := t.TempDir() + configPath := filepath.Join(projectRoot, "config.yml") + if err := os.WriteFile(configPath, []byte("provider:\n type: docker-container\n"), 0o600); err != nil { + t.Fatal(err) + } + coordinator := Coordinator{Config: config.Default(), ProjectRoot: projectRoot, ConfigPath: configPath} + now := time.Date(2026, 7, 29, 1, 2, 3, 0, time.UTC) + if err := coordinator.beginDockerRoleAcquisition("docker:daemon", "build-source", "example/source:latest", "sha256:before", now); err != nil { + t.Fatal(err) + } + store, err := storagecatalog.Open("") + if err != nil { + t.Fatal(err) + } + value, err := store.Load(now) + if err != nil { + t.Fatal(err) + } + if len(value.Journals) != 1 { + t.Fatalf("journals = %#v, want one acquisition journal", value.Journals) + } + journal := value.Journals[0] + if journal.Operation != "docker-image-acquisition" || journal.Phase != "acquiring" || journal.BackendID != "docker:daemon" || journal.Locator != "example/source:latest" || journal.PreviousIdentity != "sha256:before" { + t.Fatalf("acquisition journal omitted pre-pull evidence: %#v", journal) + } + if len(value.Configs) != 1 || value.Configs[0].InstallationID == "" { + t.Fatalf("acquisition journal did not register its exact EPAR installation: %#v", value.Configs) + } +} + +func TestNonTartControllerPreservesTartCatalogEvidence(t *testing.T) { + coordinator := Coordinator{Config: config.Default()} + coordinator.Config.Provider.Type = "docker-container" + exists, err := coordinator.catalogResourceExists(context.Background(), storagecatalog.Resource{ + Kind: catalogTartImageKind, + Locator: "epar-tart-image", + Identity: "tart-mac:001122334455", + }) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("non-Tart controller discarded Tart catalog evidence") + } +} + +func TestCurrentReferenceUpdateWaitsForBackendLock(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "host-state")) + projectRoot := t.TempDir() + configPath := filepath.Join(projectRoot, "config.yml") + if err := os.WriteFile(configPath, []byte("provider:\n type: wsl\n"), 0o600); err != nil { + t.Fatal(err) + } + coordinator := Coordinator{Config: config.Default(), ProjectRoot: projectRoot, ConfigPath: configPath} + store, err := storagecatalog.Open("") + if err != nil { + t.Fatal(err) + } + backendID := "filesystem:test" + backendLock, err := store.AcquireBackendLock(context.Background(), backendID) + if err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + result <- coordinator.registerCurrentCatalogResource(context.Background(), storagecatalog.Resource{ + BackendID: backendID, + Kind: catalogWSLArtifactKind, + Provider: "wsl", + Role: "runtime-rootfs", + Locator: filepath.Join(projectRoot, "runner.tar"), + Identity: "filesystem-id", + Custody: storagecatalog.CustodyGenerated, + State: storagecatalog.StateCurrent, + }, "manifest", time.Now().UTC()) + }() + select { + case err := <-result: + t.Fatalf("reference update bypassed the held backend lock: %v", err) + case <-time.After(100 * time.Millisecond): + } + if err := backendLock.Close(); err != nil { + t.Fatal(err) + } + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("reference update did not proceed after backend lock release") + } +} + +func TestSandboxWorkspaceIsCatalogedBeforeBuildAndSupersededExactly(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "host-state")) + projectRoot := t.TempDir() + configPath := filepath.Join(projectRoot, "config.yml") + if err := os.WriteFile(configPath, []byte("provider:\n type: docker-sandboxes\n"), 0o600); err != nil { + t.Fatal(err) + } + first := filepath.Join(projectRoot, "work", "template-builds", "docker-sandboxes", "first") + second := filepath.Join(projectRoot, "work", "template-builds", "docker-sandboxes", "second") + for _, path := range []string{first, second} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + } + coordinator := Coordinator{Config: config.Default(), ProjectRoot: projectRoot, ConfigPath: configPath} + now := time.Date(2026, 7, 30, 1, 2, 3, 0, time.UTC) + if err := coordinator.recordSandboxWorkspace(context.Background(), first, "manifest-first", storagecatalog.StateStaging, now); err != nil { + t.Fatal(err) + } + if err := coordinator.recordSandboxWorkspace(context.Background(), second, "manifest-second", storagecatalog.StateStaging, now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + store, err := storagecatalog.Open("") + if err != nil { + t.Fatal(err) + } + value, err := store.Load(now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + firstResource := findCatalogResourceByLocator(t, value.Resources, first) + secondResource := findCatalogResourceByLocator(t, value.Resources, second) + if firstResource.State != storagecatalog.StateSuperseded || len(firstResource.References) != 0 || firstResource.SupersededAt == nil { + t.Fatalf("replaced staging workspace = %#v", firstResource) + } + if secondResource.State != storagecatalog.StateStaging || len(secondResource.References) != 1 || secondResource.References[0].Role != "template-staging" { + t.Fatalf("current staging workspace = %#v", secondResource) + } + if err := coordinator.recordSandboxWorkspace(context.Background(), second, "manifest-second", storagecatalog.StateSuperseded, now.Add(2*time.Minute)); err != nil { + t.Fatal(err) + } + value, err = store.Load(now.Add(2 * time.Minute)) + if err != nil { + t.Fatal(err) + } + secondResource = findCatalogResourceByLocator(t, value.Resources, second) + if secondResource.State != storagecatalog.StateSuperseded || len(secondResource.References) != 0 || secondResource.SupersededAt == nil { + t.Fatalf("completed staging workspace = %#v", secondResource) + } +} + +func findCatalogResourceByLocator(t *testing.T, resources []storagecatalog.Resource, locator string) storagecatalog.Resource { + t.Helper() + for _, resource := range resources { + if filepath.Clean(resource.Locator) == filepath.Clean(locator) { + return resource + } + } + t.Fatalf("catalog resource %q not found in %#v", locator, resources) + return storagecatalog.Resource{} +} + +func TestTartCurrentStateRequiresExactCatalogManifestReference(t *testing.T) { + value := storagecatalog.Catalog{Resources: []storagecatalog.Resource{{ + Kind: catalogTartImageKind, + Locator: "epar-tart-image", + Identity: "tart-mac:001122334455", + References: []storagecatalog.Reference{{ + ConfigID: "config-one", + Role: "provider-artifact", + ManifestHash: "manifest-one", + }}, + }}} + if !tartCatalogReferenceMatches(value, "config-one", "epar-tart-image", "tart-mac:001122334455", "manifest-one") { + t.Fatal("exact Tart catalog reference was not recognized") + } + for _, mismatch := range []struct { + configID string + locator string + identity string + manifestHash string + }{ + {configID: "config-two", locator: "epar-tart-image", identity: "tart-mac:001122334455", manifestHash: "manifest-one"}, + {configID: "config-one", locator: "other-image", identity: "tart-mac:001122334455", manifestHash: "manifest-one"}, + {configID: "config-one", locator: "epar-tart-image", identity: "tart-mac:aabbccddeeff", manifestHash: "manifest-one"}, + {configID: "config-one", locator: "epar-tart-image", identity: "tart-mac:001122334455", manifestHash: "manifest-two"}, + } { + if tartCatalogReferenceMatches(value, mismatch.configID, mismatch.locator, mismatch.identity, mismatch.manifestHash) { + t.Fatalf("mismatched Tart catalog reference was accepted: %+v", mismatch) + } + } +} diff --git a/internal/image/storage_plan.go b/internal/image/storage_plan.go index 5afdbb1..6511d7a 100644 --- a/internal/image/storage_plan.go +++ b/internal/image/storage_plan.go @@ -130,7 +130,7 @@ func PlanArtifactStorage(providerType string, source SourceSizeEstimate, cached plan.LogicalRootMaximumBytes = rootBytes plan.LogicalDockerMaximumBytes = dockerDiskBytes plan.LogicalLimitsSparse = true - plan.Notes = append(plan.Notes, "Physical estimate covers Docker build data, one export archive, Sandbox template-cache import, and customization.", "The root and inner-Docker sizes are independent sparse logical limits and are not added to immediate host growth.") + plan.Notes = append(plan.Notes, "Physical estimate covers dedicated BuildKit state, one directly exported archive, Sandbox template-cache import, and customization; no Docker Engine output image is created.", "The root and inner-Docker sizes are independent sparse logical limits and are not added to immediate host growth.") case "wsl": for _, value := range []uint64{source.CompressedBytes, source.ExpandedBytes, source.ExpandedBytes, CustomizationAllowanceBytes} { if err := add(value); err != nil { diff --git a/internal/image/tart_activation_test.go b/internal/image/tart_activation_test.go new file mode 100644 index 0000000..6de325e --- /dev/null +++ b/internal/image/tart_activation_test.go @@ -0,0 +1,154 @@ +package image + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" +) + +type fakeTartActivationProvider struct { + instances map[string]provider.Instance + nextID int + failSource string + failTarget string +} + +func (p *fakeTartActivationProvider) Clone(_ context.Context, source, name string) error { + if source == p.failSource && name == p.failTarget { + return errors.New("injected clone failure") + } + if _, exists := p.instances[source]; !exists { + return fmt.Errorf("source %q does not exist", source) + } + p.nextID++ + p.instances[name] = provider.Instance{Name: name, Source: source, ProviderID: fmt.Sprintf("tart-mac:%012d", p.nextID), State: "stopped"} + return nil +} + +func (p *fakeTartActivationProvider) Start(context.Context, string, provider.StartOptions) (*provider.RunningProcess, error) { + return nil, errors.New("unexpected start") +} + +func (p *fakeTartActivationProvider) Exec(context.Context, string, []string, provider.ExecOptions) (provider.ExecResult, error) { + return provider.ExecResult{}, errors.New("unexpected exec") +} + +func (p *fakeTartActivationProvider) IP(context.Context, string, int) (string, error) { + return "", errors.New("unexpected IP") +} + +func (p *fakeTartActivationProvider) Stop(_ context.Context, name string) error { + instance, exists := p.instances[name] + if !exists { + return fmt.Errorf("instance %q does not exist", name) + } + instance.State = "stopped" + p.instances[name] = instance + return nil +} + +func (p *fakeTartActivationProvider) Delete(_ context.Context, name string) error { + if _, exists := p.instances[name]; !exists { + return fmt.Errorf("instance %q does not exist", name) + } + delete(p.instances, name) + return nil +} + +func (p *fakeTartActivationProvider) List(context.Context) ([]provider.Instance, error) { + result := make([]provider.Instance, 0, len(p.instances)) + for _, instance := range p.instances { + result = append(result, instance) + } + return result, nil +} + +func TestTartActivationRetainsRollbackUntilReplacementReadback(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "state")) + t.Setenv("TART_HOME", filepath.Join(t.TempDir(), "tart")) + previous := provider.Instance{Name: "runner-image", ProviderID: "tart-mac:000000000001", State: "stopped"} + fake := &fakeTartActivationProvider{instances: map[string]provider.Instance{ + previous.Name: previous, + "runner-build-hash": {Name: "runner-build-hash", ProviderID: "tart-mac:000000000002", State: "stopped"}, + }, nextID: 2} + coordinator := Coordinator{Config: config.Default(), Provider: fake, ProjectRoot: t.TempDir()} + if err := coordinator.activateTartImage(context.Background(), previous, true, "runner-build-hash", previous.Name); err != nil { + t.Fatal(err) + } + if current, exists := fake.instances[previous.Name]; !exists || current.ProviderID == previous.ProviderID { + t.Fatalf("replacement was not activated: %#v", fake.instances) + } + if _, exists := fake.instances["runner-build-hash"]; exists { + t.Fatal("verified Tart build candidate was not retired after activation") + } + if _, exists := fake.instances[tartBackupName(previous.Name, previous.ProviderID)]; exists { + t.Fatal("Tart rollback image was retired only after readback but still remains") + } +} + +func TestTartActivationFailureRestoresPreviousGeneration(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "state")) + t.Setenv("TART_HOME", filepath.Join(t.TempDir(), "tart")) + previous := provider.Instance{Name: "runner-image", ProviderID: "tart-mac:000000000001", State: "stopped"} + fake := &fakeTartActivationProvider{instances: map[string]provider.Instance{ + previous.Name: previous, + "runner-build-hash": {Name: "runner-build-hash", ProviderID: "tart-mac:000000000002", State: "stopped"}, + }, nextID: 2, failSource: "runner-build-hash", failTarget: previous.Name} + coordinator := Coordinator{Config: config.Default(), Provider: fake, ProjectRoot: t.TempDir()} + err := coordinator.activateTartImage(context.Background(), previous, true, "runner-build-hash", previous.Name) + if err == nil || !strings.Contains(err.Error(), "previous image was restored") { + t.Fatalf("activation error = %v", err) + } + if current, exists := fake.instances[previous.Name]; !exists || current.ProviderID == "" { + t.Fatalf("previous generation was not restored: %#v", fake.instances) + } +} + +func TestTartStartupReconciliationRestoresCatalogedRollbackBeforeCleanup(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "state")) + t.Setenv("TART_HOME", filepath.Join(t.TempDir(), "tart")) + projectRoot := t.TempDir() + configPath := filepath.Join(projectRoot, "config.yml") + if err := os.WriteFile(configPath, []byte("provider:\n type: tart\n"), 0o600); err != nil { + t.Fatal(err) + } + backupName := "runner-image-epar-previous-000000000001" + fake := &fakeTartActivationProvider{instances: map[string]provider.Instance{ + backupName: {Name: backupName, ProviderID: "tart-mac:000000000002", State: "stopped"}, + }, nextID: 2} + cfg := config.Default() + cfg.Provider.Type = "tart" + cfg.Image.OutputImage = "runner-image" + coordinator := Coordinator{Config: cfg, Provider: fake, ProjectRoot: projectRoot, ConfigPath: configPath} + if err := coordinator.recordTartStagingImage(context.Background(), backupName, "activation-rollback"); err != nil { + t.Fatal(err) + } + if err := coordinator.reconcileInterruptedTartArtifacts(context.Background()); err != nil { + t.Fatal(err) + } + if current, exists := fake.instances[cfg.Image.OutputImage]; !exists || current.ProviderID == "" { + t.Fatalf("startup did not restore the configured Tart output: %#v", fake.instances) + } + store, err := storagecatalog.Open("") + if err != nil { + t.Fatal(err) + } + value, err := store.Load(time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + for _, resource := range value.Resources { + if resource.Locator == backupName && (resource.State != storagecatalog.StateSuperseded || len(resource.References) != 0) { + t.Fatalf("restored rollback staging reference was not released: %#v", resource) + } + } +} diff --git a/internal/image/wsl_artifact.go b/internal/image/wsl_artifact.go new file mode 100644 index 0000000..84abf61 --- /dev/null +++ b/internal/image/wsl_artifact.go @@ -0,0 +1,180 @@ +package image + +import ( + "errors" + "fmt" + "os" +) + +const wslPreviousArtifactSuffix = ".epar-previous" + +func activateWSLArtifact(candidatePath, outputPath string) (err error) { + candidateSidecar := wslImageManifestSidecarPath(candidatePath) + outputSidecar := wslImageManifestSidecarPath(outputPath) + previousPath := outputPath + wslPreviousArtifactSuffix + previousSidecar := outputSidecar + wslPreviousArtifactSuffix + + if err := requireRegularFile(candidatePath); err != nil { + return fmt.Errorf("candidate archive: %w", err) + } + if err := requireRegularFile(candidateSidecar); err != nil { + return fmt.Errorf("candidate manifest: %w", err) + } + if err := recoverWSLArtifactSwap(outputPath); err != nil { + return err + } + + hadOutput, err := regularFileExists(outputPath) + if err != nil { + return err + } + hadSidecar, err := regularFileExists(outputSidecar) + if err != nil { + return err + } + if hadOutput != hadSidecar { + return fmt.Errorf("existing WSL artifact is incomplete: archive=%t manifest=%t", hadOutput, hadSidecar) + } + + rollbackNeeded := false + rollback := func() { + _ = removeRegularFileIfPresent(outputPath) + _ = removeRegularFileIfPresent(outputSidecar) + if hadOutput { + _ = os.Rename(previousPath, outputPath) + } + if hadSidecar { + _ = os.Rename(previousSidecar, outputSidecar) + } + } + defer func() { + if err != nil && rollbackNeeded { + rollback() + } + }() + + if hadOutput { + if err = os.Rename(outputPath, previousPath); err != nil { + return err + } + if err = os.Rename(outputSidecar, previousSidecar); err != nil { + _ = os.Rename(previousPath, outputPath) + return err + } + } + rollbackNeeded = true + if err = os.Rename(candidatePath, outputPath); err != nil { + return err + } + if err = os.Rename(candidateSidecar, outputSidecar); err != nil { + return err + } + if err = requireRegularFile(outputPath); err != nil { + return err + } + if _, err = readStoredImageManifest(outputSidecar); err != nil { + return err + } + rollbackNeeded = false + if err = removeRegularFileIfPresent(previousPath); err != nil { + return err + } + if err = removeRegularFileIfPresent(previousSidecar); err != nil { + return err + } + return nil +} + +func recoverWSLArtifactSwap(outputPath string) error { + outputSidecar := wslImageManifestSidecarPath(outputPath) + previousPath := outputPath + wslPreviousArtifactSuffix + previousSidecar := outputSidecar + wslPreviousArtifactSuffix + + hasPrevious, err := regularFileExists(previousPath) + if err != nil { + return err + } + hasPreviousSidecar, err := regularFileExists(previousSidecar) + if err != nil { + return err + } + if !hasPrevious && !hasPreviousSidecar { + return nil + } + hasOutput, err := regularFileExists(outputPath) + if err != nil { + return err + } + hasOutputSidecar, err := regularFileExists(outputSidecar) + if err != nil { + return err + } + if hasOutput && hasOutputSidecar { + if _, readErr := readStoredImageManifest(outputSidecar); readErr == nil { + if err := removeRegularFileIfPresent(previousPath); err != nil { + return err + } + return removeRegularFileIfPresent(previousSidecar) + } + } + if hasPrevious && !hasPreviousSidecar { + if !hasOutput && hasOutputSidecar { + return os.Rename(previousPath, outputPath) + } + return fmt.Errorf("incomplete WSL activation recovery evidence: previous archive without its manifest") + } + if !hasPrevious && hasPreviousSidecar { + return fmt.Errorf("incomplete WSL activation recovery evidence: previous manifest without its archive") + } + + if err := removeRegularFileIfPresent(outputPath); err != nil { + return err + } + if err := removeRegularFileIfPresent(outputSidecar); err != nil { + return err + } + if err := os.Rename(previousPath, outputPath); err != nil { + return err + } + if err := os.Rename(previousSidecar, outputSidecar); err != nil { + _ = os.Rename(outputPath, previousPath) + return err + } + return nil +} + +func requireRegularFile(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", path) + } + return nil +} + +func regularFileExists(path string) (bool, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + return false, fmt.Errorf("%s is not a regular file", path) + } + return true, nil +} + +func removeRegularFileIfPresent(path string) error { + exists, err := regularFileExists(path) + if err != nil { + return err + } + if !exists { + return nil + } + return os.Remove(path) +} diff --git a/internal/image/wsl_artifact_test.go b/internal/image/wsl_artifact_test.go new file mode 100644 index 0000000..5024177 --- /dev/null +++ b/internal/image/wsl_artifact_test.go @@ -0,0 +1,97 @@ +package image + +import ( + "os" + "path/filepath" + "testing" +) + +func TestActivateWSLArtifactReplacesVerifiedPair(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "runner.tar") + candidate := output + ".epar-candidate-test" + writeWSLArtifactTestPair(t, output, "old", ImageManifest{SchemaVersion: 1, OutputImage: "old"}) + writeWSLArtifactTestPair(t, candidate, "new", ImageManifest{SchemaVersion: 1, OutputImage: "new"}) + + if err := activateWSLArtifact(candidate, output); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if string(content) != "new" { + t.Fatalf("active archive = %q, want new", content) + } + stored, err := readStoredImageManifest(wslImageManifestSidecarPath(output)) + if err != nil { + t.Fatal(err) + } + if stored.Manifest.OutputImage != "new" { + t.Fatalf("active manifest output = %q, want new", stored.Manifest.OutputImage) + } + if _, err := os.Stat(output + wslPreviousArtifactSuffix); !os.IsNotExist(err) { + t.Fatalf("previous archive still exists: %v", err) + } +} + +func TestRecoverWSLArtifactSwapRestoresPreviousPair(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "runner.tar") + previous := output + wslPreviousArtifactSuffix + previousSidecar := wslImageManifestSidecarPath(output) + wslPreviousArtifactSuffix + writeWSLArtifactTestPair(t, previous, "old", ImageManifest{SchemaVersion: 1, OutputImage: "old"}) + if err := os.Rename(wslImageManifestSidecarPath(previous), previousSidecar); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, []byte("partial-new"), 0o600); err != nil { + t.Fatal(err) + } + + if err := recoverWSLArtifactSwap(output); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if string(content) != "old" { + t.Fatalf("recovered archive = %q, want old", content) + } + stored, err := readStoredImageManifest(wslImageManifestSidecarPath(output)) + if err != nil { + t.Fatal(err) + } + if stored.Manifest.OutputImage != "old" { + t.Fatalf("recovered manifest output = %q, want old", stored.Manifest.OutputImage) + } +} + +func TestActivateWSLArtifactRejectsSymlinkCandidate(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.tar") + if err := os.WriteFile(target, []byte("candidate"), 0o600); err != nil { + t.Fatal(err) + } + candidate := filepath.Join(dir, "candidate.tar") + if err := os.Symlink(target, candidate); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if err := writeStoredImageManifest(wslImageManifestSidecarPath(candidate), ImageManifest{SchemaVersion: 1}); err != nil { + t.Fatal(err) + } + + if err := activateWSLArtifact(candidate, filepath.Join(dir, "output.tar")); err == nil { + t.Fatal("activateWSLArtifact accepted a symlink candidate") + } +} + +func writeWSLArtifactTestPair(t *testing.T, output, content string, manifest ImageManifest) { + t.Helper() + if err := os.WriteFile(output, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := writeStoredImageManifest(wslImageManifestSidecarPath(output), manifest); err != nil { + t.Fatal(err) + } +} diff --git a/internal/pool/host_trust.go b/internal/pool/host_trust.go index 92171bb..c8b925d 100644 --- a/internal/pool/host_trust.go +++ b/internal/pool/host_trust.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -23,7 +24,8 @@ const ( hostTrustGuestDir = "/usr/local/share/ca-certificates/epar-host" hostTrustMarkerGuest = "/opt/epar/host-trust-generation.json" hostTrustLeaseGuest = "/run/epar/host-trust-lease.json" - hostTrustLeaseLifetime = 20 * time.Second + hostTrustLeaseLifetime = 90 * time.Second + hostTrustHandoffLease = 2 * time.Minute hostTrustMaximumAge = 30 * time.Second hostTrustNativePoll = 15 * time.Second ) @@ -34,7 +36,8 @@ const HostTrustMarkerGuest = hostTrustMarkerGuest // HostTrustLeaseLifetime is the default shared guest lease duration. const HostTrustLeaseLifetime = hostTrustLeaseLifetime -var hostTrustRefreshInterval = 5 * time.Second +var hostTrustRefreshInterval = 30 * time.Second +var hostTrustWriteTimeout = 10 * time.Second var hostTrustControllerInContainer = linuxControllerInContainer var hostTrustControllerOS = runtime.GOOS @@ -303,16 +306,23 @@ func validateHostTrustMarkerAgainstSnapshot(marker hostTrustMarker, snapshot hos } func hostTrustLeaseJSON(snapshot hosttrust.Snapshot, now time.Time) ([]byte, error) { + return hostTrustLeaseJSONWithLifetime(snapshot, now, hostTrustLeaseLifetime) +} + +func hostTrustLeaseJSONWithLifetime(snapshot hosttrust.Snapshot, now time.Time, lifetime time.Duration) ([]byte, error) { if _, err := validateHostTrustSnapshot(snapshot, now); err != nil { return nil, err } + if lifetime <= 0 { + return nil, fmt.Errorf("host trust lease lifetime must be positive") + } return json.MarshalIndent(hostTrustLease{ SchemaVersion: 1, Generation: snapshot.Generation, HostOS: snapshot.HostOS, Mode: hosttrust.ModeOverlay, Scopes: append([]string(nil), snapshot.Scopes...), - ExpiresAt: now.Add(hostTrustLeaseLifetime).UTC().Format(time.RFC3339Nano), + ExpiresAt: now.Add(lifetime).UTC().Format(time.RFC3339Nano), }, "", " ") } @@ -394,24 +404,39 @@ func (m *Manager) writeHostTrustBuildInputs(buildContext string, snapshot hosttr } func (m *Manager) issueHostTrustLease(ctx context.Context, instanceName string, snapshot hosttrust.Snapshot) error { + return m.issueHostTrustLeaseWithLifetime(ctx, instanceName, snapshot, hostTrustLeaseLifetime) +} + +func (m *Manager) issueHostTrustLeaseWithLifetime(ctx context.Context, instanceName string, snapshot hosttrust.Snapshot, lifetime time.Duration) error { if !m.hostTrustEnabled() { return nil } - now := time.Now().UTC() - content, err := hostTrustLeaseJSON(snapshot, now) + content, err := hostTrustLeaseJSONWithLifetime(snapshot, time.Now().UTC(), lifetime) if err != nil { return err } - if _, err := m.execGuest(ctx, instanceName, provider.ShellCommand("if command -v sudo >/dev/null 2>&1; then sudo install -d -m 0755 /run/epar; else install -d -m 0755 /run/epar; fi"), provider.ExecOptions{}); err != nil { + writeCtx, cancel := context.WithTimeout(ctx, hostTrustWriteTimeout) + defer cancel() + staging := hostTrustLeaseGuest + ".tmp" + script := fmt.Sprintf("cat > /tmp/epar-host-trust-lease && if command -v sudo >/dev/null 2>&1; then sudo install -d -m 0755 /run/epar && sudo install -m 0644 /tmp/epar-host-trust-lease %s && sudo mv -f %s %s; else install -d -m 0755 /run/epar && install -m 0644 /tmp/epar-host-trust-lease %s && mv -f %s %s; fi && rm -f /tmp/epar-host-trust-lease", shellQuote(staging), shellQuote(staging), shellQuote(hostTrustLeaseGuest), shellQuote(staging), shellQuote(staging), shellQuote(hostTrustLeaseGuest)) + if _, err := m.execGuest(writeCtx, instanceName, provider.ShellCommand(script), provider.ExecOptions{Stdin: string(content) + "\n"}); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("host trust lease write exceeded %s: %w", hostTrustWriteTimeout, err) + } return err } - return m.copyTextGuest(ctx, instanceName, hostTrustLeaseGuest, "0644", string(content)+"\n", true) + return nil } -func (m *Manager) reconcileHostTrustRunners(ctx context.Context, active map[string]ProvisionedInstance, current hosttrust.Snapshot) int { +func (m *Manager) reconcileHostTrustRunners(ctx context.Context, active map[string]ProvisionedInstance, current hosttrust.Snapshot, busyHandoff map[string]bool) int { if m.GitHub == nil { return 0 } + for name := range busyHandoff { + if _, found := active[name]; !found { + delete(busyHandoff, name) + } + } retired := 0 for name, instance := range active { if instance.HostTrustGeneration != current.Generation { @@ -424,19 +449,40 @@ func (m *Manager) reconcileHostTrustRunners(ctx context.Context, active map[stri } runner, found, err := m.GitHub.RunnerByName(ctx, name) if err != nil { - m.warnf("[%s] host trust reconciliation warning; lease not refreshed: %v\n", name, err) + if isTransientGitHubLivenessError(err) { + m.warnf("[%s] GitHub API is temporarily unavailable during host trust refresh; the existing lease will expire closed and EPAR will retry: %v\n", name, err) + } else { + m.warnf("[%s] host trust reconciliation warning; lease not refreshed: %v\n", name, err) + } continue } if instance.HostTrustGeneration == current.Generation { - if !found || runner.Busy { + if !found { + delete(busyHandoff, name) + continue + } + if runner.Busy { + if busyHandoff[name] { + continue + } + // GitHub can mark a runner busy before the job-start hook executes. + // Issue one bounded handoff lease for that transition, but never + // renew it while the job remains busy. + if err := m.issueHostTrustLeaseWithLifetime(ctx, name, current, hostTrustHandoffLease); err != nil { + m.warnf("[%s] host trust job handoff lease warning: %v\n", name, err) + continue + } + busyHandoff[name] = true continue } + delete(busyHandoff, name) if err := m.issueHostTrustLease(ctx, name, current); err != nil { m.warnf("[%s] host trust lease refresh warning: %v\n", name, err) } continue } if found && runner.Busy { + delete(busyHandoff, name) m.infof("[%s] draining busy runner on old host trust generation %s\n", name, instance.HostTrustGeneration) instance.Phase = LifecycleDraining active[name] = instance @@ -448,6 +494,7 @@ func (m *Manager) reconcileHostTrustRunners(ctx context.Context, active map[stri continue } delete(active, name) + delete(busyHandoff, name) retired++ } return retired diff --git a/internal/pool/host_trust_test.go b/internal/pool/host_trust_test.go index e7fd8e5..c675b10 100644 --- a/internal/pool/host_trust_test.go +++ b/internal/pool/host_trust_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "io" + "net/http" "os" "path/filepath" "slices" @@ -18,6 +19,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" ) func TestDockerContainerBuildContextKeepsHostAndExplicitTrustSeparate(t *testing.T) { @@ -283,7 +285,7 @@ func TestHostTrustReconciliationRevokesAndRetiresIdleOldGeneration(t *testing.T) CollectedAt: time.Now().UTC(), } active := map[string]ProvisionedInstance{"runner-1": {Name: "runner-1", RunnerID: 42, HostTrustGeneration: "g1"}} - manager.reconcileHostTrustRunners(context.Background(), active, current) + manager.reconcileHostTrustRunners(context.Background(), active, current, make(map[string]bool)) if len(active) != 0 { t.Fatalf("active runners = %#v, want old idle runner retired", active) } @@ -317,7 +319,7 @@ func TestHostTrustReconciliationRevokesButDoesNotRetireBusyOldGeneration(t *test CollectedAt: time.Now().UTC(), } active := map[string]ProvisionedInstance{"runner-1": {Name: "runner-1", RunnerID: 42, HostTrustGeneration: "g1"}} - manager.reconcileHostTrustRunners(context.Background(), active, current) + manager.reconcileHostTrustRunners(context.Background(), active, current, make(map[string]bool)) if len(active) != 1 { t.Fatal("busy old-generation runner was retired before its job completed") } @@ -335,6 +337,152 @@ func TestHostTrustReconciliationRevokesButDoesNotRetireBusyOldGeneration(t *test } } +func TestHostTrustReconciliationPreservesRunnerDuringGitHub503(t *testing.T) { + fake := &fakeProvider{} + github := &fakeGitHub{runnerErr: &gh.HTTPError{StatusCode: http.StatusServiceUnavailable}} + manager := Manager{ + Config: config.Config{Image: config.ImageConfig{HostTrustMode: config.HostTrustModeOverlay, HostTrustScopes: []string{"system"}}}, + Provider: fake, + GitHub: github, + } + current := hosttrust.Snapshot{ + Generation: "g1", + HostOS: "linux", + Scopes: []string{"system"}, + Certificates: []hosttrust.Certificate{{Name: "root.crt", PEM: []byte("pem")}}, + CollectedAt: time.Now().UTC(), + } + active := map[string]ProvisionedInstance{"runner-1": {Name: "runner-1", RunnerID: 42, HostTrustGeneration: "g1"}} + if retired := manager.reconcileHostTrustRunners(context.Background(), active, current, make(map[string]bool)); retired != 0 { + t.Fatalf("retired runners = %d, want 0 during GitHub 503", retired) + } + if len(active) != 1 { + t.Fatal("GitHub 503 removed the active runner") + } + if got := atomic.LoadInt32(&fake.execCalls); got != 0 { + t.Fatalf("guest lease commands = %d, want 0 when GitHub status is unknown", got) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 0 { + t.Fatalf("provider delete calls = %d, want 0 during GitHub 503", got) + } +} + +func TestHostTrustReconciliationIssuesOneBoundedBusyHandoffLease(t *testing.T) { + provider := &fakeProvider{} + github := &fakeGitHub{runner: gh.Runner{Name: "runner-1", ID: 42, Status: "online", Busy: true}, found: true} + manager := Manager{ + Config: config.Config{Image: config.ImageConfig{HostTrustMode: config.HostTrustModeOverlay, HostTrustScopes: []string{"system"}}}, + Provider: provider, + GitHub: github, + } + current := hosttrust.Snapshot{ + Generation: "g1", + HostOS: "linux", + Scopes: []string{"system"}, + Certificates: []hosttrust.Certificate{{Name: "root.crt", PEM: []byte("pem")}}, + CollectedAt: time.Now().UTC(), + } + active := map[string]ProvisionedInstance{"runner-1": {Name: "runner-1", RunnerID: 42, HostTrustGeneration: "g1"}} + busyHandoff := make(map[string]bool) + + manager.reconcileHostTrustRunners(context.Background(), active, current, busyHandoff) + manager.reconcileHostTrustRunners(context.Background(), active, current, busyHandoff) + + leases := hostTrustLeaseInputs(provider) + if len(leases) != 1 { + t.Fatalf("busy handoff lease writes = %d, want 1", len(leases)) + } + var lease hostTrustLease + if err := json.Unmarshal([]byte(leases[0]), &lease); err != nil { + t.Fatal(err) + } + expires, err := time.Parse(time.RFC3339Nano, lease.ExpiresAt) + if err != nil { + t.Fatal(err) + } + if remaining := time.Until(expires); remaining < hostTrustHandoffLease-5*time.Second || remaining > hostTrustHandoffLease+time.Second { + t.Fatalf("busy handoff lease remaining lifetime = %s, want about %s", remaining, hostTrustHandoffLease) + } + + github.runner.Busy = false + manager.reconcileHostTrustRunners(context.Background(), active, current, busyHandoff) + github.runner.Busy = true + manager.reconcileHostTrustRunners(context.Background(), active, current, busyHandoff) + if leases = hostTrustLeaseInputs(provider); len(leases) != 3 { + t.Fatalf("lease writes after idle and second busy transition = %d, want 3", len(leases)) + } +} + +func TestHostTrustLeaseUsesOneBoundedAtomicGuestCommand(t *testing.T) { + fake := &fakeProvider{} + manager := Manager{ + Config: config.Config{Image: config.ImageConfig{HostTrustMode: config.HostTrustModeOverlay, HostTrustScopes: []string{"system"}}}, + Provider: fake, + } + snapshot := hosttrust.Snapshot{ + Generation: "g1", + HostOS: "linux", + Scopes: []string{"system"}, + Certificates: []hosttrust.Certificate{{Name: "root.crt", PEM: []byte("pem")}}, + CollectedAt: time.Now().UTC(), + } + if err := manager.issueHostTrustLease(context.Background(), "runner-1", snapshot); err != nil { + t.Fatal(err) + } + if got := atomic.LoadInt32(&fake.execCalls); got != 1 { + t.Fatalf("guest commands = %d, want one atomic lease write", got) + } + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.commands) != 1 || !strings.Contains(fake.commands[0], "install -d -m 0755 /run/epar") || !strings.Contains(fake.commands[0], "mv -f") { + t.Fatalf("lease command = %q, want directory creation and atomic rename in one command", strings.Join(fake.commands, "\n")) + } + if len(fake.execOptions) != 1 || !strings.Contains(fake.execOptions[0].Stdin, `"expiresAt"`) { + t.Fatal("lease payload was not supplied to the atomic guest command") + } +} + +func TestHostTrustLeaseWriteTimeoutIsReportedWithoutBlocking(t *testing.T) { + oldTimeout := hostTrustWriteTimeout + hostTrustWriteTimeout = 5 * time.Millisecond + t.Cleanup(func() { hostTrustWriteTimeout = oldTimeout }) + fake := &fakeProvider{execFunc: func(ctx context.Context, _ string, _ []string, _ provider.ExecOptions) (provider.ExecResult, error) { + <-ctx.Done() + return provider.ExecResult{}, ctx.Err() + }} + manager := Manager{ + Config: config.Config{Image: config.ImageConfig{HostTrustMode: config.HostTrustModeOverlay, HostTrustScopes: []string{"system"}}}, + Provider: fake, + } + snapshot := hosttrust.Snapshot{ + Generation: "g1", + HostOS: "linux", + Scopes: []string{"system"}, + Certificates: []hosttrust.Certificate{{Name: "root.crt", PEM: []byte("pem")}}, + CollectedAt: time.Now().UTC(), + } + started := time.Now() + err := manager.issueHostTrustLease(context.Background(), "runner-1", snapshot) + if err == nil || !strings.Contains(err.Error(), "host trust lease write exceeded") { + t.Fatalf("issueHostTrustLease() error = %v, want bounded-timeout detail", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("lease timeout took %s, want less than one second", elapsed) + } +} + +func hostTrustLeaseInputs(provider *fakeProvider) []string { + provider.mu.Lock() + defer provider.mu.Unlock() + var leases []string + for _, options := range provider.execOptions { + if strings.Contains(options.Stdin, `"expiresAt"`) { + leases = append(leases, options.Stdin) + } + } + return leases +} + func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing.T) { root := t.TempDir() for _, dir := range []string{ diff --git a/internal/pool/image_acquisition_test.go b/internal/pool/image_acquisition_test.go index bfa6731..e44ea53 100644 --- a/internal/pool/image_acquisition_test.go +++ b/internal/pool/image_acquisition_test.go @@ -2,17 +2,128 @@ package pool import ( "bytes" + "context" "encoding/json" + "io" "os" "path/filepath" "strings" "testing" + "time" "github.com/solutionforest/ephemeral-action-runner/internal/config" "github.com/solutionforest/ephemeral-action-runner/internal/image" "github.com/solutionforest/ephemeral-action-runner/internal/logging" ) +func TestBuildxProgressHeartbeatDefaultIsFiveSeconds(t *testing.T) { + if got, want := buildxProgressHeartbeatInterval, 5*time.Second; got != want { + t.Fatalf("Buildx progress heartbeat = %s, want %s", got, want) + } +} + +func TestBuildxProgressUsesManagerLoggerAndPreservesRawTranscript(t *testing.T) { + root := t.TempDir() + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: root, + ManagerSinks: logging.SinkConsole, + TranscriptSinks: logging.SinkFile, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + cfg := config.Default() + cfg.Provider.Type = "docker-sandboxes" + cfg.Logging.Directory = root + cfg.Logging.ManagerSinks = []string{"console"} + cfg.Logging.TranscriptSinks = []string{"file"} + manager := Manager{Config: cfg, Logging: runtime} + logPath := filepath.Join(root, "builds", "docker-sandboxes-test.docker-build.log") + previousTerminal := dockerPullProgressTerminal + dockerPullProgressTerminal = func() bool { return false } + t.Cleanup(func() { dockerPullProgressTerminal = previousTerminal }) + previousLogged := runHostLoggedCommand + runHostLoggedCommand = func(_ context.Context, _ string, stdout, stderr io.Writer, name string, args ...string) error { + if name != "docker" || len(args) < 2 || args[0] != "buildx" || args[1] != "build" { + t.Fatalf("unexpected command: %s %v", name, args) + } + digest := "sha256:" + strings.Repeat("d", 64) + _, _ = io.WriteString(stderr, "#12 "+digest+" 1.00GB / 2.00GB 10.0s\n") + _, _ = io.WriteString(stdout, "#12 "+digest+" 2.00GB / 2.00GB 20.0s done\n") + return nil + } + t.Cleanup(func() { runHostLoggedCommand = previousLogged }) + + if err := manager.runHostBuildxLogged(context.Background(), logPath, "docker", "buildx", "build"); err != nil { + t.Fatal(err) + } + if err := manager.releaseTranscript(logPath); err != nil { + t.Fatal(err) + } + + consoleText := console.String() + if !strings.Contains(consoleText, "Docker Sandboxes template build: 953.7 MiB/1.9 GiB (50%); 0/1 layer downloads complete; BuildKit step #12") { + t.Fatalf("manager console did not receive Buildx progress: %q", consoleText) + } + if !strings.Contains(consoleText, "Docker Sandboxes template Buildx phase complete; finalizing evidence and importing the template next") { + t.Fatalf("manager console did not receive Buildx completion: %q", consoleText) + } + raw, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "1.00GB / 2.00GB") || !strings.Contains(string(raw), "2.00GB / 2.00GB") { + t.Fatalf("raw Buildx transcript was not preserved: %q", raw) + } +} + +func TestBuildxProgressHeartbeatShowsLongSilentStepIsAlive(t *testing.T) { + root := t.TempDir() + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: root, + ManagerSinks: logging.SinkConsole, + TranscriptSinks: logging.SinkFile, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + cfg := config.Default() + cfg.Provider.Type = "docker-sandboxes" + cfg.Logging.Directory = root + cfg.Logging.ManagerSinks = []string{"console"} + cfg.Logging.TranscriptSinks = []string{"file"} + manager := Manager{Config: cfg, Logging: runtime} + previousTerminal := dockerPullProgressTerminal + dockerPullProgressTerminal = func() bool { return false } + t.Cleanup(func() { dockerPullProgressTerminal = previousTerminal }) + previousInterval := buildxProgressHeartbeatInterval + buildxProgressHeartbeatInterval = 5 * time.Millisecond + t.Cleanup(func() { buildxProgressHeartbeatInterval = previousInterval }) + previousLogged := runHostLoggedCommand + runHostLoggedCommand = func(_ context.Context, _ string, _, _ io.Writer, _ string, _ ...string) error { + time.Sleep(20 * time.Millisecond) + return nil + } + t.Cleanup(func() { runHostLoggedCommand = previousLogged }) + + if err := manager.runHostBuildxLogged(context.Background(), filepath.Join(root, "builds", "docker-sandboxes-heartbeat.docker-build.log"), "docker", "buildx", "build"); err != nil { + t.Fatal(err) + } + if !strings.Contains(console.String(), "Docker Sandboxes template build: elapsed") { + t.Fatalf("long silent Buildx step had no heartbeat: %q", console.String()) + } +} + func TestDockerPullProgressUsesManagerLoggerAndPreservesSourceTranscript(t *testing.T) { root := t.TempDir() var console bytes.Buffer @@ -137,3 +248,18 @@ func TestDockerPullProgressUsesSingleLineTerminalDisplayForTextManagerConsole(t t.Fatalf("interactive pull progress was duplicated through manager logger: %q", got) } } + +func TestBuildxDockerArchiveDestinationRecognizesDirectExporter(t *testing.T) { + path := filepath.Join(t.TempDir(), "runner-template.tar.partial") + for _, args := range [][]string{ + {"buildx", "build", "--output", "type=docker,dest=" + path}, + {"buildx", "build", "--output=type=docker,dest=" + path}, + } { + if got := buildxDockerArchiveDestination(args); got != path { + t.Fatalf("buildxDockerArchiveDestination(%v) = %q, want %q", args, got, path) + } + } + if got := buildxDockerArchiveDestination([]string{"buildx", "build", "--load"}); got != "" { + t.Fatalf("non-archive output returned %q", got) + } +} diff --git a/internal/pool/image_service.go b/internal/pool/image_service.go index a9eab8c..af69db3 100644 --- a/internal/pool/image_service.go +++ b/internal/pool/image_service.go @@ -52,7 +52,9 @@ var ( ) func (m *Manager) imageCoordinator() *artifactimage.Coordinator { - return artifactimage.NewCoordinator(m.Config, m.Provider, m.Lifecycle, m.ProjectRoot, m.DryRun, imageEnvironment{manager: m}) + coordinator := artifactimage.NewCoordinator(m.Config, m.Provider, m.Lifecycle, m.ProjectRoot, m.DryRun, imageEnvironment{manager: m}) + coordinator.ConfigPath = m.ConfigPath + return coordinator } func (m *Manager) UpdateUpstream(ctx context.Context) error { @@ -64,7 +66,29 @@ func (m *Manager) BuildImage(ctx context.Context, options ImageBuildOptions) err } func (m *Manager) EnsureImage(ctx context.Context) error { - return m.imageCoordinator().EnsureImage(ctx) + m.imageEnsureMu.Lock() + defer m.imageEnsureMu.Unlock() + if m.imageEnsured { + return nil + } + if err := m.imageCoordinator().EnsureImage(ctx); err != nil { + return err + } + if m.Config.Provider.Type == "docker-container" && !m.DryRun { + identity, err := runHostOutputCommand(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", m.Config.Image.OutputImage) + if err != nil { + return fmt.Errorf("resolve immutable Docker Container runtime image: %w", err) + } + identity = strings.TrimSpace(identity) + if identity == "" { + return fmt.Errorf("Docker Container runtime image returned an empty immutable identity") + } + // Keep user configuration as the desired mutable name while the live + // manager and all replacements consume the exact verified image ID. + m.Config.Provider.SourceImage = identity + } + m.imageEnsured = true + return nil } func (m *Manager) RefreshScripts(ctx context.Context) error { @@ -217,6 +241,10 @@ func (environment imageEnvironment) RunHostLogged(ctx context.Context, logPath, return environment.manager.runHostLogged(ctx, logPath, name, args...) } +func (environment imageEnvironment) RunHostBuildxLogged(ctx context.Context, logPath, name string, args ...string) error { + return environment.manager.runHostBuildxLogged(ctx, logPath, name, args...) +} + func (environment imageEnvironment) RunHost(ctx context.Context, name string, args ...string) error { return runHostCommand(ctx, name, args...) } @@ -277,11 +305,11 @@ func (environment imageEnvironment) LogWarn(message string, args ...any) { environment.manager.logger().Warn(message, args...) } -func (environment imageEnvironment) DockerPullProgressTerminal() bool { +func (environment imageEnvironment) ProgressTerminal() bool { return dockerPullProgressTerminal() } -func (environment imageEnvironment) DockerPullProgressConsole() io.Writer { +func (environment imageEnvironment) ProgressConsole() io.Writer { return dockerPullProgressConsole } diff --git a/internal/pool/lifecycle_cleanup_test.go b/internal/pool/lifecycle_cleanup_test.go index 5ccab43..ed6a344 100644 --- a/internal/pool/lifecycle_cleanup_test.go +++ b/internal/pool/lifecycle_cleanup_test.go @@ -1,6 +1,7 @@ package pool import ( + "bytes" "context" "strings" "sync/atomic" @@ -9,6 +10,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" gh "github.com/solutionforest/ephemeral-action-runner/internal/github" + "github.com/solutionforest/ephemeral-action-runner/internal/logging" poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" "github.com/solutionforest/ephemeral-action-runner/internal/provider" ) @@ -172,6 +174,18 @@ func TestInterruptedProvisionRecoveryPreservesJobLease(t *testing.T) { func TestRemoteAbsenceReleasesExactJobLease(t *testing.T) { manager, store, name := readyLifecycleManager(t) + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: t.TempDir(), + ManagerSinks: logging.SinkConsole, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + manager.Logging = runtime if _, err := store.Transition(context.Background(), name, poolstate.Transition{Action: poolstate.ActionJobStarted}); err != nil { t.Fatal(err) } @@ -194,6 +208,92 @@ func TestRemoteAbsenceReleasesExactJobLease(t *testing.T) { if lease, active := activeLifecycleLease(record.Leases, time.Now()); active { t.Fatalf("job lease remained active after exact remote absence: %+v", lease) } + if message := console.String(); !strings.Contains(message, "["+name+"] Job finished and GitHub released the ephemeral runner; GitHub Actions has the success or failure result.") { + t.Fatalf("remote-absence lifecycle output omitted job completion: %q", message) + } +} + +func TestReconciliationLogsJobFinishBeforeExactCleanup(t *testing.T) { + manager, store, name := readyLifecycleManager(t) + if _, err := store.Transition(context.Background(), name, poolstate.Transition{Action: poolstate.ActionJobStarted}); err != nil { + t.Fatal(err) + } + if _, err := store.AcquireLease(context.Background(), name, poolstate.Lease{Purpose: "job", Holder: "github-42", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: t.TempDir(), + ManagerSinks: logging.SinkConsole, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + fake := &fakeProvider{instances: []provider.Instance{{Name: name, ProviderID: "docker:ready-id", State: "running"}}} + manager.Logging = runtime + manager.Provider = fake + manager.Lifecycle = provider.AdaptLegacy(fake) + manager.GitHub = &fakeGitHub{} + + active, err := manager.reconcilePhysicalPool(context.Background(), nil, true) + if err != nil { + t.Fatal(err) + } + if len(active) != 0 { + t.Fatalf("active instances = %+v, want none after completed ephemeral runner cleanup", active) + } + output := console.String() + finishedAt := strings.Index(output, "["+name+"] Job finished and GitHub released the ephemeral runner") + cleanupAt := strings.Index(output, "cleanup: deleted exact owned instance "+name) + if finishedAt < 0 || cleanupAt < 0 || finishedAt >= cleanupAt { + t.Fatalf("job completion was not logged before exact cleanup: %q", output) + } +} + +func TestLifecycleJobObservationLogsStartAndFinishOnce(t *testing.T) { + manager, store, name := readyLifecycleManager(t) + var console bytes.Buffer + runtime, err := logging.NewRuntime(logging.Options{ + Directory: t.TempDir(), + ManagerSinks: logging.SinkConsole, + Stdout: &console, + Stderr: &console, + }) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + manager.Logging = runtime + + runner := gh.Runner{Name: name, ID: 42, Status: "online", Busy: true} + if err := manager.recordLifecycleJobObservation(context.Background(), runner); err != nil { + t.Fatal(err) + } + if err := manager.recordLifecycleJobObservation(context.Background(), runner); err != nil { + t.Fatal(err) + } + runner.Busy = false + if err := manager.recordLifecycleJobObservation(context.Background(), runner); err != nil { + t.Fatal(err) + } + + record, err := store.Read(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if record.Phase != poolstate.PhaseDraining { + t.Fatalf("phase = %s, want %s", record.Phase, poolstate.PhaseDraining) + } + output := console.String() + if count := strings.Count(output, "["+name+"] Job started; GitHub assigned work to this runner."); count != 1 { + t.Fatalf("job-start log count = %d, want 1: %q", count, output) + } + if count := strings.Count(output, "["+name+"] Job finished; GitHub Actions has the success or failure result."); count != 1 { + t.Fatalf("job-finish log count = %d, want 1: %q", count, output) + } } func TestReconciliationPreservesStoppedBusyRunnerProtectedByJobLease(t *testing.T) { diff --git a/internal/pool/lifecycle_state.go b/internal/pool/lifecycle_state.go index 6dc98bc..1f02cfb 100644 --- a/internal/pool/lifecycle_state.go +++ b/internal/pool/lifecycle_state.go @@ -104,18 +104,27 @@ func (m *Manager) recordLifecycleJobObservation(ctx context.Context, runner gh.R } holder := "github-" + strconv.FormatInt(runner.ID, 10) if runner.Busy { + started := false if record.Phase == poolstate.PhaseReady { if _, err := m.LifecycleState.Transition(ctx, runner.Name, poolstate.Transition{Action: poolstate.ActionJobStarted}); err != nil { return err } + started = true } - return m.acquireLifecycleLease(ctx, runner.Name, "job", holder, 10*time.Minute) + if err := m.acquireLifecycleLease(ctx, runner.Name, "job", holder, 10*time.Minute); err != nil { + return err + } + if started { + m.infof("[%s] Job started; GitHub assigned work to this runner.\n", runner.Name) + } + return nil } if record.Phase == poolstate.PhaseBusy { if _, err := m.LifecycleState.Transition(ctx, runner.Name, poolstate.Transition{Action: poolstate.ActionJobFinished}); err != nil { return err } m.releaseLifecycleLease(ctx, runner.Name, "job", holder) + m.infof("[%s] Job finished; GitHub Actions has the success or failure result.\n", runner.Name) return nil } for _, lease := range record.Leases { @@ -135,6 +144,7 @@ func (m *Manager) recordLifecycleRemoteAbsence(ctx context.Context, name string) if err != nil { return err } + jobFinished := record.Phase == poolstate.PhaseBusy if record.Phase == poolstate.PhaseBusy { if _, err := m.LifecycleState.Transition(ctx, name, poolstate.Transition{Action: poolstate.ActionJobFinished}); err != nil { return err @@ -143,6 +153,11 @@ func (m *Manager) recordLifecycleRemoteAbsence(ctx context.Context, name string) if record.GitHub.RunnerID != 0 { m.releaseLifecycleLease(ctx, name, "job", "github-"+strconv.FormatInt(record.GitHub.RunnerID, 10)) } + if jobFinished { + m.infof("[%s] Job finished and GitHub released the ephemeral runner; GitHub Actions has the success or failure result.\n", name) + } else if record.Phase == poolstate.PhaseReady { + m.infof("[%s] GitHub runner registration disappeared before EPAR observed a job start; starting exact cleanup.\n", name) + } return nil } diff --git a/internal/pool/logging.go b/internal/pool/logging.go index 9a5f750..4e0672a 100644 --- a/internal/pool/logging.go +++ b/internal/pool/logging.go @@ -9,11 +9,15 @@ import ( "os" "path/filepath" "strings" + "sync" "time" + artifactimage "github.com/solutionforest/ephemeral-action-runner/internal/image" "github.com/solutionforest/ephemeral-action-runner/internal/logging" ) +var buildxProgressHeartbeatInterval = 5 * time.Second + func (m *Manager) logger() *slog.Logger { if m != nil && m.Logging != nil { return m.Logging.Logger() @@ -35,11 +39,11 @@ func (m *Manager) warnf(format string, args ...any) { } func (m *Manager) logPoolRunning(label string) { - m.infof("%s is running. Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.\n", label) + m.infof("%s is running. Press Ctrl-C once to stop, then wait for cleanup to finish before closing this window.\n", label) } func (m *Manager) logReplacementReady(label, name string) { - m.infof("Replacement runner %s is online; %s is ready for the next job. Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.\n", name, label) + m.infof("Replacement runner %s is online; %s is ready for the next job. Press Ctrl-C once to stop, then wait for cleanup to finish before closing this window.\n", name, label) } func (m *Manager) cleanupPoolWithStatus(resources string, cleanup func() error) error { @@ -147,6 +151,100 @@ func (m *Manager) runHostLogged(ctx context.Context, logPath, name string, args return runHostLoggedCommand(ctx, logPath, transcript.Stdout, transcript.Stderr, name, args...) } +func (m *Manager) runHostBuildxLogged(ctx context.Context, logPath, name string, args ...string) error { + transcript, err := m.transcript(logPath, "", transcriptComponent(logPath)) + if err != nil { + return err + } + prefix := "Docker image build" + if m.Config.Provider.Type == "docker-sandboxes" { + prefix = "Docker Sandboxes template build" + } + attributes := []any{"provider", m.Config.Provider.Type, "operation", "buildx-build", "logPath", logPath} + rawTranscriptOnConsole := stringSliceContains(m.Config.Logging.TranscriptSinks, "console") + interactive := dockerPullProgressTerminal() && stringSliceContains(m.Config.Logging.ManagerSinks, "console") && m.Config.Logging.ManagerConsoleFormat == "text" && !rawTranscriptOnConsole + archiveExportPath := buildxDockerArchiveDestination(args) + var reportMu sync.Mutex + report := func(snapshot artifactimage.BuildxProgressSnapshot) { + if rawTranscriptOnConsole { + return + } + reportMu.Lock() + defer reportMu.Unlock() + line := artifactimage.FormatBuildxProgress(prefix, snapshot) + if archiveExportPath != "" { + if info, err := os.Lstat(archiveExportPath); err == nil && info.Mode().IsRegular() { + line += "; archive written " + artifactimage.FormatDockerPullBytes(info.Size()) + } + } + if interactive { + _, _ = fmt.Fprintf(dockerPullProgressConsole, "\r\033[2K%s", line) + return + } + m.logger().Info(line, attributes...) + } + monitor := artifactimage.NewBuildxProgressMonitor(report) + stdoutProgress := monitor.NewStream() + stderrProgress := monitor.NewStream() + heartbeatDone := make(chan struct{}) + var heartbeat sync.WaitGroup + if !rawTranscriptOnConsole && buildxProgressHeartbeatInterval > 0 { + heartbeat.Add(1) + go func() { + defer heartbeat.Done() + ticker := time.NewTicker(buildxProgressHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + report(monitor.Snapshot()) + case <-heartbeatDone: + return + } + } + }() + } + commandErr := runHostLoggedCommand(ctx, logPath, io.MultiWriter(transcript.Stdout, stdoutProgress), io.MultiWriter(transcript.Stderr, stderrProgress), name, args...) + close(heartbeatDone) + heartbeat.Wait() + stdoutProgress.Flush() + stderrProgress.Flush() + if interactive { + fmt.Fprintln(dockerPullProgressConsole) + } + if commandErr == nil { + snapshot := monitor.Snapshot() + completion := prefix + " complete" + if m.Config.Provider.Type == "docker-sandboxes" { + completion = "Docker Sandboxes template Buildx phase complete; finalizing evidence and importing the template next" + } + m.logger().Info(completion, append(attributes, "elapsed", snapshot.Elapsed.Round(time.Second))...) + } + return commandErr +} + +func buildxDockerArchiveDestination(args []string) string { + const prefix = "type=docker,dest=" + for index, argument := range args { + if argument == "--output" && index+1 < len(args) && strings.HasPrefix(args[index+1], prefix) { + return strings.TrimPrefix(args[index+1], prefix) + } + if strings.HasPrefix(argument, "--output="+prefix) { + return strings.TrimPrefix(argument, "--output="+prefix) + } + } + return "" +} + +func stringSliceContains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + func (m *Manager) retentionPolicy() logging.RetentionPolicy { days := func(value int) time.Duration { return time.Duration(value) * 24 * time.Hour } return logging.RetentionPolicy{ diff --git a/internal/pool/logging_test.go b/internal/pool/logging_test.go index cbb9de8..f6a7c1d 100644 --- a/internal/pool/logging_test.go +++ b/internal/pool/logging_test.go @@ -39,7 +39,7 @@ func TestPoolLifecycleConsoleGuidance(t *testing.T) { output := console.String() for _, expected := range []string{ "Docker Sandboxes pool is running.", - "Press Ctrl-C once to stop; wait for cleanup confirmation before closing this window.", + "Press Ctrl-C once to stop, then wait for cleanup to finish before closing this window.", "Replacement runner epar-test-002 is online; Docker Sandboxes pool is ready for the next job.", "Stopping EPAR pool. Cleaning up owned runner resources.", "Please wait; do not press Ctrl-C again or close this window.", diff --git a/internal/pool/manager.go b/internal/pool/manager.go index 496e0c4..1fbacac 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -40,6 +40,7 @@ type Manager struct { Logging *logging.Runtime AllowInsufficientStorage bool StorageOverrideCommand string + AutomaticImageLifecycle bool // AcknowledgeFailedDiagnostics permits the explicit cleanup command to // dispose an exact retained sandbox after the operator has captured the // durable failed-diagnostics evidence. Normal startup and automatic cleanup @@ -53,6 +54,8 @@ type Manager struct { buildTrustResolver func(context.Context) (hosttrust.Snapshot, error) hostTrustImageEnsurer func(context.Context) error hostTrustImageMu sync.Mutex + imageEnsureMu sync.Mutex + imageEnsured bool now func() time.Time randomFloat64 func() float64 } @@ -100,6 +103,13 @@ const ( LifecycleCleanupPending LifecyclePhase = "cleanup-pending" ) +const ( + runnerProcessRunningSentinel = "EPAR_RUNNER_PROCESS=running" + runnerProcessStoppedSentinel = "EPAR_RUNNER_PROCESS=stopped" + runnerProcessInactiveReason = "actions runner process is confirmed inactive" + runnerConfirmedInactiveCheckLimit = 2 +) + type ProvisionedInstance struct { Name string IP string @@ -143,6 +153,16 @@ func (m *Manager) Verify(ctx context.Context, opts VerifyOptions) error { if controllerLock != nil { defer controllerLock.Close() } + stopStorageLease, err := m.startStorageCatalogControllerLease() + if err != nil { + return err + } + defer stopStorageLease() + if m.AutomaticImageLifecycle { + if err := m.EnsureImage(ctx); err != nil { + return fmt.Errorf("ensure current provider artifact before verification: %w", err) + } + } opts.Instances = m.requestedInstances(opts.Instances) names := RunnerNames(m.Config.Pool.NamePrefix, opts.Instances, time.Now()) m.logger().Info("verifying instances", "provider", m.Config.Provider.Type, "operation", "verify", "instances", opts.Instances, "instanceNames", strings.Join(names, ", "), "sourceImage", m.Config.Provider.SourceImage) @@ -228,6 +248,16 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { defer controllerLock.Close() } } + stopStorageLease, err := m.startStorageCatalogControllerLease() + if err != nil { + return err + } + defer stopStorageLease() + if m.AutomaticImageLifecycle { + if err := m.EnsureImage(ctx); err != nil { + return fmt.Errorf("ensure current provider artifact before pool startup: %w", err) + } + } opts.Instances = m.requestedInstances(opts.Instances) if opts.MonitorInterval <= 0 { opts.MonitorInterval = 15 * time.Second @@ -314,6 +344,8 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { nextRetention := time.Now().Add(time.Duration(m.Config.Logging.RetentionIntervalMinutes) * time.Minute) nextHostTrustCollection := time.Time{} var currentHostTrust hosttrust.Snapshot + hostTrustBusyHandoff := make(map[string]bool) + confirmedInactiveChecks := make(map[string]int) retry := replacementRetryState{} for { select { @@ -344,7 +376,7 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { // receive a mismatching lease so no subsequent job can start. currentHostTrust = current if !dependencyCooldown { - trustRetired += m.reconcileHostTrustRunners(ctx, active, current) + trustRetired += m.reconcileHostTrustRunners(ctx, active, current, hostTrustBusyHandoff) } m.infof("host trust generation changed (%s -> %s); building replacement image\n", emptyDash(poolTrustGeneration), current.Generation) ready = false @@ -380,7 +412,7 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { } } if currentHostTrust.Generation != "" && !dependencyCooldown { - trustRetired += m.reconcileHostTrustRunners(ctx, active, currentHostTrust) + trustRetired += m.reconcileHostTrustRunners(ctx, active, currentHostTrust, hostTrustBusyHandoff) } } if dependencyCooldown { @@ -396,18 +428,29 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { for name, vm := range active { alive, reason, err := m.runnerAlive(ctx, vm) if err != nil { - m.logger().Warn("liveness check failed", "provider", m.Config.Provider.Type, "instance", name, "operation", "liveness-check", "error", err) + recordRunnerLiveness(confirmedInactiveChecks, name, alive, reason, err) + m.warnf("[%s] runner health is temporarily unknown; keeping the runner and retrying: %v\n", name, err) continue } if alive { + recordRunnerLiveness(confirmedInactiveChecks, name, alive, reason, nil) + continue + } + confirmedCount, retire := recordRunnerLiveness(confirmedInactiveChecks, name, alive, reason, nil) + if !retire { + m.warnf("[%s] runner process is confirmed inactive (%d/%d); EPAR will verify once more before cleanup\n", name, confirmedCount, runnerConfirmedInactiveCheckLimit) continue } + if reason == runnerProcessInactiveReason { + m.captureRunnerReadinessDiagnostics(name, vm.GuestLogPath) + } m.infof("[%s] runner is finished or unhealthy: %s\n", name, reason) if err := m.retireInstance(context.Background(), vm, reason); err != nil { m.warnf("[%s] retirement warning: %v\n", name, err) continue } delete(active, name) + delete(confirmedInactiveChecks, name) } } var reconcileErr error @@ -621,7 +664,13 @@ func (m *Manager) reconcilePhysicalPool(ctx context.Context, known map[string]Pr continue } alive, _, processErr := m.runnerProcessAlive(ctx, vm) - if processErr == nil && alive { + if processErr != nil { + vm.Phase = LifecycleQuarantined + reconciled[name] = vm + m.warnf("[%s] reconciliation could not verify the Actions runner process; preserving the exact instance in quarantine: %v\n", name, processErr) + continue + } + if alive { vm.Phase = LifecycleQuarantined reconciled[name] = vm continue @@ -1601,23 +1650,62 @@ func (m *Manager) runnerAlive(ctx context.Context, vm ProvisionedInstance) (bool return m.runnerProcessAlive(ctx, vm) } +func recordRunnerLiveness(confirmedInactive map[string]int, name string, alive bool, reason string, err error) (int, bool) { + if err != nil || alive { + delete(confirmedInactive, name) + return 0, false + } + if reason != runnerProcessInactiveReason { + delete(confirmedInactive, name) + return 0, true + } + confirmedInactive[name]++ + return confirmedInactive[name], confirmedInactive[name] >= runnerConfirmedInactiveCheckLimit +} + func (m *Manager) runnerProcessAlive(ctx context.Context, vm ProvisionedInstance) (bool, string, error) { - if err := m.checkRunnerProcess(ctx, vm.Name); err != nil { - return false, "actions runner process is no longer active", nil + running, err := m.probeRunnerProcess(ctx, vm.Name) + if err != nil { + return true, "runner process health could not be measured", err + } + if !running { + return false, runnerProcessInactiveReason, nil } return true, "", nil } func (m *Manager) checkRunnerProcess(ctx context.Context, name string) error { + running, err := m.probeRunnerProcess(ctx, name) + if err != nil { + return err + } + if !running { + return fmt.Errorf(runnerProcessInactiveReason) + } + return nil +} + +func (m *Manager) probeRunnerProcess(ctx context.Context, name string) (bool, error) { checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - _, err := m.execGuest(checkCtx, name, provider.ShellCommand("if test -x /opt/epar/check-runner.sh; then sudo bash /opt/epar/check-runner.sh; else systemctl is-active --quiet actions-runner.service; fi"), provider.ExecOptions{}) - return err + script := fmt.Sprintf("if test -x /opt/epar/check-runner.sh; then if sudo bash /opt/epar/check-runner.sh; then printf '%%s\\n' %s; else printf '%%s\\n' %s; fi; elif systemctl is-active --quiet actions-runner.service; then printf '%%s\\n' %s; else printf '%%s\\n' %s; fi", shellQuote(runnerProcessRunningSentinel), shellQuote(runnerProcessStoppedSentinel), shellQuote(runnerProcessRunningSentinel), shellQuote(runnerProcessStoppedSentinel)) + result, err := m.execGuest(checkCtx, name, provider.ShellCommand(script), provider.ExecOptions{SuppressTranscript: true}) + if err != nil { + return false, fmt.Errorf("execute runner process health probe: %w", err) + } + switch strings.TrimSpace(result.Stdout) { + case runnerProcessRunningSentinel: + return true, nil + case runnerProcessStoppedSentinel: + return false, nil + default: + return false, fmt.Errorf("runner process health probe returned an unsupported response") + } } func isTransientGitHubLivenessError(err error) bool { var httpErr *gh.HTTPError - return errors.As(err, &httpErr) && httpErr.StatusCode >= http.StatusInternalServerError + return errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusTooManyRequests || httpErr.StatusCode >= http.StatusInternalServerError) } func (m *Manager) retireInstance(ctx context.Context, vm ProvisionedInstance, reason string) error { @@ -1740,15 +1828,17 @@ func (m *Manager) execGuest(ctx context.Context, name string, cmd []string, opts if timeout <= 0 { timeout = 15 * time.Minute } - if opts.LogPath == "" { - opts.LogPath = m.instanceLogPath(name, ".guest.log") - } - transcript, err := m.transcript(opts.LogPath, name, transcriptComponent(opts.LogPath)) - if err != nil { - return provider.ExecResult{}, err + if !opts.SuppressTranscript { + if opts.LogPath == "" { + opts.LogPath = m.instanceLogPath(name, ".guest.log") + } + transcript, err := m.transcript(opts.LogPath, name, transcriptComponent(opts.LogPath)) + if err != nil { + return provider.ExecResult{}, err + } + opts.Stdout = transcript.Stdout + opts.Stderr = transcript.Stderr } - opts.Stdout = transcript.Stdout - opts.Stderr = transcript.Stderr cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() if m.Lifecycle != nil { diff --git a/internal/pool/manager_test.go b/internal/pool/manager_test.go index 5491051..2337e15 100644 --- a/internal/pool/manager_test.go +++ b/internal/pool/manager_test.go @@ -217,7 +217,12 @@ func TestRetirementSuccessIsNotReversedByTranscriptCloseFailure(t *testing.T) { } func TestRunnerAliveRetiresIdleRunnerWhenServiceIsInactive(t *testing.T) { - provider := &fakeProvider{execErr: errors.New("inactive")} + provider := &fakeProvider{execFunc: func(_ context.Context, _ string, command []string, _ provider.ExecOptions) (provider.ExecResult, error) { + if strings.Contains(strings.Join(command, " "), runnerProcessRunningSentinel) { + return provider.ExecResult{Stdout: runnerProcessStoppedSentinel + "\n"}, nil + } + return provider.ExecResult{}, nil + }} github := &fakeGitHub{ runner: gh.Runner{Name: "epar-test-1", Status: "online", Busy: false}, found: true, @@ -231,12 +236,66 @@ func TestRunnerAliveRetiresIdleRunnerWhenServiceIsInactive(t *testing.T) { if alive { t.Fatal("runnerAlive() alive = true, want false") } - if reason != "actions runner process is no longer active" { + if reason != runnerProcessInactiveReason { t.Fatalf("reason = %q", reason) } if got := atomic.LoadInt32(&provider.execCalls); got != 1 { t.Fatalf("service check ran %d time(s), want 1", got) } + provider.mu.Lock() + defer provider.mu.Unlock() + if len(provider.execOptions) != 1 || !provider.execOptions[0].SuppressTranscript { + t.Fatal("machine-readable health probe was not excluded from the guest transcript") + } +} + +func TestRunnerAlivePreservesRunnerWhenProcessProbeIsUnavailable(t *testing.T) { + provider := &fakeProvider{execErr: context.DeadlineExceeded} + github := &fakeGitHub{ + runner: gh.Runner{Name: "epar-test-1", Status: "online", Busy: false}, + found: true, + } + manager := Manager{Provider: provider, GitHub: github} + + alive, reason, err := manager.runnerAlive(context.Background(), ProvisionedInstance{Name: "epar-test-1"}) + if err == nil { + t.Fatal("runnerAlive() error = nil, want unavailable process-probe error") + } + if !alive { + t.Fatalf("runnerAlive() alive = false, reason = %q; an unavailable probe must preserve the runner", reason) + } + if got := atomic.LoadInt32(&provider.deleteCalls); got != 0 { + t.Fatalf("provider delete calls = %d, want 0", got) + } +} + +func TestRunnerProcessProbeRejectsUnsupportedOutput(t *testing.T) { + provider := &fakeProvider{execFunc: func(context.Context, string, []string, provider.ExecOptions) (provider.ExecResult, error) { + return provider.ExecResult{Stdout: "unexpected\n"}, nil + }} + manager := Manager{Provider: provider} + if _, err := manager.probeRunnerProcess(context.Background(), "epar-test-1"); err == nil || !strings.Contains(err.Error(), "unsupported response") { + t.Fatalf("probeRunnerProcess() error = %v, want unsupported-response error", err) + } +} + +func TestRunnerLivenessRequiresConsecutiveConfirmedInactiveProbes(t *testing.T) { + counts := make(map[string]int) + if count, retire := recordRunnerLiveness(counts, "runner-1", false, runnerProcessInactiveReason, nil); count != 1 || retire { + t.Fatalf("first confirmed inactive probe = count %d retire %t, want 1 false", count, retire) + } + if count, retire := recordRunnerLiveness(counts, "runner-1", true, "", context.DeadlineExceeded); count != 0 || retire { + t.Fatalf("unavailable probe = count %d retire %t, want 0 false", count, retire) + } + if count, retire := recordRunnerLiveness(counts, "runner-1", false, runnerProcessInactiveReason, nil); count != 1 || retire { + t.Fatalf("first probe after uncertainty = count %d retire %t, want 1 false", count, retire) + } + if count, retire := recordRunnerLiveness(counts, "runner-1", false, runnerProcessInactiveReason, nil); count != 2 || !retire { + t.Fatalf("second consecutive confirmed inactive probe = count %d retire %t, want 2 true", count, retire) + } + if count, retire := recordRunnerLiveness(counts, "runner-2", false, "GitHub runner record is gone", nil); count != 0 || !retire { + t.Fatalf("authoritative remote absence = count %d retire %t, want 0 true", count, retire) + } } func TestRunnerAliveFallsBackToServiceCheckWhenGitHubLivenessHasServerError(t *testing.T) { @@ -1342,6 +1401,9 @@ func (p *fakeProvider) Exec(ctx context.Context, name string, command []string, if int(call) <= len(p.execErrs) { return provider.ExecResult{}, p.execErrs[call-1] } + if p.execErr == nil && strings.Contains(commandText, runnerProcessRunningSentinel) { + return provider.ExecResult{Stdout: runnerProcessRunningSentinel + "\n"}, nil + } return provider.ExecResult{}, p.execErr } diff --git a/internal/pool/storage_catalog_lease.go b/internal/pool/storage_catalog_lease.go new file mode 100644 index 0000000..5af4635 --- /dev/null +++ b/internal/pool/storage_catalog_lease.go @@ -0,0 +1,100 @@ +package pool + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" +) + +const ( + storageCatalogControllerLeaseLifetime = 2 * time.Minute + storageCatalogControllerLeaseRefresh = 30 * time.Second +) + +func (m *Manager) startStorageCatalogControllerLease() (func(), error) { + if !m.AutomaticImageLifecycle { + return func() {}, nil + } + store, err := storagecatalog.Open("") + if err != nil { + return nil, err + } + projectRoot := strings.TrimSpace(m.ProjectRoot) + if projectRoot == "" { + projectRoot = "." + } + configPath := strings.TrimSpace(m.ConfigPath) + if configPath == "" { + configPath = filepath.Join(projectRoot, ".local", "config.yml") + } + configuredLimit := strings.TrimSpace(m.Config.Storage.BuildCacheLimit) + if configuredLimit == "" { + configuredLimit = "20GiB" + } + limit, err := config.ParseByteSize(configuredLimit) + if err != nil { + return nil, err + } + refresh := func(now time.Time) error { + _, updateErr := store.WithLock(now, func(value *storagecatalog.Catalog) error { + record, registerErr := storagecatalog.RegisterConfig(value, projectRoot, configPath, now) + if registerErr != nil { + return registerErr + } + for index := range value.Configs { + if value.Configs[index].ID == record.ID { + value.Configs[index].BuildCacheLimitBytes = uint64(limit) + break + } + } + return storagecatalog.RefreshControllerLease(value, record.ID, now.Add(storageCatalogControllerLeaseLifetime)) + }) + return updateErr + } + if err := refresh(m.currentTime()); err != nil { + return nil, fmt.Errorf("register EPAR storage controller lease: %w", err) + } + leaseContext, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(storageCatalogControllerLeaseRefresh) + defer ticker.Stop() + for { + select { + case <-leaseContext.Done(): + return + case <-ticker.C: + if err := refresh(m.currentTime()); err != nil { + m.warnf("EPAR storage controller lease refresh warning: %v\n", err) + } + } + } + }() + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + <-done + now := m.currentTime() + _, releaseErr := store.WithLock(now, func(value *storagecatalog.Catalog) error { + configID, idErr := storagecatalog.ConfigID(projectRoot, configPath) + if idErr != nil { + return idErr + } + storagecatalog.ReleaseControllerLease(value, configID) + return nil + }) + if releaseErr != nil { + m.warnf("EPAR storage controller lease release warning: %v\n", releaseErr) + } + }) + } + return stop, nil +} diff --git a/internal/pool/storage_preflight.go b/internal/pool/storage_preflight.go index dc47662..3dfb02b 100644 --- a/internal/pool/storage_preflight.go +++ b/internal/pool/storage_preflight.go @@ -16,6 +16,21 @@ const ( ) func (m *Manager) preflightStorage(operation string, peakBytes uint64) error { + return m.preflightStorageAttempt(operation, peakBytes, false) +} + +func (m *Manager) preflightStorageAttempt(operation string, peakBytes uint64, housekeepingRetried bool) error { + if !housekeepingRetried && m.AutomaticImageLifecycle && operation == "instance-create" { + pending, pendingErr := m.imageCoordinator().StorageCleanupPending() + if pendingErr != nil { + m.warnf("EPAR cleanup-pending check before %s was deferred: %v\n", operation, pendingErr) + } else if pending { + if cleanupErr := m.imageCoordinator().HousekeepStorage(context.Background()); cleanupErr != nil { + m.warnf("EPAR cleanup-pending retry before %s was deferred: %v\n", operation, cleanupErr) + } + housekeepingRetried = true + } + } minimumFree, err := config.EffectiveMinimumFreeBytes(m.Config) if err != nil { return err @@ -52,6 +67,12 @@ func (m *Manager) preflightStorage(operation string, peakBytes uint64) error { return fmt.Errorf("evaluate storage capacity before %s: %w", operation, err) } if check.Status != storage.CapacityReady { + if !housekeepingRetried && m.AutomaticImageLifecycle && operation == "instance-create" { + if cleanupErr := m.imageCoordinator().HousekeepStorage(context.Background()); cleanupErr != nil { + m.warnf("EPAR storage housekeeping retry before %s was deferred: %v\n", operation, cleanupErr) + } + return m.preflightStorageAttempt(operation, peakBytes, true) + } admissionErr := storageAdmissionError(operation, surface, requirement, check, m.Config.Provider.Type, m.StorageOverrideCommand) if m.AllowInsufficientStorage { m.warnStorageOverride(operation, admissionErr) @@ -93,6 +114,12 @@ func (m *Manager) preflightStorage(operation string, peakBytes uint64) error { return fmt.Errorf("evaluate storage capacity before %s: %w", operation, err) } if check.Status != storage.CapacityReady { + if !housekeepingRetried && m.AutomaticImageLifecycle && operation == "instance-create" { + if cleanupErr := m.imageCoordinator().HousekeepStorage(context.Background()); cleanupErr != nil { + m.warnf("EPAR storage housekeeping retry before %s was deferred: %v\n", operation, cleanupErr) + } + return m.preflightStorageAttempt(operation, peakBytes, true) + } admissionErr := storageAdmissionError(operation, surface, requirement, check, m.Config.Provider.Type, m.StorageOverrideCommand) if m.AllowInsufficientStorage { m.warnStorageOverride(operation, admissionErr) diff --git a/internal/provider/dockersandboxes/json_parsing.go b/internal/provider/dockersandboxes/json_parsing.go index f229e2c..d9c9b5d 100644 --- a/internal/provider/dockersandboxes/json_parsing.go +++ b/internal/provider/dockersandboxes/json_parsing.go @@ -71,24 +71,6 @@ func parseTemplateInventory(data []byte) ([]cachedTemplate, error) { return images, nil } -func parseLocalTemplateImage(data []byte) (LocalTemplateImage, error) { - var record map[string]json.RawMessage - if err := decodeStrictJSON(data, &record); err != nil { - return LocalTemplateImage{}, fmt.Errorf("local docker image inspection returned an unsupported json schema") - } - digest, digestErr := requiredJSONString(record, "Id") - osName, osErr := requiredJSONString(record, "Os") - architecture, architectureErr := requiredJSONString(record, "Architecture") - if digestErr != nil || osErr != nil || architectureErr != nil || !validFullTemplateDigest(digest) { - return LocalTemplateImage{}, fmt.Errorf("local docker image inspection returned an unsupported image schema") - } - platform := osName + "/" + architecture - if platform != "linux/amd64" && platform != "linux/arm64" { - return LocalTemplateImage{}, fmt.Errorf("local docker image inspection returned an unsupported linux template platform") - } - return LocalTemplateImage{Digest: digest, Platform: platform}, nil -} - func parseInventory(data []byte) ([]provider.InventoryItem, error) { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() diff --git a/internal/provider/dockersandboxes/promotion/preflight.go b/internal/provider/dockersandboxes/promotion/preflight.go index 24e3741..81ebea0 100644 --- a/internal/provider/dockersandboxes/promotion/preflight.go +++ b/internal/provider/dockersandboxes/promotion/preflight.go @@ -50,7 +50,6 @@ type PreflightOptions struct { NativeController bool ControllerRevision string RunSBX func(context.Context, []string) ([]byte, error) - InspectTemplate func(context.Context, string) (string, error) HostSpace func(string) (HostSpace, error) CheckVirtualization func() error } @@ -84,7 +83,6 @@ func LocalPreflight(ctx context.Context, record Record, projectRoot string, nati NativeController: nativeController, ControllerRevision: controllerRevision, RunSBX: runSBXCommand, - InspectTemplate: inspectLocalTemplateImage, HostSpace: sandboxHostSpace, CheckVirtualization: sandboxVirtualizationAvailable, }) @@ -163,20 +161,6 @@ func RunPreflight(ctx context.Context, record Record, opts PreflightOptions) Pre } else if err := verifyPromotedTemplate(templateOutput, record.Template, record.TemplateCacheID); err != nil { add("promoted template", err.Error(), fmt.Sprintf("Build and load the exact promoted template %s with cache ID %s, then rerun setup.", record.Template, record.TemplateCacheID)) } - if opts.InspectTemplate == nil { - add("promoted template evidence", "the independent local Docker image identity reader is unavailable", "Keep the full promoted template image in the local Docker image store, then rerun setup.") - } else { - fullIdentity, inspectErr := opts.InspectTemplate(ctx, record.Template) - fullIdentity = strings.TrimSpace(fullIdentity) - switch { - case inspectErr != nil: - add("promoted template evidence", inspectErr.Error(), "Restore the exact locally built and hash-anchored promoted template image, then rerun setup.") - case !validSHA256(fullIdentity): - add("promoted template evidence", "local Docker image inspection did not return a full lowercase sha256 identity", "Restore the exact locally built and hash-anchored promoted template image, then rerun setup.") - case fullIdentity != record.TemplateDigest: - add("promoted template evidence", fmt.Sprintf("full local Docker image identity is %s, want %s", fullIdentity, record.TemplateDigest), "Restore the exact locally built and hash-anchored promoted template image, then rerun setup.") - } - } policyOutput, policyErr := opts.RunSBX(ctx, []string{"policy", "ls", "--include-inactive", "--json"}) if policyErr != nil { @@ -221,26 +205,6 @@ func runSBXCommand(ctx context.Context, args []string) ([]byte, error) { return append([]byte(nil), stdout.Bytes()...), nil } -func inspectLocalTemplateImage(ctx context.Context, reference string) (string, error) { - command := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", reference) - command.Env = sandboxCommandEnvironment() - stdout := &preflightBuffer{limit: 1024} - stderr := &preflightBuffer{limit: 4096} - command.Stdout = stdout - command.Stderr = stderr - err := command.Run() - if stdout.overflow || stderr.overflow { - err = errors.Join(err, errors.New("Docker image inspection output limit exceeded")) - } - if err != nil { - if detail := strings.TrimSpace(stderr.String()); detail != "" { - return "", fmt.Errorf("docker image inspect failed: %w: %s", err, detail) - } - return "", fmt.Errorf("docker image inspect failed: %w", err) - } - return stdout.String(), nil -} - func sandboxCommandEnvironment() []string { environment := make([]string, 0, len(os.Environ())) for _, item := range os.Environ() { diff --git a/internal/provider/dockersandboxes/promotion/preflight_test.go b/internal/provider/dockersandboxes/promotion/preflight_test.go index 75cd87b..04413b0 100644 --- a/internal/provider/dockersandboxes/promotion/preflight_test.go +++ b/internal/provider/dockersandboxes/promotion/preflight_test.go @@ -29,12 +29,6 @@ func TestRunPreflightPassesEveryIndependentGateWithExactReadOnlyArgv(t *testing. } return append([]byte(nil), output...), nil }, - InspectTemplate: func(_ context.Context, reference string) (string, error) { - if reference != record.Template { - t.Fatalf("inspected template = %q, want %q", reference, record.Template) - } - return record.TemplateDigest, nil - }, HostSpace: func(path string) (HostSpace, error) { if path != storageRoot { t.Fatalf("capacity path = %q, want provider storage %q", path, storageRoot) @@ -140,15 +134,6 @@ func TestRunPreflightFailsClosedForEveryAdmissionGate(t *testing.T) { outputs["template\x00ls\x00--json"] = []byte(`{"images":[{"id":"bbbbbbbbbbbb","repository":"docker.io/library/epar-template","tag":"promoted","flavor":"shell","created_at":"2026-07-23T00:00:00Z","size":1024}]}`) }, }, - { - name: "full template evidence", - gate: "promoted template evidence", - edit: func(_ *Record, _ map[string][]byte, opts *PreflightOptions) { - opts.InspectTemplate = func(context.Context, string) (string, error) { - return "sha256:" + strings.Repeat("9", 64), nil - } - }, - }, { name: "policy", gate: "promoted policy", @@ -170,9 +155,6 @@ func TestRunPreflightFailsClosedForEveryAdmissionGate(t *testing.T) { NativeController: true, ControllerRevision: record.EPARRevision, RunSBX: fixtureCommandRunner(outputs), - InspectTemplate: func(context.Context, string) (string, error) { - return record.TemplateDigest, nil - }, HostSpace: func(string) (HostSpace, error) { return HostSpace{AvailableBytes: required, TotalBytes: 500 << 30}, nil }, @@ -209,9 +191,6 @@ func TestRunPreflightAcceptsDiagnosticWarningsAndSkips(t *testing.T) { NativeController: true, ControllerRevision: record.EPARRevision, RunSBX: fixtureCommandRunner(outputs), - InspectTemplate: func(context.Context, string) (string, error) { - return record.TemplateDigest, nil - }, HostSpace: func(string) (HostSpace, error) { return HostSpace{AvailableBytes: required, TotalBytes: 500 << 30}, nil }, @@ -234,9 +213,6 @@ func TestRunPreflightDiagnosticFailureExplainsHowToInspectHints(t *testing.T) { NativeController: true, ControllerRevision: record.EPARRevision, RunSBX: fixtureCommandRunner(outputs), - InspectTemplate: func(context.Context, string) (string, error) { - return record.TemplateDigest, nil - }, HostSpace: func(string) (HostSpace, error) { return HostSpace{AvailableBytes: required, TotalBytes: 500 << 30}, nil }, @@ -259,9 +235,6 @@ func TestRunPreflightDoesNotInferVirtualizationFromDiagnostics(t *testing.T) { NativeController: true, ControllerRevision: record.EPARRevision, RunSBX: fixtureCommandRunner(outputs), - InspectTemplate: func(context.Context, string) (string, error) { - return record.TemplateDigest, nil - }, HostSpace: func(string) (HostSpace, error) { return HostSpace{AvailableBytes: required, TotalBytes: 500 << 30}, nil }, diff --git a/internal/provider/dockersandboxes/provider.go b/internal/provider/dockersandboxes/provider.go index 3ff21f2..abdc08b 100644 --- a/internal/provider/dockersandboxes/provider.go +++ b/internal/provider/dockersandboxes/provider.go @@ -53,12 +53,10 @@ docker info --format '{{json .ServerVersion}}'` type Provider struct { Binary string - runCommand runCommandFunc - inspectImage inspectImageFunc - inspectTemplate inspectLocalTemplateFunc - activeMu sync.RWMutex - activeTemplate provider.TemplateArtifact - dryRun bool + runCommand runCommandFunc + activeMu sync.RWMutex + activeTemplate provider.TemplateArtifact + dryRun bool } type instanceReceipt struct { @@ -78,13 +76,6 @@ type CachedTemplate struct { SizeBytes int64 } -// LocalTemplateImage is the independently read local Docker image identity -// and guest platform for a repository:tag template reference. -type LocalTemplateImage struct { - Digest string - Platform string -} - // HostReadiness is the validated machine-readable summary returned by // `sbx diagnose --output json`. type HostReadiness struct { @@ -106,8 +97,6 @@ type commandRequest struct { } type runCommandFunc func(ctx context.Context, request commandRequest) (provider.ExecResult, error) -type inspectImageFunc func(context.Context, string) (string, error) -type inspectLocalTemplateFunc func(context.Context, string) (LocalTemplateImage, error) func New(binary string) *Provider { return NewWithDryRun(binary, false) @@ -169,7 +158,7 @@ func (p *Provider) Create(ctx context.Context, request provider.CreateRequest) ( if err := p.VerifyAdmission(ctx); err != nil { return provider.Instance{}, err } - if err := p.verifyCachedTemplate(ctx, request.Template, request.TemplateDigest); err != nil { + if err := p.verifyImportedTemplate(ctx, request.Template, request.TemplateDigest); err != nil { return provider.Instance{}, err } items, err := p.inventoryVerified(ctx) @@ -279,14 +268,14 @@ func (p *Provider) ImportTemplate(ctx context.Context, archivePath string) error return nil } -func (p *Provider) VerifyTemplate(ctx context.Context, artifact provider.TemplateArtifact) error { +func (p *Provider) VerifyImportedTemplate(ctx context.Context, artifact provider.TemplateArtifact) error { if artifact.Reference == "" || !validFullTemplateDigest(artifact.Digest) { return fmt.Errorf("Docker Sandboxes template reference and digest are required") } if artifact.CacheID != strings.TrimPrefix(artifact.Digest, "sha256:")[:12] { return fmt.Errorf("Docker Sandboxes template cache ID does not match its full digest") } - return p.verifyCachedTemplate(ctx, artifact.Reference, artifact.Digest) + return p.verifyImportedTemplate(ctx, artifact.Reference, artifact.Digest) } func (p *Provider) ActivateTemplate(artifact provider.TemplateArtifact) error { @@ -308,6 +297,79 @@ func (p *Provider) ActivateTemplate(artifact provider.TemplateArtifact) error { return nil } +// RemoveTemplate removes one exact imported template cache identity. The +// Docker-managed shell-docker base template is never an EPAR cleanup target. +func (p *Provider) RemoveTemplate(ctx context.Context, artifact provider.TemplateArtifact) error { + if artifact.Reference == "docker.io/docker/sandbox-templates:shell-docker" || artifact.Reference == "docker/sandbox-templates:shell-docker" { + return fmt.Errorf("refusing to remove the Docker Sandboxes shell-docker base template") + } + if artifact.CacheID == "" || len(artifact.CacheID) != 12 { + return fmt.Errorf("Docker Sandboxes template cleanup requires an exact 12-character cache ID") + } + instances, err := p.Inventory(ctx) + if err != nil { + return fmt.Errorf("verify active Docker Sandboxes before template cleanup: %w", err) + } + if len(instances) != 0 { + return fmt.Errorf("refusing template cleanup while %d Docker Sandbox instance(s) exist", len(instances)) + } + templates, err := p.CachedTemplates(ctx) + if err != nil { + return err + } + found := false + for _, item := range templates { + if item.CacheID != artifact.CacheID { + continue + } + if item.Reference != artifact.Reference { + return fmt.Errorf("Docker Sandboxes template cache identity %s now belongs to %s, not %s", artifact.CacheID, item.Reference, artifact.Reference) + } + found = true + break + } + if !found { + return nil + } + if _, err := p.run(ctx, commandRequest{ + args: []string{"template", "rm", artifact.CacheID}, + operation: "remove exact Docker Sandboxes runner template", + outputLimit: diagnosticOutputLimit, + }); err != nil { + return err + } + templates, err = p.CachedTemplates(ctx) + if err != nil { + return err + } + for _, item := range templates { + if item.CacheID == artifact.CacheID { + return fmt.Errorf("Docker Sandboxes template %s still exists after exact removal", artifact.CacheID) + } + } + return nil +} + +func (p *Provider) ObserveTemplate(ctx context.Context, artifact provider.TemplateArtifact) (bool, error) { + if artifact.Reference == "" || artifact.CacheID == "" || len(artifact.CacheID) != 12 { + return false, fmt.Errorf("Docker Sandboxes template observation requires an exact reference and 12-character cache ID") + } + templates, err := p.CachedTemplates(ctx) + if err != nil { + return false, err + } + for _, item := range templates { + if item.CacheID != artifact.CacheID { + continue + } + if item.Reference != artifact.Reference { + return false, fmt.Errorf("Docker Sandboxes template cache identity %s now belongs to %s, not %s", artifact.CacheID, item.Reference, artifact.Reference) + } + return true, nil + } + return false, nil +} + // VerifyAdmission fail-closes on provider-wide channels Docker Sandboxes can // inject into every sandbox. EPAR deliberately does not consume global secrets; // repository and workflow input can never opt out of this check. @@ -713,34 +775,7 @@ func (p *Provider) CachedTemplates(ctx context.Context) ([]CachedTemplate, error return templates, nil } -// InspectLocalTemplate independently reads a local Docker image's full -// identity and guest platform. The supplied reference must be a repository:tag -// reference; digests, untagged repositories, and shell-style input are refused. -func (p *Provider) InspectLocalTemplate(ctx context.Context, reference string) (LocalTemplateImage, error) { - if err := validateLocalTemplateReference(reference); err != nil { - return LocalTemplateImage{}, err - } - inspect := p.inspectTemplate - if inspect == nil { - inspect = inspectLocalDockerImage - } - image, err := inspect(ctx, reference) - if err != nil { - return LocalTemplateImage{}, err - } - if !validFullTemplateDigest(image.Digest) { - return LocalTemplateImage{}, fmt.Errorf("local docker image inspection did not return a full lowercase sha256 identity") - } - if image.Platform != "linux/amd64" && image.Platform != "linux/arm64" { - return LocalTemplateImage{}, fmt.Errorf("local docker image inspection did not return a supported linux template platform") - } - return image, nil -} - -func (p *Provider) verifyCachedTemplate(ctx context.Context, reference, digest string) error { - if err := p.verifyLocalTemplateImage(ctx, reference, digest); err != nil { - return err - } +func (p *Provider) verifyImportedTemplate(ctx context.Context, reference, digest string) error { result, err := p.run(ctx, commandRequest{args: []string{"template", "ls", "--json"}, operation: "verify cached docker sandbox template"}) if err != nil { return err @@ -757,63 +792,12 @@ func (p *Provider) verifyCachedTemplate(ctx context.Context, reference, digest s for _, image := range images { if image.Repository == repository && image.Tag == tag { if image.ID != wantCacheID { - return fmt.Errorf("cached docker sandbox template cache ID did not match the first 12 hexadecimal characters of the independently verified full local image identity") + return fmt.Errorf("cached Docker Sandbox template ID %s does not match imported archive identity %s", image.ID, wantCacheID) } return nil } } - return fmt.Errorf("configured docker sandbox template was not present in the local cache") -} - -func (p *Provider) verifyLocalTemplateImage(ctx context.Context, reference, expectedDigest string) error { - inspect := p.inspectImage - if inspect == nil { - inspect = inspectLocalDockerImageDigest - } - actualDigest, err := inspect(ctx, reference) - if err != nil { - return fmt.Errorf("verify full local docker sandbox template image identity: %w", err) - } - actualDigest = strings.TrimSpace(actualDigest) - if !validFullTemplateDigest(actualDigest) { - return fmt.Errorf("local docker image inspection did not return a full lowercase sha256 identity") - } - if actualDigest != expectedDigest { - return fmt.Errorf("full local docker sandbox template image identity %s did not match configured identity %s", actualDigest, expectedDigest) - } - return nil -} - -func inspectLocalDockerImageDigest(ctx context.Context, reference string) (string, error) { - image, err := inspectLocalDockerImage(ctx, reference) - if err != nil { - return "", err - } - return image.Digest, nil -} - -func inspectLocalDockerImage(ctx context.Context, reference string) (LocalTemplateImage, error) { - command := exec.CommandContext(ctx, "docker", localTemplateInspectArgs(reference)...) - command.Env = childEnvironment(nil) - stdout := &boundedBuffer{limit: diagnosticOutputLimit} - stderr := &boundedBuffer{limit: 4096} - command.Stdout = stdout - command.Stderr = stderr - err := command.Run() - if stdout.exceeded || stderr.exceeded { - err = errors.Join(err, fmt.Errorf("docker image inspection output limit exceeded")) - } - if err != nil { - if detail := strings.TrimSpace(stderr.String()); detail != "" { - return LocalTemplateImage{}, fmt.Errorf("docker image inspect failed: %w: %s", err, detail) - } - return LocalTemplateImage{}, fmt.Errorf("docker image inspect failed: %w", err) - } - return parseLocalTemplateImage([]byte(stdout.String())) -} - -func localTemplateInspectArgs(reference string) []string { - return []string{"image", "inspect", "--format", "{{json .}}", reference} + return fmt.Errorf("%w: configured Docker Sandbox template was not present in the authoritative Sandbox cache", provider.ErrTemplateNotFound) } func validFullTemplateDigest(value string) bool { @@ -1042,3 +1026,5 @@ var _ provider.AdmissionVerifier = (*Provider)(nil) var _ provider.InstanceAdmissionVerifier = (*Provider)(nil) var _ provider.PolicyManager = (*Provider)(nil) var _ provider.TemplateArtifactRuntime = (*Provider)(nil) +var _ provider.TemplateArtifactCleaner = (*Provider)(nil) +var _ provider.TemplateArtifactObserver = (*Provider)(nil) diff --git a/internal/provider/dockersandboxes/provider_test.go b/internal/provider/dockersandboxes/provider_test.go index 2487edf..1861f83 100644 --- a/internal/provider/dockersandboxes/provider_test.go +++ b/internal/provider/dockersandboxes/provider_test.go @@ -57,6 +57,45 @@ func TestStartDaemonUsesExactDetachedCommand(t *testing.T) { done() } +func TestRemoveTemplateUsesExactCacheIDAndRefusesLiveSandboxes(t *testing.T) { + artifact := provider.TemplateArtifact{ + Reference: "docker.io/library/epar-template:one", + CacheID: "aaaaaaaaaaaa", + Digest: "sha256:aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000", + } + template := `{"images":[{"id":"aaaaaaaaaaaa","repository":"docker.io/library/epar-template","tag":"one","flavor":"","created_at":"2026-07-29T00:00:00Z","size":1024}]}` + p, done := scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: `{"sandboxes":[]}`}}, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: template}}, + commandStep{args: []string{"template", "rm", "aaaaaaaaaaaa"}}, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: `{"images":[]}`}}, + ) + if err := p.RemoveTemplate(context.Background(), artifact); err != nil { + t.Fatal(err) + } + done() + + p, done = scriptedProvider(t, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + ) + if err := p.RemoveTemplate(context.Background(), artifact); err == nil || !strings.Contains(err.Error(), "while 1 Docker Sandbox") { + t.Fatalf("RemoveTemplate() error = %v, want live Sandbox refusal", err) + } + done() +} + +func TestObserveTemplateRequiresExactReferenceAndCacheID(t *testing.T) { + artifact := provider.TemplateArtifact{Reference: "docker.io/library/epar-template:one", CacheID: "aaaaaaaaaaaa"} + p, done := scriptedProvider(t, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: `{"images":[{"id":"aaaaaaaaaaaa","repository":"docker.io/library/epar-template","tag":"one","flavor":"","created_at":"2026-07-29T00:00:00Z","size":1024}]}`}}, + ) + exists, err := p.ObserveTemplate(context.Background(), artifact) + if err != nil || !exists { + t.Fatalf("ObserveTemplate() = %t, %v, want true", exists, err) + } + done() +} + type commandStep struct { args []string result provider.ExecResult @@ -80,13 +119,6 @@ func (writer *cancellationSignalWriter) Write(data []byte) (int, error) { func scriptedProvider(t *testing.T, steps ...commandStep) (*Provider, func()) { t.Helper() p := New("sbx-test-double") - p.inspectImage = func(_ context.Context, reference string) (string, error) { - t.Helper() - if reference != testTemplate { - t.Fatalf("inspected image reference = %q, want %q", reference, testTemplate) - } - return testDigest, nil - } index := 0 p.runCommand = func(_ context.Context, request commandRequest) (provider.ExecResult, error) { t.Helper() @@ -198,36 +230,26 @@ func TestCreateFailsClosedOnCachedTemplateIdentityMismatch(t *testing.T) { commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: mismatch}}, ) - if _, err := p.Create(context.Background(), validCreateRequest()); err == nil || !strings.Contains(err.Error(), "did not match") { - t.Fatalf("err = %v", err) - } - done() -} - -func TestCreateFailsClosedWhenFullLocalImageIdentityDiffersDespiteMatchingCacheID(t *testing.T) { - p, done := scriptedProvider(t, - commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, - commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, - ) - p.inspectImage = func(context.Context, string) (string, error) { - return "sha256:39cf20eca861ffffffffffffffffffffffffffffffffffffffffffffffffffff", nil - } - if _, err := p.Create(context.Background(), validCreateRequest()); err == nil || !strings.Contains(err.Error(), "full local docker sandbox template image identity") { + if _, err := p.Create(context.Background(), validCreateRequest()); err == nil || !strings.Contains(err.Error(), "does not match") { t.Fatalf("err = %v", err) } done() } -func TestCreateFailsClosedWhenFullLocalImageIdentityCannotBeRead(t *testing.T) { +func TestCreateSucceedsWithImportedTemplateAndNoDockerStagingImage(t *testing.T) { p, done := scriptedProvider(t, commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: templateListJSON}}, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: `{"sandboxes":[]}`}}, + commandStep{args: []string{"create", "--name", testName, "--cpus", "4", "--memory", "8g", "--template", testTemplate, "shell", testWorkspace}, environment: map[string]string{}}, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"ports", testName, "--json"}, result: provider.ExecResult{Stdout: emptyPortsJSON}}, + commandStep{args: []string{"inspect", "--json", testName}, result: provider.ExecResult{Stdout: inspectionJSON}}, + commandStep{args: []string{"exec", "-i", testName, "--", "bash", "-lc", directWorkspaceVerificationScript}}, ) - p.inspectImage = func(context.Context, string) (string, error) { - return "", errors.New("local image missing") - } - if _, err := p.Create(context.Background(), validCreateRequest()); err == nil || !strings.Contains(err.Error(), "local image missing") { - t.Fatalf("err = %v", err) + if _, err := p.Create(context.Background(), validCreateRequest()); err != nil { + t.Fatal(err) } done() } @@ -399,72 +421,6 @@ func TestReadGlobalNetworkPolicyFailsClosedOnMalformedJSON(t *testing.T) { done() } -func TestLocalTemplateInspectionUsesExactDockerImageInspectArgv(t *testing.T) { - if got, want := localTemplateInspectArgs(testTemplate), []string{"image", "inspect", "--format", "{{json .}}", testTemplate}; !reflect.DeepEqual(got, want) { - t.Fatalf("docker image inspect args = %#v, want %#v", got, want) - } -} - -func TestParseLocalTemplateImageValidatesFullIdentityAndPlatform(t *testing.T) { - valid := `{"Id":"` + testDigest + `","Os":"linux","Architecture":"amd64"}` - image, err := parseLocalTemplateImage([]byte(valid)) - if err != nil { - t.Fatal(err) - } - if image.Digest != testDigest || image.Platform != "linux/amd64" { - t.Fatalf("local template image = %#v", image) - } - for _, fixture := range []string{ - `{"Id":"sha256:short","Os":"linux","Architecture":"amd64"}`, - `{"Id":"` + testDigest + `","Os":"linux","Architecture":"s390x"}`, - `{"Id":"` + testDigest + `","Os":"windows","Architecture":"amd64"}`, - `{"Id":"` + testDigest + `","Os":"linux","Architecture":"amd64"} trailing`, - } { - if _, err := parseLocalTemplateImage([]byte(fixture)); err == nil { - t.Fatalf("invalid local image inspection was accepted: %s", fixture) - } - } -} - -func TestInspectLocalTemplateRejectsNonRepositoryTagReference(t *testing.T) { - p := New("sbx-test-double") - p.inspectTemplate = func(context.Context, string) (LocalTemplateImage, error) { - t.Fatal("invalid local template reference reached Docker image inspection") - return LocalTemplateImage{}, nil - } - for _, reference := range []string{"", "template", "template@sha256:deadbeef", "template:tag/with-slash", "-template:tag", "template:tag;other"} { - if _, err := p.InspectLocalTemplate(context.Background(), reference); err == nil { - t.Fatalf("invalid local template reference was accepted: %q", reference) - } - } -} - -func TestInspectLocalTemplatePreservesValidatedIdentityAndPlatform(t *testing.T) { - p := New("sbx-test-double") - p.inspectTemplate = func(_ context.Context, reference string) (LocalTemplateImage, error) { - if reference != testTemplate { - t.Fatalf("inspected local template = %q, want %q", reference, testTemplate) - } - return LocalTemplateImage{Digest: testDigest, Platform: "linux/arm64"}, nil - } - image, err := p.InspectLocalTemplate(context.Background(), testTemplate) - if err != nil { - t.Fatal(err) - } - if image.Digest != testDigest || image.Platform != "linux/arm64" { - t.Fatalf("local template = %#v", image) - } - for _, invalid := range []LocalTemplateImage{ - {Digest: "sha256:short", Platform: "linux/amd64"}, - {Digest: testDigest, Platform: "linux/s390x"}, - } { - p.inspectTemplate = func(context.Context, string) (LocalTemplateImage, error) { return invalid, nil } - if _, err := p.InspectLocalTemplate(context.Background(), testTemplate); err == nil { - t.Fatalf("invalid local template metadata was accepted: %#v", invalid) - } - } -} - func TestDiagnosticsGateFailsClosedBeforeMutation(t *testing.T) { p, done := scriptedProvider(t, commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: `{"version":"1.0","checks":[{"name":"daemon","status":"fail","message":"unhealthy","detail":"","hint":"restart the daemon"}],"summary":{"pass":0,"warn":0,"fail":1,"skip":0}}`}}, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 9991329..715f8b8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -3,6 +3,7 @@ package provider import ( "context" "encoding/json" + "errors" "fmt" "io" "strings" @@ -11,6 +12,8 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/storage" ) +var ErrTemplateNotFound = errors.New("imported provider template not found") + type Instance struct { Name string ProviderID string @@ -79,12 +82,13 @@ type RunningProcess struct { } type ExecOptions struct { - Stdin string - Env map[string]string - SensitiveValues []string - LogPath string - Stdout io.Writer - Stderr io.Writer + Stdin string + Env map[string]string + SensitiveValues []string + LogPath string + Stdout io.Writer + Stderr io.Writer + SuppressTranscript bool } type ExecResult struct { @@ -129,10 +133,23 @@ type TemplateArtifact struct { // remain owned by the shared image and storage packages. type TemplateArtifactRuntime interface { ImportTemplate(ctx context.Context, archivePath string) error - VerifyTemplate(ctx context.Context, artifact TemplateArtifact) error + VerifyImportedTemplate(ctx context.Context, artifact TemplateArtifact) error ActivateTemplate(artifact TemplateArtifact) error } +// TemplateArtifactCleaner is an optional exact cleanup capability for +// template-backed providers. The shared image/storage lifecycle calls it only +// for an immutable cache identity backed by EPAR ownership evidence. +type TemplateArtifactCleaner interface { + RemoveTemplate(ctx context.Context, artifact TemplateArtifact) error +} + +// TemplateArtifactObserver performs an exact, read-only cache lookup for +// catalog reconciliation without activating or deleting the template. +type TemplateArtifactObserver interface { + ObserveTemplate(ctx context.Context, artifact TemplateArtifact) (bool, error) +} + // StorageContribution is required for every registered provider. It describes // the provider's measurable capacity surface and operation expansion before // the shared pool performs provider side effects. diff --git a/internal/provider/tart/tart.go b/internal/provider/tart/tart.go index 09a3d90..ae73c26 100644 --- a/internal/provider/tart/tart.go +++ b/internal/provider/tart/tart.go @@ -193,6 +193,9 @@ func (p *Provider) runWithLogRaw(ctx context.Context, stdin io.Reader, stdoutSin return provider.ExecResult{}, nil } cmd := exec.CommandContext(ctx, p.Binary, args...) + if len(args) > 0 && args[0] == "clone" { + cmd.Env = tartCloneEnvironment(os.Environ()) + } if stdin != nil { cmd.Stdin = stdin } @@ -207,6 +210,16 @@ func (p *Provider) runWithLogRaw(ctx context.Context, stdin io.Reader, stdoutSin return result, nil } +func tartCloneEnvironment(base []string) []string { + result := make([]string, 0, len(base)+1) + for _, value := range base { + if !strings.HasPrefix(value, "TART_NO_AUTO_PRUNE=") { + result = append(result, value) + } + } + return append(result, "TART_NO_AUTO_PRUNE=1") +} + func captureWriter(capture io.Writer, sink io.Writer) io.Writer { if sink == nil { return capture diff --git a/internal/provider/tart/tart_test.go b/internal/provider/tart/tart_test.go index f2ca5c2..1813eef 100644 --- a/internal/provider/tart/tart_test.go +++ b/internal/provider/tart/tart_test.go @@ -123,3 +123,19 @@ func TestReadLocalVMIdentitiesUsesStableMACAddress(t *testing.T) { t.Fatalf("identity = %q", got) } } + +func TestCloneEnvironmentDisablesTartAutomaticPruning(t *testing.T) { + environment := tartCloneEnvironment([]string{"PATH=/bin", "TART_NO_AUTO_PRUNE="}) + if got := environment[len(environment)-1]; got != "TART_NO_AUTO_PRUNE=1" { + t.Fatalf("clone environment final override = %q", got) + } + count := 0 + for _, value := range environment { + if strings.HasPrefix(value, "TART_NO_AUTO_PRUNE=") { + count++ + } + } + if count != 1 { + t.Fatalf("clone environment contains %d TART_NO_AUTO_PRUNE entries: %#v", count, environment) + } +} diff --git a/internal/storage/catalog/catalog.go b/internal/storage/catalog/catalog.go new file mode 100644 index 0000000..e138c57 --- /dev/null +++ b/internal/storage/catalog/catalog.go @@ -0,0 +1,518 @@ +// Package catalog persists exact, per-user EPAR resource custody across +// projects and configuration files. +package catalog + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/filelock" +) + +const SchemaVersion = 1 + +type Custody string + +const ( + CustodyGenerated Custody = "generated" + CustodyAcquired Custody = "acquired" +) + +type State string + +const ( + StateCurrent State = "current" + StateStaging State = "staging" + StateSuperseded State = "superseded" + StateCleanupPending State = "cleanup-pending" +) + +type Reference struct { + ConfigID string `json:"configId"` + ManifestHash string `json:"manifestHash,omitempty"` + Role string `json:"role,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Resource struct { + Key string `json:"key"` + BackendID string `json:"backendId"` + InstallationIDs []string `json:"installationIds,omitempty"` + Kind string `json:"kind"` + Provider string `json:"provider,omitempty"` + Role string `json:"role,omitempty"` + Locator string `json:"locator"` + Identity string `json:"identity"` + Fingerprint string `json:"fingerprint,omitempty"` + Custody Custody `json:"custody"` + ManifestHash string `json:"manifestHash,omitempty"` + IntroducedTags []string `json:"introducedTags,omitempty"` + State State `json:"state"` + References []Reference `json:"references,omitempty"` + CreatedAt time.Time `json:"createdAt"` + LastSeenAt time.Time `json:"lastSeenAt"` + SupersededAt *time.Time `json:"supersededAt,omitempty"` + LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"` + CleanupError string `json:"cleanupError,omitempty"` +} + +type Config struct { + ID string `json:"id"` + InstallationID string `json:"installationId"` + Path string `json:"path"` + ProjectRoot string `json:"projectRoot"` + BuildCacheLimitBytes uint64 `json:"buildCacheLimitBytes,omitempty"` + ControllerLeaseUntil *time.Time `json:"controllerLeaseUntil,omitempty"` + LastSeenAt time.Time `json:"lastSeenAt"` +} + +type Journal struct { + ID string `json:"id"` + Operation string `json:"operation"` + ResourceKey string `json:"resourceKey,omitempty"` + BackendID string `json:"backendId,omitempty"` + ConfigID string `json:"configId,omitempty"` + Role string `json:"role,omitempty"` + Locator string `json:"locator,omitempty"` + PreviousIdentity string `json:"previousIdentity,omitempty"` + Phase string `json:"phase"` + StartedAt time.Time `json:"startedAt"` + UpdatedAt time.Time `json:"updatedAt"` + Error string `json:"error,omitempty"` +} + +type Catalog struct { + SchemaVersion int `json:"schemaVersion"` + InstallationID string `json:"installationId"` + UpdatedAt time.Time `json:"updatedAt"` + Configs []Config `json:"configs,omitempty"` + Resources []Resource `json:"resources,omitempty"` + Journals []Journal `json:"journals,omitempty"` +} + +type Store struct { + root string +} + +func DefaultRoot() (string, error) { + if override := strings.TrimSpace(os.Getenv("EPAR_STATE_HOME")); override != "" { + return filepath.Abs(override) + } + if runtime.GOOS == "windows" { + base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) + if base == "" { + return "", errors.New("LOCALAPPDATA is required for the EPAR host resource catalog") + } + return filepath.Join(base, "ephemeral-action-runner", "state"), nil + } + if runtime.GOOS == "linux" { + if base := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); base != "" { + return filepath.Join(base, "ephemeral-action-runner"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory for EPAR host resource catalog: %w", err) + } + return filepath.Join(home, ".local", "state", "ephemeral-action-runner"), nil + } + base, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve user state directory for EPAR host resource catalog: %w", err) + } + return filepath.Join(base, "ephemeral-action-runner", "state"), nil +} + +func Open(root string) (*Store, error) { + if strings.TrimSpace(root) == "" { + var err error + root, err = DefaultRoot() + if err != nil { + return nil, err + } + } + absolute, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve EPAR host resource catalog root: %w", err) + } + if err := os.MkdirAll(absolute, 0o700); err != nil { + return nil, fmt.Errorf("create EPAR host resource catalog root: %w", err) + } + info, err := os.Lstat(absolute) + if err != nil { + return nil, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("EPAR host resource catalog root must be a real directory: %s", absolute) + } + return &Store{root: absolute}, nil +} + +func (s *Store) Root() string { return s.root } + +func (s *Store) Path() string { return filepath.Join(s.root, "resources-v1.json") } + +func (s *Store) AcquireBackendLock(ctx context.Context, backendID string) (*filelock.Lock, error) { + if strings.TrimSpace(backendID) == "" { + return nil, errors.New("backend identity is required") + } + sum := sha256.Sum256([]byte(strings.TrimSpace(backendID))) + path := filepath.Join(s.root, "backend-"+hex.EncodeToString(sum[:12])+".lock") + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + lock, err := filelock.Acquire(path) + if err == nil { + return lock, nil + } + if !errors.Is(err, filelock.ErrLocked) { + return nil, fmt.Errorf("acquire EPAR backend lock for %s: %w", backendID, err) + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("wait for EPAR backend lock for %s: %w", backendID, ctx.Err()) + case <-ticker.C: + } + } +} + +func (s *Store) WithLock(now time.Time, update func(*Catalog) error) (Catalog, error) { + if update == nil { + return Catalog{}, errors.New("catalog update function is required") + } + lock, err := filelock.Acquire(filepath.Join(s.root, "resources-v1.lock")) + if err != nil { + return Catalog{}, fmt.Errorf("acquire EPAR host resource catalog lock: %w", err) + } + defer lock.Close() + value, err := s.loadUnlocked(now) + if err != nil { + return Catalog{}, err + } + if err := update(&value); err != nil { + return Catalog{}, err + } + normalize(&value) + value.UpdatedAt = now.UTC() + if err := s.writeUnlocked(value); err != nil { + return Catalog{}, err + } + return value, nil +} + +func (s *Store) Load(now time.Time) (Catalog, error) { + lock, err := filelock.Acquire(filepath.Join(s.root, "resources-v1.lock")) + if err != nil { + return Catalog{}, fmt.Errorf("acquire EPAR host resource catalog lock: %w", err) + } + defer lock.Close() + return s.loadUnlocked(now) +} + +func (s *Store) loadUnlocked(now time.Time) (Catalog, error) { + content, err := os.ReadFile(s.Path()) + if errors.Is(err, os.ErrNotExist) { + installationID, idErr := randomID() + if idErr != nil { + return Catalog{}, idErr + } + return Catalog{SchemaVersion: SchemaVersion, InstallationID: installationID, UpdatedAt: now.UTC()}, nil + } + if err != nil { + return Catalog{}, fmt.Errorf("read EPAR host resource catalog: %w", err) + } + var value Catalog + if err := json.Unmarshal(content, &value); err != nil { + return Catalog{}, fmt.Errorf("decode EPAR host resource catalog: %w", err) + } + if value.SchemaVersion != SchemaVersion || strings.TrimSpace(value.InstallationID) == "" { + return Catalog{}, fmt.Errorf("unsupported or incomplete EPAR host resource catalog schema %d", value.SchemaVersion) + } + normalize(&value) + return value, nil +} + +func (s *Store) writeUnlocked(value Catalog) error { + content, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("encode EPAR host resource catalog: %w", err) + } + temp, err := os.CreateTemp(s.root, ".resources-v1-*.tmp") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(append(content, '\n')); err != nil { + temp.Close() + return err + } + if err := temp.Sync(); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempPath, s.Path()); err != nil { + return fmt.Errorf("publish EPAR host resource catalog: %w", err) + } + return nil +} + +func ConfigID(projectRoot, configPath string) (string, error) { + root, err := canonicalPath(projectRoot) + if err != nil { + return "", err + } + path, err := canonicalPath(configPath) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(root + "\x00" + path)) + return hex.EncodeToString(sum[:12]), nil +} + +func ResourceKey(backendID, kind, identity string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(backendID) + "\x00" + strings.TrimSpace(kind) + "\x00" + strings.TrimSpace(identity))) + return hex.EncodeToString(sum[:16]) +} + +func RegisterConfig(value *Catalog, projectRoot, configPath string, now time.Time) (Config, error) { + id, err := ConfigID(projectRoot, configPath) + if err != nil { + return Config{}, err + } + root, err := canonicalPath(projectRoot) + if err != nil { + return Config{}, err + } + path, err := canonicalPath(configPath) + if err != nil { + return Config{}, err + } + installationSum := sha256.Sum256([]byte(value.InstallationID + "\x00" + root)) + record := Config{ID: id, InstallationID: hex.EncodeToString(installationSum[:12]), Path: path, ProjectRoot: root, LastSeenAt: now.UTC()} + for index := range value.Configs { + if value.Configs[index].ID == id { + if value.Configs[index].InstallationID != "" { + record.InstallationID = value.Configs[index].InstallationID + } + record.BuildCacheLimitBytes = value.Configs[index].BuildCacheLimitBytes + record.ControllerLeaseUntil = value.Configs[index].ControllerLeaseUntil + value.Configs[index] = record + return record, nil + } + } + value.Configs = append(value.Configs, record) + return record, nil +} + +func RefreshControllerLease(value *Catalog, configID string, expiresAt time.Time) error { + if expiresAt.IsZero() { + return errors.New("controller lease expiry is required") + } + for index := range value.Configs { + if value.Configs[index].ID == configID { + expiry := expiresAt.UTC() + value.Configs[index].ControllerLeaseUntil = &expiry + return nil + } + } + return fmt.Errorf("catalog configuration %s is not registered", configID) +} + +func ReleaseControllerLease(value *Catalog, configID string) { + for index := range value.Configs { + if value.Configs[index].ID == configID { + value.Configs[index].ControllerLeaseUntil = nil + return + } + } +} + +func UpsertResource(value *Catalog, resource Resource) error { + if resource.BackendID == "" || resource.Kind == "" || resource.Identity == "" || resource.Locator == "" { + return errors.New("catalog resource backend, kind, identity, and locator are required") + } + if resource.Custody != CustodyGenerated && resource.Custody != CustodyAcquired { + return fmt.Errorf("unsupported catalog custody %q", resource.Custody) + } + if resource.Key == "" { + resource.Key = ResourceKey(resource.BackendID, resource.Kind, resource.Identity) + } + for index := range value.Resources { + if value.Resources[index].Key == resource.Key { + resource.InstallationIDs = mergeStrings(value.Resources[index].InstallationIDs, resource.InstallationIDs) + resource.CreatedAt = value.Resources[index].CreatedAt + if resource.CreatedAt.IsZero() { + resource.CreatedAt = time.Now().UTC() + } + value.Resources[index] = resource + return nil + } + } + if resource.CreatedAt.IsZero() { + resource.CreatedAt = time.Now().UTC() + } + value.Resources = append(value.Resources, resource) + return nil +} + +func mergeStrings(groups ...[]string) []string { + seen := make(map[string]bool) + var result []string + for _, group := range groups { + for _, value := range group { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + } + sort.Strings(result) + return result +} + +func ReplaceConfigReferences(value *Catalog, configID string, references map[string]Reference, now time.Time) { + ReplaceConfigRoleReferences(value, configID, "", references, now) +} + +// ReplaceConfigRoleReferences atomically replaces one config's references for +// a single logical role without disturbing its other provider or bootstrap +// resources. An empty role preserves the original all-reference behavior. +func ReplaceConfigRoleReferences(value *Catalog, configID, role string, references map[string]Reference, now time.Time) { + for index := range value.Resources { + resource := &value.Resources[index] + filtered := resource.References[:0] + for _, reference := range resource.References { + if reference.ConfigID != configID || (role != "" && reference.Role != role) { + filtered = append(filtered, reference) + } + } + resource.References = filtered + if reference, found := references[resource.Key]; found { + reference.ConfigID = configID + if role != "" { + reference.Role = role + } + reference.UpdatedAt = now.UTC() + resource.References = append(resource.References, reference) + resource.State = StateCurrent + resource.SupersededAt = nil + resource.CleanupError = "" + } else if len(resource.References) == 0 && resource.State == StateCurrent { + when := now.UTC() + resource.State = StateSuperseded + resource.SupersededAt = &when + } + } +} + +// Compact removes references to missing configurations without a live lease, +// drops missing resources through the supplied exact observer, and discards +// completed journals. Observer errors preserve the resource fail-closed. +func Compact(value *Catalog, now time.Time, exists func(Resource) (bool, error)) []string { + var warnings []string + liveConfigs := make(map[string]bool) + configs := value.Configs[:0] + for _, config := range value.Configs { + configPresent := false + if info, err := os.Lstat(config.Path); err == nil && info.Mode().IsRegular() { + configPresent = true + } + leaseActive := config.ControllerLeaseUntil != nil && config.ControllerLeaseUntil.After(now) + if configPresent || leaseActive { + liveConfigs[config.ID] = true + configs = append(configs, config) + } + } + value.Configs = configs + resources := value.Resources[:0] + for _, resource := range value.Resources { + references := resource.References[:0] + for _, reference := range resource.References { + if liveConfigs[reference.ConfigID] { + references = append(references, reference) + } + } + resource.References = references + present, err := exists(resource) + if err != nil { + warnings = append(warnings, fmt.Sprintf("catalog resource %s could not be observed: %v", resource.Key, err)) + resources = append(resources, resource) + continue + } + if !present { + continue + } + resource.LastSeenAt = now.UTC() + if len(resource.References) == 0 && resource.State == StateCurrent { + when := now.UTC() + resource.State = StateSuperseded + resource.SupersededAt = &when + } + resources = append(resources, resource) + } + value.Resources = resources + resourceKeys := make(map[string]bool, len(value.Resources)) + for _, resource := range value.Resources { + resourceKeys[resource.Key] = true + } + journals := value.Journals[:0] + for _, journal := range value.Journals { + if journal.Phase != "complete" && (journal.ResourceKey == "" || resourceKeys[journal.ResourceKey]) { + journals = append(journals, journal) + } + } + value.Journals = journals + return warnings +} + +func normalize(value *Catalog) { + sort.Slice(value.Configs, func(i, j int) bool { return value.Configs[i].ID < value.Configs[j].ID }) + sort.Slice(value.Resources, func(i, j int) bool { return value.Resources[i].Key < value.Resources[j].Key }) + for index := range value.Resources { + resource := &value.Resources[index] + sort.Strings(resource.InstallationIDs) + sort.Strings(resource.IntroducedTags) + sort.Slice(resource.References, func(i, j int) bool { return resource.References[i].ConfigID < resource.References[j].ConfigID }) + } + sort.Slice(value.Journals, func(i, j int) bool { return value.Journals[i].ID < value.Journals[j].ID }) +} + +func canonicalPath(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + absolute = filepath.Clean(absolute) + if runtime.GOOS == "windows" { + absolute = strings.ToLower(absolute) + } + return absolute, nil +} + +func randomID() (string, error) { + content := make([]byte, 16) + if _, err := rand.Read(content); err != nil { + return "", fmt.Errorf("generate EPAR installation identity: %w", err) + } + return hex.EncodeToString(content), nil +} diff --git a/internal/storage/catalog/catalog_test.go b/internal/storage/catalog/catalog_test.go new file mode 100644 index 0000000..e5e7ffa --- /dev/null +++ b/internal/storage/catalog/catalog_test.go @@ -0,0 +1,226 @@ +package catalog + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestMultipleConfigsShareResourceUntilLastReferenceIsRemoved(t *testing.T) { + root := t.TempDir() + project := filepath.Join(root, "project") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + firstPath := filepath.Join(project, "first.yml") + secondPath := filepath.Join(project, "second.yml") + for _, path := range []string{firstPath, secondPath} { + if err := os.WriteFile(path, []byte("provider: test\n"), 0o600); err != nil { + t.Fatal(err) + } + } + now := time.Date(2026, 7, 29, 1, 2, 3, 0, time.UTC) + store, err := Open(filepath.Join(root, "catalog")) + if err != nil { + t.Fatal(err) + } + value, err := store.WithLock(now, func(value *Catalog) error { + first, err := RegisterConfig(value, project, firstPath, now) + if err != nil { + return err + } + second, err := RegisterConfig(value, project, secondPath, now) + if err != nil { + return err + } + resource := Resource{BackendID: "docker:test", Kind: "docker-image", Locator: "image:test", Identity: "sha256:abc", Custody: CustodyGenerated, State: StateCurrent, CreatedAt: now} + resource.Key = ResourceKey(resource.BackendID, resource.Kind, resource.Identity) + resource.References = []Reference{{ConfigID: first.ID}, {ConfigID: second.ID}} + return UpsertResource(value, resource) + }) + if err != nil { + t.Fatal(err) + } + if value.Configs[0].InstallationID == "" || value.Configs[0].InstallationID != value.Configs[1].InstallationID { + t.Fatalf("configs in one project did not share an installation identity: %#v", value.Configs) + } + key := value.Resources[0].Key + firstID, _ := ConfigID(project, firstPath) + ReplaceConfigReferences(&value, firstID, nil, now.Add(time.Minute)) + if got := len(value.Resources[0].References); got != 1 { + t.Fatalf("references after first removal = %d, want 1", got) + } + secondID, _ := ConfigID(project, secondPath) + ReplaceConfigReferences(&value, secondID, nil, now.Add(2*time.Minute)) + if value.Resources[0].State != StateSuperseded || value.Resources[0].SupersededAt == nil { + t.Fatalf("resource %s was not superseded after its final reference was removed", key) + } +} + +func TestDifferentProjectRootsHaveDifferentInstallationIdentities(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + value := Catalog{InstallationID: "host-catalog"} + var ids []string + for _, name := range []string{"one", "two"} { + project := filepath.Join(root, name) + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(project, "config.yml") + if err := os.WriteFile(configPath, []byte("provider: test\n"), 0o600); err != nil { + t.Fatal(err) + } + record, err := RegisterConfig(&value, project, configPath, now) + if err != nil { + t.Fatal(err) + } + ids = append(ids, record.InstallationID) + } + if ids[0] == "" || ids[0] == ids[1] { + t.Fatalf("different project roots share installation identity %q", ids[0]) + } +} + +func TestBackendLocksAreSeparatedAndSerializeTheSameBackend(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + first, err := store.AcquireBackendLock(context.Background(), "docker:first") + if err != nil { + t.Fatal(err) + } + defer first.Close() + secondBackend, err := store.AcquireBackendLock(context.Background(), "docker:second") + if err != nil { + t.Fatalf("different backend was unnecessarily blocked: %v", err) + } + if err := secondBackend.Close(); err != nil { + t.Fatal(err) + } + waitContext, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + if _, err := store.AcquireBackendLock(waitContext, "docker:first"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("same backend lock error = %v, want context deadline", err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + reacquired, err := store.AcquireBackendLock(context.Background(), "docker:first") + if err != nil { + t.Fatalf("released backend lock could not be reacquired: %v", err) + } + if err := reacquired.Close(); err != nil { + t.Fatal(err) + } +} + +func TestCompactDropsMissingConfigsResourcesAndCompletedJournals(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + value := Catalog{ + SchemaVersion: SchemaVersion, + Configs: []Config{{ID: "gone", Path: filepath.Join(root, "gone.yml")}}, + Resources: []Resource{{ + Key: "missing", BackendID: "docker:test", Kind: "docker-image", Locator: "x", Identity: "y", + Custody: CustodyGenerated, State: StateCurrent, References: []Reference{{ConfigID: "gone"}}, + }}, + Journals: []Journal{{ID: "done", Phase: "complete"}, {ID: "pending", Phase: "pull"}}, + } + warnings := Compact(&value, now, func(Resource) (bool, error) { return false, nil }) + if len(warnings) != 0 || len(value.Configs) != 0 || len(value.Resources) != 0 || len(value.Journals) != 1 || value.Journals[0].ID != "pending" { + t.Fatalf("unexpected compact result: %#v warnings=%v", value, warnings) + } +} + +func TestCompactPreservesMissingConfigWhileControllerLeaseIsActive(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC() + value := Catalog{ + SchemaVersion: SchemaVersion, + Configs: []Config{{ + ID: "active", Path: filepath.Join(root, "removed.yml"), ControllerLeaseUntil: timePointer(now.Add(time.Minute)), + }}, + Resources: []Resource{{ + Key: "present", BackendID: "docker:test", Kind: "docker-image", Locator: "x", Identity: "y", + Custody: CustodyGenerated, State: StateCurrent, References: []Reference{{ConfigID: "active"}}, + }}, + } + Compact(&value, now, func(Resource) (bool, error) { return true, nil }) + if len(value.Configs) != 1 || len(value.Resources) != 1 || len(value.Resources[0].References) != 1 { + t.Fatalf("active controller lease did not preserve missing config reference: %#v", value) + } + Compact(&value, now.Add(2*time.Minute), func(Resource) (bool, error) { return true, nil }) + if len(value.Configs) != 0 || len(value.Resources[0].References) != 0 || value.Resources[0].State != StateSuperseded { + t.Fatalf("expired controller lease still protected missing config: %#v", value) + } +} + +func TestRegisterConfigPreservesControllerLease(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "config.yml") + if err := os.WriteFile(configPath, []byte("provider: test\n"), 0o600); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + value := Catalog{} + record, err := RegisterConfig(&value, root, configPath, now) + if err != nil { + t.Fatal(err) + } + if err := RefreshControllerLease(&value, record.ID, now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if _, err := RegisterConfig(&value, root, configPath, now.Add(time.Second)); err != nil { + t.Fatal(err) + } + if value.Configs[0].ControllerLeaseUntil == nil || !value.Configs[0].ControllerLeaseUntil.Equal(now.Add(time.Minute)) { + t.Fatalf("registered config lost controller lease: %#v", value.Configs[0]) + } + ReleaseControllerLease(&value, record.ID) + if value.Configs[0].ControllerLeaseUntil != nil { + t.Fatalf("controller lease was not released: %#v", value.Configs[0]) + } +} + +func TestDefaultRootHonorsExplicitOverride(t *testing.T) { + override := filepath.Join(t.TempDir(), "state") + t.Setenv("EPAR_STATE_HOME", override) + got, err := DefaultRoot() + if err != nil { + t.Fatal(err) + } + want, _ := filepath.Abs(override) + if got != want { + t.Fatalf("DefaultRoot = %q, want %q", got, want) + } +} + +func TestRegisterConfigPreservesRegisteredCacheLimit(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "config.yml") + if err := os.WriteFile(configPath, []byte("provider: test\n"), 0o600); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + value := Catalog{} + record, err := RegisterConfig(&value, root, configPath, now) + if err != nil { + t.Fatal(err) + } + value.Configs[0].BuildCacheLimitBytes = 20 << 30 + if _, err := RegisterConfig(&value, root, configPath, now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if value.Configs[0].ID != record.ID || value.Configs[0].BuildCacheLimitBytes != 20<<30 { + t.Fatalf("registered config lost persisted cache policy: %#v", value.Configs[0]) + } +} + +func timePointer(value time.Time) *time.Time { + return &value +} diff --git a/internal/storage/inventory/collect_test.go b/internal/storage/inventory/collect_test.go index 93d0100..6150fb0 100644 --- a/internal/storage/inventory/collect_test.go +++ b/internal/storage/inventory/collect_test.go @@ -82,11 +82,11 @@ func TestCollectDeterministicInventoryAndPreview(t *testing.T) { t.Fatalf("logs artifact protections = %+v", logArtifact.Protections) } currentArchive := findArtifactByArchiveDigest(t, first.Artifacts, currentTemplate.ArchiveSHA256) - if !currentArchive.Current || !hasProtection(currentArchive, storage.ProtectionConfiguration) { - t.Fatalf("current archive = %+v", currentArchive) + if currentArchive.Current || hasProtection(currentArchive, storage.ProtectionConfiguration) { + t.Fatalf("imported-template selection retained its transient archive = %+v", currentArchive) } oldArchive := findArtifactByArchiveDigest(t, first.Artifacts, oldTemplate.ArchiveSHA256) - if !hasProtection(oldArchive, storage.ProtectionCertification) || oldArchive.SupersededAt == nil { + if !hasProtection(oldArchive, storage.ProtectionCertification) || oldArchive.SupersededAt != nil { t.Fatalf("old archive = %+v", oldArchive) } diff --git a/internal/storage/inventory/native.go b/internal/storage/inventory/native.go index ac3ea3b..6834685 100644 --- a/internal/storage/inventory/native.go +++ b/internal/storage/inventory/native.go @@ -2,6 +2,7 @@ package inventory import ( "bufio" + "errors" "fmt" "os" "path/filepath" @@ -62,7 +63,16 @@ func collectNative(options nativeOptions) ([]storage.Artifact, []string) { var revisions []nativeRevision var artifacts []storage.Artifact + stable, stableNames, stableCurrentMatch, stableFound, stableErr := inspectStableNativeController(rootTarget.Locator, currentExecutable) + if stableFound && stableErr == nil { + artifacts = append(artifacts, stable) + } else if stableFound { + warnings = append(warnings, fmt.Sprintf("stable native-controller files remain ownership-unknown: %v", stableErr)) + } for _, entry := range entries { + if stableErr == nil && stableNames[entry.Name()] { + continue + } path := filepath.Join(rootTarget.Locator, entry.Name()) info, infoErr := entry.Info() if infoErr != nil || isRedirect(info) { @@ -115,7 +125,7 @@ func collectNative(options nativeOptions) ([]storage.Artifact, []string) { supersededAt := current.completed revisions[index].artifact.SupersededAt = &supersededAt } - } else if currentKey != "" || currentExecutable.Identity != "" { + } else if (currentKey != "" || currentExecutable.Identity != "") && !stableCurrentMatch { warnings = append(warnings, "explicit current native-controller identity did not match a recognized revision") } for _, revision := range revisions { @@ -124,6 +134,115 @@ func collectNative(options nativeOptions) ([]storage.Artifact, []string) { return artifacts, warnings } +func inspectStableNativeController(root string, currentExecutable storage.Target) (storage.Artifact, map[string]bool, bool, bool, error) { + manifestPath := filepath.Join(root, "ephemeral-action-runner.manifest") + manifestInfo, err := os.Lstat(manifestPath) + if errors.Is(err, os.ErrNotExist) { + return storage.Artifact{}, nil, false, false, nil + } + if err != nil { + return storage.Artifact{}, nil, false, true, err + } + if !manifestInfo.Mode().IsRegular() || isRedirect(manifestInfo) { + return storage.Artifact{}, nil, false, true, fmt.Errorf("manifest is not an exact regular file") + } + fields, err := parseStableManifest(manifestPath) + if err != nil { + return storage.Artifact{}, nil, false, true, err + } + executableName := fields["executable"] + if executableName != "ephemeral-action-runner" && executableName != "ephemeral-action-runner.exe" { + return storage.Artifact{}, nil, false, true, fmt.Errorf("manifest executable is invalid") + } + binaryPath := filepath.Join(root, executableName) + binaryInfo, err := os.Lstat(binaryPath) + if err != nil { + return storage.Artifact{}, nil, false, true, err + } + if !binaryInfo.Mode().IsRegular() || isRedirect(binaryInfo) { + return storage.Artifact{}, nil, false, true, fmt.Errorf("controller executable is not an exact regular file") + } + binary, err := storage.SnapshotFilesystemTarget(binaryPath) + if err != nil { + return storage.Artifact{}, nil, false, true, err + } + manifestSHA, _, err := hashFile(manifestPath) + if err != nil { + return storage.Artifact{}, nil, false, true, err + } + completed, err := time.Parse(time.RFC3339Nano, fields["completedAtUtc"]) + if err != nil { + return storage.Artifact{}, nil, false, true, fmt.Errorf("manifest completedAtUtc is invalid") + } + size := uint64(binaryInfo.Size() + manifestInfo.Size()) + artifact := storage.Artifact{ + ID: "native-controller-stable:" + fields["fingerprint"], + SurfaceID: ProjectSurfaceID, + Kind: storage.ArtifactNativeControllerRevision, + RetentionGroup: "native-controller", + Target: binary, + Ownership: storage.Ownership{ + Kind: storage.OwnershipExact, + OwnerID: "native-controller:" + fields["fingerprint"], + Evidence: "ephemeral-action-runner.manifest@" + manifestSHA, + }, + SizeBytes: size, + CreatedAt: completed.UTC(), + Current: true, + Protections: []storage.Protection{{ + Kind: storage.ProtectionCurrent, Detail: "stable native-controller manifest", + }}, + } + currentMatch := currentExecutable.Identity != "" && binary.Identity == currentExecutable.Identity && binary.Fingerprint == currentExecutable.Fingerprint + stableNames := map[string]bool{executableName: true, filepath.Base(manifestPath): true} + lockPath := filepath.Join(root, ".native-controller.lock") + if lockInfo, lockErr := os.Lstat(lockPath); lockErr == nil && lockInfo.Mode().IsRegular() && !isRedirect(lockInfo) && lockInfo.Size() == 0 { + stableNames[filepath.Base(lockPath)] = true + } + return artifact, stableNames, currentMatch, true, nil +} + +func parseStableManifest(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + fields := make(map[string]string, 6) + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024), 16*1024) + for scanner.Scan() { + key, value, ok := strings.Cut(scanner.Text(), "=") + if !ok || key == "" || value == "" { + return nil, fmt.Errorf("manifest contains an invalid field") + } + if _, exists := fields[key]; exists { + return nil, fmt.Errorf("manifest contains duplicate field %q", key) + } + fields[key] = value + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(fields) != 6 { + return nil, fmt.Errorf("manifest must contain exactly six fields") + } + for _, key := range []string{"schemaVersion", "fingerprint", "executable", "toolchainImageID", "sourceRevision", "completedAtUtc"} { + if fields[key] == "" { + return nil, fmt.Errorf("manifest is missing %q", key) + } + } + if fields["schemaVersion"] != "2" || !cacheKeyPattern.MatchString(fields["fingerprint"]) { + return nil, fmt.Errorf("manifest schema or fingerprint is invalid") + } + sourceFingerprint := strings.TrimPrefix(strings.TrimPrefix(fields["sourceRevision"], "dirty:"), "sha256:") + sourceMatches := fields["sourceRevision"] == "unknown" || sourceFingerprint == fields["fingerprint"] + if !sourceMatches || !strings.HasPrefix(fields["toolchainImageID"], "sha256:") || !cacheKeyPattern.MatchString(strings.TrimPrefix(fields["toolchainImageID"], "sha256:")) { + return nil, fmt.Errorf("manifest source or toolchain identity is invalid") + } + return fields, nil +} + func inspectNativeRevision(path, cacheKey string) (nativeRevision, error) { entries, err := os.ReadDir(path) if err != nil { diff --git a/internal/storage/inventory/native_test.go b/internal/storage/inventory/native_test.go index 064ccc3..8a22500 100644 --- a/internal/storage/inventory/native_test.go +++ b/internal/storage/inventory/native_test.go @@ -56,6 +56,31 @@ func TestCollectNativeCurrentExecutableIdentity(t *testing.T) { } } +func TestCollectNativeRecognizesStableControllerLayout(t *testing.T) { + t.Parallel() + root := t.TempDir() + fingerprint := repeatedHex("d") + toolchainID := repeatedHex("e") + executableName := "ephemeral-action-runner" + if runtime.GOOS == "windows" { + executableName += ".exe" + } + executable := filepath.Join(root, executableName) + mustWriteFile(t, executable, []byte("stable-binary")) + completed := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + manifest := fmt.Sprintf("schemaVersion=2\nfingerprint=%s\nexecutable=%s\ntoolchainImageID=sha256:%s\nsourceRevision=dirty:sha256:%s\ncompletedAtUtc=%s\n", fingerprint, executableName, toolchainID, fingerprint, completed.Format(time.RFC3339Nano)) + mustWriteFile(t, filepath.Join(root, "ephemeral-action-runner.manifest"), []byte(manifest)) + mustWriteFile(t, filepath.Join(root, ".native-controller.lock"), nil) + artifacts, warnings := collectNative(nativeOptions{Root: root, CurrentExecutable: executable}) + if len(warnings) != 0 || len(artifacts) != 1 { + t.Fatalf("collectNative() artifacts=%+v warnings=%v", artifacts, warnings) + } + stable := findArtifact(t, artifacts, "native-controller-stable:"+fingerprint) + if !stable.Current || stable.Ownership.Kind != storage.OwnershipExact || !hasProtection(stable, storage.ProtectionCurrent) || stable.Target.Locator != executable { + t.Fatalf("stable native controller = %+v", stable) + } +} + func TestCollectNativeRejectsSymlinkedRevision(t *testing.T) { t.Parallel() root := t.TempDir() diff --git a/internal/storage/inventory/template.go b/internal/storage/inventory/template.go index d34bc7e..231622b 100644 --- a/internal/storage/inventory/template.go +++ b/internal/storage/inventory/template.go @@ -104,7 +104,6 @@ func collectTemplates(options templateOptions) ([]storage.Artifact, []string, er record.artifact.Protections = append(record.artifact.Protections, protections[record.metadata.Template.ArchiveSHA256]...) records = append(records, record) } - applyTemplateSelections(records, options.Selections, &warnings) for index := range records { sortTemplateProtections(records[index].artifact.Protections) record := records[index] @@ -248,48 +247,6 @@ func validateTemplateInputs(options templateOptions) error { return nil } -func applyTemplateSelections(records []templateRecord, selections []TemplateSelection, warnings *[]string) { - for _, selection := range selections { - var matches []int - for index := range records { - record := records[index] - if (selection.Profile == "" || record.metadata.Profile == selection.Profile) && (selection.Platform == "" || record.metadata.Platform == selection.Platform) && normalizedTemplateTag(record.metadata.Template.Tag) == normalizedTemplateTag(selection.Tag) && record.metadata.Template.Digest == selection.TemplateDigest && (selection.MetadataSHA256 == "" || record.metadataSHA256 == selection.MetadataSHA256) { - matches = append(matches, index) - } - } - selectionIdentity := selection.Tag + "@" + selection.TemplateDigest - if len(matches) == 0 { - *warnings = append(*warnings, fmt.Sprintf("configured Docker Sandboxes template %q did not match a verified archive", selectionIdentity)) - continue - } - if len(matches) > 1 { - *warnings = append(*warnings, fmt.Sprintf("configured Docker Sandboxes template %q matched multiple archives; all are protected", selectionIdentity)) - group := records[matches[0]].artifact.RetentionGroup - for index := range records { - if records[index].artifact.RetentionGroup == group { - records[index].artifact.Protections = append(records[index].artifact.Protections, storage.Protection{Kind: storage.ProtectionUncertain, Detail: "ambiguous configured template archive"}) - } - } - continue - } - currentIndex := matches[0] - group := records[currentIndex].artifact.RetentionGroup - records[currentIndex].artifact.Current = true - records[currentIndex].artifact.Protections = append(records[currentIndex].artifact.Protections, storage.Protection{Kind: storage.ProtectionConfiguration, Detail: "configured Docker Sandboxes template"}) - if selection.ActivatedAt.IsZero() { - continue - } - activatedAt := selection.ActivatedAt.UTC() - for index := range records { - if index == currentIndex || records[index].artifact.RetentionGroup != group || !records[index].artifact.CreatedAt.Before(activatedAt) { - continue - } - supersededAt := activatedAt - records[index].artifact.SupersededAt = &supersededAt - } - } -} - func normalizedTemplateTag(value string) string { return strings.TrimPrefix(value, "docker.io/library/") } diff --git a/internal/storage/inventory/template_test.go b/internal/storage/inventory/template_test.go index 6aa2010..33691a8 100644 --- a/internal/storage/inventory/template_test.go +++ b/internal/storage/inventory/template_test.go @@ -60,7 +60,7 @@ func TestCollectTemplatesStrictIntegrityValidation(t *testing.T) { } } -func TestCollectTemplatesConfiguredIdentityAndActivation(t *testing.T) { +func TestCollectTemplatesDoesNotRetainArchiveForImportedTemplate(t *testing.T) { t.Parallel() root := t.TempDir() now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) @@ -86,10 +86,10 @@ func TestCollectTemplatesConfiguredIdentityAndActivation(t *testing.T) { } currentArtifact := findArtifactByArchiveDigest(t, artifacts, current.ArchiveSHA256) oldArtifact := findArtifactByArchiveDigest(t, artifacts, old.ArchiveSHA256) - if !currentArtifact.Current || !hasProtection(currentArtifact, storage.ProtectionConfiguration) { - t.Fatalf("current artifact = %+v", currentArtifact) + if currentArtifact.Current || hasProtection(currentArtifact, storage.ProtectionConfiguration) { + t.Fatalf("imported-template selection retained its transient archive = %+v", currentArtifact) } - if oldArtifact.SupersededAt == nil || !oldArtifact.SupersededAt.Equal(now.Add(-9*24*time.Hour)) || !hasProtection(oldArtifact, storage.ProtectionPromotion) { + if oldArtifact.SupersededAt != nil || !hasProtection(oldArtifact, storage.ProtectionPromotion) { t.Fatalf("old artifact = %+v", oldArtifact) } } diff --git a/internal/storage/planner.go b/internal/storage/planner.go index 9963219..c1f62b6 100644 --- a/internal/storage/planner.go +++ b/internal/storage/planner.go @@ -13,6 +13,12 @@ const planSchemaVersion = 1 var automaticKinds = map[ArtifactKind]bool{ ArtifactNativeControllerRevision: true, ArtifactTemplateArchive: true, + ArtifactDockerImage: true, + ArtifactSandboxTemplate: true, + ArtifactDockerVolume: true, + ArtifactProviderImage: true, + ArtifactProviderCache: true, + ArtifactOther: true, } // Preview deterministically classifies a complete storage snapshot. It never @@ -207,6 +213,18 @@ func classifyArtifact(now time.Time, policy Policy, artifact Artifact) (Decision } var candidateAt time.Time + if artifact.LifecycleState == "superseded" || artifact.LifecycleState == "cleanup-pending" { + if artifact.SupersededAt == nil || artifact.SupersededAt.IsZero() { + return add(ActionReportOnly, "superseded-state-uncertain") + } + candidateAt = artifact.SupersededAt.UTC() + if candidateAt.After(now) { + return add(ActionReportOnly, "retention-clock-skew") + } + decision.Action = ActionKeep + decision.Reasons = []string{"eligible-immediately"} + return decision, &candidateAt + } switch artifact.Kind { case ArtifactNativeControllerRevision, ArtifactTemplateArchive: if artifact.RetentionGroup == "" || artifact.SupersededAt == nil || artifact.SupersededAt.IsZero() { @@ -233,6 +251,14 @@ func automaticTargetKind(artifactKind ArtifactKind, targetKind TargetKind) bool return targetKind == TargetDirectory || targetKind == TargetFile case ArtifactTemplateArchive: return targetKind == TargetFile + case ArtifactDockerImage: + return targetKind == TargetDockerImageTag + case ArtifactSandboxTemplate: + return targetKind == TargetSandboxTemplate + case ArtifactDockerVolume: + return targetKind == TargetDockerVolume + case ArtifactProviderImage, ArtifactProviderCache, ArtifactOther: + return targetKind == TargetFile || targetKind == TargetDirectory || targetKind == TargetExternal || targetKind == TargetBuildKitRecord default: return false } @@ -245,7 +271,7 @@ func applyGenerationCount(policy Policy, decisions []Decision, candidates map[st continue } switch decision.Artifact.Kind { - case ArtifactNativeControllerRevision, ArtifactTemplateArchive: + case ArtifactNativeControllerRevision, ArtifactTemplateArchive, ArtifactDockerImage, ArtifactSandboxTemplate, ArtifactDockerVolume, ArtifactProviderImage, ArtifactProviderCache, ArtifactOther: key := string(decision.Artifact.Kind) + "\x00" + decision.Artifact.RetentionGroup groups[key] = append(groups[key], index) } @@ -266,7 +292,11 @@ func applyGenerationCount(policy Policy, decisions []Decision, candidates map[st continue } decisions[index].Action = ActionRemove - decisions[index].Reasons = []string{"superseded", "grace-period-expired"} + if decisions[index].Artifact.LifecycleState == "superseded" || decisions[index].Artifact.LifecycleState == "cleanup-pending" { + decisions[index].Reasons = []string{"superseded", "immediate-retirement"} + } else { + decisions[index].Reasons = []string{"superseded", "grace-period-expired"} + } } } } diff --git a/internal/storage/types.go b/internal/storage/types.go index 1b3884a..a19c57e 100644 --- a/internal/storage/types.go +++ b/internal/storage/types.go @@ -8,7 +8,7 @@ const ( DefaultMinimumFreeBytes = 1 * GiB DefaultGracePeriod = 168 * time.Hour DefaultKeepPrevious = 0 - DefaultBuildKitMaxBytes = 64 * GiB + DefaultBuildKitMaxBytes = 20 * GiB DefaultGoCacheMaxBytes = 10 * GiB ) @@ -190,6 +190,11 @@ type Artifact struct { Active bool `json:"active,omitempty"` Lease *Lease `json:"lease,omitempty"` Protections []Protection `json:"protections,omitempty"` + BackendID string `json:"backendId,omitempty"` + Custody string `json:"custody,omitempty"` + LifecycleState string `json:"lifecycleState,omitempty"` + ConfigRefs []string `json:"configReferences,omitempty"` + CleanupError string `json:"cleanupError,omitempty"` } // Budget bounds one automatically managed artifact kind. diff --git a/scripts/bootstrap-trust/main.go b/scripts/bootstrap-trust/main.go new file mode 100644 index 0000000..8e08a4b --- /dev/null +++ b/scripts/bootstrap-trust/main.go @@ -0,0 +1,296 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + feedSchemaVersion = 1 + maxFeedBytes = 32 << 20 + maxFeedAge = 30 * time.Second + maxCertificates = 4096 +) + +type feedDocument struct { + SchemaVersion int `json:"schemaVersion"` + HostOS string `json:"hostOS"` + Scopes []string `json:"scopes"` + GeneratedAt time.Time `json:"generatedAt"` + ExpiresAt time.Time `json:"expiresAt"` + Certificates []feedCertificate `json:"certificates"` + DistrustSHA256 []string `json:"distrustSHA256"` +} + +type feedCertificate struct { + SHA256 string `json:"sha256"` + PEM string `json:"pem"` +} + +type bundleSummary struct { + HostOS string + Scopes []string + Certificates int + FeedSHA256 string + BundleSHA256 string +} + +func main() { + feedPath := flag.String("feed", "", "host trust feed path") + outputPath := flag.String("output", "", "validated PEM bundle output path") + expectedHostOS := flag.String("expected-host-os", "", "expected feed host OS") + flag.Parse() + if flag.NArg() != 0 || strings.TrimSpace(*feedPath) == "" || strings.TrimSpace(*outputPath) == "" || strings.TrimSpace(*expectedHostOS) == "" { + fmt.Fprintln(os.Stderr, "usage: bootstrap-trust --feed --output --expected-host-os ") + os.Exit(2) + } + summary, err := materializeBundle(*feedPath, *outputPath, *expectedHostOS, time.Now().UTC()) + if err != nil { + fmt.Fprintf(os.Stderr, "bootstrap build trust rejected: %v\n", err) + os.Exit(1) + } + fmt.Printf("hostOS=%s scopes=%s certificates=%d feedSHA256=%s bundleSHA256=%s\n", summary.HostOS, strings.Join(summary.Scopes, ","), summary.Certificates, summary.FeedSHA256, summary.BundleSHA256) +} + +func materializeBundle(feedPath, outputPath, expectedHostOS string, now time.Time) (bundleSummary, error) { + feedPath = filepath.Clean(feedPath) + info, err := os.Lstat(feedPath) + if err != nil { + return bundleSummary{}, fmt.Errorf("inspect feed: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return bundleSummary{}, fmt.Errorf("feed must be a regular non-symlink file") + } + file, err := os.Open(feedPath) + if err != nil { + return bundleSummary{}, fmt.Errorf("open feed: %w", err) + } + content, readErr := io.ReadAll(io.LimitReader(file, maxFeedBytes+1)) + closeErr := file.Close() + if readErr != nil { + return bundleSummary{}, fmt.Errorf("read feed: %w", readErr) + } + if closeErr != nil { + return bundleSummary{}, fmt.Errorf("close feed: %w", closeErr) + } + if len(content) > maxFeedBytes { + return bundleSummary{}, fmt.Errorf("feed exceeds %d bytes", maxFeedBytes) + } + feedHash := sha256.Sum256(content) + + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var document feedDocument + if err := decoder.Decode(&document); err != nil { + return bundleSummary{}, fmt.Errorf("parse feed: %w", err) + } + if err := requireJSONEOF(decoder); err != nil { + return bundleSummary{}, err + } + hostOS := strings.ToLower(strings.TrimSpace(document.HostOS)) + expected := strings.ToLower(strings.TrimSpace(expectedHostOS)) + if document.SchemaVersion != feedSchemaVersion { + return bundleSummary{}, fmt.Errorf("unsupported schemaVersion %d", document.SchemaVersion) + } + if expected != "windows" && expected != "darwin" && expected != "linux" { + return bundleSummary{}, fmt.Errorf("unsupported expected host OS %q", expectedHostOS) + } + if hostOS != expected { + return bundleSummary{}, fmt.Errorf("feed hostOS %q does not match expected %q", hostOS, expected) + } + scopes, err := validateScopes(document.Scopes, hostOS) + if err != nil { + return bundleSummary{}, err + } + now = now.UTC() + generatedAt := document.GeneratedAt.UTC() + expiresAt := document.ExpiresAt.UTC() + if generatedAt.IsZero() || expiresAt.IsZero() || !expiresAt.After(generatedAt) { + return bundleSummary{}, fmt.Errorf("feed has invalid generatedAt/expiresAt") + } + if generatedAt.After(now.Add(5 * time.Second)) { + return bundleSummary{}, fmt.Errorf("feed generatedAt is in the future") + } + if now.Sub(generatedAt) > maxFeedAge { + return bundleSummary{}, fmt.Errorf("feed is older than %s", maxFeedAge) + } + if now.After(expiresAt) { + return bundleSummary{}, fmt.Errorf("feed expired at %s", expiresAt.Format(time.RFC3339)) + } + if len(document.Certificates) == 0 || len(document.Certificates) > maxCertificates { + return bundleSummary{}, fmt.Errorf("feed certificate count %d is outside 1..%d", len(document.Certificates), maxCertificates) + } + + distrusted := make(map[string]struct{}, len(document.DistrustSHA256)) + for index, value := range document.DistrustSHA256 { + hash, err := normalizedSHA256(value) + if err != nil { + return bundleSummary{}, fmt.Errorf("distrustSHA256[%d]: %w", index, err) + } + if _, exists := distrusted[hash]; exists { + return bundleSummary{}, fmt.Errorf("distrustSHA256[%d] duplicates %s", index, hash) + } + distrusted[hash] = struct{}{} + } + + certificates := make(map[string][]byte, len(document.Certificates)) + for index, encoded := range document.Certificates { + declaredHash, err := normalizedSHA256(encoded.SHA256) + if err != nil { + return bundleSummary{}, fmt.Errorf("certificate %d SHA-256: %w", index, err) + } + if _, exists := certificates[declaredHash]; exists { + return bundleSummary{}, fmt.Errorf("certificate %d duplicates %s", index, declaredHash) + } + block, rest := pem.Decode([]byte(encoded.PEM)) + if block == nil || block.Type != "CERTIFICATE" || len(bytes.TrimSpace(rest)) != 0 { + return bundleSummary{}, fmt.Errorf("certificate %d must contain exactly one CERTIFICATE PEM block", index) + } + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return bundleSummary{}, fmt.Errorf("certificate %d parse: %w", index, err) + } + if !certificate.IsCA || !certificate.BasicConstraintsValid { + return bundleSummary{}, fmt.Errorf("certificate %d is not a valid CA certificate", index) + } + actualHash := sha256.Sum256(block.Bytes) + actualHex := hex.EncodeToString(actualHash[:]) + if actualHex != declaredHash { + return bundleSummary{}, fmt.Errorf("certificate %d SHA-256 mismatch: declared %s, got %s", index, declaredHash, actualHex) + } + if _, denied := distrusted[actualHex]; denied { + return bundleSummary{}, fmt.Errorf("certificate %d is also present in distrustSHA256", index) + } + certificates[actualHex] = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: block.Bytes}) + } + + hashes := make([]string, 0, len(certificates)) + for hash := range certificates { + hashes = append(hashes, hash) + } + sort.Strings(hashes) + var bundle bytes.Buffer + for _, hash := range hashes { + bundle.Write(certificates[hash]) + } + bundleHash := sha256.Sum256(bundle.Bytes()) + if err := writeAtomically(outputPath, bundle.Bytes()); err != nil { + return bundleSummary{}, err + } + return bundleSummary{ + HostOS: hostOS, + Scopes: scopes, + Certificates: len(hashes), + FeedSHA256: hex.EncodeToString(feedHash[:]), + BundleSHA256: hex.EncodeToString(bundleHash[:]), + }, nil +} + +func requireJSONEOF(decoder *json.Decoder) error { + var extra any + err := decoder.Decode(&extra) + if errors.Is(err, io.EOF) { + return nil + } + if err == nil { + return fmt.Errorf("feed contains multiple JSON values") + } + return fmt.Errorf("parse trailing feed content: %w", err) +} + +func validateScopes(values []string, hostOS string) ([]string, error) { + if len(values) == 0 { + return nil, fmt.Errorf("feed scopes are empty") + } + seen := make(map[string]struct{}, len(values)) + scopes := make([]string, 0, len(values)) + for index, value := range values { + scope := strings.ToLower(strings.TrimSpace(value)) + if scope != "system" && scope != "user" { + return nil, fmt.Errorf("feed scope %d is unsupported: %q", index, value) + } + if hostOS == "linux" && scope == "user" { + return nil, fmt.Errorf("Linux build trust cannot declare user scope") + } + if _, exists := seen[scope]; exists { + return nil, fmt.Errorf("feed scope %d duplicates %q", index, scope) + } + seen[scope] = struct{}{} + scopes = append(scopes, scope) + } + if _, exists := seen["system"]; !exists { + return nil, fmt.Errorf("build trust feed must include system scope") + } + sort.Strings(scopes) + return scopes, nil +} + +func normalizedSHA256(value string) (string, error) { + hash := strings.ToLower(strings.TrimSpace(value)) + if len(hash) != sha256.Size*2 { + return "", fmt.Errorf("must be %d hexadecimal characters", sha256.Size*2) + } + if _, err := hex.DecodeString(hash); err != nil { + return "", fmt.Errorf("invalid hexadecimal value: %w", err) + } + return hash, nil +} + +func writeAtomically(path string, content []byte) (err error) { + path = filepath.Clean(path) + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, 0o700); err != nil { + return fmt.Errorf("create bundle directory: %w", err) + } + if info, statErr := os.Lstat(path); statErr == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("bundle output must be a regular non-symlink file") + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("inspect bundle output: %w", statErr) + } + temporary, err := os.CreateTemp(parent, ".bootstrap-ca-*.tmp") + if err != nil { + return fmt.Errorf("create temporary bundle: %w", err) + } + temporaryPath := temporary.Name() + defer func() { + if temporary != nil { + _ = temporary.Close() + } + if err != nil { + _ = os.Remove(temporaryPath) + } + }() + if err = temporary.Chmod(0o600); err != nil { + return fmt.Errorf("protect temporary bundle: %w", err) + } + if _, err = temporary.Write(content); err != nil { + return fmt.Errorf("write temporary bundle: %w", err) + } + if err = temporary.Sync(); err != nil { + return fmt.Errorf("sync temporary bundle: %w", err) + } + if err = temporary.Close(); err != nil { + temporary = nil + return fmt.Errorf("close temporary bundle: %w", err) + } + temporary = nil + if err = os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("activate bundle: %w", err) + } + return nil +} diff --git a/scripts/bootstrap-trust/main_test.go b/scripts/bootstrap-trust/main_test.go new file mode 100644 index 0000000..74e2797 --- /dev/null +++ b/scripts/bootstrap-trust/main_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestMaterializeBundleValidatesAndWritesCanonicalCA(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + certificatePEM, certificateHash := testCA(t, now) + document := testFeed(now, certificatePEM, certificateHash) + root := t.TempDir() + feedPath := writeTestFeed(t, root, document) + outputPath := filepath.Join(root, "out", "ca.pem") + + summary, err := materializeBundle(feedPath, outputPath, "windows", now) + if err != nil { + t.Fatalf("materializeBundle: %v", err) + } + if summary.HostOS != "windows" || strings.Join(summary.Scopes, ",") != "system,user" || summary.Certificates != 1 { + t.Fatalf("unexpected summary: %+v", summary) + } + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + block, rest := pem.Decode(content) + if block == nil || block.Type != "CERTIFICATE" || len(strings.TrimSpace(string(rest))) != 0 { + t.Fatalf("output is not one canonical certificate") + } + actual := sha256.Sum256(block.Bytes) + if hex.EncodeToString(actual[:]) != certificateHash { + t.Fatalf("output hash mismatch") + } +} + +func TestMaterializeBundleFailsClosed(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + certificatePEM, certificateHash := testCA(t, now) + tests := []struct { + name string + mutate func(*feedDocument) + expectedOS string + want string + }{ + { + name: "expired", + mutate: func(document *feedDocument) { + document.GeneratedAt = now.Add(-time.Minute) + document.ExpiresAt = now.Add(-30 * time.Second) + }, + expectedOS: "windows", + want: "older than", + }, + { + name: "wrong host", + mutate: func(document *feedDocument) { + document.HostOS = "darwin" + }, + expectedOS: "windows", + want: "does not match", + }, + { + name: "hash mismatch", + mutate: func(document *feedDocument) { + document.Certificates[0].SHA256 = strings.Repeat("0", 64) + }, + expectedOS: "windows", + want: "SHA-256 mismatch", + }, + { + name: "distrusted certificate", + mutate: func(document *feedDocument) { + document.DistrustSHA256 = []string{certificateHash} + }, + expectedOS: "windows", + want: "also present in distrustSHA256", + }, + { + name: "missing system scope", + mutate: func(document *feedDocument) { + document.Scopes = []string{"user"} + }, + expectedOS: "windows", + want: "must include system scope", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + document := testFeed(now, certificatePEM, certificateHash) + test.mutate(&document) + root := t.TempDir() + feedPath := writeTestFeed(t, root, document) + _, err := materializeBundle(feedPath, filepath.Join(root, "ca.pem"), test.expectedOS, now) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestMaterializeBundleRejectsSymlinkPaths(t *testing.T) { + t.Parallel() + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) + certificatePEM, certificateHash := testCA(t, now) + root := t.TempDir() + feedPath := writeTestFeed(t, root, testFeed(now, certificatePEM, certificateHash)) + feedLink := filepath.Join(root, "feed-link.json") + if err := os.Symlink(feedPath, feedLink); err != nil { + t.Skipf("symlink creation is unavailable: %v", err) + } + if _, err := materializeBundle(feedLink, filepath.Join(root, "ca.pem"), "windows", now); err == nil || !strings.Contains(err.Error(), "non-symlink") { + t.Fatalf("symlink feed error = %v", err) + } + + outputTarget := filepath.Join(root, "target.pem") + if err := os.WriteFile(outputTarget, []byte("target"), 0o600); err != nil { + t.Fatalf("write output target: %v", err) + } + outputLink := filepath.Join(root, "output-link.pem") + if err := os.Symlink(outputTarget, outputLink); err != nil { + t.Fatalf("create output symlink: %v", err) + } + if _, err := materializeBundle(feedPath, outputLink, "windows", now); err == nil || !strings.Contains(err.Error(), "non-symlink") { + t.Fatalf("symlink output error = %v", err) + } +} + +func testFeed(now time.Time, certificatePEM []byte, certificateHash string) feedDocument { + return feedDocument{ + SchemaVersion: 1, + HostOS: "windows", + Scopes: []string{"system", "user"}, + GeneratedAt: now.Add(-time.Second), + ExpiresAt: now.Add(29 * time.Second), + Certificates: []feedCertificate{{ + SHA256: certificateHash, + PEM: string(certificatePEM), + }}, + DistrustSHA256: []string{}, + } +} + +func writeTestFeed(t *testing.T, root string, document feedDocument) string { + t.Helper() + content, err := json.Marshal(document) + if err != nil { + t.Fatalf("marshal feed: %v", err) + } + path := filepath.Join(root, "feed.json") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("write feed: %v", err) + } + return path +} + +func testCA(t *testing.T, now time.Time) ([]byte, string) { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + hash := sha256.Sum256(der) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), hex.EncodeToString(hash[:]) +} diff --git a/scripts/build-native-controller.ps1 b/scripts/build-native-controller.ps1 index 0304fad..6dda43b 100644 --- a/scripts/build-native-controller.ps1 +++ b/scripts/build-native-controller.ps1 @@ -7,7 +7,7 @@ param( $ErrorActionPreference = 'Stop' $RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) . (Join-Path $RepoRoot 'scripts\host-trust\wrapper-lib.ps1') -$GoImage = if ($env:GO_DOCKER_IMAGE) { $env:GO_DOCKER_IMAGE } else { 'golang:1.25' } +$GoImage = if ($env:GO_DOCKER_IMAGE) { $env:GO_DOCKER_IMAGE } else { 'golang:latest' } $DevImage = if ($env:EPAR_DEV_IMAGE) { $env:EPAR_DEV_IMAGE } else { 'epar-dev-toolchain' } $repoRootPath = [System.IO.Path]::GetFullPath($RepoRoot) $projectHasher = [System.Security.Cryptography.SHA256]::Create() @@ -63,8 +63,15 @@ if ($bootstrapAvailableBytes -lt $BootstrapMinimumFreeBytes) { function Get-EparGoCacheVolumeIdentity { param([Parameter(Mandatory = $true)][string] $Name) - $inspectionJSON = @((docker volume inspect $Name 2>$null)) - if ($LASTEXITCODE -ne 0) { + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'SilentlyContinue' + $inspectionJSON = @((docker volume inspect $Name 2>$null)) + $inspectionExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($inspectionExitCode -ne 0) { return [pscustomobject]@{ Exists = $false; Identity = '' } } try { @@ -186,6 +193,94 @@ function Get-EparNativeSourceHash { } } +function Write-EparBootstrapAcquisitionJournal { + param( + [Parameter(Mandatory = $true)][string] $Phase, + [AllowEmptyString()][string] $PreviousGoImageID = '', + [AllowEmptyString()][string] $ResolvedGoImageID = '', + [AllowEmptyString()][string] $ResolvedDevImageID = '', + [AllowEmptyString()][string] $PreviousDevImageID = '' + ) + # The native wrapper runs before the controller can update the shared host + # catalog. Keep a deliberately narrow, atomic hand-off record for it. + $journalDirectory = Join-Path $RepoRoot '.local\storage\bootstrap' + New-Item -ItemType Directory -Force -Path $journalDirectory | Out-Null + $journalPath = Join-Path $journalDirectory 'native-controller-acquisition.json' + $temporaryPath = Join-Path $journalDirectory ('.native-controller-acquisition-' + [guid]::NewGuid().ToString('N') + '.tmp') + $record = [ordered]@{ + schemaVersion = 1 + projectID = $ProjectID + projectRoot = $repoRootPath + phase = $Phase + goImage = $GoImage + devImage = $DevImage + previousGoImageID = $PreviousGoImageID + previousDevImageID = $PreviousDevImageID + resolvedGoImageID = $ResolvedGoImageID + resolvedDevImageID = $ResolvedDevImageID + updatedAtUtc = [DateTime]::UtcNow.ToString('o') + } + [System.IO.File]::WriteAllText($temporaryPath, ($record | ConvertTo-Json -Compress), [System.Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporaryPath -Destination $journalPath -Force +} + +function Get-EparDockerImageID { + param([Parameter(Mandatory = $true)][string] $Reference) + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $imageID = ((docker image inspect --format '{{.Id}}' $Reference 2>$null) -join '').Trim() + $inspectExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($inspectExitCode -ne 0) { return '' } + if ($imageID -notmatch '^sha256:[0-9a-f]{64}$') { + throw "Docker returned an invalid immutable image ID for $Reference" + } + return $imageID +} + +function Resolve-EparGoToolchainImage { + param([AllowEmptyString()][string] $PreviousDevImageID = '') + $previousImageID = Get-EparDockerImageID -Reference $GoImage + Write-EparBootstrapAcquisitionJournal -Phase 'pulling-go-toolchain' -PreviousGoImageID $previousImageID -PreviousDevImageID $PreviousDevImageID + docker pull $GoImage | Out-Host + if ($LASTEXITCODE -ne 0) { throw "failed to resolve the current Go toolchain image $GoImage" } + $resolvedImageID = Get-EparDockerImageID -Reference $GoImage + if (-not $resolvedImageID) { throw "could not resolve the immutable Docker image ID for $GoImage after pull" } + Write-EparBootstrapAcquisitionJournal -Phase 'go-toolchain-resolved' -PreviousGoImageID $previousImageID -ResolvedGoImageID $resolvedImageID -PreviousDevImageID $PreviousDevImageID + return [pscustomobject]@{ PreviousImageID = $previousImageID; ResolvedImageID = $resolvedImageID } +} + +function Read-EparStableNativeControllerManifest { + param([Parameter(Mandatory = $true)][string] $Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $fields = @{} + foreach ($line in @(Get-Content -LiteralPath $Path -ErrorAction Stop)) { + $separator = $line.IndexOf('=') + if ($separator -le 0) { return $null } + $key = $line.Substring(0, $separator) + if ($fields.ContainsKey($key)) { return $null } + $fields[$key] = $line.Substring($separator + 1) + } + if ($fields.schemaVersion -ne '2' -or $fields.executable -ne 'ephemeral-action-runner.exe' -or $fields.fingerprint -notmatch '^[0-9a-f]{64}$' -or $fields.toolchainImageID -notmatch '^sha256:[0-9a-f]{64}$') { return $null } + return $fields +} + +function Enter-EparStableNativeControllerBuildLock { + param([Parameter(Mandatory = $true)][string] $Path) + $deadline = [DateTime]::UtcNow.AddMinutes(2) + while ($true) { + try { + return [System.IO.File]::Open($Path, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + } catch [System.IO.IOException] { + if ([DateTime]::UtcNow -ge $deadline) { throw 'another EPAR native-controller build is still in progress; wait for it to finish and retry.' } + Start-Sleep -Milliseconds 200 + } + } +} + function Get-EparDirectoryBytes { param([Parameter(Mandatory = $true)][string] $Path) $measurement = Get-ChildItem -LiteralPath $Path -File -Force -Recurse -ErrorAction Stop | Measure-Object -Property Length -Sum @@ -274,7 +369,8 @@ function Invoke-EparNativeControllerCacheRetention { [ValidateRange(0, 50)][int] $KeepPrevious = 5, [ValidateRange(1, [long]::MaxValue)][long] $MaxBytes = 268435456, [TimeSpan] $GracePeriod = ([TimeSpan]::FromDays(7)), - [TimeSpan] $AbandonedBuildGracePeriod = ([TimeSpan]::FromHours(24)) + [TimeSpan] $AbandonedBuildGracePeriod = ([TimeSpan]::FromHours(24)), + [switch] $RemoveCurrent ) if (-not (Test-Path -LiteralPath $CacheRoot -PathType Container)) { return } $resolvedRoot = [System.IO.Path]::GetFullPath($CacheRoot).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) @@ -296,7 +392,7 @@ function Invoke-EparNativeControllerCacheRetention { foreach ($directory in @(Get-ChildItem -LiteralPath $resolvedRoot -Directory -Force | Where-Object { $_.Name -match '^[0-9a-f]{64}$' })) { if (($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue } $candidate = [System.IO.Path]::GetFullPath($directory.FullName) - if ($candidate -eq $expectedCurrent) { continue } + if ($candidate -eq $expectedCurrent -and -not $RemoveCurrent) { continue } if ([System.IO.Path]::GetDirectoryName($candidate) -ne $resolvedRoot) { continue } if (Test-EparNativeControllerLeaseActive -Directory $candidate) { continue } @@ -354,6 +450,131 @@ function Test-EparRetryableDockerContextMetadataDiagnostic { return $normalized -match '^(?:docker(?:\.exe|\.cmd)?\s*:\s*)?ERROR: failed to build: failed to read metadata: open [^:*?"<>|\r\n]+:\\[^:*?"<>|\r\n]*\\\.docker\\contexts\\meta\\[0-9a-f]{64}\\meta\.json: The process cannot access the file because it is being used by another process\.(?: At .* FullyQualifiedErrorId\s*:\s*NativeCommandError)?$' } +function Get-EparTLSFailureHost { + param([Parameter(Mandatory = $true)][string] $Transcript) + if ($Transcript -notmatch '(?i)(?:x509:\s*certificate signed by unknown authority|certificate verify failed|unable to (?:get local issuer certificate|verify the first certificate))') { + return '' + } + foreach ($match in [regex]::Matches($Transcript, 'https://(?[A-Za-z0-9.-]+)(?=[:/"])', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)) { + $hostName = $match.Groups['host'].Value.Trim().ToLowerInvariant() + if ($hostName -match '^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$') { + return $hostName + } + } + return '' +} + +function Get-EparCertificateSHA256 { + param([Parameter(Mandatory = $true)][System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate) + $algorithm = [System.Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($algorithm.ComputeHash($Certificate.RawData))).Replace('-', '') + } finally { + $algorithm.Dispose() + } +} + +function Find-EparWindowsIssuerRoots { + param([Parameter(Mandatory = $true)][string] $IssuerCommonName) + $matches = [System.Collections.Generic.List[object]]::new() + foreach ($store in @( + [pscustomobject]@{ Name = 'LocalMachine\Root'; Path = 'Cert:\LocalMachine\Root' }, + [pscustomobject]@{ Name = 'CurrentUser\Root'; Path = 'Cert:\CurrentUser\Root' } + )) { + if (-not (Test-Path -LiteralPath $store.Path)) { continue } + foreach ($certificate in Get-ChildItem -LiteralPath $store.Path -ErrorAction SilentlyContinue) { + if ($certificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) -cne $IssuerCommonName) { continue } + $matches.Add([pscustomobject]@{ + Store = $store.Name + Subject = $certificate.Subject + SHA1 = $certificate.Thumbprint + SHA256 = Get-EparCertificateSHA256 -Certificate $certificate + NotAfter = $certificate.NotAfter.ToString('o') + }) + } + } + return @($matches) +} + +function Invoke-EparTLSFailureDiagnostic { + param( + [Parameter(Mandatory = $true)][string] $Transcript, + [Parameter(Mandatory = $true)][string] $LogPath + ) + $hostName = Get-EparTLSFailureHost -Transcript $Transcript + if (-not $hostName) { return } + + $diagnosticScript = @' +set -u +raw="$(mktemp)" +leaf="$(mktemp)" +cleanup() { rm -f -- "$raw" "$leaf"; } +trap cleanup EXIT +openssl s_client -connect "${EPAR_TLS_DIAGNOSTIC_HOST}:443" -servername "${EPAR_TLS_DIAGNOSTIC_HOST}" -showcerts "$raw" 2>&1 || true +awk '/-----BEGIN CERTIFICATE-----/{capture=1} capture{print} /-----END CERTIFICATE-----/{exit}' "$raw" >"$leaf" +grep -E 'verify error|Verify return code' "$raw" || true +if [ -s "$leaf" ]; then + openssl x509 -in "$leaf" -noout -subject -issuer -fingerprint -sha256 -dates +fi +'@ + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $diagnosticOutput = @(& docker run --rm -e "EPAR_TLS_DIAGNOSTIC_HOST=$hostName" $DevImage sh -c $diagnosticScript 2>&1 | ForEach-Object { "$_" }) + $diagnosticExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + + $subject = (($diagnosticOutput | Where-Object { $_ -match '^subject=' } | Select-Object -First 1) -replace '^subject=', '').Trim() + $issuer = (($diagnosticOutput | Where-Object { $_ -match '^issuer=' } | Select-Object -First 1) -replace '^issuer=', '').Trim() + $fingerprint = (($diagnosticOutput | Where-Object { $_ -match '^sha256 Fingerprint=' } | Select-Object -First 1) -replace '^sha256 Fingerprint=', '').Replace(':', '').Trim() + $notBefore = (($diagnosticOutput | Where-Object { $_ -match '^notBefore=' } | Select-Object -First 1) -replace '^notBefore=', '').Trim() + $notAfter = (($diagnosticOutput | Where-Object { $_ -match '^notAfter=' } | Select-Object -First 1) -replace '^notAfter=', '').Trim() + $verifyErrors = @($diagnosticOutput | Where-Object { $_ -match '(?i)verify error|Verify return code' }) + + $report = [System.Collections.Generic.List[string]]::new() + $report.Add('') + $report.Add('EPAR TLS certificate diagnostic') + $report.Add(" Requested host: $hostName`:443") + $report.Add(" Toolchain image: $DevImage") + if ($diagnosticExitCode -ne 0 -or -not $subject) { + $report.Add(' Certificate inspection: unavailable; see the raw build error above.') + } else { + $report.Add(' Certificate presented to the build container:') + $report.Add(" Subject: $subject") + $report.Add(" Issuer: $issuer") + if ($fingerprint) { $report.Add(" SHA-256: $fingerprint") } + if ($notBefore) { $report.Add(" Valid from: $notBefore") } + if ($notAfter) { $report.Add(" Valid until: $notAfter") } + foreach ($verifyError in $verifyErrors) { $report.Add(" OpenSSL: $($verifyError.Trim())") } + + $issuerCommonName = '' + if ($issuer -match '(?:^|,)\s*CN\s*=\s*(?[^,]+)') { + $issuerCommonName = $Matches['cn'].Trim() + } + $roots = if ($issuerCommonName) { @(Find-EparWindowsIssuerRoots -IssuerCommonName $issuerCommonName) } else { @() } + if ($roots.Count -eq 0) { + $report.Add(' Candidate Windows root: none with the same issuer name was found in LocalMachine\Root or CurrentUser\Root.') + } else { + $report.Add(' Candidate Windows root certificate(s) with the same issuer name:') + foreach ($root in $roots) { + $report.Add(" Store: $($root.Store)") + $report.Add(" Subject: $($root.Subject)") + $report.Add(" SHA-1: $($root.SHA1)") + $report.Add(" SHA-256: $($root.SHA256)") + $report.Add(" Valid until: $($root.NotAfter)") + } + $report.Add(' Interpretation: Windows has candidate issuer trust, but the Linux bootstrap container does not currently trust that issuer.') + } + } + $report.Add(' TLS verification was not disabled, and EPAR did not retry the download insecurely.') + $report.Add(" Full native-controller build log: $LogPath") + + foreach ($line in $report) { [Console]::Error.WriteLine($line) } + [System.IO.File]::AppendAllLines($LogPath, $report, [System.Text.UTF8Encoding]::new($false)) +} + function Invoke-EparDockerBuild { $stderrPath = [System.IO.Path]::GetTempFileName() $previousErrorActionPreference = $ErrorActionPreference @@ -364,7 +585,7 @@ function Invoke-EparDockerBuild { $ErrorActionPreference = 'Continue' $maximumAttempts = 5 for ($attempt = 1; $attempt -le $maximumAttempts; $attempt++) { - docker build --quiet --build-arg "GO_IMAGE=$GoImage" -t $DevImage -f (Join-Path $RepoRoot 'scripts\docker\dev.Dockerfile') (Join-Path $RepoRoot 'scripts\docker') 2> $stderrPath | Out-Null + docker build --quiet --provenance=false --build-arg "GO_IMAGE=$GoImage" -t $DevImage -f (Join-Path $RepoRoot 'scripts\docker\dev.Dockerfile') (Join-Path $RepoRoot 'scripts\docker') 2> $stderrPath | Out-Null $exitCode = $LASTEXITCODE $stderrTranscript = if (Test-Path -LiteralPath $stderrPath) { Get-Content -Raw -LiteralPath $stderrPath } else { '' } if ($exitCode -ne 0 -and $attempt -lt $maximumAttempts -and (Test-EparRetryableDockerContextMetadataDiagnostic -Transcript $stderrTranscript)) { @@ -385,13 +606,93 @@ function Invoke-EparDockerBuild { } } +function Initialize-EparBootstrapBuildTrust { + param( + [Parameter(Mandatory = $true)][string] $ProjectRoot, + [string[]] $Arguments + ) + + $configPath = Get-EparHostTrustConfigPath -ProjectRoot $ProjectRoot -Arguments $Arguments + $helper = Join-Path $ProjectRoot 'scripts\host-trust\host-trust-feed.ps1' + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $feedOutput = @(& $helper sync -ProjectRoot $ProjectRoot -Config $configPath -Purpose build 2>&1 | ForEach-Object { "$_" }) + $feedExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($feedExitCode -ne 0) { + throw "could not publish host trust for the native-controller build: $($feedOutput -join [Environment]::NewLine)" + } + $feedPath = ($feedOutput | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1) + if (-not $feedPath) { + throw 'the host trust publisher did not return a build feed' + } + $feedPath = [System.IO.Path]::GetFullPath($feedPath.Trim()) + $feedItem = Get-Item -LiteralPath $feedPath -Force -ErrorAction Stop + if (-not (Test-Path -LiteralPath $feedPath -PathType Leaf)) { + throw "bootstrap build trust feed is not a regular file: $feedPath" + } + if ($feedItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "bootstrap build trust feed must not be a reparse point: $feedPath" + } + + $configHasher = [System.Security.Cryptography.SHA256]::Create() + try { + $configMaterial = [System.Text.Encoding]::UTF8.GetBytes($configPath.ToLowerInvariant()) + $configID = ([BitConverter]::ToString($configHasher.ComputeHash($configMaterial))).Replace('-', '').ToLowerInvariant().Substring(0, 32) + } finally { + $configHasher.Dispose() + } + $bundleDirectory = Join-Path $ProjectRoot ".local\storage\bootstrap-trust\$configID" + New-Item -ItemType Directory -Force -Path $bundleDirectory | Out-Null + $bundleDirectoryItem = Get-Item -LiteralPath $bundleDirectory -Force + if ($bundleDirectoryItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "bootstrap build trust directory must not be a reparse point: $bundleDirectory" + } + $bundlePath = Join-Path $bundleDirectory 'ca.pem' + if (Test-Path -LiteralPath $bundlePath) { + $existingBundle = Get-Item -LiteralPath $bundlePath -Force + if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf) -or ($existingBundle.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "bootstrap build trust output must be a regular non-reparse file: $bundlePath" + } + } + + $validatorDirectory = Join-Path $ProjectRoot 'scripts\bootstrap-trust' + try { + $ErrorActionPreference = 'Continue' + $validatorOutput = @(& docker run --rm ` + --network none ` + -e GO111MODULE=off ` + -e GOTOOLCHAIN=local ` + -v "${validatorDirectory}:/bootstrap:ro" ` + -v "${feedPath}:/feed/current.json:ro" ` + -v "${bundleDirectory}:/out" ` + $DevImage ` + /usr/local/go/bin/go run /bootstrap/main.go --feed /feed/current.json --output /out/ca.pem --expected-host-os windows 2>&1 | ForEach-Object { "$_" }) + $validatorExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($validatorExitCode -ne 0) { + throw "bootstrap build trust validation failed: $($validatorOutput -join [Environment]::NewLine)" + } + $bundleItem = Get-Item -LiteralPath $bundlePath -Force -ErrorAction Stop + if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf) -or $bundleItem.Length -eq 0 -or ($bundleItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "bootstrap build trust validator did not produce a regular nonempty bundle: $bundlePath" + } + $summary = ($validatorOutput | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1) + return [pscustomobject]@{ BundlePath = $bundlePath; Summary = $summary; ConfigPath = $configPath } +} + +$previousDevImageID = Get-EparDockerImageID -Reference $DevImage +$goToolchain = Resolve-EparGoToolchainImage -PreviousDevImageID $previousDevImageID $buildExit = Invoke-EparDockerBuild if ($buildExit -ne 0) { exit $buildExit } -$DevImageID = ((docker image inspect --format '{{.Id}}' $DevImage) -join '').Trim() -if ($LASTEXITCODE -ne 0 -or $DevImageID -notmatch '^sha256:[0-9a-f]{64}$') { - Write-Error "could not resolve the immutable Docker toolchain image ID for $DevImage" - exit 1 -} +$DevImageID = Get-EparDockerImageID -Reference $DevImage +if (-not $DevImageID) { Write-Error "could not resolve the immutable Docker toolchain image ID for $DevImage"; exit 1 } +Write-EparBootstrapAcquisitionJournal -Phase 'toolchain-built' -PreviousGoImageID $goToolchain.PreviousImageID -ResolvedGoImageID $goToolchain.ResolvedImageID -ResolvedDevImageID $DevImageID -PreviousDevImageID $previousDevImageID if ($ManageGoCache) { Initialize-EparGoCacheVolume -Name $GomodVolume -Role 'gomod' Initialize-EparGoCacheVolume -Name $GocacheVolume -Role 'gobuild' @@ -411,50 +712,116 @@ if (Get-Command git -ErrorAction SilentlyContinue) { } } -$cacheKey = Get-EparNativeSourceHash -DevImageID $DevImageID -GitCommit $gitCommit -SourceState $sourceState -$controllerSourceRevision = if ($sourceState -eq 'clean') { "sha256:$cacheKey" } elseif ($sourceState -eq 'dirty') { "dirty:sha256:$cacheKey" } else { 'unknown' } +$fingerprint = Get-EparNativeSourceHash -DevImageID $DevImageID -GitCommit $gitCommit -SourceState $sourceState +$controllerSourceRevision = if ($sourceState -eq 'clean') { "sha256:$fingerprint" } elseif ($sourceState -eq 'dirty') { "dirty:sha256:$fingerprint" } else { 'unknown' } $cacheRoot = Join-Path $RepoRoot '.local\bin' -$cacheDirectory = Join-Path $cacheRoot $cacheKey -$binary = Join-Path $cacheDirectory 'ephemeral-action-runner.exe' -if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { - New-Item -ItemType Directory -Force -Path $cacheRoot | Out-Null - $temporaryDirectory = Join-Path $cacheRoot ('.build-' + [guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null - $buildLeasePath = Join-Path $temporaryDirectory ("lease-build-{0}-{1}.txt" -f $PID, [guid]::NewGuid().ToString('N')) - $buildProcessStartUtc = (Get-Process -Id $PID).StartTime.ToUniversalTime().ToString('o') - [System.IO.File]::WriteAllLines($buildLeasePath, @( - 'schemaVersion=1', - "host=$([Environment]::MachineName)", - "pid=$PID", - "processStartUtc=$buildProcessStartUtc", - "startedAtUtc=$([DateTime]::UtcNow.ToString('o'))" - ), [System.Text.UTF8Encoding]::new($false)) +$binary = Join-Path $cacheRoot 'ephemeral-action-runner.exe' +$manifestPath = Join-Path $cacheRoot 'ephemeral-action-runner.manifest' +New-Item -ItemType Directory -Force -Path $cacheRoot | Out-Null + +# Historical hash directories were created by older no-Go wrappers. Delete only +# complete, exactly shaped, inactive revisions; unknown paths are intentionally +# left for storage's legacy inventory. +try { + Invoke-EparNativeControllerCacheRetention -CacheRoot $cacheRoot -CurrentCacheKey ([string]::new([char]'0', 64)) -KeepPrevious 0 -MaxBytes 1 -GracePeriod ([TimeSpan]::Zero) -RemoveCurrent +} catch { + Write-Warning "Native-controller legacy revision cleanup skipped after an error: $($_.Exception.Message)" +} + +$existingManifest = Read-EparStableNativeControllerManifest -Path $manifestPath +$needsBuild = -not (Test-Path -LiteralPath $binary -PathType Leaf) -or $null -eq $existingManifest -or $existingManifest.fingerprint -cne $fingerprint -or $existingManifest.toolchainImageID -cne $DevImageID +if ($needsBuild -and $null -ne $existingManifest -and (Test-Path -LiteralPath $binary -PathType Leaf) -and (Test-EparNativeControllerLeaseActive -Directory $cacheRoot)) { + # A mutable bootstrap dependency can produce a new dev-toolchain image + # while an already-built controller is serving another invocation. Reuse + # that stable binary only when the current source still fingerprints + # exactly against the toolchain recorded beside it. Real source changes + # continue to fail with the stop-running-process instruction below. + $activeControllerFingerprint = Get-EparNativeSourceHash -DevImageID $existingManifest.toolchainImageID -GitCommit $gitCommit -SourceState $sourceState + if ($existingManifest.fingerprint -ceq $activeControllerFingerprint) { + $needsBuild = $false + } +} +if ($needsBuild) { + $buildLock = Enter-EparStableNativeControllerBuildLock -Path (Join-Path $cacheRoot '.native-controller.lock') try { - docker run --rm ` - -e CGO_ENABLED=0 ` - -e GOOS=windows ` - -e GOARCH=amd64 ` - -v "${RepoRoot}:/src:ro" ` - -v "${temporaryDirectory}:/out" ` - -v "${GomodVolume}:/go/pkg/mod" ` - -v "${GocacheVolume}:/root/.cache/go-build" ` - -w /src ` - $DevImage ` - go build -trimpath -ldflags "-X main.sourceRevision=$controllerSourceRevision" -o /out/ephemeral-action-runner.exe ./cmd/ephemeral-action-runner - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if (-not (Test-Path -LiteralPath (Join-Path $temporaryDirectory 'ephemeral-action-runner.exe') -PathType Leaf)) { - Write-Error 'native EPAR build completed without producing the expected Windows binary' - exit 1 - } - if (-not (Test-Path -LiteralPath $cacheDirectory)) { - Remove-Item -LiteralPath $buildLeasePath -Force - Move-Item -LiteralPath $temporaryDirectory -Destination $cacheDirectory - $temporaryDirectory = $null + $existingManifest = Read-EparStableNativeControllerManifest -Path $manifestPath + $needsBuild = -not (Test-Path -LiteralPath $binary -PathType Leaf) -or $null -eq $existingManifest -or $existingManifest.fingerprint -cne $fingerprint -or $existingManifest.toolchainImageID -cne $DevImageID + if ($needsBuild) { + if (Test-EparNativeControllerLeaseActive -Directory $cacheRoot) { + throw 'EPAR source or its Go toolchain changed while a native EPAR controller is running. Stop the running EPAR process, then run ./start again; EPAR keeps one stable native controller binary and will not create another versioned copy.' + } + $buildLogDirectory = Join-Path $RepoRoot 'work\logs' + New-Item -ItemType Directory -Force -Path $buildLogDirectory | Out-Null + $buildLogPath = Join-Path $buildLogDirectory 'epar-native-controller-build.log' + $bootstrapBuildTrust = Initialize-EparBootstrapBuildTrust -ProjectRoot $RepoRoot -Arguments $EparArgs + [System.IO.File]::WriteAllLines($buildLogPath, @( + "EPAR native-controller build started at $([DateTime]::UtcNow.ToString('o'))", + "Toolchain image: $DevImage", + "Target: windows/amd64", + "Bootstrap build trust: $($bootstrapBuildTrust.Summary)", + '' + ), [System.Text.UTF8Encoding]::new($false)) + Write-Output "Native controller build log: $buildLogPath" + $temporaryDirectory = Join-Path $cacheRoot ('.build-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null + $buildLeasePath = Join-Path $temporaryDirectory ("lease-build-{0}-{1}.txt" -f $PID, [guid]::NewGuid().ToString('N')) + $buildProcessStartUtc = (Get-Process -Id $PID).StartTime.ToUniversalTime().ToString('o') + [System.IO.File]::WriteAllLines($buildLeasePath, @('schemaVersion=1', "host=$([Environment]::MachineName)", "pid=$PID", "processStartUtc=$buildProcessStartUtc", "startedAtUtc=$([DateTime]::UtcNow.ToString('o'))"), [System.Text.UTF8Encoding]::new($false)) + try { + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $buildOutput = @(& docker run --rm ` + -e CGO_ENABLED=0 ` + -e GOOS=windows ` + -e GOARCH=amd64 ` + -e GOTOOLCHAIN=local ` + -e SSL_CERT_FILE=/run/epar-bootstrap-ca.pem ` + -v "${RepoRoot}:/src:ro" ` + -v "${temporaryDirectory}:/out" ` + -v "${GomodVolume}:/go/pkg/mod" ` + -v "${GocacheVolume}:/root/.cache/go-build" ` + -v "$($bootstrapBuildTrust.BundlePath):/run/epar-bootstrap-ca.pem:ro" ` + -w /src ` + $DevImage ` + go build -trimpath -ldflags "-X main.sourceRevision=$controllerSourceRevision" -o /out/ephemeral-action-runner.exe ./cmd/ephemeral-action-runner 2>&1 | ForEach-Object { "$_" }) + $nativeBuildExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + $buildTranscript = if ($buildOutput.Count -ne 0) { ($buildOutput -join [Environment]::NewLine) + [Environment]::NewLine } else { '' } + if ($buildTranscript) { + [System.IO.File]::AppendAllText($buildLogPath, $buildTranscript, [System.Text.UTF8Encoding]::new($false)) + } + if ($nativeBuildExitCode -ne 0) { + $tlsFailureHost = Get-EparTLSFailureHost -Transcript $buildTranscript + if ($tlsFailureHost) { + [Console]::Error.WriteLine("Native controller build failed while downloading dependencies from https://$tlsFailureHost.") + [Console]::Error.WriteLine(' The build container rejected the presented TLS certificate as an unknown issuer.') + [Console]::Error.WriteLine(" Full compiler output: $buildLogPath") + } elseif ($buildTranscript) { + [Console]::Error.Write($buildTranscript) + } + Invoke-EparTLSFailureDiagnostic -Transcript $buildTranscript -LogPath $buildLogPath + exit $nativeBuildExitCode + } + if ($buildTranscript) { [Console]::Error.Write($buildTranscript) } + $temporaryBinary = Join-Path $temporaryDirectory 'ephemeral-action-runner.exe' + if (-not (Test-Path -LiteralPath $temporaryBinary -PathType Leaf)) { throw 'native EPAR build completed without producing the expected Windows binary' } + try { + Move-Item -LiteralPath $temporaryBinary -Destination $binary -Force + } catch { + throw "could not atomically replace $binary. Stop every EPAR process using the native controller and retry: $($_.Exception.Message)" + } + $manifestTemporaryPath = Join-Path $cacheRoot ('.native-controller-manifest-' + [guid]::NewGuid().ToString('N') + '.tmp') + [System.IO.File]::WriteAllLines($manifestTemporaryPath, @('schemaVersion=2', "fingerprint=$fingerprint", 'executable=ephemeral-action-runner.exe', "toolchainImageID=$DevImageID", "sourceRevision=$controllerSourceRevision", "completedAtUtc=$([DateTime]::UtcNow.ToString('o'))"), [System.Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $manifestTemporaryPath -Destination $manifestPath -Force + } finally { + if ($temporaryDirectory -and (Test-Path -LiteralPath $temporaryDirectory)) { Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue } + } } } finally { - if ($temporaryDirectory -and (Test-Path -LiteralPath $temporaryDirectory)) { - Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue - } + $buildLock.Dispose() } } if ($ManageGoCache) { @@ -467,17 +834,7 @@ if ($ManageGoCache) { Invoke-EparGoCacheLimit } -$manifestPath = Join-Path $cacheDirectory 'controller-cache.manifest' -$manifestTemporaryPath = Join-Path $cacheDirectory ('.manifest-' + [guid]::NewGuid().ToString('N')) -[System.IO.File]::WriteAllLines($manifestTemporaryPath, @( - 'schemaVersion=1', - "cacheKey=$cacheKey", - 'executable=ephemeral-action-runner.exe', - "completedAtUtc=$([DateTime]::UtcNow.ToString('o'))" -), [System.Text.UTF8Encoding]::new($false)) -Move-Item -LiteralPath $manifestTemporaryPath -Destination $manifestPath -Force - -$leasePath = Join-Path $cacheDirectory ("lease-{0}-{1}.txt" -f $PID, [guid]::NewGuid().ToString('N')) +$leasePath = Join-Path $cacheRoot ("lease-native-{0}-{1}.txt" -f $PID, [guid]::NewGuid().ToString('N')) $processStartUtc = (Get-Process -Id $PID).StartTime.ToUniversalTime().ToString('o') [System.IO.File]::WriteAllLines($leasePath, @( 'schemaVersion=1', @@ -486,31 +843,26 @@ $processStartUtc = (Get-Process -Id $PID).StartTime.ToUniversalTime().ToString(' "processStartUtc=$processStartUtc", "startedAtUtc=$([DateTime]::UtcNow.ToString('o'))" ), [System.Text.UTF8Encoding]::new($false)) -try { - Invoke-EparNativeControllerCacheRetention -CacheRoot $cacheRoot -CurrentCacheKey $cacheKey -} catch { - Write-Warning "Native-controller cache retention skipped after an error: $($_.Exception.Message)" -} $previousNative = $env:EPAR_NATIVE_CONTROLLER $previousControllerOS = $env:EPAR_CONTROLLER_HOST_OS $previousHostName = $env:EPAR_HOST_NAME $previousHints = $env:DOCKER_CLI_HINTS -$previousRunnerTrustFeed = $env:EPAR_HOST_TRUST_FEED -$previousBuildTrustFeed = $env:EPAR_BUILD_TRUST_FEED $controllerCommand = if ($EparArgs -and $EparArgs.Count -gt 0) { [string]$EparArgs[0] } else { 'start' } -$bridge = Start-EparHostTrustBridge -ProjectRoot $RepoRoot -Command $controllerCommand -Arguments $EparArgs +$bridge = if ($controllerCommand -eq 'init') { Start-EparHostTrustBridge -ProjectRoot $RepoRoot -Command $controllerCommand -Arguments $EparArgs } else { $null } try { $env:EPAR_NATIVE_CONTROLLER = '1' $env:EPAR_CONTROLLER_HOST_OS = 'windows' - if ($bridge.BuildFeedDir) { $env:EPAR_BUILD_TRUST_FEED = Join-Path $bridge.BuildFeedDir 'current.json' } - if ($bridge.RunnerFeedDir) { $env:EPAR_HOST_TRUST_FEED = Join-Path $bridge.RunnerFeedDir 'current.json' } if (-not $env:EPAR_HOST_NAME) { $env:EPAR_HOST_NAME = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { [System.Net.Dns]::GetHostName() } } if (-not $env:DOCKER_CLI_HINTS) { $env:DOCKER_CLI_HINTS = 'false' } & $binary @EparArgs - exit $LASTEXITCODE + $nativeExitCode = $LASTEXITCODE + if ($nativeExitCode -eq 0 -and $controllerCommand -eq 'init') { + Complete-EparHostTrustInit -ProjectRoot $RepoRoot -Bridge $bridge + } + exit $nativeExitCode } finally { Stop-EparHostTrustBridge -Bridge $bridge Remove-Item -LiteralPath $leasePath -Force -ErrorAction SilentlyContinue @@ -518,6 +870,4 @@ try { if ($null -eq $previousControllerOS) { Remove-Item Env:EPAR_CONTROLLER_HOST_OS -ErrorAction SilentlyContinue } else { $env:EPAR_CONTROLLER_HOST_OS = $previousControllerOS } if ($null -eq $previousHostName) { Remove-Item Env:EPAR_HOST_NAME -ErrorAction SilentlyContinue } else { $env:EPAR_HOST_NAME = $previousHostName } if ($null -eq $previousHints) { Remove-Item Env:DOCKER_CLI_HINTS -ErrorAction SilentlyContinue } else { $env:DOCKER_CLI_HINTS = $previousHints } - if ($null -eq $previousRunnerTrustFeed) { Remove-Item Env:EPAR_HOST_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_HOST_TRUST_FEED = $previousRunnerTrustFeed } - if ($null -eq $previousBuildTrustFeed) { Remove-Item Env:EPAR_BUILD_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_BUILD_TRUST_FEED = $previousBuildTrustFeed } } diff --git a/scripts/build-native-controller.sh b/scripts/build-native-controller.sh index 536532b..1f99109 100644 --- a/scripts/build-native-controller.sh +++ b/scripts/build-native-controller.sh @@ -4,7 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" EPAR_HOST_TRUST_HELPER="${repo_root}/scripts/host-trust/host-trust-feed.sh" source "${repo_root}/scripts/host-trust/wrapper-lib.sh" -go_image="${GO_DOCKER_IMAGE:-golang:1.25}" +go_image="${GO_DOCKER_IMAGE:-golang:latest}" dev_image="${EPAR_DEV_IMAGE:-epar-dev-toolchain}" native_cache_keep_previous=5 native_cache_max_bytes=$((256 * 1024 * 1024)) @@ -79,6 +79,139 @@ epar_enforce_go_cache_limit() { sh -ceu 'mod_kib="$(du -sk /go/pkg/mod | awk "{print \$1}")"; build_kib="$(du -sk /root/.cache/go-build | awk "{print \$1}")"; used_bytes="$(((mod_kib + build_kib) * 1024))"; if [ "$used_bytes" -gt "$EPAR_GO_CACHE_LIMIT_BYTES" ]; then go clean -cache -modcache; fi' } +epar_write_bootstrap_acquisition_journal() { + local phase="$1" + local previous_id="${2:-}" + local resolved_go_id="${3:-}" + local resolved_dev_id="${4:-}" + local previous_dev_id="${5:-}" + local journal_directory="${repo_root}/.local/storage/bootstrap" + local journal_path="${journal_directory}/native-controller-acquisition.json" + local temporary_path + epar_bootstrap_json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" + } + mkdir -p "$journal_directory" + temporary_path="$(mktemp "${journal_directory}/.native-controller-acquisition.XXXXXX")" + printf '{"schemaVersion":1,"projectID":"%s","projectRoot":"%s","phase":"%s","goImage":"%s","devImage":"%s","previousGoImageID":"%s","previousDevImageID":"%s","resolvedGoImageID":"%s","resolvedDevImageID":"%s","updatedAtUnix":%s}\n' \ + "$(epar_bootstrap_json_escape "$project_id")" "$(epar_bootstrap_json_escape "$repo_root")" "$(epar_bootstrap_json_escape "$phase")" "$(epar_bootstrap_json_escape "$go_image")" "$(epar_bootstrap_json_escape "$dev_image")" "$(epar_bootstrap_json_escape "$previous_id")" "$(epar_bootstrap_json_escape "$previous_dev_id")" "$(epar_bootstrap_json_escape "$resolved_go_id")" "$(epar_bootstrap_json_escape "$resolved_dev_id")" "$(date +%s)" >"$temporary_path" + mv -f -- "$temporary_path" "$journal_path" +} + +epar_docker_image_id() { + local reference="$1" + local image_id + image_id="$(docker image inspect --format '{{.Id}}' "$reference" 2>/dev/null || true)" + if [[ -z "$image_id" ]]; then return 0; fi + [[ "$image_id" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Docker returned an invalid immutable image ID for ${reference}" >&2; return 1; } + printf '%s\n' "$image_id" +} + +epar_resolve_go_toolchain_image() { + local previous_id resolved_id + previous_id="$(epar_docker_image_id "$go_image")" + epar_write_bootstrap_acquisition_journal pulling-go-toolchain "$previous_id" '' '' "$previous_dev_image_id" + docker pull "$go_image" >&2 + resolved_id="$(epar_docker_image_id "$go_image")" + [[ -n "$resolved_id" ]] || { echo "could not resolve the immutable Docker image ID for ${go_image} after pull" >&2; return 1; } + epar_write_bootstrap_acquisition_journal go-toolchain-resolved "$previous_id" "$resolved_id" '' "$previous_dev_image_id" + EPAR_GO_PREVIOUS_IMAGE_ID="$previous_id" + EPAR_GO_RESOLVED_IMAGE_ID="$resolved_id" +} + +epar_prepare_bootstrap_build_trust() { + local config_path feed_path config_id bundle_directory validator_output host_os + config_path="$(epar_host_trust_config_path "$repo_root" "$@")" + feed_path="$("$EPAR_HOST_TRUST_HELPER" sync --project-root "$repo_root" --config "$config_path" --purpose build)" + [[ -n "$feed_path" && -f "$feed_path" && ! -L "$feed_path" ]] || { echo "the host trust publisher did not return a regular build feed" >&2; return 1; } + config_id="$(printf '%s' "$config_path" | shasum -a 256 | awk '{print substr($1,1,32)}')" + bundle_directory="${repo_root}/.local/storage/bootstrap-trust/${config_id}" + mkdir -p "$bundle_directory" + [[ -d "$bundle_directory" && ! -L "$bundle_directory" ]] || { echo "bootstrap build trust directory must be a regular directory: ${bundle_directory}" >&2; return 1; } + bootstrap_trust_bundle="${bundle_directory}/ca.pem" + if [[ -e "$bootstrap_trust_bundle" && (! -f "$bootstrap_trust_bundle" || -L "$bootstrap_trust_bundle") ]]; then + echo "bootstrap build trust output must be a regular non-symlink file: ${bootstrap_trust_bundle}" >&2 + return 1 + fi + host_os="$(epar_host_trust_host_os)" + validator_output="$( + docker run --rm \ + --network none \ + -e GO111MODULE=off \ + -e GOTOOLCHAIN=local \ + -v "${repo_root}/scripts/bootstrap-trust:/bootstrap:ro" \ + -v "${feed_path}:/feed/current.json:ro" \ + -v "${bundle_directory}:/out" \ + "$dev_image" \ + /usr/local/go/bin/go run /bootstrap/main.go --feed /feed/current.json --output /out/ca.pem --expected-host-os "$host_os" + )" + [[ -s "$bootstrap_trust_bundle" && ! -L "$bootstrap_trust_bundle" ]] || { echo "bootstrap build trust validator did not produce a regular nonempty bundle" >&2; return 1; } + bootstrap_trust_summary="$(printf '%s\n' "$validator_output" | sed -n '$p')" +} + +epar_tls_failure_host() { + local transcript="$1" + grep -Eqi 'x509: certificate signed by unknown authority|certificate verify failed|unable to (get local issuer certificate|verify the first certificate)' "$transcript" || return 0 + sed -nE 's#.*https://([A-Za-z0-9.-]+)([:/"].*)?#\1#p' "$transcript" | sed -n '1p' | tr '[:upper:]' '[:lower:]' +} + +epar_report_tls_failure() { + local transcript="$1" + local log_path="$2" + local host_name diagnostic_output subject issuer fingerprint not_before not_after + host_name="$(epar_tls_failure_host "$transcript")" + [[ -n "$host_name" ]] || return 0 + diagnostic_output="$( + docker run --rm \ + -e "EPAR_TLS_DIAGNOSTIC_HOST=${host_name}" \ + "$dev_image" \ + sh -c ' + set -u + raw="$(mktemp)" + leaf="$(mktemp)" + cleanup() { rm -f -- "$raw" "$leaf"; } + trap cleanup EXIT + openssl s_client -connect "${EPAR_TLS_DIAGNOSTIC_HOST}:443" -servername "${EPAR_TLS_DIAGNOSTIC_HOST}" -showcerts "$raw" 2>&1 || true + awk '"'"'/-----BEGIN CERTIFICATE-----/{capture=1} capture{print} /-----END CERTIFICATE-----/{exit}'"'"' "$raw" >"$leaf" + grep -E "verify error|Verify return code" "$raw" || true + if [ -s "$leaf" ]; then + openssl x509 -in "$leaf" -noout -subject -issuer -fingerprint -sha256 -dates + fi + ' 2>&1 + )" || true + subject="$(printf '%s\n' "$diagnostic_output" | sed -n 's/^subject=//p' | sed -n '1p')" + issuer="$(printf '%s\n' "$diagnostic_output" | sed -n 's/^issuer=//p' | sed -n '1p')" + fingerprint="$(printf '%s\n' "$diagnostic_output" | sed -n 's/^sha256 Fingerprint=//p' | sed -n '1p' | tr -d ':')" + not_before="$(printf '%s\n' "$diagnostic_output" | sed -n 's/^notBefore=//p' | sed -n '1p')" + not_after="$(printf '%s\n' "$diagnostic_output" | sed -n 's/^notAfter=//p' | sed -n '1p')" + { + printf '\n%s\n' 'EPAR TLS certificate diagnostic' + printf ' Requested host: %s:443\n' "$host_name" + printf ' Toolchain image: %s\n' "$dev_image" + if [[ -z "$subject" ]]; then + printf '%s\n' ' Certificate inspection: unavailable; see the raw build error above.' + else + printf '%s\n' ' Certificate presented to the build container:' + printf ' Subject: %s\n' "$subject" + printf ' Issuer: %s\n' "$issuer" + [[ -z "$fingerprint" ]] || printf ' SHA-256: %s\n' "$fingerprint" + [[ -z "$not_before" ]] || printf ' Valid from: %s\n' "$not_before" + [[ -z "$not_after" ]] || printf ' Valid until: %s\n' "$not_after" + printf '%s\n' "$diagnostic_output" | grep -E 'verify error|Verify return code' | sed 's/^/ OpenSSL: /' || true + printf '%s\n' ' Interpretation: the host network presented a certificate whose issuer is unavailable to the Linux bootstrap container.' + printf '%s\n' ' Check the host system or user trust store for a root matching the issuer above.' + fi + printf '%s\n' ' TLS verification was not disabled, and EPAR did not retry the download insecurely.' + printf ' Full native-controller build log: %s\n' "$log_path" + } | tee -a "$log_path" >&2 +} + epar_directory_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" } @@ -126,6 +259,7 @@ epar_native_controller_build_lease_valid() { epar_prune_native_controller_cache() { local cache_root="$1" local current_cache_key="$2" + local remove_current="${3:-0}" local now path name mtime bytes manifest unexpected lease valid_build_leases executable local retained_count=0 local retained_bytes=0 @@ -158,7 +292,7 @@ epar_prune_native_controller_cache() { [[ ! -L "$path" ]] || continue name="${path##*/}" [[ "$name" =~ ^[0-9a-f]{64}$ ]] || continue - [[ "$name" != "$current_cache_key" ]] || continue + [[ "$name" != "$current_cache_key" || "$remove_current" == 1 ]] || continue epar_native_controller_lease_active "$path" && continue manifest="${path}/controller-cache.manifest" [[ -f "$manifest" && ! -L "$manifest" ]] || continue @@ -202,13 +336,19 @@ case "$(uname -s)/$(uname -m)" in *) echo "unsupported native EPAR controller platform: $(uname -s)/$(uname -m)" >&2; exit 1 ;; esac +previous_dev_image_id="$(epar_docker_image_id "$dev_image")" +epar_resolve_go_toolchain_image +go_toolchain_previous_id="$EPAR_GO_PREVIOUS_IMAGE_ID" +go_toolchain_resolved_id="$EPAR_GO_RESOLVED_IMAGE_ID" docker build --quiet \ + --provenance=false \ --build-arg "GO_IMAGE=${go_image}" \ -t "$dev_image" \ -f "${repo_root}/scripts/docker/dev.Dockerfile" \ "${repo_root}/scripts/docker" >/dev/null -dev_image_id="$(docker image inspect --format '{{.Id}}' "$dev_image")" +dev_image_id="$(epar_docker_image_id "$dev_image")" [[ "$dev_image_id" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "could not resolve the immutable Docker toolchain image ID for ${dev_image}" >&2; exit 1; } +epar_write_bootstrap_acquisition_journal toolchain-built "$go_toolchain_previous_id" "$go_toolchain_resolved_id" "$dev_image_id" "$previous_dev_image_id" if ((manage_go_cache == 1)); then epar_ensure_go_cache_volume "$gomod_volume" gomod epar_ensure_go_cache_volume "$gocache_volume" gobuild @@ -238,57 +378,130 @@ source_manifest="$( shasum -a 256 "$file" | awk '{print $1}' done )" -cache_key="$(printf '%s' "$source_manifest" | shasum -a 256 | awk '{print $1}')" +fingerprint="$(printf '%s' "$source_manifest" | shasum -a 256 | awk '{print $1}')" case "$source_state" in - clean) controller_source_revision="sha256:${cache_key}" ;; - dirty) controller_source_revision="dirty:sha256:${cache_key}" ;; + clean) controller_source_revision="sha256:${fingerprint}" ;; + dirty) controller_source_revision="dirty:sha256:${fingerprint}" ;; *) controller_source_revision=unknown ;; esac cache_root="${repo_root}/.local/bin" -cache_directory="${cache_root}/${cache_key}" -binary="${cache_directory}/ephemeral-action-runner" +binary="${cache_root}/ephemeral-action-runner" +manifest_path="${cache_root}/ephemeral-action-runner.manifest" +lock_directory="${cache_root}/.native-controller.lock" temporary_directory="" lease_file="" build_lease_file="" retention_inventory_file="" manifest_temporary="" +lock_lease_file="" cleanup_build() { if [[ -n "$temporary_directory" && -d "$temporary_directory" ]]; then rm -rf -- "$temporary_directory"; fi if [[ -n "$lease_file" && -f "$lease_file" ]]; then rm -f -- "$lease_file"; fi if [[ -n "$build_lease_file" && -f "$build_lease_file" ]]; then rm -f -- "$build_lease_file"; fi if [[ -n "$retention_inventory_file" && -f "$retention_inventory_file" ]]; then rm -f -- "$retention_inventory_file"; fi if [[ -n "$manifest_temporary" && -f "$manifest_temporary" ]]; then rm -f -- "$manifest_temporary"; fi + if [[ -n "$lock_lease_file" && -f "$lock_lease_file" ]]; then rm -f -- "$lock_lease_file"; fi + if [[ -n "$lock_directory" && -d "$lock_directory" ]]; then rmdir -- "$lock_directory" 2>/dev/null || true; fi } trap cleanup_build EXIT INT TERM -if [[ ! -x "$binary" ]]; then - mkdir -p "$cache_root" - temporary_directory="$(mktemp -d "${cache_root}/.build.XXXXXX")" - build_lease_file="$(mktemp "${temporary_directory}/lease-build-$$.XXXXXX")" - printf '%s\n' \ - 'schemaVersion=1' \ - "host=$(hostname 2>/dev/null || true)" \ - "pid=$$" \ - "startedAtUnix=$(date +%s)" >"$build_lease_file" - docker run --rm \ - -e CGO_ENABLED=0 \ - -e "GOOS=${goos}" \ - -e "GOARCH=${goarch}" \ - -v "${repo_root}:/src:ro" \ - -v "${temporary_directory}:/out" \ - -v "${gomod_volume}:/go/pkg/mod" \ - -v "${gocache_volume}:/root/.cache/go-build" \ - -w /src \ - "$dev_image" \ - go build -trimpath -ldflags "-X main.sourceRevision=${controller_source_revision}" -o /out/ephemeral-action-runner ./cmd/ephemeral-action-runner - [[ -f "${temporary_directory}/ephemeral-action-runner" ]] || { echo "native EPAR build did not produce the expected binary" >&2; exit 1; } - chmod 0755 "${temporary_directory}/ephemeral-action-runner" - if [[ ! -e "$cache_directory" ]]; then - rm -f -- "$build_lease_file" - build_lease_file="" - mv -- "$temporary_directory" "$cache_directory" - temporary_directory="" +mkdir -p "$cache_root" +# Retire only old hash-directory revisions whose manifest and inactive lease +# prove ownership. Unknown paths remain available to storage's legacy preview. +native_cache_keep_previous=0 +native_cache_max_bytes=1 +native_cache_grace_seconds=0 +if ! epar_prune_native_controller_cache "$cache_root" "$(printf '%064d' 0)" 1; then + echo "warning: native-controller legacy revision cleanup skipped after an error" >&2 +fi + +epar_stable_manifest_matches() { + [[ -f "$manifest_path" && ! -L "$manifest_path" && -x "$binary" && ! -L "$binary" ]] || return 1 + grep -Fqx 'schemaVersion=2' "$manifest_path" && grep -Fqx "fingerprint=${fingerprint}" "$manifest_path" && grep -Fqx 'executable=ephemeral-action-runner' "$manifest_path" && grep -Fqx "toolchainImageID=${dev_image_id}" "$manifest_path" +} + +epar_acquire_stable_build_lock() { + local deadline=$(( $(date +%s) + 120 )) + while ! mkdir "$lock_directory" 2>/dev/null; do + if [[ -d "$lock_directory" && ! -L "$lock_directory" ]]; then + local valid=0 candidate unexpected + for candidate in "$lock_directory"/lease-build-*; do + epar_native_controller_build_lease_valid "$candidate" && valid=$((valid + 1)) + done + unexpected="$(find "$lock_directory" -mindepth 1 -maxdepth 1 ! \( -type f -name 'lease-build-*' \) -print -quit)" + if ((valid == 1)) && [[ -z "$unexpected" ]] && ! epar_native_controller_lease_active "$lock_directory"; then + rm -rf -- "$lock_directory" + continue + fi + fi + (( $(date +%s) < deadline )) || { echo 'another EPAR native-controller build is still in progress; wait for it to finish and retry.' >&2; return 1; } + sleep 0.2 + done + lock_lease_file="$(mktemp "${lock_directory}/lease-build-$$.XXXXXX")" + printf '%s\n' 'schemaVersion=1' "host=$(hostname 2>/dev/null || true)" "pid=$$" "startedAtUnix=$(date +%s)" >"$lock_lease_file" +} + +if ! epar_stable_manifest_matches; then + epar_acquire_stable_build_lock + if ! epar_stable_manifest_matches; then + if epar_native_controller_lease_active "$cache_root"; then + echo 'EPAR source or its Go toolchain changed while a native EPAR controller is running. Stop the running EPAR process, then run ./start again; EPAR keeps one stable native controller binary and will not create another versioned copy.' >&2 + exit 1 + fi + epar_prepare_bootstrap_build_trust "$@" + build_log_directory="${repo_root}/work/logs" + build_log_path="${build_log_directory}/epar-native-controller-build.log" + mkdir -p "$build_log_directory" + printf '%s\n' \ + "EPAR native-controller build started at $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "Toolchain image: ${dev_image}" \ + "Target: ${goos}/${goarch}" \ + "Bootstrap build trust: ${bootstrap_trust_summary}" \ + '' >"$build_log_path" + printf 'Native controller build log: %s\n' "$build_log_path" + temporary_directory="$(mktemp -d "${cache_root}/.build.XXXXXX")" + build_stderr="${temporary_directory}/native-controller-build.stderr" + build_lease_file="$(mktemp "${temporary_directory}/lease-build-$$.XXXXXX")" + printf '%s\n' 'schemaVersion=1' "host=$(hostname 2>/dev/null || true)" "pid=$$" "startedAtUnix=$(date +%s)" >"$build_lease_file" + set +e + docker run --rm \ + -e CGO_ENABLED=0 \ + -e "GOOS=${goos}" \ + -e "GOARCH=${goarch}" \ + -e GOTOOLCHAIN=local \ + -e SSL_CERT_FILE=/run/epar-bootstrap-ca.pem \ + -v "${repo_root}:/src:ro" \ + -v "${temporary_directory}:/out" \ + -v "${gomod_volume}:/go/pkg/mod" \ + -v "${gocache_volume}:/root/.cache/go-build" \ + -v "${bootstrap_trust_bundle}:/run/epar-bootstrap-ca.pem:ro" \ + -w /src \ + "$dev_image" \ + go build -trimpath -ldflags "-X main.sourceRevision=${controller_source_revision}" -o /out/ephemeral-action-runner ./cmd/ephemeral-action-runner 2>"$build_stderr" + native_build_exit_code=$? + set -e + if [[ -s "$build_stderr" ]]; then cat "$build_stderr" >>"$build_log_path"; fi + if ((native_build_exit_code != 0)); then + tls_failure_host="$(epar_tls_failure_host "$build_stderr")" + if [[ -n "$tls_failure_host" ]]; then + printf 'Native controller build failed while downloading dependencies from https://%s.\n' "$tls_failure_host" >&2 + printf '%s\n' ' The build container rejected the presented TLS certificate as an unknown issuer.' >&2 + printf ' Full compiler output: %s\n' "$build_log_path" >&2 + elif [[ -s "$build_stderr" ]]; then + cat "$build_stderr" >&2 + fi + epar_report_tls_failure "$build_stderr" "$build_log_path" + exit "$native_build_exit_code" + fi + if [[ -s "$build_stderr" ]]; then cat "$build_stderr" >&2; fi + [[ -f "${temporary_directory}/ephemeral-action-runner" ]] || { echo "native EPAR build did not produce the expected binary" >&2; exit 1; } + chmod 0755 "${temporary_directory}/ephemeral-action-runner" + mv -f -- "${temporary_directory}/ephemeral-action-runner" "$binary" + manifest_temporary="$(mktemp "${cache_root}/.native-controller-manifest.XXXXXX")" + printf '%s\n' 'schemaVersion=2' "fingerprint=${fingerprint}" 'executable=ephemeral-action-runner' "toolchainImageID=${dev_image_id}" "sourceRevision=${controller_source_revision}" "completedAtUnix=$(date +%s)" >"$manifest_temporary" + mv -f -- "$manifest_temporary" "$manifest_path" + manifest_temporary="" fi fi if ((manage_go_cache == 1)); then @@ -297,25 +510,12 @@ if ((manage_go_cache == 1)); then epar_enforce_go_cache_limit fi -manifest_temporary="$(mktemp "${cache_directory}/.manifest.XXXXXX")" -printf '%s\n' \ - 'schemaVersion=1' \ - "cacheKey=${cache_key}" \ - 'executable=ephemeral-action-runner' \ - "completedAtUnix=$(date +%s)" >"$manifest_temporary" -mv -f -- "$manifest_temporary" "${cache_directory}/controller-cache.manifest" -manifest_temporary="" - -lease_file="$(mktemp "${cache_directory}/lease.$$.XXXXXX")" +lease_file="$(mktemp "${cache_root}/lease-native-$$.XXXXXX")" printf '%s\n' \ 'schemaVersion=1' \ "host=$(hostname 2>/dev/null || true)" \ "pid=$$" \ "startedAtUnix=$(date +%s)" >"$lease_file" -if ! epar_prune_native_controller_cache "$cache_root" "$cache_key"; then - echo "warning: native-controller cache retention skipped after an error" >&2 -fi - export EPAR_NATIVE_CONTROLLER=1 export EPAR_CONTROLLER_HOST_OS="$goos" export DOCKER_CLI_HINTS="${DOCKER_CLI_HINTS:-false}" diff --git a/scripts/docker/dev.Dockerfile b/scripts/docker/dev.Dockerfile index 2d2e38f..126f1e7 100644 --- a/scripts/docker/dev.Dockerfile +++ b/scripts/docker/dev.Dockerfile @@ -1,7 +1,7 @@ # Go toolchain + Docker CLI, for running EPAR from source with no local Go # install (scripts/run-with-docker.sh). The Docker CLI lets EPAR's own # runtime docker calls reach the host daemon over the mounted socket. -ARG GO_IMAGE=golang:1.25 +ARG GO_IMAGE=golang:latest FROM ${GO_IMAGE} COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker COPY --from=docker:27-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins diff --git a/scripts/host-trust/host-trust-feed.sh b/scripts/host-trust/host-trust-feed.sh index 5bace19..cc22c63 100755 --- a/scripts/host-trust/host-trust-feed.sh +++ b/scripts/host-trust/host-trust-feed.sh @@ -51,13 +51,15 @@ if [[ "$config_path" != /* ]]; then config_path="$project_root/$config_path" fi if [[ ! -f "$config_path" ]]; then - # The first `start` can create config interactively. Treat missing config as - # disabled: native controller code will re-evaluate after init. - exit 0 -fi -config_path="$(cd "$(dirname "$config_path")" && pwd -P)/$(basename "$config_path")" -if command -v realpath >/dev/null 2>&1; then - config_path="$(realpath "$config_path")" + # A first `start` still needs operational system trust to compile the native + # controller before the wizard can create a config. Runner inheritance + # remains disabled until an existing config explicitly enables it. + if [[ "$purpose" != "build" ]]; then exit 0; fi +else + config_path="$(cd "$(dirname "$config_path")" && pwd -P)/$(basename "$config_path")" + if command -v realpath >/dev/null 2>&1; then + config_path="$(realpath "$config_path")" + fi fi sha256_text() { @@ -69,6 +71,7 @@ sha256_text() { } config_values() { + [[ -f "$config_path" ]] || return 0 # Supported deliberately-small YAML subset: EPAR's own parser is flat in # each section and supports inline or block lists. Emit mode followed by # one scope per line. diff --git a/scripts/run-with-docker.ps1 b/scripts/run-with-docker.ps1 index 0e10cf4..586346e 100644 --- a/scripts/run-with-docker.ps1 +++ b/scripts/run-with-docker.ps1 @@ -31,7 +31,7 @@ if ($env:EPAR_LEGACY_CONTROLLER_IN_DOCKER -ne '1') { exit $nativeExitCode } -$Image = if ($env:GO_DOCKER_IMAGE) { $env:GO_DOCKER_IMAGE } else { "golang:1.25" } +$Image = if ($env:GO_DOCKER_IMAGE) { $env:GO_DOCKER_IMAGE } else { "golang:latest" } $DevImage = if ($env:EPAR_DEV_IMAGE) { $env:EPAR_DEV_IMAGE } else { "epar-dev-toolchain" } $DockerSock = if ($env:EPAR_DOCKER_SOCK) { $env:EPAR_DOCKER_SOCK } else { "/var/run/docker.sock" } $OriginalDockerCliHintsExists = Test-Path Env:DOCKER_CLI_HINTS diff --git a/scripts/run-with-docker.sh b/scripts/run-with-docker.sh index e0b32ee..cf28cbd 100755 --- a/scripts/run-with-docker.sh +++ b/scripts/run-with-docker.sh @@ -26,7 +26,7 @@ fi # # Usage: scripts/run-with-docker.sh [epar-args...] -image="${GO_DOCKER_IMAGE:-golang:1.25}" +image="${GO_DOCKER_IMAGE:-golang:latest}" dev_image="${EPAR_DEV_IMAGE:-epar-dev-toolchain}" gomod_volume="${EPAR_GOMOD_VOLUME:-}" gocache_volume="${EPAR_GOCACHE_VOLUME:-}" diff --git a/scripts/test/host-trust-wrapper-smoke.ps1 b/scripts/test/host-trust-wrapper-smoke.ps1 index 582e9e4..fc2cdbd 100644 --- a/scripts/test/host-trust-wrapper-smoke.ps1 +++ b/scripts/test/host-trust-wrapper-smoke.ps1 @@ -70,6 +70,8 @@ image: $bridge = Start-EparHostTrustBridge -ProjectRoot $ProjectRoot -Command pool -Arguments @('pool', 'up', '--config', $config) if (-not $bridge.WatchProcess -or $bridge.WatchProcess.HasExited) { throw 'Windows host-trust watcher did not start' } + $publishedFeed = Join-Path $bridge.RunnerFeedDir 'current.json' + $firstPublishedAt = (Get-Content -LiteralPath $publishedFeed -Raw | ConvertFrom-Json).generatedAt $liveLock = $bridge.FeedDir + '.lock' $deadline = [DateTime]::UtcNow.AddSeconds(5) while (-not (Test-Path -LiteralPath $liveLock -PathType Container) -and [DateTime]::UtcNow -lt $deadline) { @@ -79,6 +81,13 @@ image: $lockRejected = $false try { & $helper sync -ProjectRoot $ProjectRoot -Config $config *> $null } catch { $lockRejected = $true } if (-not $lockRejected) { throw 'second controller unexpectedly acquired the live Windows wrapper lock' } + $refreshDeadline = [DateTime]::UtcNow.AddSeconds(15) + $refreshedPublishedAt = $firstPublishedAt + while ($refreshedPublishedAt -eq $firstPublishedAt -and [DateTime]::UtcNow -lt $refreshDeadline) { + Start-Sleep -Milliseconds 250 + $refreshedPublishedAt = (Get-Content -LiteralPath $publishedFeed -Raw | ConvertFrom-Json).generatedAt + } + if ($refreshedPublishedAt -eq $firstPublishedAt) { throw 'Windows host-trust watcher did not refresh its published feed' } Stop-EparHostTrustBridge -Bridge $bridge $bridge = $null if (Test-Path -LiteralPath $liveLock) { throw 'Windows wrapper shutdown left its singleton lock behind' } diff --git a/scripts/test/host-trust-wrapper-smoke.sh b/scripts/test/host-trust-wrapper-smoke.sh index ef5ecea..db7f100 100644 --- a/scripts/test/host-trust-wrapper-smoke.sh +++ b/scripts/test/host-trust-wrapper-smoke.sh @@ -204,6 +204,8 @@ EPAR_HOST_TRUST_HELPER="$helper" missing_config="$temporary/missing-project/.local/config.yml" missing_output="$("$helper" sync --project-root "$project_root" --config "$missing_config")" [[ -z "$missing_output" ]] || { echo "missing config unexpectedly produced a host-trust feed" >&2; exit 1; } +missing_build_output="$("$helper" sync --project-root "$project_root" --config "$missing_config" --purpose build)" +[[ -s "$missing_build_output" ]] || { echo "missing config did not produce automatic system build trust" >&2; exit 1; } native_project="$temporary/native-go-project" fake_go="$temporary/fake-go" diff --git a/scripts/test/native-controller-cache-retention.sh b/scripts/test/native-controller-cache-retention.sh index c939f21..20f5726 100644 --- a/scripts/test/native-controller-cache-retention.sh +++ b/scripts/test/native-controller-cache-retention.sh @@ -3,7 +3,8 @@ set -euo pipefail source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" builder="${source_root}/scripts/build-native-controller.sh" -eval "$(sed -n '/^epar_directory_mtime()/,/^case "$(uname -s)\/$(uname -m)"/p' "$builder" | sed '$d')" +grep -q -- '--provenance=false' "$builder" || { echo 'native controller toolchain build must disable nondeterministic default provenance' >&2; exit 1; } +eval "$(sed -n '/^epar_tls_failure_host()/,/^case "$(uname -s)\/$(uname -m)"/p' "$builder" | sed '$d')" native_cache_keep_previous=2 native_cache_max_bytes=$((1024 * 1024)) @@ -17,6 +18,12 @@ cleanup() { } trap cleanup EXIT INT TERM +tls_transcript="${temporary}/tls-error.log" +printf '%s\n' 'module: Get "https://proxy.golang.org/example/@v/v1.0.0.zip": tls: failed to verify certificate: x509: certificate signed by unknown authority' >"$tls_transcript" +[[ "$(epar_tls_failure_host "$tls_transcript")" == 'proxy.golang.org' ]] || { echo 'native-controller TLS failure host was not extracted' >&2; exit 1; } +printf '%s\n' 'ordinary build failure' >"$tls_transcript" +[[ -z "$(epar_tls_failure_host "$tls_transcript")" ]] || { echo 'ordinary native-controller failure was misclassified as TLS' >&2; exit 1; } + repeat_character() { printf '%64s' '' | tr ' ' "$1" } @@ -138,4 +145,9 @@ epar_prune_native_controller_cache "$policy_root" "$policy_current" [[ ! -e "${policy_root}/${policy_expired}" ]] || { echo "retention kept an expired revision beyond the byte budget" >&2; exit 1; } [[ -d "${policy_root}/${policy_grace}" ]] || { echo "retention removed a grace-protected revision beyond the byte budget" >&2; exit 1; } +builder_source="$(cat "$builder")" +for required in 'golang:latest' 'ephemeral-action-runner.manifest' 'schemaVersion=2' 'lease-native-' 'epar_write_bootstrap_acquisition_journal' 'epar_resolve_go_toolchain_image' 'previousDevImageID' 'previous_dev_image_id' 'epar-native-controller-build.log' 'epar_report_tls_failure' 'TLS verification was not disabled' 'epar_prepare_bootstrap_build_trust' '--network none' 'GO111MODULE=off' 'GOTOOLCHAIN=local' 'SSL_CERT_FILE=/run/epar-bootstrap-ca.pem' 'scripts/bootstrap-trust' ':/run/epar-bootstrap-ca.pem:ro'; do + [[ "$builder_source" == *"$required"* ]] || { echo "stable native-controller wrapper contract is missing: ${required}" >&2; exit 1; } +done + echo "Unix native-controller cache retention contract passed" diff --git a/scripts/test/windows-native-controller-contract.ps1 b/scripts/test/windows-native-controller-contract.ps1 index 835014e..e83224b 100644 --- a/scripts/test/windows-native-controller-contract.ps1 +++ b/scripts/test/windows-native-controller-contract.ps1 @@ -51,7 +51,10 @@ $builderAst = [System.Management.Automation.Language.Parser]::ParseFile($builder if (@($builderParseErrors).Count -ne 0) { throw "build-native-controller.ps1 failed to parse: $(@($builderParseErrors).Message -join '; ')" } -foreach ($functionName in @('Get-EparDirectoryBytes', 'Test-EparNativeControllerLeaseActive', 'Test-EparNativeControllerBuildLeaseValid', 'Invoke-EparNativeControllerCacheRetention', 'Get-EparGoCacheVolumeIdentity', 'Invoke-EparGoCacheLimit')) { +if ($builderSource -notmatch 'docker build --quiet --provenance=false') { + throw 'native controller toolchain build must disable nondeterministic default provenance' +} +foreach ($functionName in @('Get-EparDirectoryBytes', 'Test-EparNativeControllerLeaseActive', 'Test-EparNativeControllerBuildLeaseValid', 'Invoke-EparNativeControllerCacheRetention', 'Get-EparGoCacheVolumeIdentity', 'Invoke-EparGoCacheLimit', 'Read-EparStableNativeControllerManifest', 'Get-EparDockerImageID', 'Get-EparTLSFailureHost', 'Get-EparCertificateSHA256', 'Find-EparWindowsIssuerRoots', 'Invoke-EparTLSFailureDiagnostic', 'Initialize-EparBootstrapBuildTrust')) { $function = $builderAst.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName @@ -61,6 +64,70 @@ foreach ($functionName in @('Get-EparDirectoryBytes', 'Test-EparNativeController } Invoke-Expression $function.Extent.Text } +$previousErrorActionPreferenceForImageProbe = $ErrorActionPreference +try { + function docker { + Write-Error 'No such image: contract-missing' + $global:LASTEXITCODE = 1 + } + if (Get-EparDockerImageID -Reference 'contract-missing') { + throw 'missing Docker image probe returned an identity' + } + if ($ErrorActionPreference -ne $previousErrorActionPreferenceForImageProbe) { + throw 'missing Docker image probe did not restore ErrorActionPreference' + } +} finally { + Remove-Item Function:\docker -ErrorAction SilentlyContinue +} +$tlsFailureTranscript = 'module: Get "https://proxy.golang.org/example/@v/v1.0.0.zip": tls: failed to verify certificate: x509: certificate signed by unknown authority' +if ((Get-EparTLSFailureHost -Transcript $tlsFailureTranscript) -cne 'proxy.golang.org') { + throw 'native-controller TLS failure host was not extracted' +} +if (Get-EparTLSFailureHost -Transcript 'ordinary build failure without a certificate error') { + throw 'ordinary native-controller failure was misclassified as a TLS certificate failure' +} +$tlsDiagnosticRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('epar-native-tls-diagnostic-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tlsDiagnosticRoot | Out-Null +$previousDevImage = $DevImage +try { + $tlsDiagnosticLog = Join-Path $tlsDiagnosticRoot 'build.log' + [System.IO.File]::WriteAllText($tlsDiagnosticLog, $tlsFailureTranscript) + $DevImage = 'contract-dev-image' + function docker { + Write-Output 'depth=0 CN=proxy.golang.org' + Write-Output 'verify error:num=20:unable to get local issuer certificate' + Write-Output 'subject=CN=proxy.golang.org' + Write-Output 'issuer=CN=EPAR contract root' + Write-Output 'sha256 Fingerprint=AA:BB' + Write-Output 'notBefore=Jul 29 00:00:00 2026 GMT' + Write-Output 'notAfter=Jul 29 00:00:00 2027 GMT' + $global:LASTEXITCODE = 0 + } + Invoke-EparTLSFailureDiagnostic -Transcript $tlsFailureTranscript -LogPath $tlsDiagnosticLog + $tlsDiagnosticContent = Get-Content -Raw -LiteralPath $tlsDiagnosticLog + foreach ($required in @('EPAR TLS certificate diagnostic', 'Requested host: proxy.golang.org:443', 'Issuer: CN=EPAR contract root', 'SHA-256: AABB', 'TLS verification was not disabled')) { + if (-not $tlsDiagnosticContent.Contains($required)) { + throw "native-controller TLS diagnostic log is missing: $required" + } + } +} finally { + $DevImage = $previousDevImage + Remove-Item Function:\docker -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tlsDiagnosticRoot -Recurse -Force -ErrorAction SilentlyContinue +} +$stableManifestRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('epar-native-stable-manifest-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $stableManifestRoot | Out-Null +try { + $stableManifestPath = Join-Path $stableManifestRoot 'ephemeral-action-runner.manifest' + $stableFingerprint = [string]::new([char]'a', 64) + [System.IO.File]::WriteAllLines($stableManifestPath, @('schemaVersion=2', "fingerprint=$stableFingerprint", 'executable=ephemeral-action-runner.exe', ('toolchainImageID=sha256:' + [string]::new([char]'b', 64)), 'sourceRevision=sha256:test', 'completedAtUtc=2026-07-29T00:00:00Z')) + $stableManifest = Read-EparStableNativeControllerManifest -Path $stableManifestPath + if ($null -eq $stableManifest -or $stableManifest.fingerprint -ne $stableFingerprint) { throw 'stable native-controller manifest was not parsed' } + Add-Content -LiteralPath $stableManifestPath -Value 'fingerprint=duplicate' + if ($null -ne (Read-EparStableNativeControllerManifest -Path $stableManifestPath)) { throw 'stable native-controller manifest accepted duplicate keys' } +} finally { + Remove-Item -LiteralPath $stableManifestRoot -Recurse -Force -ErrorAction SilentlyContinue +} $GomodVolume = 'contract-gomod' $GocacheVolume = 'contract-gocache' $ProjectID = 'contract' @@ -209,6 +276,14 @@ try { if (-not (Test-Path -LiteralPath (Join-Path $policyRoot $policyKeys[2]))) { throw 'native controller retention removed a grace-protected revision beyond the byte budget' } + $migrationRoot = Join-Path $retentionRoot 'migration-contract' + $migrationKey = [string]::new([char]'f', 64) + $migrationDirectory = Join-Path $migrationRoot $migrationKey + New-Item -ItemType Directory -Force -Path $migrationDirectory | Out-Null + [System.IO.File]::WriteAllText((Join-Path $migrationDirectory 'ephemeral-action-runner.exe'), 'legacy') + [System.IO.File]::WriteAllLines((Join-Path $migrationDirectory 'controller-cache.manifest'), @('schemaVersion=1', "cacheKey=$migrationKey", 'executable=ephemeral-action-runner.exe')) + Invoke-EparNativeControllerCacheRetention -CacheRoot $migrationRoot -CurrentCacheKey $migrationKey -KeepPrevious 0 -MaxBytes 1 -GracePeriod ([TimeSpan]::Zero) -AbandonedBuildGracePeriod ([TimeSpan]::Zero) -RemoveCurrent + if (Test-Path -LiteralPath $migrationDirectory) { throw 'native controller legacy migration left an inactive exact revision behind' } } finally { Remove-Item -LiteralPath $retentionRoot -Recurse -Force -ErrorAction SilentlyContinue } @@ -323,7 +398,7 @@ foreach ($required in @('$env:EPAR_INVOCATION = "start"', 'if ($ControllerArgs.C if ($startSource.Contains('@StartArgs')) { throw 'start command-forwarding contract still forces the start command' } -foreach ($required in @('CGO_ENABLED=0', 'GOOS=windows', 'GOARCH=amd64', '.local\bin', 'dirty:sha256:', 'EPAR_NATIVE_CONTROLLER', 'Get-EparNativeSourceHash', 'Invoke-EparNativeControllerCacheRetention', 'Test-EparNativeControllerBuildLeaseValid', 'controller-cache.manifest', 'lease-build-', '[System.IO.FileAttributes]::ReparsePoint', '$maximumAttempts = 5', 'Start-Sleep -Milliseconds')) { +foreach ($required in @('CGO_ENABLED=0', 'GOOS=windows', 'GOARCH=amd64', '.local\bin', 'dirty:sha256:', 'EPAR_NATIVE_CONTROLLER', 'Get-EparNativeSourceHash', 'activeControllerFingerprint', 'existingManifest.toolchainImageID', 'Invoke-EparNativeControllerCacheRetention', 'Test-EparNativeControllerBuildLeaseValid', 'controller-cache.manifest', 'ephemeral-action-runner.manifest', 'schemaVersion=2', 'lease-native-', 'golang:latest', 'Write-EparBootstrapAcquisitionJournal', 'Resolve-EparGoToolchainImage', 'previousDevImageID', '$previousDevImageID = Get-EparDockerImageID', '[System.IO.FileAttributes]::ReparsePoint', '$maximumAttempts = 5', 'Start-Sleep -Milliseconds', 'epar-native-controller-build.log', 'Invoke-EparTLSFailureDiagnostic', 'TLS verification was not disabled', 'Initialize-EparBootstrapBuildTrust', '--network none', 'GO111MODULE=off', 'GOTOOLCHAIN=local', 'SSL_CERT_FILE=/run/epar-bootstrap-ca.pem', 'scripts\bootstrap-trust', ':/run/epar-bootstrap-ca.pem:ro')) { if (-not $builderSource.Contains($required)) { throw "native controller build contract is missing: $required" } diff --git a/start.ps1 b/start.ps1 index e55bfa1..c37241b 100644 --- a/start.ps1 +++ b/start.ps1 @@ -60,20 +60,8 @@ if (-not $goUsable) { exit 1 } -$bridge = Start-EparHostTrustBridge -ProjectRoot $Root -Command $ControllerCommand -Arguments $ControllerArgs -$previousHostOS = $env:EPAR_CONTROLLER_HOST_OS -$previousFeed = $env:EPAR_HOST_TRUST_FEED -$previousBuildFeed = $env:EPAR_BUILD_TRUST_FEED +$bridge = if ($ControllerCommand -eq "init") { Start-EparHostTrustBridge -ProjectRoot $Root -Command $ControllerCommand -Arguments $ControllerArgs } else { $null } try { - if ($bridge.BuildFeedDir -or $bridge.RunnerFeedDir) { - $env:EPAR_CONTROLLER_HOST_OS = Get-EparHostTrustHostOS - } - if ($bridge.BuildFeedDir) { - $env:EPAR_BUILD_TRUST_FEED = Join-Path $bridge.BuildFeedDir "current.json" - } - if ($bridge.RunnerFeedDir) { - $env:EPAR_HOST_TRUST_FEED = Join-Path $bridge.RunnerFeedDir "current.json" - } & $GoBin run ./cmd/ephemeral-action-runner @ControllerArgs $exitCode = $LASTEXITCODE if ($exitCode -eq 0 -and $ControllerCommand -eq "init") { @@ -81,9 +69,6 @@ try { } } finally { Stop-EparHostTrustBridge -Bridge $bridge - if ($null -eq $previousHostOS) { Remove-Item Env:EPAR_CONTROLLER_HOST_OS -ErrorAction SilentlyContinue } else { $env:EPAR_CONTROLLER_HOST_OS = $previousHostOS } - if ($null -eq $previousFeed) { Remove-Item Env:EPAR_HOST_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_HOST_TRUST_FEED = $previousFeed } - if ($null -eq $previousBuildFeed) { Remove-Item Env:EPAR_BUILD_TRUST_FEED -ErrorAction SilentlyContinue } else { $env:EPAR_BUILD_TRUST_FEED = $previousBuildFeed } if ($OriginalInvocationExists) { $env:EPAR_INVOCATION = $OriginalInvocation } else { Remove-Item Env:EPAR_INVOCATION -ErrorAction SilentlyContinue } } exit $exitCode diff --git a/templates/docker-sandboxes/Dockerfile b/templates/docker-sandboxes/Dockerfile index 8a57d48..5e1dd2c 100644 --- a/templates/docker-sandboxes/Dockerfile +++ b/templates/docker-sandboxes/Dockerfile @@ -14,8 +14,9 @@ RUN echo "${HOOK_LAUNCHER_SHA256} /src/main.go" | sha256sum --check - \ && install -d -m 0755 /out \ && CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" GOTOOLCHAIN=local go build -trimpath -buildvcs=false -ldflags='-s -w -buildid=' -o /out/epar-hook-bash /src/main.go -FROM --platform=${TEMPLATE_PLATFORM} ${SOURCE_IMAGE} +FROM --platform=${TEMPLATE_PLATFORM} ${SOURCE_IMAGE} AS runner-template +ARG BUILDKIT_SBOM_SCAN_STAGE=true ARG TEMPLATE_PLATFORM ARG SOURCE_IMAGE ARG TARGETPLATFORM @@ -55,7 +56,7 @@ RUN [[ "${TARGETPLATFORM}" == "${TEMPLATE_PLATFORM}" ]] \ && /opt/epar/prepare-template.sh \ && /opt/epar/install-trusted-ca-certificates.sh \ && rm -rf /opt/actions-runner \ - && install -d -m 0755 -o agent -g agent /opt/actions-runner /var/log/actions-runner \ + && install -d -m 0755 -o agent -g agent /opt/actions-runner /opt/actions-runner/_work /opt/actions-runner/_work/_tool /var/log/actions-runner \ && tar -xzf /tmp/actions-runner.tar.gz -C /opt/actions-runner \ && rm -f /tmp/actions-runner.tar.gz \ && chown -R agent:agent /opt/actions-runner /var/log/actions-runner \ @@ -67,6 +68,9 @@ RUN [[ "${TARGETPLATFORM}" == "${TEMPLATE_PLATFORM}" ]] \ ENV HOME=/home/agent \ USER=agent \ LOGNAME=agent \ + RUNNER_TOOL_CACHE=/opt/actions-runner/_work/_tool \ + AGENT_TOOLSDIRECTORY=/opt/actions-runner/_work/_tool \ + DOTNET_INSTALL_DIR=/opt/actions-runner/_work/_tool/dotnet \ EPAR_ACTIONS_RUNNER_DIR=/opt/actions-runner \ EPAR_RUNNER_WORK_DIR=/opt/actions-runner \ EPAR_TEMPLATE_PLATFORM=${TEMPLATE_PLATFORM} \ @@ -90,3 +94,11 @@ WORKDIR /home/agent USER agent ENTRYPOINT ["/usr/local/bin/tini", "-g", "--", "/opt/epar/template-entrypoint.sh"] CMD ["sleep", "infinity"] + +FROM runner-template AS software-inventory +USER root +RUN install -d -m 0755 /out \ + && /opt/epar/collect-software-inventory.sh > /out/software-inventory.txt + +FROM scratch AS software-inventory-export +COPY --from=software-inventory /out/software-inventory.txt /software-inventory.txt diff --git a/templates/docker-sandboxes/guest/run-runner.sh b/templates/docker-sandboxes/guest/run-runner.sh index 2821723..aaef7fa 100644 --- a/templates/docker-sandboxes/guest/run-runner.sh +++ b/templates/docker-sandboxes/guest/run-runner.sh @@ -2,6 +2,7 @@ set -euo pipefail runner_dir="${EPAR_RUNNER_WORK_DIR:-/opt/actions-runner}" +tool_cache="${EPAR_RUNNER_TOOL_CACHE:-${runner_dir}/_work/_tool}" pid_file="${EPAR_RUNNER_PID_FILE:-/var/run/actions-runner.pid}" pid_start_file="${EPAR_RUNNER_PID_START_FILE:-${pid_file}.start}" log_file="${EPAR_RUNNER_LOG_FILE:-/var/log/actions-runner/run.log}" @@ -19,7 +20,7 @@ process_start_time() { printf '%s\n' "${fields[19]}" } -install -d -m 0755 -o agent -g agent "$(dirname "${log_file}")" +install -d -m 0755 -o agent -g agent "$(dirname "${log_file}")" "${tool_cache}" "${tool_cache}/dotnet" old_pid="$(cat "${pid_file}" 2>/dev/null || true)" if [[ "${old_pid}" =~ ^[1-9][0-9]*$ ]] && kill -0 "${old_pid}" >/dev/null 2>&1; then echo "actions-runner is already running as PID ${old_pid}" >&2 @@ -58,6 +59,9 @@ PY )" runner_environment=( "EPAR_RUNNER_WORK_DIR=${runner_dir}" + "RUNNER_TOOL_CACHE=${tool_cache}" + "AGENT_TOOLSDIRECTORY=${tool_cache}" + "DOTNET_INSTALL_DIR=${tool_cache}/dotnet" "PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ) if [[ "${trust_mode}" == "overlay" ]]; then diff --git a/templates/docker-sandboxes/helpers.sha256 b/templates/docker-sandboxes/helpers.sha256 index c565d0d..4ed1128 100644 --- a/templates/docker-sandboxes/helpers.sha256 +++ b/templates/docker-sandboxes/helpers.sha256 @@ -5,6 +5,6 @@ 90a13d73f74e7628f1b7c4c768d300d2a4620387061c056dd67ca42692180e2b ./configure-runner.sh 32bc68fe28dbbe9eb6947a1023606c32fc9b20088efcb90b5c543d0fa3db1218 ./install-trusted-ca-certificates.sh a32f6bdb3b883272172e2d7318feb40d88e3ebfaffa909527ca57a3c06773ae1 ./prepare-template.sh -051fe7b5eb3c522da10c2ead6c0a8f4bae0963dc40f432c7dcc562dc07e73969 ./run-runner.sh +647fd19d18e608cb794f2b08be3019bd8e6243009aea8b9308703a465f0089a3 ./run-runner.sh 0746da2abc0a1033ecdad83ec6e432c7a006c83fefd2ce5424d3932c8f38d9aa ./template-entrypoint.sh d1e3b6808118e4c04bfff8db2b42228cb7a376910874b4a1fb486eaaa55d5d5f ./verify-template.sh From 9414d6d376838b96ef883eeffe6c25bafccd6448 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 30 Jul 2026 23:03:30 +0800 Subject: [PATCH 09/22] feat: schedule image and runner updates Persist per-config freshness checks with a weekly 07:00 local default and add an explicit image update command. Resolve exact Actions runner assets, coordinate scheduled pool maintenance across providers, and update onboarding, status, tests, wrappers, examples, and documentation. --- cmd/ephemeral-action-runner/init.go | 90 ++- cmd/ephemeral-action-runner/init_test.go | 55 ++ cmd/ephemeral-action-runner/main.go | 39 +- cmd/ephemeral-action-runner/start_test.go | 2 +- configs/docker-container.act.example.yml | 2 + configs/docker-container.core.example.yml | 2 + configs/docker-container.example.yml | 2 + configs/docker-container.web-e2e.example.yml | 2 + configs/docker-sandboxes.example.yml | 2 + configs/tart.example.yml | 2 + configs/tart.web-e2e.example.yml | 2 + configs/wsl.example.yml | 2 + configs/wsl.lean.example.yml | 2 + configs/wsl.web-e2e.example.yml | 2 + docs/advanced/docker-sandboxes-template.md | 4 +- docs/configuration.md | 6 +- docs/development/principles.md | 2 +- docs/image-build.md | 4 +- docs/providers/docker-container.md | 4 +- docs/providers/docker-sandboxes.md | 4 +- docs/providers/tart.md | 2 + docs/providers/wsl.md | 2 + docs/storage.md | 2 +- docs/troubleshooting.md | 5 + docs/usage.md | 14 +- internal/config/config.go | 50 +- internal/config/config_test.go | 84 +++ internal/image/artifact_lifecycle.go | 231 +++++++- internal/image/build.go | 31 +- internal/image/compat.go | 7 + internal/image/coordinator.go | 8 + internal/image/docker_sandboxes.go | 252 +++++++-- internal/image/manifest.go | 6 +- internal/image/oci_resolver.go | 42 ++ internal/image/runner_release.go | 179 ++++++ internal/image/runner_release_test.go | 81 +++ internal/image/update_policy.go | 534 ++++++++++++++++++ internal/image/update_policy_test.go | 282 +++++++++ internal/pool/host_trust_test.go | 23 +- internal/pool/image_manifest_test.go | 2 +- internal/pool/image_service.go | 79 ++- internal/pool/manager.go | 106 ++++ internal/pool/manager_test.go | 41 ++ internal/pool/trusted_ca_test.go | 4 +- .../dockercontainer/docker_container.go | 6 +- internal/provider/dockersandboxes/provider.go | 9 +- internal/provider/provider.go | 24 + internal/provider/tart/tart.go | 8 +- internal/provider/wsl/wsl.go | 4 +- scripts/ci/core-runner-controller.sh | 2 + scripts/docker-sandboxes/validate-assets.ps1 | 14 +- scripts/guest/ubuntu/install-runner.sh | 26 +- scripts/host-trust/wrapper-lib.ps1 | 2 +- scripts/host-trust/wrapper-lib.sh | 2 +- scripts/test/start-command-forwarding.sh | 10 + templates/docker-sandboxes/Dockerfile | 8 +- .../docker-sandboxes/guest/verify-template.sh | 3 +- templates/docker-sandboxes/helpers.sha256 | 2 +- templates/docker-sandboxes/sources.lock.json | 11 - 59 files changed, 2272 insertions(+), 156 deletions(-) create mode 100644 internal/image/oci_resolver.go create mode 100644 internal/image/runner_release.go create mode 100644 internal/image/runner_release_test.go create mode 100644 internal/image/update_policy.go create mode 100644 internal/image/update_policy_test.go diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index 3ca5e57..81900f8 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -150,6 +150,11 @@ type initDockerSandboxesProfile struct { DockerDisk string } +type initImageUpdatePolicy struct { + Frequency string + Time string +} + var initDiscoverDockerSandboxes = discoverDockerSandboxes var initDockerSandboxesRootMeasurementFor = dockerSandboxesRootMeasurement @@ -336,6 +341,10 @@ func runInitWithOptions(opts initOptions) error { } } } + updatePolicy, err := promptImageUpdatePolicy(opts.Out, reader) + if err != nil { + return err + } profile := selectedProfile if providerType != "tart" && profile == nil { @@ -344,17 +353,17 @@ func runInitWithOptions(opts initOptions) error { var content string switch providerType { case "docker-container": - content = defaultDockerContainerConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile) + content = defaultDockerContainerConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile, updatePolicy) case "wsl": - content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup, *profile) + content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup, *profile, updatePolicy) case "tart": - content = defaultTartConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup) + content = defaultTartConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup, updatePolicy) case "docker-sandboxes": guestPlatform, runnerArchitectureLabel, platformErr := dockerSandboxesPlatform(profile.HostPlatform) if platformErr != nil { return platformErr } - content = defaultDockerSandboxesConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile, guestPlatform, runnerArchitectureLabel) + content = defaultDockerSandboxesConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile, updatePolicy, guestPlatform, runnerArchitectureLabel) default: return fmt.Errorf("unsupported provider.type %q", providerType) } @@ -850,6 +859,55 @@ func promptDockerSandboxesProfile(ctx context.Context, projectRoot string, hostP return promptDockerImageProfile(ctx, projectRoot, "docker-sandboxes", hostPlatform, out, reader) } +func promptImageUpdatePolicy(out io.Writer, reader *bufio.Reader) (initImageUpdatePolicy, error) { + var frequency string + for frequency == "" { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Automatic image and Actions runner updates:") + fmt.Fprintln(out, " 1. Weekly (default)") + fmt.Fprintln(out, " 2. Daily") + fmt.Fprintln(out, " 3. Every two weeks") + fmt.Fprintln(out, " 4. Monthly") + fmt.Fprintln(out, " 5. Manual — check only when you run ./start image update") + choice, hitEOF, err := promptDefault(out, reader, "Update frequency", "1") + if err != nil { + return initImageUpdatePolicy{}, err + } + switch strings.ToLower(strings.TrimSpace(choice)) { + case "1", "weekly": + frequency = config.ImageUpdateFrequencyWeekly + case "2", "daily": + frequency = config.ImageUpdateFrequencyDaily + case "3", "biweekly", "every two weeks": + frequency = config.ImageUpdateFrequencyBiweekly + case "4", "monthly": + frequency = config.ImageUpdateFrequencyMonthly + case "5", "manual": + return initImageUpdatePolicy{Frequency: config.ImageUpdateFrequencyManual, Time: config.DefaultImageUpdateTime}, nil + default: + fmt.Fprintln(out, " Choose 1–5 or enter daily, weekly, biweekly, monthly, or manual.") + if hitEOF { + return initImageUpdatePolicy{}, fmt.Errorf("invalid image update frequency %q", choice) + } + } + } + for { + updateTime, _, promptErr := promptDefault(out, reader, "Local update time (24-hour HH:MM)", config.DefaultImageUpdateTime) + if promptErr != nil { + return initImageUpdatePolicy{}, promptErr + } + policy := initImageUpdatePolicy{Frequency: frequency, Time: updateTime} + image := config.Default().Image + image.UpdateFrequency = policy.Frequency + image.UpdateTime = policy.Time + if validationErr := config.ValidateImageUpdatePolicy(image); validationErr != nil { + fmt.Fprintf(out, " %v\n", validationErr) + continue + } + return policy, nil + } +} + func promptDockerImageProfile(ctx context.Context, projectRoot, providerType string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (*initDockerSandboxesProfile, bool, error) { guestPlatform, err := initDockerGuestPlatform(providerType, hostPlatform) if err != nil { @@ -1706,7 +1764,7 @@ func (b *boundedBuffer) Write(p []byte) (int, error) { return b.Buffer.Write(p) } -func defaultDockerContainerConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile) string { +func defaultDockerContainerConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile, updatePolicy initImageUpdatePolicy) string { return fmt.Sprintf(`github: appId: %d organization: %s @@ -1722,6 +1780,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: %s + updateTime: "%s" hostTrustMode: %s hostTrustScopes: [%s] customInstallScripts: @@ -1790,7 +1850,7 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, profile.SourceImage, profile.GuestPlatform, hostTrustMode, strings.Join(hostTrustScopes, ", "), renderInitCustomInstallScripts(profile.CustomScripts), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) +`, appID, organization, privateKeyPath, profile.SourceImage, profile.GuestPlatform, updatePolicy.Frequency, updatePolicy.Time, hostTrustMode, strings.Join(hostTrustScopes, ", "), renderInitCustomInstallScripts(profile.CustomScripts), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } func promotedDockerSandboxesPlatform(record sandboxpromotion.Record) (string, string, error) { @@ -1816,7 +1876,7 @@ func dockerSandboxesPlatform(platform sandboxpromotion.Platform) (string, string } } -func defaultDockerSandboxesConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile, guestPlatform, runnerArchitectureLabel string) string { +func defaultDockerSandboxesConfig(appID int64, organization, privateKeyPath string, poolNamePrefix, hostTrustMode string, hostTrustScopes []string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile, updatePolicy initImageUpdatePolicy, guestPlatform, runnerArchitectureLabel string) string { return fmt.Sprintf(`github: appId: %d organization: %s @@ -1829,6 +1889,8 @@ image: sourceImage: %s sourcePlatform: %s runnerVersion: latest + updateFrequency: %s + updateTime: "%s" customInstallScripts: %s hostTrustMode: %s @@ -1902,7 +1964,7 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, profile.SourceImage, guestPlatform, renderInitCustomInstallScripts(profile.CustomScripts), hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerArchitectureLabel, runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled, guestPlatform, profile.PolicyFingerprint, profile.RootDisk, profile.DockerDisk) +`, appID, organization, privateKeyPath, profile.SourceImage, guestPlatform, updatePolicy.Frequency, updatePolicy.Time, renderInitCustomInstallScripts(profile.CustomScripts), hostTrustMode, strings.Join(hostTrustScopes, ", "), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerArchitectureLabel, runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled, guestPlatform, profile.PolicyFingerprint, profile.RootDisk, profile.DockerDisk) } func renderInitCustomInstallScripts(paths []string) string { @@ -1916,7 +1978,7 @@ func renderInitCustomInstallScripts(paths []string) string { return strings.Join(lines, "\n") } -func defaultWSLConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile) string { +func defaultWSLConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection, profile initDockerSandboxesProfile, updatePolicy initImageUpdatePolicy) string { return fmt.Sprintf(`github: appId: %d organization: %s @@ -1932,6 +1994,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: %s + updateTime: "%s" customInstallScripts: %s @@ -2000,10 +2064,10 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, profile.SourceImage, profile.GuestPlatform, renderInitCustomInstallScripts(profile.CustomScripts), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) +`, appID, organization, privateKeyPath, profile.SourceImage, profile.GuestPlatform, updatePolicy.Frequency, updatePolicy.Time, renderInitCustomInstallScripts(profile.CustomScripts), poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } -func defaultTartConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection) string { +func defaultTartConfig(appID int64, organization, privateKeyPath string, poolNamePrefix string, runnerGroup initRunnerGroupSelection, updatePolicy initImageUpdatePolicy) string { return fmt.Sprintf(`# Experimental: this default is a basic Ubuntu ARM64 Tart VM, not a GitHub-hosted runner image. # It does not include the broad dependency set from https://github.com/actions/runner-images. github: @@ -2019,6 +2083,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: %s + updateTime: "%s" customInstallScripts: # - examples/custom-install/install-extra-apt-tools.sh @@ -2086,7 +2152,7 @@ timeouts: bootSeconds: 180 githubOnlineSeconds: 180 commandSeconds: 900 -`, appID, organization, privateKeyPath, poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) +`, appID, organization, privateKeyPath, updatePolicy.Frequency, updatePolicy.Time, poolNamePrefix, strconv.Quote(runnerGroup.Group.Name), runnerGroup.Policy.Enforcement, runnerGroup.Policy.RequireExplicitGroup, runnerGroup.Policy.RequireNonDefaultGroup, runnerGroup.Policy.RequiredRepositoryAccess, runnerGroup.Policy.RequirePublicRepositoriesDisabled) } var stdinIsInteractive = func() bool { diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index e94e0b6..dd79c89 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -922,6 +922,61 @@ func TestSharedDockerImageWizardCoversDockerContainerSandboxesAndWSL(t *testing. } } +func TestImageUpdatePolicyWizardDefaultsAndOrdering(t *testing.T) { + var out bytes.Buffer + policy, err := promptImageUpdatePolicy(&out, bufio.NewReader(strings.NewReader("\n\n"))) + if err != nil { + t.Fatal(err) + } + if policy.Frequency != config.ImageUpdateFrequencyWeekly || policy.Time != config.DefaultImageUpdateTime { + t.Fatalf("default policy = %+v", policy) + } + text := out.String() + choices := []string{"1. Weekly (default)", "2. Daily", "3. Every two weeks", "4. Monthly", "5. Manual"} + last := -1 + for _, choice := range choices { + index := strings.Index(text, choice) + if index <= last { + t.Fatalf("wizard choices are missing or out of order: %q\n%s", choice, text) + } + last = index + } + if strings.Contains(strings.ToLower(text), "monthly") && strings.Contains(strings.ToLower(text), "warning") { + t.Fatalf("monthly choice should not carry a warning:\n%s", text) + } +} + +func TestImageUpdatePolicyWizardManualSkipsTimePrompt(t *testing.T) { + var out bytes.Buffer + policy, err := promptImageUpdatePolicy(&out, bufio.NewReader(strings.NewReader("5\n"))) + if err != nil { + t.Fatal(err) + } + if policy.Frequency != config.ImageUpdateFrequencyManual { + t.Fatalf("manual policy = %+v", policy) + } + if strings.Contains(out.String(), "Local update time") { + t.Fatalf("manual policy unexpectedly prompted for a time:\n%s", out.String()) + } + if !strings.Contains(out.String(), "./start image update") { + t.Fatalf("manual policy omitted its neutral trigger explanation:\n%s", out.String()) + } +} + +func TestImageUpdatePolicyWizardRepromptsInvalidChoiceAndTime(t *testing.T) { + var out bytes.Buffer + policy, err := promptImageUpdatePolicy(&out, bufio.NewReader(strings.NewReader("invalid\n2\n7am\n06:30\n"))) + if err != nil { + t.Fatal(err) + } + if policy.Frequency != config.ImageUpdateFrequencyDaily || policy.Time != "06:30" { + t.Fatalf("reprompted policy = %+v", policy) + } + if !strings.Contains(out.String(), "Choose 1") || !strings.Contains(out.String(), "24-hour HH:MM") { + t.Fatalf("wizard did not explain invalid input:\n%s", out.String()) + } +} + func TestDockerSandboxesWizardCollectsCustomTagAndInstallScript(t *testing.T) { stubInitDockerSandboxesSetup(t, sandboxpromotion.DarwinARM64, initDockerSandboxesDiscovery{ PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), diff --git a/cmd/ephemeral-action-runner/main.go b/cmd/ephemeral-action-runner/main.go index a7a6703..b436e2a 100644 --- a/cmd/ephemeral-action-runner/main.go +++ b/cmd/ephemeral-action-runner/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -24,6 +25,8 @@ import ( const binaryName = "ephemeral-action-runner" +var imageUpdateDefaultNotice sync.Once + func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, binaryName+":", err) @@ -221,9 +224,36 @@ func retentionPolicy(cfg config.LoggingConfig) logging.RetentionPolicy { func runImage(args []string) error { if len(args) == 0 { - return fmt.Errorf("image requires subcommand: update-upstream or build") + return fmt.Errorf("image requires subcommand: update, update-upstream, build, or refresh-scripts") } switch args[0] { + case "update": + fs := flag.NewFlagSet("image update", flag.ExitOnError) + common := addCommonFlags(fs) + allowInsufficientStorage := fs.Bool("allow-insufficient-storage", false, "continue this invocation after storage-only admission warnings") + if err := fs.Parse(args[1:]); err != nil { + return err + } + m, err := newManager(*common.configPath, *common.projectRoot, *common.dryRun, false) + if err != nil { + return err + } + defer m.Close() + m.ConfigureStorageAdmissionOverride(*allowInsufficientStorage, invocation.Command(append([]string{"image", "update"}, appendStorageOverride(args[1:])...)...)) + ctx := interruptContext() + poolControllerLock, err := m.AcquirePoolControllerLock() + if err != nil { + return err + } + defer poolControllerLock.Close() + hostTrustControllerLock, err := m.AcquireHostTrustControllerLock() + if err != nil { + return err + } + if hostTrustControllerLock != nil { + defer hostTrustControllerLock.Close() + } + return m.UpdateImage(ctx) case "update-upstream": fs := flag.NewFlagSet("image update-upstream", flag.ExitOnError) common := addCommonFlags(fs) @@ -609,6 +639,12 @@ func loggingSinks(values []string) logging.Sinks { func printConfigWarnings(cfg config.Config) { for _, warning := range cfg.Warnings() { + if strings.Contains(warning, "image update policy is not configured") { + imageUpdateDefaultNotice.Do(func() { + fmt.Fprintln(os.Stderr, "warning:", warning) + }) + continue + } fmt.Fprintln(os.Stderr, "warning:", warning) } } @@ -653,6 +689,7 @@ Commands: ephemeral-action-runner ephemeral-action-runner start [--instances N] [--config .local/config.yml] ephemeral-action-runner init + ephemeral-action-runner image update [--config .local/config.yml] ephemeral-action-runner image update-upstream [--config .local/config.yml] ephemeral-action-runner image build [--replace] [--update-upstream] ephemeral-action-runner image refresh-scripts diff --git a/cmd/ephemeral-action-runner/start_test.go b/cmd/ephemeral-action-runner/start_test.go index 5ac63bc..58463c8 100644 --- a/cmd/ephemeral-action-runner/start_test.go +++ b/cmd/ephemeral-action-runner/start_test.go @@ -189,7 +189,7 @@ func TestStartInteractiveMissingConfigCanExitToReview(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\n\n\nn\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\n\n\nn\n\n\nn\n"), Out: &out, ManagerFactory: func(string, string, bool, bool) (starterManager, error) { t.Fatal("manager factory should not run after choosing to review the new config") diff --git a/configs/docker-container.act.example.yml b/configs/docker-container.act.example.yml index 8ce9257..588f5fb 100644 --- a/configs/docker-container.act.example.yml +++ b/configs/docker-container.act.example.yml @@ -12,6 +12,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" # Existing configs stay disabled. Set overlay to add the host's trusted root # anchors to Ubuntu runners. Windows/macOS: [system, user]; Linux: [system]. hostTrustMode: disabled diff --git a/configs/docker-container.core.example.yml b/configs/docker-container.core.example.yml index 1835aff..6527953 100644 --- a/configs/docker-container.core.example.yml +++ b/configs/docker-container.core.example.yml @@ -13,6 +13,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" # Existing configs stay disabled. Set overlay to add the host's trusted root # anchors to Ubuntu runners. Windows/macOS: [system, user]; Linux: [system]. hostTrustMode: disabled diff --git a/configs/docker-container.example.yml b/configs/docker-container.example.yml index 40dcc6b..adac62d 100644 --- a/configs/docker-container.example.yml +++ b/configs/docker-container.example.yml @@ -12,6 +12,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" # Existing configs stay disabled. Set overlay to add the host's trusted root # anchors to Ubuntu runners. Windows/macOS: [system, user]; Linux: [system]. hostTrustMode: disabled diff --git a/configs/docker-container.web-e2e.example.yml b/configs/docker-container.web-e2e.example.yml index 70a3f30..af2c374 100644 --- a/configs/docker-container.web-e2e.example.yml +++ b/configs/docker-container.web-e2e.example.yml @@ -12,6 +12,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" # Existing configs stay disabled. Set overlay to add the host's trusted root # anchors to Ubuntu runners. Windows/macOS: [system, user]; Linux: [system]. hostTrustMode: disabled diff --git a/configs/docker-sandboxes.example.yml b/configs/docker-sandboxes.example.yml index 4b1ea41..9e74387 100644 --- a/configs/docker-sandboxes.example.yml +++ b/configs/docker-sandboxes.example.yml @@ -10,6 +10,8 @@ image: sourceImage: ghcr.io/catthehacker/ubuntu:full-latest sourcePlatform: linux/amd64 runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" customInstallScripts: # - examples/custom-install/install-extra-apt-tools.sh hostTrustMode: overlay diff --git a/configs/tart.example.yml b/configs/tart.example.yml index d49466e..2932a04 100644 --- a/configs/tart.example.yml +++ b/configs/tart.example.yml @@ -13,6 +13,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" customInstallScripts: # - examples/custom-install/install-extra-apt-tools.sh diff --git a/configs/tart.web-e2e.example.yml b/configs/tart.web-e2e.example.yml index af15b08..e21957b 100644 --- a/configs/tart.web-e2e.example.yml +++ b/configs/tart.web-e2e.example.yml @@ -11,6 +11,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" customInstallScripts: - scripts/guest/ubuntu/install-web-e2e.sh diff --git a/configs/wsl.example.yml b/configs/wsl.example.yml index 150f73f..043b4b5 100644 --- a/configs/wsl.example.yml +++ b/configs/wsl.example.yml @@ -13,6 +13,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" customInstallScripts: # - examples/custom-install/install-extra-apt-tools.sh diff --git a/configs/wsl.lean.example.yml b/configs/wsl.lean.example.yml index ec90701..2c7a0a8 100644 --- a/configs/wsl.lean.example.yml +++ b/configs/wsl.lean.example.yml @@ -12,6 +12,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" customInstallScripts: # - examples/custom-install/install-extra-apt-tools.sh diff --git a/configs/wsl.web-e2e.example.yml b/configs/wsl.web-e2e.example.yml index 776a02d..787d0d1 100644 --- a/configs/wsl.web-e2e.example.yml +++ b/configs/wsl.web-e2e.example.yml @@ -12,6 +12,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" customInstallScripts: - scripts/guest/ubuntu/install-web-e2e.sh diff --git a/docs/advanced/docker-sandboxes-template.md b/docs/advanced/docker-sandboxes-template.md index 87a6c16..2bb508a 100644 --- a/docs/advanced/docker-sandboxes-template.md +++ b/docs/advanced/docker-sandboxes-template.md @@ -10,7 +10,7 @@ Use a native Docker server that matches the template platform. An amd64 server b sbx diagnose --output json ``` -EPAR requires at least one diagnostic pass and no diagnostic failures. Warnings and skipped checks are accepted. When a diagnostic fails, review the failed item and its hint in the JSON output. The lock file at [`templates/docker-sandboxes/sources.lock.json`](../../templates/docker-sandboxes/sources.lock.json) pins the build tooling, runner, and platform inputs. The selected Catthehacker source tag is resolved independently to exact OCI index and platform-manifest digests for each build. +EPAR requires at least one diagnostic pass and no diagnostic failures. Warnings and skipped checks are accepted. When a diagnostic fails, review the failed item and its hint in the JSON output. The lock file at [`templates/docker-sandboxes/sources.lock.json`](../../templates/docker-sandboxes/sources.lock.json) pins build tooling and platform inputs. The selected Catthehacker source tag and Actions runner selector are resolved independently to exact immutable identities when the update schedule is due. ## Build, Import, And Review @@ -50,6 +50,6 @@ The direct build does not create a Docker staging image. Once the imported templ ## Evidence And Certification -The source lock pins build tooling, Actions runner, Tini, helper inputs, and platform-specific inputs. EPAR resolves a mutable source selector on every start and activates a new immutable template only after build, import, and exact readback succeed. +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. The current ARM64 path has pinned inputs and code support but no equivalent recorded native real-host lifecycle or independent-certification evidence. Treat it as capability-ready only after local admission and your own workload validation. An independent certification record, when available, must bind the reviewed native-controller source/build, full template identity, cache ID, metadata/archive digests, and reviewed evidence. diff --git a/docs/configuration.md b/docs/configuration.md index 166377d..2b2f0b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,7 +52,9 @@ Docker Sandboxes never falls back to Docker Container. Its wizard is available w | `outputImage` | string; provider default | Image-building providers. | EPAR-owned reusable runner artifact name or path. | | `upstreamDir` | string; `third_party/runner-images` | Image builds that adapt upstream scripts. | Local checkout/cache location for the pinned upstream runner-image scripts. | | `upstreamLock` | string; `third_party/runner-images.lock` | Image builds that adapt upstream scripts. | Lock file identifying the approved upstream revision. | -| `runnerVersion` | string; `latest` | Runner image builds. | Runner release selector. Pin a version when repeatability matters. | +| `runnerVersion` | string; `latest` | Runner image builds. | Runner release selector. EPAR resolves it to an exact platform package and verified SHA-256 when a remote check is due. | +| `updateFrequency` | `daily`, `weekly`, `biweekly`, `monthly`, or `manual`; `weekly` | Mutable source-image tags and `runnerVersion: latest`. | Controls remote freshness checks only. Local configuration, scripts, trust inputs, EPAR assets, and missing or corrupt artifacts apply immediately. | +| `updateTime` | local 24-hour `HH:MM`; `"07:00"` | Automatic update frequencies. | Preferred local check time. Manual mode ignores it. | | `customInstallScripts` | list of non-empty paths; empty | Optional image customization. | Scripts run while creating the runner image; treat them as trusted build input. | | `trustedCaCertificatePaths` | list of non-empty paths; empty | Optional additional TLS roots. | PEM or DER CA files are validated, supplied to EPAR's operational builder, and installed in the runner artifact. They supplement, not replace, system or runner-overlay trust. | | `hostTrustMode` | `disabled` or `overlay`; `disabled` | Optional host-root inheritance for ephemeral runners. | Controls runner inheritance only. `overlay` requires `runner.ephemeral: true`; it collects a current host-trust generation before registration and fails closed on an invalid or stale result. EPAR's owned builder independently receives operational system trust. | @@ -60,6 +62,8 @@ Docker Sandboxes never falls back to Docker Container. Its wizard is available w Runner host trust is a common ephemeral-runner contract, not a Docker Container-only configuration rule. The interactive Docker Container and Docker Sandboxes paths offer it, while the configuration validator applies the same overlay and ephemeral requirements independently of provider type. Operational builder trust is automatic and separate: system roots are supplied to EPAR's dedicated BuildKit builder even when runner overlay is disabled, user roots remain opt-in through runner overlay scope, and explicit CA paths apply to both paths. A host Docker daemon must separately trust a private registry before EPAR can pull an image; neither builder nor guest overlay can repair a failed host-daemon pull. +The default update policy checks remotely mutable inputs weekly at 07:00 local time. Repeated starts before the next check verify and reuse local artifacts without contacting the image registry or Actions runner release API. Run `./start image update` for an immediate check; `image build` remains the force-build command. Scheduled failures keep an exactly verified current generation available with visible persisted retry state, while user-requested local changes remain fail-closed. + ### `pool` | Property | Type and default | Required or applies when | Effect and caution | diff --git a/docs/development/principles.md b/docs/development/principles.md index 968499f..b1f7d7f 100644 --- a/docs/development/principles.md +++ b/docs/development/principles.md @@ -4,7 +4,7 @@ EPAR extensions preserve the existing user flow and controller design. ## First Run -`./start` is the Quick Start and general source entry point. With no command, or with start flags only, it runs the `start` command and opens the missing-configuration wizard when needed; with an explicit command, it must forward that command and all arguments exactly as the binary and `go run ./cmd/ephemeral-action-runner` do. The local-Go and no-Go native-controller paths must behave the same, including native-host operational build trust, startup reconciliation of exactly owned stale work, and user-facing remediation commands that use the entry point the user actually invoked. A no-Go bootstrap TLS failure must preserve the native build transcript and report the requested host and presented certificate metadata without disabling verification or retrying insecurely. A selectable provider must appear in the wizard with its tooling and daemon prerequisite status; storage estimates never make provider selection unavailable or prevent configuration creation. Docker Container, Docker Sandboxes, and WSL use the same Catthehacker image and custom-script onboarding flow. The wizard writes the desired configuration first, then an embedded `./start` continues through the ordinary artifact provisioning and storage-admission path. Reject an unavailable platform or invalid image clearly and never silently switch a configured provider or artifact. Builder operational trust and optional runner trust are separate contracts: system roots always support the owned builder, while `image.hostTrustMode` controls only runner inheritance. +`./start` is the Quick Start and general source entry point. With no command, or with start flags only, it runs the `start` command and opens the missing-configuration wizard when needed; with an explicit command, it must forward that command and all arguments exactly as the binary and `go run ./cmd/ephemeral-action-runner` do. The local-Go and no-Go native-controller paths must behave the same, including native-host operational build trust, startup reconciliation of exactly owned stale work, and user-facing remediation commands that use the entry point the user actually invoked. A no-Go bootstrap TLS failure must preserve the native build transcript and report the requested host and presented certificate metadata without disabling verification or retrying insecurely. A selectable provider must appear in the wizard with its tooling and daemon prerequisite status; storage estimates never make provider selection unavailable or prevent configuration creation. Docker Container, Docker Sandboxes, and WSL use the same Catthehacker image, custom-script, and update-policy onboarding flow. The wizard writes the desired configuration first, then an embedded `./start` continues through the ordinary artifact provisioning and storage-admission path. Local artifact inputs always apply immediately; provider-neutral scheduling may defer only remote mutable source and Actions runner observations. Reject an unavailable platform or invalid image clearly and never silently switch a configured provider or artifact. Builder operational trust and optional runner trust are separate contracts: system roots always support the owned builder, while `image.hostTrustMode` controls only runner inheritance. ## Runner Names diff --git a/docs/image-build.md b/docs/image-build.md index 428b648..e520bc2 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -21,7 +21,9 @@ flowchart LR | Tart | `ghcr.io/cirruslabs/ubuntu:latest` | Tart VM image | `image build --replace` | | Docker Sandboxes | Selected Catthehacker source | Verified imported runner template | `image build` | -The first-run wizard gives Docker Container, Docker Sandboxes, and WSL the same ordered Catthehacker choices (`full-latest`, `act-latest`, `dotnet-latest`, `js-latest`, or another validated tag), platform resolution, optional custom-script collection, and storage estimate. `./start` compares the desired image settings with the active artifact receipt and builds a replacement when the source digest, platform, EPAR assets, runner inputs, trust inputs, or custom-script hashes change. Docker Sandboxes imports the replacement into its template cache and activates it only after exact readback succeeds. +The first-run wizard gives Docker Container, Docker Sandboxes, and WSL the same ordered Catthehacker choices (`full-latest`, `act-latest`, `dotnet-latest`, `js-latest`, or another validated tag), platform resolution, optional custom-script collection, update policy, and storage estimate. `./start` always verifies local inputs and the active artifact, but checks mutable source tags and `runnerVersion: latest` only when the configured schedule is due. The default is weekly at 07:00 local time. Docker Sandboxes imports a replacement into its template cache and activates it only after exact readback succeeds. + +Use `./start image update` for an immediate remote check that rebuilds only when an immutable source or Actions runner identity changed. Use `./start image build` to force a build. Actions runner packages are selected by exact platform, downloaded into a content-addressed cache by the native controller, and SHA-256 verified before entering any provider build; guests do not resolve `latest`. ## Add Tools diff --git a/docs/providers/docker-container.md b/docs/providers/docker-container.md index a978551..3b58bd2 100644 --- a/docs/providers/docker-container.md +++ b/docs/providers/docker-container.md @@ -25,6 +25,8 @@ image: sourceType: docker-image sourceImage: ghcr.io/catthehacker/ubuntu:full-latest outputImage: epar-docker-container-catthehacker-ubuntu + updateFrequency: weekly + updateTime: "07:00" provider: type: docker-container @@ -39,7 +41,7 @@ Use `configs/docker-container.act.example.yml` for a smaller Docker-focused Catt ## Normal Workflow 1. Create a local configuration with the wizard or copy an example. -2. Run `./start`. EPAR builds or refreshes the reusable image when its manifest no longer matches the configuration, then starts the pool. +2. Run `./start`. EPAR immediately applies local input changes and checks mutable upstream identities when the configured schedule is due, then starts the pool. 3. Target the configured label in a workflow, for example `runs-on: [self-hosted, linux, epar-docker-container-catthehacker-ubuntu]`. The outer container has no host Docker socket mount and does not publish host ports by default. The inner daemon defaults to the reliable nested-Docker `vfs` storage driver. Use a different `EPAR_DOCKERD_STORAGE_DRIVER` only in a derived image after validating the exact host runtime. diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index 911b254..9d28541 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -56,6 +56,8 @@ image: sourceImage: ghcr.io/catthehacker/ubuntu:full-latest sourcePlatform: linux/amd64 runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" customInstallScripts: # - examples/custom-install/install-extra-apt-tools.sh @@ -84,7 +86,7 @@ dockerSandboxes: powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-native-controller.ps1 pool verify --config .local/docker-sandboxes.yml --project-root . --instances 1 --cleanup ``` -4. Start the pool with `./start`. EPAR re-resolves mutable selectors such as `full-latest` and reuses or rebuilds the exact desired template automatically. +4. Start the pool with `./start`. EPAR reuses the verified imported template without a registry check until the configured update schedule is due; local input changes and missing templates still rebuild immediately. Each allocation receives an empty owner-restricted staging directory, but Actions `_work` stays on the guest filesystem. EPAR verifies the guest, policy, private daemon, and runner trust policy before requesting a short-lived registration token. With `image.hostTrustMode: overlay`, the common pool lifecycle installs the selected roots, verifies the immutable generation, and maintains the job-start lease. With the setting omitted or disabled, the template carries an explicit disabled-policy marker and does not install the trust hook. The token remains on the native host except for registration through `sbx exec` standard input. diff --git a/docs/providers/tart.md b/docs/providers/tart.md index 190a9a0..6aa0f5c 100644 --- a/docs/providers/tart.md +++ b/docs/providers/tart.md @@ -24,6 +24,8 @@ Start with [`configs/tart.example.yml`](../../configs/tart.example.yml): image: sourceImage: ghcr.io/cirruslabs/ubuntu:latest outputImage: epar-ubuntu-24-arm64 + updateFrequency: weekly + updateTime: "07:00" provider: type: tart diff --git a/docs/providers/wsl.md b/docs/providers/wsl.md index 91e701a..67e34c1 100644 --- a/docs/providers/wsl.md +++ b/docs/providers/wsl.md @@ -26,6 +26,8 @@ image: sourceImage: ghcr.io/catthehacker/ubuntu:full-latest sourcePlatform: linux/amd64 outputImage: work/images/epar-wsl-catthehacker-ubuntu.tar + updateFrequency: weekly + updateTime: "07:00" provider: type: wsl diff --git a/docs/storage.md b/docs/storage.md index b977ce8..238f7a5 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -37,7 +37,7 @@ Docker Sandboxes builds directly to one transient archive, so no Docker staging Physical host growth and logical virtual-disk limits are reported separately. A 300 GiB VHDX or `Docker.raw` maximum/apparent length does not mean 300 GiB of live Docker content or 300 GiB of new host space is required: Docker Desktop, WSL, and Docker Sandboxes use dynamically allocated or sparse backing storage. `docker system df` reports Docker-managed usage, not host free capacity. EPAR probes the physical filesystem that contains the backing file when it is measurable and treats an unexposed Docker Desktop internal-free value as advisory. -Storage admission remains fail-closed during normal artifact provisioning, creation, and replacement. To accept the storage risk for one invocation while retaining every non-storage safety check, pass `--allow-insufficient-storage` to `start`, `pool up`, `pool verify`, `image build`, or `image update-upstream`. +Storage admission remains fail-closed during normal artifact provisioning, creation, and replacement. To accept the storage risk for one invocation while retaining every non-storage safety check, pass `--allow-insufficient-storage` to `start`, `pool up`, `pool verify`, `image update`, `image build`, or `image update-upstream`. Unknown, shared, prefix-only, custom-path, or identity-drifted resources are report-only. EPAR never turns storage cleanup into a broad Docker prune, Docker Sandboxes reset, Docker Desktop reset, WSL reset, or VHDX compaction. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c615739..769c508 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -10,6 +10,7 @@ Start with the symptom that most closely matches the failure. EPAR is trusted-jo - [Docker Sandboxes is unavailable or its preflight fails](#docker-sandboxes-is-unavailable-or-its-preflight-fails) - [Docker Sandboxes rejects template, policy, or capacity](#docker-sandboxes-rejects-template-policy-or-capacity) - [An idle runner reports GitHub or Sandbox health warnings](#an-idle-runner-reports-github-or-sandbox-health-warnings) +- [A scheduled image check or update fails](#a-scheduled-image-check-or-update-fails) - [A runner is held for diagnostics or an acknowledgement](#a-runner-is-held-for-diagnostics-or-an-acknowledgement) - [Docker image build runs out of space](#docker-image-build-runs-out-of-space) - [Storage keeps growing after updates](#storage-keeps-growing-after-updates) @@ -124,6 +125,10 @@ A GitHub 429/5xx response or an `sbx` command timeout makes runner health tempor `networkBaseline: open` is a sandbox-scoped public-egress compatibility rule with EPAR host-alias deny guardrails. It does not alter the host-global policy. If a required service is blocked, use a narrow `additionalAllow` hostname rule; do not allow `host.docker.internal`, `gateway.docker.internal`, `kubernetes.docker.internal`, or `host.containers.internal` through the Open-policy guardrails. +## A scheduled image check or update fails + +Run `./start status` to see the last successful remote check, next check or retry, pending immutable identity, deferred reason, and last error. A failed scheduled check or build keeps the previous exactly verified generation available and retries with bounded backoff; a missing artifact or changed local configuration still fails closed. Use `./start image update` to retry an immediate remote check, or correct local input errors and rerun `./start`. + ## A runner is held for diagnostics or an acknowledgement ### Symptom diff --git a/docs/usage.md b/docs/usage.md index 39b33d6..d6e7116 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -68,12 +68,24 @@ go run ./cmd/ephemeral-action-runner start --config .local\ci.yml --instances 2 If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.instances` from the selected config. EPAR resolves configuration from `--config`, `EPAR_CONFIG`, `.local/config.yml`, then `~/.config/ephemeral-action-runner/config.yml`. Tracked files in `configs/` are examples; keep App values and key paths in an ignored local file. See [Configuration](configuration.md) for every setting and [Runner Group Security](runner-groups.md) before broadening repository access. -Storage-consuming commands fail before their provider side effects when an authoritative physical surface cannot retain `storage.minimumFree`. The one-invocation `--allow-insufficient-storage` option keeps all probes and warnings but permits only storage admission to continue; provider diagnostics, GitHub policy, ownership, lifecycle, and cleanup protections remain enforced. The option is available on `start`, `pool up`, `pool verify`, `image build`, and `image update-upstream`, including the equivalent `./start ...` wrapper forms. +Storage-consuming commands fail before their provider side effects when an authoritative physical surface cannot retain `storage.minimumFree`. The one-invocation `--allow-insufficient-storage` option keeps all probes and warnings but permits only storage admission to continue; provider diagnostics, GitHub policy, ownership, lifecycle, and cleanup protections remain enforced. The option is available on `start`, `pool up`, `pool verify`, `image update`, `image build`, and `image update-upstream`, including the equivalent `./start ...` wrapper forms. Each normal start also reconciles interrupted exact-owned work and retires unreferenced superseded artifacts after replacement readback. Use `./start storage status` to inspect the result. `./start storage prune --legacy` previews prefix-era resources, which remain manual and require the displayed plan hash before execution. Press `Ctrl-C` once to stop a foreground pool, then wait for cleanup to finish before closing the terminal. Use `--keep-on-exit` only to retain owned resources for deliberate debugging. +## Update runner artifacts + +By default, EPAR checks mutable source-image tags and `runnerVersion: latest` weekly at 07:00 local time. The wizard can select daily, weekly, every two weeks, monthly, or manual checks. Local image settings, script or certificate content, platform, EPAR assets, and missing or corrupt artifacts always apply on the next start without waiting for the schedule. + +Force an immediate remote check without forcing a rebuild: + +```bash +./start image update +``` + +Manual policy means this command triggers remote checks. `./start image build` remains the force-build path. A running ephemeral pool checks when due, drains only after busy jobs finish, activates the verified replacement, and restores pool capacity; persistent runners record the update for the next process start. + ## Verify before sending jobs Verify one disposable runner without GitHub registration: diff --git a/internal/config/config.go b/internal/config/config.go index c5cddd7..dab690f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,6 +49,8 @@ type ImageConfig struct { UpstreamDir string UpstreamLock string RunnerVersion string + UpdateFrequency string + UpdateTime string CustomInstallScripts []string TrustedCACertificatePaths []string HostTrustMode string @@ -61,6 +63,13 @@ const ( HostTrustScopeSystem = "system" HostTrustScopeUser = "user" + + ImageUpdateFrequencyDaily = "daily" + ImageUpdateFrequencyWeekly = "weekly" + ImageUpdateFrequencyBiweekly = "biweekly" + ImageUpdateFrequencyMonthly = "monthly" + ImageUpdateFrequencyManual = "manual" + DefaultImageUpdateTime = "07:00" ) type PoolConfig struct { @@ -220,12 +229,14 @@ func Default() Config { WebBaseURL: "https://github.com", }, Image: ImageConfig{ - SourceImage: "ghcr.io/cirruslabs/ubuntu:latest", - OutputImage: "epar-ubuntu-24-arm64", - UpstreamDir: "third_party/runner-images", - UpstreamLock: "third_party/runner-images.lock", - RunnerVersion: "latest", - HostTrustMode: HostTrustModeDisabled, + SourceImage: "ghcr.io/cirruslabs/ubuntu:latest", + OutputImage: "epar-ubuntu-24-arm64", + UpstreamDir: "third_party/runner-images", + UpstreamLock: "third_party/runner-images.lock", + RunnerVersion: "latest", + UpdateFrequency: ImageUpdateFrequencyWeekly, + UpdateTime: DefaultImageUpdateTime, + HostTrustMode: HostTrustModeDisabled, HostTrustScopes: []string{ HostTrustScopeSystem, }, @@ -419,6 +430,9 @@ func Load(path string) (Config, error) { !explicit["security.runnerGroup.requirePublicRepositoriesDisabled"] { cfg.warnings = append(cfg.warnings, fmt.Sprintf("%s: runner-group security policy is not configured; using strict recommended checks in warn mode (add security.runnerGroup.enforcement: enforce after reviewing the policy)", path)) } + if !explicit["image.updateFrequency"] && !explicit["image.updateTime"] { + cfg.warnings = append(cfg.warnings, fmt.Sprintf("%s: image update policy is not configured; using weekly checks at 07:00 local time", path)) + } applyProviderDefaults(&cfg, explicit) applyRunnerHostLabel(&cfg) cfg.GitHub.PrivateKeyPath = expandHome(cfg.GitHub.PrivateKeyPath) @@ -472,6 +486,10 @@ func apply(cfg *Config, section, key, value string) error { cfg.Image.UpstreamLock = value case "runnerVersion": cfg.Image.RunnerVersion = value + case "updateFrequency": + cfg.Image.UpdateFrequency = strings.ToLower(value) + case "updateTime": + cfg.Image.UpdateTime = value case "profile": return fmt.Errorf("image.profile is not supported; use image.customInstallScripts") case "customInstallScripts": @@ -1142,6 +1160,9 @@ func Validate(cfg Config) error { if err := ValidateHostTrust(cfg.Image, cfg.Provider, cfg.Runner); err != nil { return err } + if err := ValidateImageUpdatePolicy(cfg.Image); err != nil { + return err + } switch cfg.Image.SourceType { case "", ImageSourceDockerImage, ImageSourceRootFSTar: default: @@ -1205,6 +1226,23 @@ func Validate(cfg Config) error { return nil } +// ValidateImageUpdatePolicy keeps remote freshness scheduling predictable while +// allowing manual mode to retain an otherwise valid update time for later use. +func ValidateImageUpdatePolicy(image ImageConfig) error { + switch image.UpdateFrequency { + case ImageUpdateFrequencyDaily, ImageUpdateFrequencyWeekly, ImageUpdateFrequencyBiweekly, ImageUpdateFrequencyMonthly, ImageUpdateFrequencyManual: + default: + return fmt.Errorf("unsupported image.updateFrequency %q; supported values are daily, weekly, biweekly, monthly, and manual", image.UpdateFrequency) + } + if image.UpdateFrequency == ImageUpdateFrequencyManual { + return nil + } + if _, err := time.Parse("15:04", image.UpdateTime); err != nil { + return fmt.Errorf("invalid image.updateTime %q; use 24-hour HH:MM local time", image.UpdateTime) + } + return nil +} + // ValidateStorage rejects policies that would silently disable capacity or // leave a supposedly bounded cache without a usable limit. func ValidateStorage(storage StorageConfig) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 55a472e..f0be9aa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -102,6 +102,90 @@ image: } } +func TestImageUpdatePolicyDefaults(t *testing.T) { + cfg := Default() + if got, want := cfg.Image.UpdateFrequency, ImageUpdateFrequencyWeekly; got != want { + t.Fatalf("Image.UpdateFrequency = %q, want %q", got, want) + } + if got, want := cfg.Image.UpdateTime, DefaultImageUpdateTime; got != want { + t.Fatalf("Image.UpdateTime = %q, want %q", got, want) + } + if err := ValidateImageUpdatePolicy(cfg.Image); err != nil { + t.Fatal(err) + } +} + +func TestLoadImageUpdatePolicy(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + content := "image:\n updateFrequency: biweekly\n updateTime: \"06:30\"\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got, want := cfg.Image.UpdateFrequency, ImageUpdateFrequencyBiweekly; got != want { + t.Fatalf("Image.UpdateFrequency = %q, want %q", got, want) + } + if got, want := cfg.Image.UpdateTime, "06:30"; got != want { + t.Fatalf("Image.UpdateTime = %q, want %q", got, want) + } + if slices.ContainsFunc(cfg.Warnings(), func(warning string) bool { + return strings.Contains(warning, "image update policy is not configured") + }) { + t.Fatalf("Warnings() = %#v, want no image-policy default notice", cfg.Warnings()) + } +} + +func TestLoadOmittedImageUpdatePolicyAddsNotice(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + if err := os.WriteFile(path, []byte("image:\n runnerVersion: latest\n"), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if !slices.ContainsFunc(cfg.Warnings(), func(warning string) bool { + return strings.Contains(warning, "using weekly checks at 07:00 local time") + }) { + t.Fatalf("Warnings() = %#v, want image-policy default notice", cfg.Warnings()) + } +} + +func TestValidateImageUpdatePolicy(t *testing.T) { + for _, frequency := range []string{ + ImageUpdateFrequencyDaily, + ImageUpdateFrequencyWeekly, + ImageUpdateFrequencyBiweekly, + ImageUpdateFrequencyMonthly, + ImageUpdateFrequencyManual, + } { + image := Default().Image + image.UpdateFrequency = frequency + if err := ValidateImageUpdatePolicy(image); err != nil { + t.Fatalf("ValidateImageUpdatePolicy(%q): %v", frequency, err) + } + } + image := Default().Image + image.UpdateFrequency = "hourly" + if err := ValidateImageUpdatePolicy(image); err == nil || !strings.Contains(err.Error(), "unsupported image.updateFrequency") { + t.Fatalf("invalid frequency error = %v", err) + } + image = Default().Image + image.UpdateTime = "7am" + if err := ValidateImageUpdatePolicy(image); err == nil || !strings.Contains(err.Error(), "24-hour HH:MM") { + t.Fatalf("invalid time error = %v", err) + } + image.UpdateFrequency = ImageUpdateFrequencyManual + if err := ValidateImageUpdatePolicy(image); err != nil { + t.Fatalf("manual mode should ignore image.updateTime: %v", err) + } +} + func TestRunnerRegistrationControlsDefaultToDisabled(t *testing.T) { cfg := Default() if cfg.Runner.Group != "" { diff --git a/internal/image/artifact_lifecycle.go b/internal/image/artifact_lifecycle.go index 98a60b3..9430b02 100644 --- a/internal/image/artifact_lifecycle.go +++ b/internal/image/artifact_lifecycle.go @@ -43,11 +43,22 @@ func hostTrustMetadata(snapshot hosttrust.Snapshot) *HostTrustMetadata { } func (m *Coordinator) EnsureImage(ctx context.Context) error { + return m.ensureImage(ctx, false) +} + +// UpdateImage forces an immediate remote freshness observation and rebuilds +// only when the resolved immutable inputs differ from the active artifact. +func (m *Coordinator) UpdateImage(ctx context.Context) error { + return m.ensureImage(ctx, true) +} + +func (m *Coordinator) ensureImage(ctx context.Context, forceRemote bool) error { if err := m.cleanupSupersededCatalog(ctx); err != nil { return fmt.Errorf("reconcile EPAR storage before image provisioning: %w", err) } + m.logEffectiveUpdatePolicy(forceRemote) if m.Config.Provider.Type == "docker-sandboxes" { - return m.ensureDockerSandboxesTemplate(ctx, false) + return m.ensureDockerSandboxesTemplateWithPolicy(ctx, forceRemote) } if artifactManager, ok := m.Lifecycle.(provider.ArtifactManager); ok { handled, err := artifactManager.EnsureArtifacts(ctx, m.DryRun) @@ -55,43 +66,185 @@ func (m *Coordinator) EnsureImage(ctx context.Context) error { return err } } - manifest, err := m.desiredImageManifest(ctx) + localManifest, err := m.desiredLocalImageManifest(ctx) if err != nil { return err } - hash, err := imageManifestHash(manifest) + localHash, err := imageManifestHash(localManifest) if err != nil { return err } if m.DryRun { - m.infof("[dry-run] would ensure image %s has manifest %s\n", m.Config.Image.OutputImage, hash) + m.infof("[dry-run] would ensure image %s has local-input manifest %s\n", m.Config.Image.OutputImage, localHash) + manifest, err := m.resolveRemoteImageManifest(ctx, localManifest) + if err != nil { + return err + } return m.BuildImage(ctx, ImageBuildOptions{Replace: true, Manifest: &manifest}) } - state, err := m.currentImageState(ctx, hash) + + now := m.now() + updateState, err := m.readUpdatePolicyState() + if err != nil { + m.warnf("ignoring stale image update state and performing an immediate check: %v\n", err) + updateState = UpdatePolicyState{SchemaVersion: updatePolicyStateSchemaVersion} + } + if m.Config.Provider.Type == "wsl" { + outputPath := config.ProjectPath(m.ProjectRoot, m.Config.Image.OutputImage) + sidecarPath := wslImageManifestSidecarPath(outputPath) + if stored, readErr := readStoredImageManifest(sidecarPath); readErr == nil { + if info, statErr := os.Stat(sidecarPath); statErr == nil { + bootstrapped, bootstrapErr := bootstrapUpdatePolicyState(&updateState, m.Config.Image, localManifest, stored.Manifest, nil, info.ModTime(), now.Location()) + if bootstrapErr != nil { + return bootstrapErr + } + if bootstrapped { + if err := m.writeUpdatePolicyState(updateState); err != nil { + return err + } + m.infof("initialized image update schedule from the verified active WSL artifact\n") + } + } + } + } + if recalculateScheduleForTimeZone(&updateState, m.Config.Image, now.Location()) { + if err := m.writeUpdatePolicyState(updateState); err != nil { + return err + } + } + + localChanged := updateState.LocalInputHash != "" && updateState.LocalInputHash != localHash + var ( + currentState imageState + currentHash string + haveResolved bool + ) + if updateState.LocalInputHash == localHash && updateState.LastResolvedManifest != nil { + currentHash, err = imageManifestHash(*updateState.LastResolvedManifest) + if err != nil { + return err + } + currentState, err = m.currentImageState(ctx, currentHash) + if err != nil { + return err + } + haveResolved = true + } + if updateState.LocalInputHash == localHash && pendingUpdateReady(updateState, now) { + if err := m.ApplyPendingUpdate(ctx, now); err != nil { + if !forceRemote && haveResolved && currentState == imageStateCurrent { + status, _ := m.UpdatePolicyStatus() + m.warnf("pending scheduled image update failed; continuing with the previous verified artifact and retrying after %s: %v\n", formatUpdateTime(status.NextRetryAt), err) + return nil + } + return err + } + m.infof("pending image update activated\n") + return nil + } + + remoteDue := updateCheckDue(updateState, m.Config.Image, now) + needsRemote := forceRemote || !haveResolved || localChanged || currentState != imageStateCurrent || remoteDue + if !needsRemote { + m.infof("image is current: %s; next remote check %s\n", m.Config.Image.OutputImage, formatUpdateTime(updateState.NextEligibleAt)) + if err := m.recordCurrentArtifact(ctx, currentHash); err != nil { + return fmt.Errorf("record current EPAR artifact ownership: %w", err) + } + return m.cleanupSupersededCatalog(ctx) + } + + updateState.LastAttemptAt = now.UTC() + manifest, resolveErr := m.resolveRemoteImageManifest(ctx, localManifest) + if resolveErr != nil { + scheduleUpdateFailure(&updateState, now, resolveErr) + _ = m.writeUpdatePolicyState(updateState) + if !forceRemote && !localChanged && haveResolved && currentState == imageStateCurrent { + m.warnf("scheduled image update check failed; continuing with the last verified artifact and retrying after %s: %v\n", formatUpdateTime(updateState.NextRetryAt), resolveErr) + return nil + } + return resolveErr + } + resolvedHash, err := imageManifestHash(manifest) + if err != nil { + return err + } + resolvedState, err := m.currentImageState(ctx, resolvedHash) if err != nil { return err } - switch state { - case imageStateCurrent: - m.infof("image is current: %s\n", m.Config.Image.OutputImage) - if err := m.recordCurrentArtifact(ctx, hash); err != nil { + if resolvedState == imageStateCurrent { + updateState.LocalInputHash = localHash + updateState.LastResolvedManifest = &manifest + updateState.PendingManifest = nil + if err := scheduleNextSuccess(&updateState, m.Config.Image, now); err != nil { + return err + } + if err := m.writeUpdatePolicyState(updateState); err != nil { + return err + } + m.infof("image is current: %s; next remote check %s\n", m.Config.Image.OutputImage, formatUpdateTime(updateState.NextEligibleAt)) + if err := m.recordCurrentArtifact(ctx, resolvedHash); err != nil { return fmt.Errorf("record current EPAR artifact ownership: %w", err) } return m.cleanupSupersededCatalog(ctx) - case imageStateMissing: + } + if resolvedState == imageStateMissing { m.infof("image is missing; building %s\n", m.Config.Image.OutputImage) - case imageStateOutdated: - m.infof("image is outdated or not aligned with config; rebuilding %s\n", m.Config.Image.OutputImage) + } else { + m.infof("image inputs changed; rebuilding %s\n", m.Config.Image.OutputImage) + } + updateState.LocalInputHash = localHash + updateState.PendingManifest = &manifest + updateState.DeferredReason = "artifact build and activation pending" + if err := m.writeUpdatePolicyState(updateState); err != nil { + return err } - if err := m.BuildImage(ctx, ImageBuildOptions{Replace: true, Manifest: &manifest}); err != nil { + if err := m.buildResolvedImage(ctx, manifest); err != nil { + scheduleUpdateFailure(&updateState, now, err) + _ = m.writeUpdatePolicyState(updateState) + if !forceRemote && !localChanged && haveResolved && currentState == imageStateCurrent { + m.warnf("scheduled image update failed; restoring the last verified artifact and retrying after %s: %v\n", formatUpdateTime(updateState.NextRetryAt), err) + return nil + } return err } - if err := m.recordCurrentArtifact(ctx, hash); err != nil { + if err := m.recordCurrentArtifact(ctx, resolvedHash); err != nil { return fmt.Errorf("record current EPAR artifact ownership: %w", err) } + updateState.LastResolvedManifest = &manifest + updateState.PendingManifest = nil + if err := scheduleNextSuccess(&updateState, m.Config.Image, m.now()); err != nil { + return err + } + if err := m.writeUpdatePolicyState(updateState); err != nil { + return err + } return m.cleanupSupersededCatalog(ctx) } +func (m *Coordinator) logEffectiveUpdatePolicy(forceRemote bool) { + if forceRemote { + m.infof("image update policy: immediate manual check requested\n") + return + } + if m.Config.Image.UpdateFrequency == config.ImageUpdateFrequencyManual { + m.infof("image update policy: manual; remote image and Actions runner checks run only when requested\n") + return + } + m.infof("image update policy: %s at %s local time\n", m.Config.Image.UpdateFrequency, m.Config.Image.UpdateTime) +} + +func (m *Coordinator) buildResolvedImage(ctx context.Context, manifest Manifest) error { + originalSource := m.Config.Image.SourceImage + if manifest.SourceType == config.ImageSourceDockerImage && strings.Contains(manifest.SourceDigest, "@sha256:") { + m.Config.Image.SourceImage = manifest.SourceDigest + } + defer func() { + m.Config.Image.SourceImage = originalSource + }() + return m.BuildImage(ctx, ImageBuildOptions{Replace: true, Manifest: &manifest}) +} + type imageState int const ( @@ -229,6 +382,22 @@ func (m *Coordinator) desiredImageManifest(ctx context.Context) (ImageManifest, } func (m *Coordinator) desiredImageManifestWithHostTrust(ctx context.Context, snapshot hosttrust.Snapshot) (ImageManifest, error) { + manifest, err := m.desiredLocalImageManifestWithHostTrust(snapshot) + if err != nil { + return ImageManifest{}, err + } + return m.resolveRemoteImageManifest(ctx, manifest) +} + +func (m *Coordinator) desiredLocalImageManifest(ctx context.Context) (ImageManifest, error) { + snapshot, err := m.resolveHostTrust(ctx) + if err != nil { + return ImageManifest{}, err + } + return m.desiredLocalImageManifestWithHostTrust(snapshot) +} + +func (m *Coordinator) desiredLocalImageManifestWithHostTrust(snapshot hosttrust.Snapshot) (ImageManifest, error) { sourceType := m.Config.Image.SourceType if sourceType == "" { sourceType = config.ImageSourceRootFSTar @@ -245,18 +414,11 @@ func (m *Coordinator) desiredImageManifestWithHostTrust(ctx context.Context, sna SourceImage: m.Config.Image.SourceImage, SourcePlatform: m.Config.Image.SourcePlatform, OutputImage: m.Config.Image.OutputImage, - RunnerVersion: m.Config.Image.RunnerVersion, + RunnerSelector: normalizedRunnerSelector(m.Config.Image.RunnerVersion), HostTrust: hostTrustMetadata(snapshot), } switch sourceType { case config.ImageSourceDockerImage: - if m.Config.Provider.Type == "docker-container" || m.Config.Provider.Type == "wsl" { - digest, err := m.refreshDockerSourceDigest(ctx) - if err != nil { - return manifest, err - } - manifest.SourceDigest = digest - } case config.ImageSourceRootFSTar: if m.Config.Provider.Type == "wsl" { digest, err := m.fileSHA256(config.ProjectPath(m.ProjectRoot, m.Config.Image.SourceImage)) @@ -294,6 +456,31 @@ func (m *Coordinator) desiredImageManifestWithHostTrust(ctx context.Context, sna return manifest, nil } +func (m *Coordinator) resolveRemoteImageManifest(ctx context.Context, manifest ImageManifest) (ImageManifest, error) { + if manifest.SourceType == config.ImageSourceDockerImage { + switch m.Config.Provider.Type { + case "docker-container", "wsl": + source, err := m.resolveDockerSandboxesSource(ctx) + if err != nil { + return manifest, err + } + manifest.SourceImage = source.Reference + manifest.SourcePlatform = source.Platform + manifest.SourceDigest = source.ImmutableReference + manifest.SourcePlatformDigest = source.PlatformDigest + case "tart": + source, err := m.resolveTartOCIReference(ctx, manifest.SourceImage) + if err != nil { + return manifest, err + } + manifest.SourceDigest = source + default: + return manifest, fmt.Errorf("unsupported provider.type %q for Docker image source resolution", m.Config.Provider.Type) + } + } + return m.resolveActionsRunner(ctx, manifest) +} + func (m *Coordinator) refreshDockerSourceDigest(ctx context.Context) (string, error) { var digest string err := m.timeStartupStage("source_image_pull", func() error { diff --git a/internal/image/build.go b/internal/image/build.go index 440ddc1..23c3381 100644 --- a/internal/image/build.go +++ b/internal/image/build.go @@ -143,12 +143,16 @@ func (m *Coordinator) buildDockerContainerImageUntimed(ctx context.Context, opts return err } manifest := opts.Manifest - if m.hostTrustEnabled() || manifest == nil { + if manifest == nil { value, err := m.desiredImageManifestWithHostTrust(ctx, snapshot) if err != nil { return err } manifest = &value + } else if m.hostTrustEnabled() { + value := *manifest + value.HostTrust = hostTrustMetadata(snapshot) + manifest = &value } targetImage := m.Config.Image.OutputImage if m.hostTrustEnabled() { @@ -194,6 +198,18 @@ func (m *Coordinator) buildDockerContainerImageAttempt(ctx context.Context, upst if err := m.prepareDockerContainerBuildContextWithHostTrust(buildCtx, upstreamDir, manifestContent, snapshot); err != nil { return err } + if !m.DryRun { + runnerPackage, err := m.acquireActionsRunner(ctx, manifest) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(buildCtx, "inputs"), 0o755); err != nil { + return err + } + if err := copyFile(runnerPackage, filepath.Join(buildCtx, "inputs", "actions-runner.tar.gz"), 0o600); err != nil { + return err + } + } m.infof("building Docker Container image %s from %s\n", targetImage, m.Config.Image.SourceImage) m.infof("log: %s\n", buildLogPath) args := []string{"buildx", "build", "--builder", builder, "--load", "-t", targetImage} @@ -211,7 +227,8 @@ func (m *Coordinator) buildDockerContainerImageAttempt(ctx context.Context, upst "--label", "io.solutionforest.epar.role=runtime-image", "--label", "io.solutionforest.epar.manifest="+manifestHash, "--build-arg", "BASE_IMAGE="+m.Config.Image.SourceImage, - "--build-arg", "RUNNER_VERSION="+m.Config.Image.RunnerVersion, + "--build-arg", "RUNNER_VERSION="+manifest.RunnerVersion, + "--build-arg", "RUNNER_SHA256="+manifest.RunnerAssetDigest, "--build-arg", "EPAR_IMAGE_MANIFEST_SHA256="+manifestHash, buildCtx, ) @@ -331,7 +348,7 @@ func (m *Coordinator) buildTartImageLocked(ctx context.Context, opts ImageBuildO return err } m.infof("installing GitHub Actions runner\n") - if _, err := m.execBuildGuest(ctx, buildName, []string{"sudo", "bash", "/opt/epar/install-runner.sh", m.Config.Image.RunnerVersion}, provider.ExecOptions{}); err != nil { + if err := m.installActionsRunnerPackage(ctx, buildName, *opts.Manifest); err != nil { return err } if err := m.installRosettaSupport(ctx, buildName); err != nil { @@ -641,7 +658,7 @@ func (m *Coordinator) buildWSLImageUntimed(ctx context.Context, opts ImageBuildO return err } m.infof("installing GitHub Actions runner\n") - if _, err := m.execBuildGuest(ctx, buildName, []string{"sudo", "bash", "/opt/epar/install-runner.sh", m.Config.Image.RunnerVersion}, provider.ExecOptions{}); err != nil { + if err := m.installActionsRunnerPackage(ctx, buildName, *opts.Manifest); err != nil { return err } if err := m.installWSLDockerEngine(ctx, buildName); err != nil { @@ -1228,7 +1245,8 @@ func (m *Coordinator) prepareDockerContainerBuildContextWithHostTrust(buildCtx, dockerfile := fmt.Sprintf(`ARG BASE_IMAGE=ghcr.io/catthehacker/ubuntu:full-latest FROM ${BASE_IMAGE} USER root -ARG RUNNER_VERSION=latest +ARG RUNNER_VERSION +ARG RUNNER_SHA256 ARG EPAR_IMAGE_MANIFEST_SHA256 ARG OCI_SOURCE=https://github.com/solutionforest/ephemeral-action-runner ARG OCI_DESCRIPTION="EPAR Docker Container runner image" @@ -1250,10 +1268,11 @@ COPY trusted-ca-certificates/ `+trustedCAGuestDir+`/ COPY host-trust-certificates/ `+hostTrustGuestDir+`/ COPY host-trust-metadata/ /opt/epar/ COPY image-manifest.json /opt/epar/image-manifest.json +COPY inputs/actions-runner.tar.gz /opt/epar/actions-runner.tar.gz RUN chmod 0755 /opt/epar/*.sh /opt/epar/container-entrypoint.sh /opt/epar/custom-install/*.sh 2>/dev/null || true RUN bash /opt/epar/install-trusted-ca-certificates.sh RUN bash /opt/epar/install-base.sh /opt/epar/upstream/runner-images -RUN bash /opt/epar/install-runner.sh "${RUNNER_VERSION}" +RUN bash /opt/epar/install-runner.sh "${RUNNER_VERSION}" /opt/epar/actions-runner.tar.gz "${RUNNER_SHA256}" RUN EPAR_CONTAINER_IMAGE_BUILD=true bash /opt/epar/install-docker-engine.sh /opt/epar/upstream/runner-images %sRUN EPAR_CONTAINER_IMAGE_BUILD=true bash /opt/epar/validate-runtime.sh RUN bash /opt/epar/finalize-image.sh diff --git a/internal/image/compat.go b/internal/image/compat.go index 9918a71..d5f2a9d 100644 --- a/internal/image/compat.go +++ b/internal/image/compat.go @@ -24,6 +24,13 @@ func (m *Coordinator) DesiredImageManifest(ctx context.Context) (Manifest, error return m.desiredImageManifest(ctx) } +func (m *Coordinator) DesiredLocalImageManifest(ctx context.Context) (Manifest, error) { + if m.Config.Provider.Type == "docker-sandboxes" { + return m.dockerSandboxesLocalManifest(ctx) + } + return m.desiredLocalImageManifest(ctx) +} + func (m *Coordinator) CurrentImageState(ctx context.Context, wantedHash string) (ImageState, error) { return m.currentImageState(ctx, wantedHash) } diff --git a/internal/image/coordinator.go b/internal/image/coordinator.go index d93418f..cb445be 100644 --- a/internal/image/coordinator.go +++ b/internal/image/coordinator.go @@ -58,6 +58,7 @@ type Coordinator struct { ProjectRoot string ConfigPath string DryRun bool + Clock func() time.Time environment Environment } @@ -72,6 +73,13 @@ func NewCoordinator(cfg config.Config, legacy provider.Provider, lifecycle provi } } +func (m *Coordinator) now() time.Time { + if m.Clock != nil { + return m.Clock() + } + return time.Now() +} + func (m *Coordinator) preflightStorage(operation string, peakBytes uint64) error { return m.environment.PreflightStorage(operation, peakBytes) } diff --git a/internal/image/docker_sandboxes.go b/internal/image/docker_sandboxes.go index f9e8c68..86dc534 100644 --- a/internal/image/docker_sandboxes.go +++ b/internal/image/docker_sandboxes.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "sort" "strings" "time" @@ -82,9 +83,6 @@ type dockerSandboxesSourceLock struct { HookLauncher struct { SHA256 string `json:"sha256"` } `json:"hookLauncher"` - ActionsRunner struct { - Version string `json:"version"` - } `json:"actionsRunner"` Tini struct { Version string `json:"version"` } `json:"tini"` @@ -94,11 +92,7 @@ type dockerSandboxesSourceLock struct { SBOMGeneratorReference string `json:"sbomGeneratorReference"` SBOMGeneratorManifestDigest string `json:"sbomGeneratorManifestDigest"` DockerfileFrontendManifestDigest string `json:"dockerfileFrontendManifestDigest"` - ActionsRunner struct { - URL string `json:"url"` - SHA256 string `json:"sha256"` - } `json:"actionsRunner"` - Tini struct { + Tini struct { URL string `json:"url"` SHA256 string `json:"sha256"` } `json:"tini"` @@ -247,13 +241,13 @@ func parseResolvedDockerSource(reference, platform string, indexRaw, platformRaw } compressed += layer.Size } - tagSeparator := strings.LastIndex(reference, ":") - if tagSeparator < 0 { - return ResolvedDockerSource{}, fmt.Errorf("source image %s has no tag", reference) + repository, err := dockerRepository(reference) + if err != nil { + return ResolvedDockerSource{}, err } return ResolvedDockerSource{ Reference: reference, - ImmutableReference: reference[:tagSeparator] + "@" + indexDigest, + ImmutableReference: repository + "@" + indexDigest, IndexDigest: indexDigest, PlatformDigest: matching[0].Digest, Platform: platform, @@ -286,6 +280,16 @@ func (m *Coordinator) resolveDockerSandboxesSource(ctx context.Context) (Resolve if platform == "" { platform = strings.TrimSpace(m.Config.Provider.Platform) } + if platform == "" { + architecture := runtime.GOARCH + if architecture == "386" { + architecture = "amd64" + } + if architecture != "amd64" && architecture != "arm64" { + return ResolvedDockerSource{}, fmt.Errorf("cannot infer a Linux source-image platform from host architecture %s; set image.sourcePlatform explicitly", runtime.GOARCH) + } + platform = "linux/" + architecture + } indexRaw, err := m.runHostOutput(ctx, "docker", "buildx", "imagetools", "inspect", "--raw", reference) if err != nil { return ResolvedDockerSource{}, fmt.Errorf("resolve source image %s: %w", reference, err) @@ -309,11 +313,10 @@ func (m *Coordinator) resolveDockerSandboxesSource(ctx context.Context) (Resolve if platformDigest == "" { return ResolvedDockerSource{}, fmt.Errorf("source image %s does not provide %s", reference, platform) } - tagSeparator := strings.LastIndex(reference, ":") - if tagSeparator <= strings.LastIndex(reference, "/") { - return ResolvedDockerSource{}, fmt.Errorf("source image %s must include a tag", reference) + repository, err := dockerRepository(reference) + if err != nil { + return ResolvedDockerSource{}, err } - repository := reference[:tagSeparator] platformRaw, err := m.runHostOutput(ctx, "docker", "buildx", "imagetools", "inspect", "--raw", repository+"@"+platformDigest) if err != nil { return ResolvedDockerSource{}, fmt.Errorf("resolve source platform manifest %s: %w", platform, err) @@ -321,52 +324,79 @@ func (m *Coordinator) resolveDockerSandboxesSource(ctx context.Context) (Resolve return parseResolvedDockerSource(reference, platform, []byte(indexRaw), []byte(platformRaw)) } +func dockerRepository(reference string) (string, error) { + if separator := strings.Index(reference, "@"); separator > 0 { + return reference[:separator], nil + } + tagSeparator := strings.LastIndex(reference, ":") + if tagSeparator <= strings.LastIndex(reference, "/") { + return "", fmt.Errorf("source image %s must include a tag or digest", reference) + } + return reference[:tagSeparator], nil +} + func (m *Coordinator) dockerSandboxesDesiredManifest(ctx context.Context) (Manifest, ResolvedDockerSource, error) { + manifest, err := m.dockerSandboxesLocalManifest(ctx) + if err != nil { + return Manifest{}, ResolvedDockerSource{}, err + } source, err := m.resolveDockerSandboxesSource(ctx) if err != nil { return Manifest{}, ResolvedDockerSource{}, err } - lock, err := loadDockerSandboxesSourceLock(m.ProjectRoot, source.Platform) + manifest.ProviderPlatform = source.Platform + manifest.SourceImage = source.Reference + manifest.SourcePlatform = source.Platform + manifest.SourceDigest = source.IndexDigest + manifest.SourcePlatformDigest = source.PlatformDigest + manifest, err = m.resolveActionsRunner(ctx, manifest) if err != nil { return Manifest{}, ResolvedDockerSource{}, err } - configuredRunnerVersion := strings.TrimSpace(m.Config.Image.RunnerVersion) - if configuredRunnerVersion != "" && configuredRunnerVersion != "latest" && configuredRunnerVersion != lock.ActionsRunner.Version { - return Manifest{}, ResolvedDockerSource{}, fmt.Errorf("Docker Sandboxes build inputs pin Actions runner %s; image.runnerVersion %q is not available", lock.ActionsRunner.Version, configuredRunnerVersion) + return manifest, source, nil +} + +func (m *Coordinator) dockerSandboxesLocalManifest(ctx context.Context) (Manifest, error) { + reference, err := NormalizeCatthehackerSource(strings.TrimSpace(m.Config.Image.SourceImage)) + if err != nil { + return Manifest{}, err } + platform := strings.TrimSpace(m.Config.Image.SourcePlatform) + if platform == "" { + platform = strings.TrimSpace(m.Config.Provider.Platform) + } + configuredRunnerVersion := normalizedRunnerSelector(m.Config.Image.RunnerVersion) snapshot, err := m.resolveHostTrust(ctx) if err != nil { - return Manifest{}, ResolvedDockerSource{}, err + return Manifest{}, err } customScripts, err := m.customInstallScriptDigests() if err != nil { - return Manifest{}, ResolvedDockerSource{}, err + return Manifest{}, err } trustedCertificates, err := m.trustedCACertificateDigests() if err != nil { - return Manifest{}, ResolvedDockerSource{}, err + return Manifest{}, err } templateInputs, err := fileDigestsRecursive(filepath.Join(m.ProjectRoot, "templates", "docker-sandboxes")) if err != nil { - return Manifest{}, ResolvedDockerSource{}, err + return Manifest{}, err } manifest := Manifest{ SchemaVersion: ManifestSchemaVersion, ProviderType: "docker-sandboxes", - ProviderPlatform: source.Platform, + ProviderPlatform: platform, SourceType: config.ImageSourceDockerImage, - SourceImage: source.Reference, - SourcePlatform: source.Platform, - SourceDigest: source.IndexDigest, - SourcePlatformDigest: source.PlatformDigest, + SourceImage: reference, + SourcePlatform: platform, OutputImage: "docker-sandboxes-template", - RunnerVersion: lock.ActionsRunner.Version, + RunnerSelector: configuredRunnerVersion, TemplateInputs: templateInputs, CustomInstallScripts: customScripts, TrustedCACertificates: trustedCertificates, HostTrust: hostTrustMetadata(snapshot), } - return manifest, source, nil + return manifest, nil } func fileDigestsRecursive(root string) ([]FileDigest, error) { @@ -464,14 +494,18 @@ func readDockerSandboxesReceiptPath(path string) (dockerSandboxesReceipt, error) } func (m *Coordinator) ensureDockerSandboxesTemplate(ctx context.Context, force bool) error { - runtime, ok := m.Lifecycle.(provider.TemplateArtifactRuntime) - if !ok { - return fmt.Errorf("docker-sandboxes provider is missing required template artifact integration") - } manifest, source, err := m.dockerSandboxesDesiredManifest(ctx) if err != nil { return err } + return m.ensureDockerSandboxesTemplateResolved(ctx, force, manifest, source) +} + +func (m *Coordinator) ensureDockerSandboxesTemplateResolved(ctx context.Context, force bool, manifest Manifest, source ResolvedDockerSource) error { + runtime, ok := m.Lifecycle.(provider.TemplateArtifactRuntime) + if !ok { + return fmt.Errorf("docker-sandboxes provider is missing required template artifact integration") + } manifestHash, err := ManifestHash(manifest) if err != nil { return err @@ -513,6 +547,139 @@ func (m *Coordinator) ensureDockerSandboxesTemplate(ctx context.Context, force b return m.buildDockerSandboxesTemplate(ctx, manifest, source, manifestHash, rootDisk, runtime) } +func (m *Coordinator) ensureDockerSandboxesTemplateWithPolicy(ctx context.Context, forceRemote bool) error { + localManifest, err := m.dockerSandboxesLocalManifest(ctx) + if err != nil { + return err + } + localHash, err := ManifestHash(localManifest) + if err != nil { + return err + } + if m.DryRun { + manifest, source, err := m.dockerSandboxesDesiredManifest(ctx) + if err != nil { + return err + } + return m.ensureDockerSandboxesTemplateResolved(ctx, false, manifest, source) + } + now := m.now() + state, err := m.readUpdatePolicyState() + if err != nil { + m.warnf("ignoring stale image update state and performing an immediate check: %v\n", err) + state = UpdatePolicyState{SchemaVersion: updatePolicyStateSchemaVersion} + } + if receipt, receiptErr := m.readDockerSandboxesReceipt(); receiptErr == nil { + runtime, ok := m.Lifecycle.(provider.TemplateArtifactRuntime) + if !ok { + return fmt.Errorf("docker-sandboxes provider is missing required template artifact integration") + } + if verifyErr := runtime.VerifyImportedTemplate(ctx, receipt.Artifact); verifyErr == nil { + bootstrapped, bootstrapErr := bootstrapUpdatePolicyState(&state, m.Config.Image, localManifest, receipt.Manifest, &receipt.Source, receipt.ActivatedAt, now.Location()) + if bootstrapErr != nil { + return bootstrapErr + } + if bootstrapped { + if err := m.writeUpdatePolicyState(state); err != nil { + return err + } + m.infof("initialized image update schedule from the verified active Docker Sandboxes template\n") + } + } else if !errors.Is(verifyErr, provider.ErrTemplateNotFound) { + return fmt.Errorf("measure configured Docker Sandboxes artifact availability: %w", verifyErr) + } + } + if recalculateScheduleForTimeZone(&state, m.Config.Image, now.Location()) { + if err := m.writeUpdatePolicyState(state); err != nil { + return err + } + } + localChanged := state.LocalInputHash != "" && state.LocalInputHash != localHash + currentVerified := false + if state.LocalInputHash == localHash && state.LastResolvedManifest != nil { + receipt, receiptErr := m.readDockerSandboxesReceipt() + if receiptErr == nil { + wantHash, hashErr := ManifestHash(*state.LastResolvedManifest) + if hashErr != nil { + return hashErr + } + runtime, ok := m.Lifecycle.(provider.TemplateArtifactRuntime) + if !ok { + return fmt.Errorf("docker-sandboxes provider is missing required template artifact integration") + } + if receipt.ManifestHash == wantHash { + if verifyErr := runtime.VerifyImportedTemplate(ctx, receipt.Artifact); verifyErr == nil { + currentVerified = true + } else if !errors.Is(verifyErr, provider.ErrTemplateNotFound) { + return fmt.Errorf("measure configured Docker Sandboxes artifact availability: %w", verifyErr) + } + } + } + } + if state.LocalInputHash == localHash && pendingUpdateReady(state, now) { + if err := m.ApplyPendingUpdate(ctx, now); err != nil { + if !forceRemote && currentVerified { + status, _ := m.UpdatePolicyStatus() + m.warnf("pending scheduled Docker Sandboxes update failed; continuing with the previous verified template and retrying after %s: %v\n", formatUpdateTime(status.NextRetryAt), err) + return m.ensureDockerSandboxesTemplateFromState(ctx, state) + } + return err + } + m.infof("pending Docker Sandboxes update activated\n") + return nil + } + if !forceRemote && currentVerified && !updateCheckDue(state, m.Config.Image, now) { + if err := m.ensureDockerSandboxesTemplateFromState(ctx, state); err != nil { + return err + } + m.infof("Docker Sandboxes runner template is current; next remote check %s\n", formatUpdateTime(state.NextEligibleAt)) + return nil + } + + state.LastAttemptAt = now.UTC() + manifest, source, resolveErr := m.dockerSandboxesDesiredManifest(ctx) + if resolveErr != nil { + scheduleUpdateFailure(&state, now, resolveErr) + _ = m.writeUpdatePolicyState(state) + if !forceRemote && !localChanged && currentVerified { + m.warnf("scheduled image update check failed; continuing with the last verified Docker Sandboxes template and retrying after %s: %v\n", formatUpdateTime(state.NextRetryAt), resolveErr) + return m.ensureDockerSandboxesTemplateFromState(ctx, state) + } + return resolveErr + } + state.LocalInputHash = localHash + state.PendingManifest = &manifest + state.PendingSource = &source + state.DeferredReason = "template build and activation pending" + if err := m.writeUpdatePolicyState(state); err != nil { + return err + } + if err := m.ensureDockerSandboxesTemplateResolved(ctx, false, manifest, source); err != nil { + scheduleUpdateFailure(&state, now, err) + _ = m.writeUpdatePolicyState(state) + if !forceRemote && !localChanged && currentVerified { + m.warnf("scheduled Docker Sandboxes update failed; restoring the last verified template and retrying after %s: %v\n", formatUpdateTime(state.NextRetryAt), err) + return m.ensureDockerSandboxesTemplateFromState(ctx, state) + } + return err + } + state.LastResolvedManifest = &manifest + state.LastResolvedSource = &source + state.PendingManifest = nil + state.PendingSource = nil + if err := scheduleNextSuccess(&state, m.Config.Image, m.now()); err != nil { + return err + } + return m.writeUpdatePolicyState(state) +} + +func (m *Coordinator) ensureDockerSandboxesTemplateFromState(ctx context.Context, state UpdatePolicyState) error { + if state.LastResolvedManifest == nil || state.LastResolvedSource == nil { + return fmt.Errorf("Docker Sandboxes update state is missing its resolved artifact inputs") + } + return m.ensureDockerSandboxesTemplateResolved(ctx, false, *state.LastResolvedManifest, *state.LastResolvedSource) +} + func estimatedDockerSandboxesExpansion(source ResolvedDockerSource) uint64 { estimate, err := EstimateSourceSize(source.CompressedLayerBytes, 0) if err != nil { @@ -591,8 +758,12 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest inputRoot := filepath.Join(artifactRoot, "inputs") actionsRunnerPath := filepath.Join(inputRoot, "actions-runner.tar.gz") tiniPath := filepath.Join(inputRoot, "tini") - if err := verifiedDownload(ctx, downloadClient, platformLock.ActionsRunner.URL, actionsRunnerPath, platformLock.ActionsRunner.SHA256, 0o600); err != nil { - return fmt.Errorf("acquire locked Actions runner: %w", err) + actionsRunnerCachePath, err := m.acquireActionsRunner(ctx, manifest) + if err != nil { + return err + } + if err := copyFile(actionsRunnerCachePath, actionsRunnerPath, 0o600); err != nil { + return fmt.Errorf("stage GitHub Actions runner %s: %w", manifest.RunnerVersion, err) } if err := verifiedDownload(ctx, downloadClient, platformLock.Tini.URL, tiniPath, platformLock.Tini.SHA256, 0o700); err != nil { return fmt.Errorf("acquire locked tini: %w", err) @@ -703,7 +874,8 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest "SOURCE_REVISION=" + source.IndexDigest, "TEMPLATE_VERSION=" + manifestHash[:16] + "-" + architecture, "COMPATIBILITY_FILE=generated.compatibility.json", - "ACTIONS_RUNNER_SHA256=sha256:" + strings.TrimPrefix(platformLock.ActionsRunner.SHA256, "sha256:"), + "ACTIONS_RUNNER_VERSION=" + manifest.RunnerVersion, + "ACTIONS_RUNNER_SHA256=sha256:" + strings.TrimPrefix(manifest.RunnerAssetDigest, "sha256:"), "TINI_SHA256=sha256:" + strings.TrimPrefix(platformLock.Tini.SHA256, "sha256:"), } for _, buildArg := range buildArguments { @@ -1311,11 +1483,11 @@ func loadDockerSandboxesSourceLock(projectRoot, platform string) (dockerSandboxe if lock.SchemaVersion != 2 { return lock, fmt.Errorf("unsupported Docker Sandboxes source lock schema %d", lock.SchemaVersion) } - if lock.DockerfileFrontend.Reference == "" || lock.SBOMGenerator.InspectionReference == "" || lock.GoBuilder.Version == "" || lock.GoBuilder.IndexDigest == "" || lock.HookLauncher.SHA256 == "" || lock.ActionsRunner.Version == "" || lock.Tini.Version == "" { + if lock.DockerfileFrontend.Reference == "" || lock.SBOMGenerator.InspectionReference == "" || lock.GoBuilder.Version == "" || lock.GoBuilder.IndexDigest == "" || lock.HookLauncher.SHA256 == "" || lock.Tini.Version == "" { return lock, errors.New("Docker Sandboxes source lock has incomplete shared build inputs") } platformLock, ok := lock.Platforms[platform] - if !ok || platformLock.GoBuilderReference == "" || platformLock.GoBuilderManifestDigest == "" || platformLock.SBOMGeneratorReference == "" || platformLock.SBOMGeneratorManifestDigest == "" || platformLock.DockerfileFrontendManifestDigest == "" || platformLock.ActionsRunner.URL == "" || platformLock.ActionsRunner.SHA256 == "" || platformLock.Tini.URL == "" || platformLock.Tini.SHA256 == "" { + if !ok || platformLock.GoBuilderReference == "" || platformLock.GoBuilderManifestDigest == "" || platformLock.SBOMGeneratorReference == "" || platformLock.SBOMGeneratorManifestDigest == "" || platformLock.DockerfileFrontendManifestDigest == "" || platformLock.Tini.URL == "" || platformLock.Tini.SHA256 == "" { return lock, fmt.Errorf("Docker Sandboxes source lock has incomplete build inputs for %s", platform) } return lock, nil diff --git a/internal/image/manifest.go b/internal/image/manifest.go index 4dce9af..25eaee5 100644 --- a/internal/image/manifest.go +++ b/internal/image/manifest.go @@ -10,7 +10,7 @@ import ( ) const ( - ManifestSchemaVersion = 1 + ManifestSchemaVersion = 2 ManifestGuestPath = "/opt/epar/image-manifest.json" ManifestLabel = "org.solutionforest.epar.manifest-sha256" ) @@ -39,7 +39,11 @@ type Manifest struct { SourceDigest string `json:"sourceDigest,omitempty"` SourcePlatformDigest string `json:"sourcePlatformDigest,omitempty"` OutputImage string `json:"outputImage"` + RunnerSelector string `json:"runnerSelector"` RunnerVersion string `json:"runnerVersion"` + RunnerAssetName string `json:"runnerAssetName,omitempty"` + RunnerAssetURL string `json:"runnerAssetUrl,omitempty"` + RunnerAssetDigest string `json:"runnerAssetDigest,omitempty"` UpstreamCommit string `json:"upstreamCommit,omitempty"` EPARScripts []FileDigest `json:"eparScripts,omitempty"` TemplateInputs []FileDigest `json:"templateInputs,omitempty"` diff --git a/internal/image/oci_resolver.go b/internal/image/oci_resolver.go new file mode 100644 index 0000000..2ffa97e --- /dev/null +++ b/internal/image/oci_resolver.go @@ -0,0 +1,42 @@ +package image + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// resolveTartOCIReference observes only the registry descriptor. Tart remains +// responsible for pulling its VM artifact; EPAR records and clones the exact +// immutable OCI identity selected by the schedule. +func (m *Coordinator) resolveTartOCIReference(ctx context.Context, reference string) (string, error) { + ref, err := name.ParseReference(strings.TrimSpace(reference)) + if err != nil { + return "", fmt.Errorf("parse Tart OCI source %q: %w", reference, err) + } + authenticator, err := authn.DefaultKeychain.Resolve(ref.Context().Registry) + if err != nil { + return "", fmt.Errorf("resolve Tart OCI registry credentials: %w", err) + } + buildTrust, err := m.resolveBuildTrust(ctx) + if err != nil { + return "", err + } + client, err := buildTrustHTTPClient(buildTrust) + if err != nil { + return "", err + } + descriptor, err := remote.Get(ref, remote.WithContext(ctx), remote.WithAuth(authenticator), remote.WithTransport(client.Transport)) + if err != nil { + return "", fmt.Errorf("resolve Tart OCI source %s: %w", reference, err) + } + digest := descriptor.Digest.String() + if !validSHA256(digest) { + return "", fmt.Errorf("Tart OCI source %s returned an invalid immutable digest", reference) + } + return ref.Context().Name() + "@" + digest, nil +} diff --git a/internal/image/runner_release.go b/internal/image/runner_release.go new file mode 100644 index 0000000..a42c9ac --- /dev/null +++ b/internal/image/runner_release.go @@ -0,0 +1,179 @@ +package image + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/solutionforest/ephemeral-action-runner/internal/provider" +) + +const defaultActionsRunnerReleaseAPI = "https://api.github.com/repos/actions/runner/releases" + +type actionsRunnerRelease struct { + TagName string `json:"tag_name"` + Assets []actionsRunnerAsset `json:"assets"` +} + +type actionsRunnerAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest"` +} + +func normalizedRunnerSelector(value string) string { + value = strings.TrimSpace(strings.TrimPrefix(value, "v")) + if value == "" { + return "latest" + } + return value +} + +func (m *Coordinator) resolveActionsRunner(ctx context.Context, manifest Manifest) (Manifest, error) { + selector := normalizedRunnerSelector(manifest.RunnerSelector) + platform, err := actionsRunnerPlatform(manifest, m.Config.Provider.Type) + if err != nil { + return manifest, err + } + architecture := "" + switch platform { + case "linux/amd64": + architecture = "x64" + case "linux/arm64": + architecture = "arm64" + default: + return manifest, fmt.Errorf("GitHub Actions runner packages are not configured for %s", platform) + } + endpoint := actionsRunnerReleaseAPI() + if selector == "latest" { + endpoint += "/latest" + } else { + endpoint += "/tags/v" + selector + } + buildTrust, err := m.resolveBuildTrust(ctx) + if err != nil { + return manifest, err + } + client, err := buildTrustHTTPClient(buildTrust) + if err != nil { + return manifest, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return manifest, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + request.Header.Set("User-Agent", "ephemeral-action-runner") + response, err := client.Do(request) + if err != nil { + return manifest, fmt.Errorf("resolve GitHub Actions runner %s: %w", selector, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return manifest, fmt.Errorf("resolve GitHub Actions runner %s: GitHub returned HTTP %d", selector, response.StatusCode) + } + var release actionsRunnerRelease + if err := json.NewDecoder(response.Body).Decode(&release); err != nil { + return manifest, fmt.Errorf("parse GitHub Actions runner release: %w", err) + } + return applyActionsRunnerRelease(manifest, selector, architecture, release) +} + +func applyActionsRunnerRelease(manifest Manifest, selector, architecture string, release actionsRunnerRelease) (Manifest, error) { + version := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v") + if version == "" { + return manifest, fmt.Errorf("GitHub Actions runner release omitted tag_name") + } + if selector != "latest" && selector != version { + return manifest, fmt.Errorf("GitHub Actions runner release returned version %q for selector %q", version, selector) + } + assetName := fmt.Sprintf("actions-runner-linux-%s-%s.tar.gz", architecture, version) + var selected *actionsRunnerAsset + for index := range release.Assets { + if release.Assets[index].Name == assetName { + selected = &release.Assets[index] + break + } + } + if selected == nil { + return manifest, fmt.Errorf("GitHub Actions runner %s does not provide %s", version, assetName) + } + digest := strings.ToLower(strings.TrimSpace(selected.Digest)) + if !validSHA256(digest) { + return manifest, fmt.Errorf("GitHub Actions runner asset %s omitted a valid SHA-256 digest", assetName) + } + if strings.TrimSpace(selected.BrowserDownloadURL) == "" { + return manifest, fmt.Errorf("GitHub Actions runner asset %s omitted its download URL", assetName) + } + manifest.RunnerSelector = selector + manifest.RunnerVersion = version + manifest.RunnerAssetName = assetName + manifest.RunnerAssetURL = selected.BrowserDownloadURL + manifest.RunnerAssetDigest = digest + return manifest, nil +} + +func actionsRunnerPlatform(manifest Manifest, providerType string) (string, error) { + for _, candidate := range []string{manifest.SourcePlatform, manifest.ProviderPlatform} { + if platform, ok := NormalizedDockerPlatform(candidate, "linux"); ok { + return platform.OS + "/" + platform.Architecture, nil + } + } + if providerType == "tart" { + return "linux/arm64", nil + } + architecture := runtime.GOARCH + if architecture == "386" { + architecture = "amd64" + } + if architecture != "amd64" && architecture != "arm64" { + return "", fmt.Errorf("cannot select a GitHub Actions runner package for host architecture %s", runtime.GOARCH) + } + return "linux/" + architecture, nil +} + +func actionsRunnerReleaseAPI() string { + if value := strings.TrimSpace(os.Getenv("EPAR_TEST_ACTIONS_RUNNER_RELEASE_API")); value != "" { + return strings.TrimRight(value, "/") + } + return defaultActionsRunnerReleaseAPI +} + +func (m *Coordinator) acquireActionsRunner(ctx context.Context, manifest Manifest) (string, error) { + if manifest.RunnerVersion == "" || manifest.RunnerAssetURL == "" || !validSHA256(manifest.RunnerAssetDigest) { + return "", fmt.Errorf("resolved Actions runner identity is incomplete") + } + digest := strings.TrimPrefix(manifest.RunnerAssetDigest, "sha256:") + cachePath := filepath.Join(m.ProjectRoot, ".local", "state", "image", "downloads", "actions-runner", digest, manifest.RunnerAssetName) + buildTrust, err := m.resolveBuildTrust(ctx) + if err != nil { + return "", err + } + client, err := buildTrustHTTPClient(buildTrust) + if err != nil { + return "", err + } + if err := verifiedDownload(ctx, client, manifest.RunnerAssetURL, cachePath, manifest.RunnerAssetDigest, 0o600); err != nil { + return "", fmt.Errorf("acquire GitHub Actions runner %s: %w", manifest.RunnerVersion, err) + } + return cachePath, nil +} + +func (m *Coordinator) installActionsRunnerPackage(ctx context.Context, instance string, manifest Manifest) error { + path, err := m.acquireActionsRunner(ctx, manifest) + if err != nil { + return err + } + const guestPath = "/opt/epar/actions-runner.tar.gz" + if err := provider.CopyFile(ctx, m.Provider, instance, path, guestPath, "0600"); err != nil { + return fmt.Errorf("copy verified Actions runner package into build guest: %w", err) + } + _, err = m.execBuildGuest(ctx, instance, []string{"sudo", "bash", "/opt/epar/install-runner.sh", manifest.RunnerVersion, guestPath, manifest.RunnerAssetDigest}, provider.ExecOptions{}) + return err +} diff --git a/internal/image/runner_release_test.go b/internal/image/runner_release_test.go new file mode 100644 index 0000000..54c3526 --- /dev/null +++ b/internal/image/runner_release_test.go @@ -0,0 +1,81 @@ +package image + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyActionsRunnerReleaseSelectsExactArchitectureAndDigest(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + release := actionsRunnerRelease{ + TagName: "v2.332.0", + Assets: []actionsRunnerAsset{ + {Name: "actions-runner-linux-x64-2.332.0.tar.gz", BrowserDownloadURL: "https://example.invalid/x64", Digest: digest}, + {Name: "actions-runner-linux-arm64-2.332.0.tar.gz", BrowserDownloadURL: "https://example.invalid/arm64", Digest: "sha256:" + strings.Repeat("b", 64)}, + }, + } + manifest, err := applyActionsRunnerRelease(Manifest{}, "latest", "x64", release) + if err != nil { + t.Fatal(err) + } + if manifest.RunnerSelector != "latest" || manifest.RunnerVersion != "2.332.0" { + t.Fatalf("runner identity = selector %q version %q", manifest.RunnerSelector, manifest.RunnerVersion) + } + if manifest.RunnerAssetName != "actions-runner-linux-x64-2.332.0.tar.gz" || manifest.RunnerAssetURL != "https://example.invalid/x64" || manifest.RunnerAssetDigest != digest { + t.Fatalf("selected asset = %+v", manifest) + } +} + +func TestApplyActionsRunnerReleaseRejectsWrongVersionAndMissingIntegrity(t *testing.T) { + release := actionsRunnerRelease{ + TagName: "v2.332.0", + Assets: []actionsRunnerAsset{{ + Name: "actions-runner-linux-x64-2.332.0.tar.gz", + BrowserDownloadURL: "https://example.invalid/x64", + }}, + } + if _, err := applyActionsRunnerRelease(Manifest{}, "2.331.0", "x64", release); err == nil || !strings.Contains(err.Error(), "returned version") { + t.Fatalf("wrong-version error = %v", err) + } + if _, err := applyActionsRunnerRelease(Manifest{}, "latest", "x64", release); err == nil || !strings.Contains(err.Error(), "valid SHA-256") { + t.Fatalf("missing-integrity error = %v", err) + } +} + +func TestActionsRunnerPlatformUsesConfiguredSourceArchitecture(t *testing.T) { + for _, test := range []struct { + platform string + want string + }{ + {"linux/amd64", "linux/amd64"}, + {"linux/arm64", "linux/arm64"}, + } { + got, err := actionsRunnerPlatform(Manifest{SourcePlatform: test.platform}, "docker-container") + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("actionsRunnerPlatform(%q) = %q, want %q", test.platform, got, test.want) + } + } +} + +func TestRunnerInstallScriptRequiresVerifiedLocalPackage(t *testing.T) { + content, err := os.ReadFile(filepath.Join("..", "..", "scripts", "guest", "ubuntu", "install-runner.sh")) + if err != nil { + t.Fatal(err) + } + text := string(content) + for _, forbidden := range []string{"releases/latest", "api.github.com", "curl ", "wget "} { + if strings.Contains(text, forbidden) { + t.Fatalf("install-runner.sh still performs guest-side remote resolution: found %q", forbidden) + } + } + for _, required := range []string{"", "sha256sum --check", "Runner.Listener --version"} { + if !strings.Contains(text, required) { + t.Fatalf("install-runner.sh omitted %q", required) + } + } +} diff --git a/internal/image/update_policy.go b/internal/image/update_policy.go new file mode 100644 index 0000000..9abefc4 --- /dev/null +++ b/internal/image/update_policy.go @@ -0,0 +1,534 @@ +package image + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" +) + +const updatePolicyStateSchemaVersion = 1 + +// UpdatePolicyState is the per-configuration durable record for remote image +// and Actions runner freshness. Local desired inputs are represented by +// LocalInputHash and are never allowed to become stale merely because a remote +// check is not due. +type UpdatePolicyState struct { + SchemaVersion int `json:"schemaVersion"` + LocalInputHash string `json:"localInputHash"` + LastAttemptAt time.Time `json:"lastAttemptAt,omitempty"` + LastSuccessfulCheckAt time.Time `json:"lastSuccessfulCheckAt,omitempty"` + NextEligibleAt time.Time `json:"nextEligibleAt,omitempty"` + NextRetryAt time.Time `json:"nextRetryAt,omitempty"` + ConsecutiveFailures int `json:"consecutiveFailures,omitempty"` + TimeZone string `json:"timeZone,omitempty"` + PolicyFrequency string `json:"policyFrequency,omitempty"` + PolicyTime string `json:"policyTime,omitempty"` + LastResolvedManifest *Manifest `json:"lastResolvedManifest,omitempty"` + LastResolvedSource *ResolvedDockerSource `json:"lastResolvedSource,omitempty"` + PendingManifest *Manifest `json:"pendingManifest,omitempty"` + PendingSource *ResolvedDockerSource `json:"pendingSource,omitempty"` + DeferredReason string `json:"deferredReason,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +// UpdatePolicyStatus is safe to expose through status and console output. +type UpdatePolicyStatus struct { + Frequency string + UpdateTime string + LastSuccessfulCheckAt time.Time + NextEligibleAt time.Time + NextRetryAt time.Time + Pending bool + PendingIdentity string + DeferredReason string + LastError string +} + +type RemoteUpdateCheck struct { + Due bool + Changed bool + CurrentManifest *Manifest + PendingManifest *Manifest + NextEligibleAt time.Time + NextRetryAt time.Time +} + +func UpdatePolicyStatePath(projectRoot, configPath string) (string, error) { + if strings.TrimSpace(configPath) == "" { + configPath = filepath.Join(projectRoot, ".local", "config.yml") + } + configID, err := storagecatalog.ConfigID(projectRoot, configPath) + if err != nil { + return "", err + } + return filepath.Join(projectRoot, ".local", "state", "image", configID, "update-policy.json"), nil +} + +func ReadUpdatePolicyState(projectRoot, configPath string) (UpdatePolicyState, error) { + path, err := UpdatePolicyStatePath(projectRoot, configPath) + if err != nil { + return UpdatePolicyState{}, err + } + content, err := os.ReadFile(path) + if err != nil { + return UpdatePolicyState{}, err + } + var state UpdatePolicyState + if err := json.Unmarshal(content, &state); err != nil { + return UpdatePolicyState{}, fmt.Errorf("parse image update state: %w", err) + } + if state.SchemaVersion != updatePolicyStateSchemaVersion { + return UpdatePolicyState{}, fmt.Errorf("unsupported image update state schema %d", state.SchemaVersion) + } + return state, nil +} + +func writeUpdatePolicyState(projectRoot, configPath string, state UpdatePolicyState) error { + path, err := UpdatePolicyStatePath(projectRoot, configPath) + if err != nil { + return err + } + state.SchemaVersion = updatePolicyStateSchemaVersion + content, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return writeAtomicFile(path, append(content, '\n'), 0o600) +} + +func (m *Coordinator) readUpdatePolicyState() (UpdatePolicyState, error) { + state, err := ReadUpdatePolicyState(m.ProjectRoot, m.effectiveConfigPath()) + if errors.Is(err, os.ErrNotExist) { + return UpdatePolicyState{SchemaVersion: updatePolicyStateSchemaVersion}, nil + } + return state, err +} + +func (m *Coordinator) writeUpdatePolicyState(state UpdatePolicyState) error { + return writeUpdatePolicyState(m.ProjectRoot, m.effectiveConfigPath(), state) +} + +func bootstrapUpdatePolicyState(state *UpdatePolicyState, image config.ImageConfig, localManifest, resolvedManifest Manifest, source *ResolvedDockerSource, activatedAt time.Time, location *time.Location) (bool, error) { + if state.LocalInputHash != "" || state.LastResolvedManifest != nil || activatedAt.IsZero() { + return false, nil + } + localHash, err := ManifestHash(localManifest) + if err != nil { + return false, err + } + projected := resolvedManifest + projected.SourceImage = localManifest.SourceImage + projected.SourcePlatform = localManifest.SourcePlatform + projected.SourceDigest = localManifest.SourceDigest + projected.SourcePlatformDigest = localManifest.SourcePlatformDigest + projected.RunnerSelector = localManifest.RunnerSelector + projected.RunnerVersion = localManifest.RunnerVersion + projected.RunnerAssetName = localManifest.RunnerAssetName + projected.RunnerAssetURL = localManifest.RunnerAssetURL + projected.RunnerAssetDigest = localManifest.RunnerAssetDigest + projectedHash, err := ManifestHash(projected) + if err != nil { + return false, err + } + if projectedHash != localHash { + return false, nil + } + if location == nil { + location = time.Local + } + state.LocalInputHash = localHash + state.LastResolvedManifest = &resolvedManifest + if source != nil { + copy := *source + state.LastResolvedSource = © + } + if err := scheduleNextSuccess(state, image, activatedAt.In(location)); err != nil { + return false, err + } + return true, nil +} + +func (m *Coordinator) UpdatePolicyStatus() (UpdatePolicyStatus, error) { + state, err := m.readUpdatePolicyState() + if err != nil { + return UpdatePolicyStatus{}, err + } + recalculateScheduleForTimeZone(&state, m.Config.Image, time.Local) + pendingIdentity := "" + if state.PendingManifest != nil { + hash, hashErr := ManifestHash(*state.PendingManifest) + if hashErr != nil { + return UpdatePolicyStatus{}, hashErr + } + pendingIdentity = shortUpdateIdentity(*state.PendingManifest, hash) + } + return UpdatePolicyStatus{ + Frequency: m.Config.Image.UpdateFrequency, + UpdateTime: m.Config.Image.UpdateTime, + LastSuccessfulCheckAt: state.LastSuccessfulCheckAt, + NextEligibleAt: state.NextEligibleAt, + NextRetryAt: state.NextRetryAt, + Pending: state.PendingManifest != nil, + PendingIdentity: pendingIdentity, + DeferredReason: state.DeferredReason, + LastError: state.LastError, + }, nil +} + +// CheckRemoteUpdate performs only the cheap immutable remote observation. It +// never builds, imports, activates, or retires an artifact. +func (m *Coordinator) CheckRemoteUpdate(ctx context.Context, now time.Time) (RemoteUpdateCheck, error) { + state, err := m.readUpdatePolicyState() + if err != nil { + return RemoteUpdateCheck{}, err + } + if recalculateScheduleForTimeZone(&state, m.Config.Image, now.Location()) { + if err := m.writeUpdatePolicyState(state); err != nil { + return RemoteUpdateCheck{}, err + } + } + if !updateCheckDue(state, m.Config.Image, now) { + return RemoteUpdateCheck{ + CurrentManifest: state.LastResolvedManifest, + PendingManifest: state.PendingManifest, + NextEligibleAt: state.NextEligibleAt, + NextRetryAt: state.NextRetryAt, + }, nil + } + var ( + localManifest Manifest + manifest Manifest + source *ResolvedDockerSource + ) + if m.Config.Provider.Type == "docker-sandboxes" { + localManifest, err = m.dockerSandboxesLocalManifest(ctx) + if err == nil { + var resolved ResolvedDockerSource + manifest, resolved, err = m.dockerSandboxesDesiredManifest(ctx) + source = &resolved + } + } else { + localManifest, err = m.desiredLocalImageManifest(ctx) + if err == nil { + manifest, err = m.resolveRemoteImageManifest(ctx, localManifest) + } + } + if err != nil { + scheduleUpdateFailure(&state, now, err) + _ = m.writeUpdatePolicyState(state) + return RemoteUpdateCheck{Due: true, NextRetryAt: state.NextRetryAt}, err + } + localHash, err := ManifestHash(localManifest) + if err != nil { + return RemoteUpdateCheck{}, err + } + if state.LocalInputHash != localHash { + return RemoteUpdateCheck{}, fmt.Errorf("local image inputs changed while the pool was running; restart EPAR to apply the new configuration safely") + } + resolvedHash, err := ManifestHash(manifest) + if err != nil { + return RemoteUpdateCheck{}, err + } + currentHash := "" + if state.LastResolvedManifest != nil { + currentHash, err = ManifestHash(*state.LastResolvedManifest) + if err != nil { + return RemoteUpdateCheck{}, err + } + } + if currentHash == resolvedHash { + if err := scheduleNextSuccess(&state, m.Config.Image, now); err != nil { + return RemoteUpdateCheck{}, err + } + state.PendingManifest = nil + state.PendingSource = nil + if err := m.writeUpdatePolicyState(state); err != nil { + return RemoteUpdateCheck{}, err + } + return RemoteUpdateCheck{ + Due: true, + CurrentManifest: state.LastResolvedManifest, + NextEligibleAt: state.NextEligibleAt, + }, nil + } + state.LastAttemptAt = now.UTC() + state.PendingManifest = &manifest + state.PendingSource = source + state.DeferredReason = "waiting for the common pool maintenance drain" + state.LastError = "" + if err := m.writeUpdatePolicyState(state); err != nil { + return RemoteUpdateCheck{}, err + } + return RemoteUpdateCheck{ + Due: true, + Changed: true, + CurrentManifest: state.LastResolvedManifest, + PendingManifest: &manifest, + }, nil +} + +// ApplyPendingUpdate performs the build and activation selected by +// CheckRemoteUpdate after the common pool lifecycle has drained all runners. +func (m *Coordinator) ApplyPendingUpdate(ctx context.Context, now time.Time) error { + state, err := m.readUpdatePolicyState() + if err != nil { + return err + } + if state.PendingManifest == nil { + return nil + } + manifest := *state.PendingManifest + if m.Config.Provider.Type == "docker-sandboxes" { + if state.PendingSource == nil { + return fmt.Errorf("pending Docker Sandboxes update is missing its immutable source observation") + } + err = m.ensureDockerSandboxesTemplateResolved(ctx, false, manifest, *state.PendingSource) + } else { + err = m.buildResolvedImage(ctx, manifest) + if err == nil { + hash, hashErr := ManifestHash(manifest) + if hashErr != nil { + err = hashErr + } else if recordErr := m.recordCurrentArtifact(ctx, hash); recordErr != nil { + err = recordErr + } + } + } + if err != nil { + scheduleUpdateFailure(&state, now, err) + state.DeferredReason = "scheduled build failed; the previous verified generation remains active" + _ = m.writeUpdatePolicyState(state) + return err + } + state.LastResolvedManifest = &manifest + state.LastResolvedSource = state.PendingSource + state.PendingManifest = nil + state.PendingSource = nil + if err := scheduleNextSuccess(&state, m.Config.Image, now); err != nil { + return err + } + if err := m.writeUpdatePolicyState(state); err != nil { + return err + } + return m.cleanupSupersededCatalog(ctx) +} + +func (m *Coordinator) DeferPendingUpdate(reason string) error { + state, err := m.readUpdatePolicyState() + if err != nil { + return err + } + if state.PendingManifest == nil { + return nil + } + state.DeferredReason = reason + return m.writeUpdatePolicyState(state) +} + +func NextImageUpdateAt(last time.Time, frequency, wallClock string, location *time.Location) (time.Time, error) { + if frequency == config.ImageUpdateFrequencyManual { + return time.Time{}, nil + } + if location == nil { + location = time.Local + } + clock, err := time.Parse("15:04", wallClock) + if err != nil { + return time.Time{}, fmt.Errorf("parse image update time: %w", err) + } + localLast := last.In(location) + year, month, day := localLast.Date() + switch frequency { + case config.ImageUpdateFrequencyDaily: + day++ + case config.ImageUpdateFrequencyWeekly: + day += 7 + case config.ImageUpdateFrequencyBiweekly: + day += 14 + case config.ImageUpdateFrequencyMonthly: + year, month, day = addCalendarMonthClamped(year, month, day) + default: + return time.Time{}, fmt.Errorf("unsupported image update frequency %q", frequency) + } + next := time.Date(year, month, day, clock.Hour(), clock.Minute(), 0, 0, location) + return normalizeLocalWallClock(next, clock.Hour(), clock.Minute(), location), nil +} + +func addCalendarMonthClamped(year int, month time.Month, day int) (int, time.Month, int) { + targetMonth := month + 1 + targetYear := year + if targetMonth > time.December { + targetMonth = time.January + targetYear++ + } + lastDay := time.Date(targetYear, targetMonth+1, 0, 12, 0, 0, 0, time.UTC).Day() + if day > lastDay { + day = lastDay + } + return targetYear, targetMonth, day +} + +// normalizeLocalWallClock handles DST gaps by selecting the first valid local +// instant after the requested wall-clock time. Repeated times use Go's earlier +// occurrence, which is deterministic and still runs only once because state is +// persisted immediately after the check. +func normalizeLocalWallClock(candidate time.Time, hour, minute int, location *time.Location) time.Time { + local := candidate.In(location) + if local.Hour() == hour && local.Minute() == minute { + return candidate + } + for i := 0; i < 180; i++ { + candidate = candidate.Add(time.Minute) + local = candidate.In(location) + if local.Hour() > hour || (local.Hour() == hour && local.Minute() >= minute) { + return candidate + } + } + return candidate +} + +func updateCheckDue(state UpdatePolicyState, image config.ImageConfig, now time.Time) bool { + if image.UpdateFrequency == config.ImageUpdateFrequencyManual { + return false + } + if state.LastResolvedManifest != nil && !manifestHasMutableRemoteInputs(*state.LastResolvedManifest) { + return false + } + if !state.NextRetryAt.IsZero() && now.Before(state.NextRetryAt) { + return false + } + if state.LastSuccessfulCheckAt.IsZero() || state.NextEligibleAt.IsZero() { + return true + } + return !now.Before(state.NextEligibleAt) +} + +func pendingUpdateReady(state UpdatePolicyState, now time.Time) bool { + if state.PendingManifest == nil { + return false + } + return state.NextRetryAt.IsZero() || !now.Before(state.NextRetryAt) +} + +func scheduleNextSuccess(state *UpdatePolicyState, image config.ImageConfig, now time.Time) error { + location := now.Location() + if location == nil || location == time.UTC { + location = time.Local + } + next, err := NextImageUpdateAt(now, image.UpdateFrequency, image.UpdateTime, location) + if err != nil { + return err + } + if state.LastResolvedManifest != nil && !manifestHasMutableRemoteInputs(*state.LastResolvedManifest) { + next = time.Time{} + } + state.LastAttemptAt = now.UTC() + state.LastSuccessfulCheckAt = now.UTC() + state.NextEligibleAt = next.UTC() + state.NextRetryAt = time.Time{} + state.ConsecutiveFailures = 0 + state.TimeZone = location.String() + state.PolicyFrequency = image.UpdateFrequency + state.PolicyTime = image.UpdateTime + state.LastError = "" + state.DeferredReason = "" + return nil +} + +func manifestHasMutableRemoteInputs(manifest Manifest) bool { + if normalizedRunnerSelector(manifest.RunnerSelector) == "latest" { + return true + } + if manifest.SourceType == config.ImageSourceDockerImage && !strings.Contains(strings.ToLower(manifest.SourceImage), "@sha256:") { + return true + } + return false +} + +func recalculateScheduleForTimeZone(state *UpdatePolicyState, image config.ImageConfig, location *time.Location) bool { + if location == nil { + location = time.Local + } + policyChanged := state.PolicyFrequency != image.UpdateFrequency || state.PolicyTime != image.UpdateTime + if image.UpdateFrequency == config.ImageUpdateFrequencyManual { + if !policyChanged && state.NextEligibleAt.IsZero() && state.TimeZone == location.String() { + return false + } + state.PolicyFrequency = image.UpdateFrequency + state.PolicyTime = image.UpdateTime + state.TimeZone = location.String() + state.NextEligibleAt = time.Time{} + return true + } + if state.LastSuccessfulCheckAt.IsZero() { + if !policyChanged && state.TimeZone == location.String() { + return false + } + state.PolicyFrequency = image.UpdateFrequency + state.PolicyTime = image.UpdateTime + state.TimeZone = location.String() + return true + } + if !policyChanged && state.TimeZone == location.String() { + return false + } + next, err := NextImageUpdateAt(state.LastSuccessfulCheckAt, image.UpdateFrequency, image.UpdateTime, location) + if err != nil { + return false + } + state.TimeZone = location.String() + state.PolicyFrequency = image.UpdateFrequency + state.PolicyTime = image.UpdateTime + state.NextEligibleAt = next.UTC() + return true +} + +func shortUpdateIdentity(manifest Manifest, hash string) string { + parts := []string{"manifest=" + shortDigest(hash)} + if manifest.SourcePlatformDigest != "" { + parts = append(parts, "source="+shortDigest(manifest.SourcePlatformDigest)) + } else if manifest.SourceDigest != "" { + parts = append(parts, "source="+shortDigest(manifest.SourceDigest)) + } + if manifest.RunnerVersion != "" { + parts = append(parts, "runner="+manifest.RunnerVersion) + } + return strings.Join(parts, " ") +} + +func shortDigest(value string) string { + value = strings.TrimSpace(strings.TrimPrefix(value, "sha256:")) + if len(value) > 16 { + value = value[:16] + } + return value +} + +func scheduleUpdateFailure(state *UpdatePolicyState, now time.Time, failure error) { + state.LastAttemptAt = now.UTC() + state.ConsecutiveFailures++ + delay := time.Hour + for i := 1; i < state.ConsecutiveFailures && delay < 24*time.Hour; i++ { + delay *= 2 + } + if delay > 24*time.Hour { + delay = 24 * time.Hour + } + state.NextRetryAt = now.Add(delay).UTC() + state.LastError = failure.Error() +} + +func formatUpdateTime(value time.Time) string { + if value.IsZero() { + return "manual" + } + return value.In(time.Local).Format("2006-01-02 15:04 MST") +} diff --git a/internal/image/update_policy_test.go b/internal/image/update_policy_test.go new file mode 100644 index 0000000..1def271 --- /dev/null +++ b/internal/image/update_policy_test.go @@ -0,0 +1,282 @@ +package image + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" +) + +func TestNextImageUpdateAtIntervals(t *testing.T) { + location := time.FixedZone("test", 8*60*60) + last := time.Date(2026, time.July, 30, 13, 45, 0, 0, location) + tests := []struct { + frequency string + want time.Time + }{ + {config.ImageUpdateFrequencyDaily, time.Date(2026, time.July, 31, 7, 0, 0, 0, location)}, + {config.ImageUpdateFrequencyWeekly, time.Date(2026, time.August, 6, 7, 0, 0, 0, location)}, + {config.ImageUpdateFrequencyBiweekly, time.Date(2026, time.August, 13, 7, 0, 0, 0, location)}, + {config.ImageUpdateFrequencyMonthly, time.Date(2026, time.August, 30, 7, 0, 0, 0, location)}, + } + for _, test := range tests { + got, err := NextImageUpdateAt(last, test.frequency, "07:00", location) + if err != nil { + t.Fatalf("%s: %v", test.frequency, err) + } + if !got.Equal(test.want) { + t.Fatalf("%s next = %s, want %s", test.frequency, got, test.want) + } + } +} + +func TestNextImageUpdateAtClampsCalendarMonth(t *testing.T) { + location := time.UTC + tests := []struct { + last time.Time + want time.Time + }{ + { + time.Date(2025, time.January, 31, 12, 0, 0, 0, location), + time.Date(2025, time.February, 28, 7, 0, 0, 0, location), + }, + { + time.Date(2024, time.January, 31, 12, 0, 0, 0, location), + time.Date(2024, time.February, 29, 7, 0, 0, 0, location), + }, + { + time.Date(2026, time.December, 31, 12, 0, 0, 0, location), + time.Date(2027, time.January, 31, 7, 0, 0, 0, location), + }, + } + for _, test := range tests { + got, err := NextImageUpdateAt(test.last, config.ImageUpdateFrequencyMonthly, "07:00", location) + if err != nil { + t.Fatal(err) + } + if !got.Equal(test.want) { + t.Fatalf("next = %s, want %s", got, test.want) + } + } +} + +func TestNextImageUpdateAtManual(t *testing.T) { + got, err := NextImageUpdateAt(time.Now(), config.ImageUpdateFrequencyManual, "07:00", time.Local) + if err != nil { + t.Fatal(err) + } + if !got.IsZero() { + t.Fatalf("manual next = %s, want zero", got) + } +} + +func TestUpdateFailureBackoffIsBounded(t *testing.T) { + now := time.Date(2026, time.July, 30, 7, 0, 0, 0, time.UTC) + state := UpdatePolicyState{} + for attempt := 1; attempt <= 8; attempt++ { + scheduleUpdateFailure(&state, now, errors.New("registry unavailable")) + delay := state.NextRetryAt.Sub(now) + if delay > 24*time.Hour { + t.Fatalf("attempt %d delay = %s, want at most 24h", attempt, delay) + } + } + if state.NextRetryAt.Sub(now) != 24*time.Hour { + t.Fatalf("final delay = %s, want 24h", state.NextRetryAt.Sub(now)) + } +} + +func TestUpdateCheckDueHonorsManualAndRetry(t *testing.T) { + now := time.Date(2026, time.July, 30, 7, 0, 0, 0, time.UTC) + image := config.Default().Image + state := UpdatePolicyState{} + if !updateCheckDue(state, image, now) { + t.Fatal("new automatic policy should be due") + } + image.UpdateFrequency = config.ImageUpdateFrequencyManual + if updateCheckDue(state, image, now) { + t.Fatal("manual policy should not be due") + } + image.UpdateFrequency = config.ImageUpdateFrequencyWeekly + state.NextRetryAt = now.Add(time.Hour) + if updateCheckDue(state, image, now) { + t.Fatal("retry backoff should defer check") + } +} + +func TestUpdateCheckDueSkipsImmutableRemoteInputs(t *testing.T) { + now := time.Date(2026, time.July, 30, 7, 0, 0, 0, time.UTC) + image := config.Default().Image + state := UpdatePolicyState{ + LastSuccessfulCheckAt: now.Add(-8 * 24 * time.Hour), + NextEligibleAt: now.Add(-24 * time.Hour), + LastResolvedManifest: &Manifest{ + SourceType: config.ImageSourceDockerImage, + SourceImage: "ghcr.io/example/runner@sha256:" + strings.Repeat("a", 64), + RunnerSelector: "2.332.0", + }, + } + if updateCheckDue(state, image, now) { + t.Fatal("immutable source and pinned runner should not schedule a remote check") + } + state.LastResolvedManifest.RunnerSelector = "latest" + if !updateCheckDue(state, image, now) { + t.Fatal("runnerVersion latest should remain scheduled") + } +} + +func TestNextImageUpdateAtHandlesDSTGapAndRepeat(t *testing.T) { + location, err := time.LoadLocation("America/New_York") + if err != nil { + t.Skipf("timezone database unavailable: %v", err) + } + gap, err := NextImageUpdateAt(time.Date(2026, time.March, 7, 7, 0, 0, 0, location), config.ImageUpdateFrequencyDaily, "02:30", location) + if err != nil { + t.Fatal(err) + } + gapLocal := gap.In(location) + if gapLocal.Year() != 2026 || gapLocal.Month() != time.March || gapLocal.Day() != 8 || gapLocal.Hour() != 3 || gapLocal.Minute() != 0 { + t.Fatalf("DST gap next = %s, want first valid local instant at 03:00", gapLocal) + } + repeat, err := NextImageUpdateAt(time.Date(2026, time.October, 31, 7, 0, 0, 0, location), config.ImageUpdateFrequencyDaily, "01:30", location) + if err != nil { + t.Fatal(err) + } + repeatLocal := repeat.In(location) + if repeatLocal.Year() != 2026 || repeatLocal.Month() != time.November || repeatLocal.Day() != 1 || repeatLocal.Hour() != 1 || repeatLocal.Minute() != 30 { + t.Fatalf("DST repeat next = %s, want local 01:30", repeatLocal) + } +} + +func TestRecalculateScheduleForTimeZone(t *testing.T) { + singapore := time.FixedZone("Asia/Singapore", 8*60*60) + tokyo := time.FixedZone("Asia/Tokyo", 9*60*60) + success := time.Date(2026, time.July, 30, 7, 0, 0, 0, singapore) + state := UpdatePolicyState{ + LastSuccessfulCheckAt: success.UTC(), + NextEligibleAt: success.AddDate(0, 0, 7).UTC(), + TimeZone: singapore.String(), + PolicyFrequency: config.ImageUpdateFrequencyWeekly, + PolicyTime: "07:00", + } + image := config.Default().Image + if !recalculateScheduleForTimeZone(&state, image, tokyo) { + t.Fatal("timezone change did not recalculate the next check") + } + want := time.Date(2026, time.August, 6, 7, 0, 0, 0, tokyo) + if !state.NextEligibleAt.Equal(want) { + t.Fatalf("next after timezone change = %s, want %s", state.NextEligibleAt, want) + } +} + +func TestRecalculateScheduleAfterPolicyChange(t *testing.T) { + location := time.FixedZone("local", 8*60*60) + success := time.Date(2026, time.July, 30, 7, 0, 0, 0, location) + state := UpdatePolicyState{ + LastSuccessfulCheckAt: success.UTC(), + NextEligibleAt: success.AddDate(0, 0, 7).UTC(), + TimeZone: location.String(), + PolicyFrequency: config.ImageUpdateFrequencyWeekly, + PolicyTime: "07:00", + } + image := config.Default().Image + image.UpdateFrequency = config.ImageUpdateFrequencyDaily + image.UpdateTime = "06:30" + if !recalculateScheduleForTimeZone(&state, image, location) { + t.Fatal("policy change did not recalculate the next check") + } + want := time.Date(2026, time.July, 31, 6, 30, 0, 0, location) + if !state.NextEligibleAt.Equal(want) { + t.Fatalf("next after policy change = %s, want %s", state.NextEligibleAt, want) + } + image.UpdateFrequency = config.ImageUpdateFrequencyManual + if !recalculateScheduleForTimeZone(&state, image, location) || !state.NextEligibleAt.IsZero() { + t.Fatalf("manual policy did not clear next automatic check: %+v", state) + } +} + +func TestBootstrapUpdatePolicyStateFromMatchingVerifiedArtifact(t *testing.T) { + location := time.FixedZone("local", 8*60*60) + activated := time.Date(2026, time.July, 1, 7, 0, 0, 0, location) + local := Manifest{ + SchemaVersion: ManifestSchemaVersion, + ProviderType: "docker-sandboxes", + SourceType: config.ImageSourceDockerImage, + SourceImage: "ghcr.io/catthehacker/ubuntu:act-latest", + RunnerSelector: "latest", + EPARScripts: []FileDigest{{Path: "runtime.sh", SHA256: "local"}}, + } + resolved := local + resolved.SourcePlatform = "linux/amd64" + resolved.SourceDigest = "ghcr.io/catthehacker/ubuntu@sha256:" + strings.Repeat("a", 64) + resolved.SourcePlatformDigest = "sha256:" + strings.Repeat("b", 64) + resolved.RunnerVersion = "2.332.0" + resolved.RunnerAssetName = "actions-runner-linux-x64-2.332.0.tar.gz" + resolved.RunnerAssetURL = "https://example.invalid/runner.tar.gz" + resolved.RunnerAssetDigest = "sha256:" + strings.Repeat("c", 64) + source := ResolvedDockerSource{Reference: local.SourceImage, Platform: "linux/amd64", PlatformDigest: resolved.SourcePlatformDigest} + state := UpdatePolicyState{SchemaVersion: updatePolicyStateSchemaVersion} + image := config.ImageConfig{UpdateFrequency: config.ImageUpdateFrequencyWeekly, UpdateTime: "07:00"} + + bootstrapped, err := bootstrapUpdatePolicyState(&state, image, local, resolved, &source, activated, location) + if err != nil { + t.Fatal(err) + } + if !bootstrapped || state.LastResolvedManifest == nil || state.LastResolvedSource == nil { + t.Fatalf("bootstrap result = %t, state = %+v", bootstrapped, state) + } + if got := state.NextEligibleAt.In(location); !got.Equal(activated.AddDate(0, 0, 7)) { + t.Fatalf("next eligible = %v, want %v", got, activated.AddDate(0, 0, 7)) + } + + changed := local + changed.EPARScripts = []FileDigest{{Path: "runtime.sh", SHA256: "changed"}} + rejected := UpdatePolicyState{SchemaVersion: updatePolicyStateSchemaVersion} + bootstrapped, err = bootstrapUpdatePolicyState(&rejected, image, changed, resolved, &source, activated, location) + if err != nil { + t.Fatal(err) + } + if bootstrapped || rejected.LastResolvedManifest != nil { + t.Fatalf("changed local inputs bootstrapped stale artifact: %+v", rejected) + } +} + +func TestUpdatePolicyStateUsesPerConfigPathAndAtomicJSON(t *testing.T) { + root := t.TempDir() + configA := filepath.Join(root, ".local", "a.yml") + configB := filepath.Join(root, ".local", "b.yml") + if err := os.MkdirAll(filepath.Dir(configA), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configA, []byte("provider:\n type: docker-container\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configB, []byte("provider:\n type: wsl\n"), 0o600); err != nil { + t.Fatal(err) + } + pathA, err := UpdatePolicyStatePath(root, configA) + if err != nil { + t.Fatal(err) + } + pathB, err := UpdatePolicyStatePath(root, configB) + if err != nil { + t.Fatal(err) + } + if pathA == pathB { + t.Fatalf("different configs share update-policy path %q", pathA) + } + state := UpdatePolicyState{LocalInputHash: "local", LastError: "registry unavailable"} + if err := writeUpdatePolicyState(root, configA, state); err != nil { + t.Fatal(err) + } + got, err := ReadUpdatePolicyState(root, configA) + if err != nil { + t.Fatal(err) + } + if got.SchemaVersion != updatePolicyStateSchemaVersion || got.LocalInputHash != "local" || got.LastError != "registry unavailable" { + t.Fatalf("read state = %+v", got) + } +} diff --git a/internal/pool/host_trust_test.go b/internal/pool/host_trust_test.go index c675b10..7159024 100644 --- a/internal/pool/host_trust_test.go +++ b/internal/pool/host_trust_test.go @@ -4,10 +4,13 @@ import ( "archive/tar" "bytes" "context" + "crypto/sha256" "encoding/json" "errors" + "fmt" "io" "net/http" + "net/http/httptest" "os" "path/filepath" "slices" @@ -576,7 +579,25 @@ func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing. } return nil } - if err := manager.buildDockerContainerImage(context.Background(), ImageBuildOptions{Replace: true}, t.TempDir()); err != nil { + runnerPackage := []byte("test-actions-runner-package") + runnerServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _, _ = response.Write(runnerPackage) + })) + defer runnerServer.Close() + runnerDigest := sha256.Sum256(runnerPackage) + manifest := ImageManifest{ + SchemaVersion: imageManifestSchemaVersion, + ProviderType: "docker-container", + SourceType: config.ImageSourceDockerImage, + SourceImage: "source:latest", + OutputImage: "output:latest", + RunnerSelector: "latest", + RunnerVersion: "2.332.0", + RunnerAssetName: "actions-runner-linux-x64-2.332.0.tar.gz", + RunnerAssetURL: runnerServer.URL, + RunnerAssetDigest: fmt.Sprintf("sha256:%x", runnerDigest), + } + if err := manager.buildDockerContainerImage(context.Background(), ImageBuildOptions{Replace: true, Manifest: &manifest}, t.TempDir()); err != nil { t.Fatal(err) } if builds != 2 { diff --git a/internal/pool/image_manifest_test.go b/internal/pool/image_manifest_test.go index 6676f55..c3841b0 100644 --- a/internal/pool/image_manifest_test.go +++ b/internal/pool/image_manifest_test.go @@ -41,7 +41,7 @@ func TestImageManifestHashChangesWithImageInputs(t *testing.T) { } hash := func() string { t.Helper() - manifest, err := manager.desiredImageManifest(context.Background()) + manifest, err := manager.desiredLocalImageManifest(context.Background()) if err != nil { t.Fatal(err) } diff --git a/internal/pool/image_service.go b/internal/pool/image_service.go index af69db3..d175039 100644 --- a/internal/pool/image_service.go +++ b/internal/pool/image_service.go @@ -54,6 +54,7 @@ var ( func (m *Manager) imageCoordinator() *artifactimage.Coordinator { coordinator := artifactimage.NewCoordinator(m.Config, m.Provider, m.Lifecycle, m.ProjectRoot, m.DryRun, imageEnvironment{manager: m}) coordinator.ConfigPath = m.ConfigPath + coordinator.Clock = m.currentTime return coordinator } @@ -74,23 +75,73 @@ func (m *Manager) EnsureImage(ctx context.Context) error { if err := m.imageCoordinator().EnsureImage(ctx); err != nil { return err } - if m.Config.Provider.Type == "docker-container" && !m.DryRun { - identity, err := runHostOutputCommand(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", m.Config.Image.OutputImage) - if err != nil { - return fmt.Errorf("resolve immutable Docker Container runtime image: %w", err) - } - identity = strings.TrimSpace(identity) - if identity == "" { - return fmt.Errorf("Docker Container runtime image returned an empty immutable identity") - } - // Keep user configuration as the desired mutable name while the live - // manager and all replacements consume the exact verified image ID. - m.Config.Provider.SourceImage = identity + if err := m.pinDockerRuntimeImage(ctx); err != nil { + return err + } + m.imageEnsured = true + return nil +} + +func (m *Manager) UpdateImage(ctx context.Context) error { + m.imageEnsureMu.Lock() + defer m.imageEnsureMu.Unlock() + if err := m.imageCoordinator().UpdateImage(ctx); err != nil { + return err + } + if err := m.pinDockerRuntimeImage(ctx); err != nil { + return err } m.imageEnsured = true return nil } +func (m *Manager) CheckRemoteImageUpdate(ctx context.Context, now time.Time) (artifactimage.RemoteUpdateCheck, error) { + m.imageEnsureMu.Lock() + defer m.imageEnsureMu.Unlock() + return m.imageCoordinator().CheckRemoteUpdate(ctx, now) +} + +func (m *Manager) ApplyPendingImageUpdate(ctx context.Context, now time.Time) error { + m.imageEnsureMu.Lock() + defer m.imageEnsureMu.Unlock() + if err := m.imageCoordinator().ApplyPendingUpdate(ctx, now); err != nil { + return err + } + if err := m.pinDockerRuntimeImage(ctx); err != nil { + return err + } + m.imageEnsured = true + return nil +} + +func (m *Manager) ImageUpdatePolicyStatus() (artifactimage.UpdatePolicyStatus, error) { + return m.imageCoordinator().UpdatePolicyStatus() +} + +func (m *Manager) DeferPendingImageUpdate(reason string) error { + m.imageEnsureMu.Lock() + defer m.imageEnsureMu.Unlock() + return m.imageCoordinator().DeferPendingUpdate(reason) +} + +func (m *Manager) pinDockerRuntimeImage(ctx context.Context) error { + if m.Config.Provider.Type != "docker-container" || m.DryRun { + return nil + } + identity, err := runHostOutputCommand(ctx, "docker", "image", "inspect", "--format", "{{.Id}}", m.Config.Image.OutputImage) + if err != nil { + return fmt.Errorf("resolve immutable Docker Container runtime image: %w", err) + } + identity = strings.TrimSpace(identity) + if identity == "" { + return fmt.Errorf("Docker Container runtime image returned an empty immutable identity") + } + // Keep user configuration as the desired mutable name while the live + // manager and all replacements consume the exact verified image ID. + m.Config.Provider.SourceImage = identity + return nil +} + func (m *Manager) RefreshScripts(ctx context.Context) error { return m.imageCoordinator().RefreshScripts(ctx) } @@ -115,6 +166,10 @@ func (m *Manager) desiredImageManifest(ctx context.Context) (ImageManifest, erro return m.imageCoordinator().DesiredImageManifest(ctx) } +func (m *Manager) desiredLocalImageManifest(ctx context.Context) (ImageManifest, error) { + return m.imageCoordinator().DesiredLocalImageManifest(ctx) +} + func (m *Manager) currentImageState(ctx context.Context, wantedHash string) (imageState, error) { return m.imageCoordinator().CurrentImageState(ctx, wantedHash) } diff --git a/internal/pool/manager.go b/internal/pool/manager.go index 1fbacac..29f9109 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -346,7 +346,9 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { var currentHostTrust hosttrust.Snapshot hostTrustBusyHandoff := make(map[string]bool) confirmedInactiveChecks := make(map[string]int) + imageMaintenanceIdleChecks := make(map[string]int) retry := replacementRetryState{} + imageMaintenancePending := false for { select { case <-ctx.Done(): @@ -358,6 +360,38 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { m.pruneLogsBestEffort() nextRetention = time.Now().Add(time.Duration(m.Config.Logging.RetentionIntervalMinutes) * time.Minute) } + if m.AutomaticImageLifecycle && !imageMaintenancePending && m.Config.Image.UpdateFrequency != config.ImageUpdateFrequencyManual { + check, checkErr := m.CheckRemoteImageUpdate(ctx, now) + if checkErr != nil { + m.warnf("scheduled image update check failed; the current verified pool remains available: %v\n", checkErr) + } else if check.Changed { + if !m.Config.Runner.Ephemeral { + _ = m.DeferPendingImageUpdate("runner.ephemeral=false; the update will be applied on the next EPAR startup") + m.warnf("image update is available but this pool uses persistent runners; restart EPAR to apply it without an assignment race\n") + } else { + imageMaintenancePending = true + m.infof("image or Actions runner update is available; draining the ephemeral pool before artifact provisioning\n") + } + } + } + if imageMaintenancePending { + remaining, drainErr := m.drainPoolForImageUpdate(ctx, active, imageMaintenanceIdleChecks) + if drainErr != nil { + m.warnf("scheduled image maintenance drain warning; retrying without creating replacements: %v\n", drainErr) + continue + } + if remaining > 0 { + continue + } + m.infof("scheduled image maintenance drain complete; building and activating the verified replacement artifact\n") + if updateErr := m.ApplyPendingImageUpdate(ctx, now); updateErr != nil { + m.warnf("scheduled image update failed; restoring pool capacity with the previous verified generation: %v\n", updateErr) + } else { + m.infof("scheduled image update activated; restoring pool capacity\n") + } + imageMaintenancePending = false + clear(imageMaintenanceIdleChecks) + } trustRetired := 0 trustCapacityReady := true if m.hostTrustEnabled() { @@ -540,6 +574,45 @@ func (m *Manager) RunPool(ctx context.Context, opts RunOptions) error { } } +func (m *Manager) drainPoolForImageUpdate(ctx context.Context, active map[string]ProvisionedInstance, confirmedIdle map[string]int) (int, error) { + for name, vm := range active { + if m.GitHub != nil { + runner, found, err := m.GitHub.RunnerByName(ctx, name) + if err != nil { + delete(confirmedIdle, name) + return len(active), fmt.Errorf("inspect GitHub runner %s before maintenance: %w", name, err) + } + if found { + vm.RunnerID = runner.ID + active[name] = vm + if err := m.recordLifecycleJobObservation(ctx, runner); err != nil { + delete(confirmedIdle, name) + return len(active), fmt.Errorf("record GitHub job state for %s before maintenance: %w", name, err) + } + } + if found && runner.Busy { + delete(confirmedIdle, name) + m.infof("[%s] scheduled image maintenance is waiting for the active job to finish\n", name) + continue + } + if found { + confirmedIdle[name]++ + if confirmedIdle[name] < 2 { + m.infof("[%s] scheduled image maintenance observed the runner idle; confirming it remains unassigned before retirement\n", name) + continue + } + } + } + m.infof("[%s] retiring idle runner for scheduled image maintenance\n", name) + if err := m.retireInstance(context.Background(), vm, "scheduled image and Actions runner update"); err != nil { + return len(active), err + } + delete(active, name) + delete(confirmedIdle, name) + } + return len(active), nil +} + func currentHostTrustCapacity(active map[string]ProvisionedInstance, generation string) int { capacity := 0 for _, instance := range active { @@ -1161,6 +1234,39 @@ func (m *Manager) cleanupLegacyTestProvider(ctx context.Context) error { func (m *Manager) Status(ctx context.Context) (string, error) { var b strings.Builder + updateStatus, updateErr := m.ImageUpdatePolicyStatus() + if updateErr != nil { + if m.Config.Image.UpdateFrequency == config.ImageUpdateFrequencyManual { + fmt.Fprintf(&b, "Image updates:\n policy=manual\tstate=unavailable\terror=%s\n", updateErr) + } else { + fmt.Fprintf(&b, "Image updates:\n policy=%s at %s local\tstate=unavailable\terror=%s\n", m.Config.Image.UpdateFrequency, m.Config.Image.UpdateTime, updateErr) + } + } else { + if updateStatus.Frequency == config.ImageUpdateFrequencyManual { + fmt.Fprintf(&b, "Image updates:\n policy=manual") + } else { + fmt.Fprintf(&b, "Image updates:\n policy=%s at %s local", updateStatus.Frequency, updateStatus.UpdateTime) + } + if !updateStatus.LastSuccessfulCheckAt.IsZero() { + fmt.Fprintf(&b, "\tlast=%s", updateStatus.LastSuccessfulCheckAt.In(time.Local).Format("2006-01-02 15:04 MST")) + } + if !updateStatus.NextEligibleAt.IsZero() { + fmt.Fprintf(&b, "\tnext=%s", updateStatus.NextEligibleAt.In(time.Local).Format("2006-01-02 15:04 MST")) + } + if !updateStatus.NextRetryAt.IsZero() { + fmt.Fprintf(&b, "\tretry=%s", updateStatus.NextRetryAt.In(time.Local).Format("2006-01-02 15:04 MST")) + } + if updateStatus.Pending { + fmt.Fprintf(&b, "\tpending=%s", updateStatus.PendingIdentity) + } + b.WriteString("\n") + if updateStatus.DeferredReason != "" { + fmt.Fprintf(&b, " deferred: %s\n", updateStatus.DeferredReason) + } + if updateStatus.LastError != "" { + fmt.Fprintf(&b, " last error: %s\n", updateStatus.LastError) + } + } items, err := m.inventoryProvider(ctx) if err != nil { return "", err diff --git a/internal/pool/manager_test.go b/internal/pool/manager_test.go index 2337e15..112ee19 100644 --- a/internal/pool/manager_test.go +++ b/internal/pool/manager_test.go @@ -42,6 +42,47 @@ func TestRunnerAliveKeepsBusyGitHubRunnerWithoutServiceCheck(t *testing.T) { } } +func TestImageMaintenanceDrainRequiresConsecutiveIdleObservations(t *testing.T) { + host := &fakeProvider{} + github := &fakeGitHub{ + runner: gh.Runner{ID: 42, Name: "epar-test-1", Status: "online"}, + found: true, + } + manager := Manager{Provider: host, GitHub: github} + active := map[string]ProvisionedInstance{ + "epar-test-1": {Name: "epar-test-1", RunnerID: 42}, + } + confirmedIdle := make(map[string]int) + + remaining, err := manager.drainPoolForImageUpdate(context.Background(), active, confirmedIdle) + if err != nil { + t.Fatal(err) + } + if remaining != 1 || atomic.LoadInt32(&github.deleteCalls) != 0 || atomic.LoadInt32(&host.deleteCalls) != 0 { + t.Fatalf("first idle observation retired runner: remaining=%d remoteDeletes=%d localDeletes=%d", remaining, github.deleteCalls, host.deleteCalls) + } + + github.runner.Busy = true + remaining, err = manager.drainPoolForImageUpdate(context.Background(), active, confirmedIdle) + if err != nil { + t.Fatal(err) + } + if remaining != 1 || confirmedIdle["epar-test-1"] != 0 { + t.Fatalf("busy observation did not reset idle confirmation: remaining=%d checks=%d", remaining, confirmedIdle["epar-test-1"]) + } + + github.runner.Busy = false + if remaining, err = manager.drainPoolForImageUpdate(context.Background(), active, confirmedIdle); err != nil || remaining != 1 { + t.Fatalf("idle confirmation after busy = remaining %d, error %v", remaining, err) + } + if remaining, err = manager.drainPoolForImageUpdate(context.Background(), active, confirmedIdle); err != nil || remaining != 0 { + t.Fatalf("second consecutive idle confirmation = remaining %d, error %v", remaining, err) + } + if atomic.LoadInt32(&github.deleteCalls) != 1 || atomic.LoadInt32(&host.deleteCalls) != 1 { + t.Fatalf("confirmed idle retirement calls = remote %d local %d, want 1 each", github.deleteCalls, host.deleteCalls) + } +} + func TestRunnerGroupPreflightEnforcesAndWarns(t *testing.T) { violation := gh.RunnerGroupPolicyResult{Violations: []string{"runner group allows public repositories"}} for _, test := range []struct { diff --git a/internal/pool/trusted_ca_test.go b/internal/pool/trusted_ca_test.go index 4aa95a7..1017024 100644 --- a/internal/pool/trusted_ca_test.go +++ b/internal/pool/trusted_ca_test.go @@ -172,7 +172,7 @@ func TestTrustedCACertificateDigestInvalidatesImageManifest(t *testing.T) { }, ProjectRoot: root, } - manifest, err := manager.desiredImageManifest(context.Background()) + manifest, err := manager.desiredLocalImageManifest(context.Background()) if err != nil { t.Fatal(err) } @@ -184,7 +184,7 @@ func TestTrustedCACertificateDigestInvalidatesImageManifest(t *testing.T) { t.Fatal(err) } writeTestCACertificate(t, certificatePath, "Enterprise Root Two") - manifest, err = manager.desiredImageManifest(context.Background()) + manifest, err = manager.desiredLocalImageManifest(context.Background()) if err != nil { t.Fatal(err) } diff --git a/internal/provider/dockercontainer/docker_container.go b/internal/provider/dockercontainer/docker_container.go index bada219..a219e21 100644 --- a/internal/provider/dockercontainer/docker_container.go +++ b/internal/provider/dockercontainer/docker_container.go @@ -60,7 +60,7 @@ func (p *Provider) Start(ctx context.Context, name string, opts provider.StartOp func (p *Provider) Exec(ctx context.Context, name string, command []string, opts provider.ExecOptions) (provider.ExecResult, error) { args := []string{"exec"} - if opts.Stdin != "" { + if opts.Stdin != "" || opts.StdinReader != nil { args = append(args, "-i") } for key, value := range opts.Env { @@ -69,7 +69,9 @@ func (p *Provider) Exec(ctx context.Context, name string, command []string, opts args = append(args, name) args = append(args, command...) var stdin io.Reader - if opts.Stdin != "" { + if opts.StdinReader != nil { + stdin = opts.StdinReader + } else if opts.Stdin != "" { stdin = strings.NewReader(opts.Stdin) } return p.runWithSensitiveLog(ctx, stdin, opts.LogPath, opts.Stdout, opts.Stderr, opts.SensitiveValues, args...) diff --git a/internal/provider/dockersandboxes/provider.go b/internal/provider/dockersandboxes/provider.go index abdc08b..69d72b1 100644 --- a/internal/provider/dockersandboxes/provider.go +++ b/internal/provider/dockersandboxes/provider.go @@ -627,7 +627,7 @@ func (p *Provider) Exec(ctx context.Context, instance provider.Instance, command args = append(args, command...) return p.run(ctx, commandRequest{ args: args, - stdin: strings.NewReader(opts.Stdin), + stdin: execOptionsReader(opts), stdout: opts.Stdout, stderr: opts.Stderr, sensitiveValues: opts.SensitiveValues, @@ -635,6 +635,13 @@ func (p *Provider) Exec(ctx context.Context, instance provider.Instance, command }) } +func execOptionsReader(opts provider.ExecOptions) io.Reader { + if opts.StdinReader != nil { + return opts.StdinReader + } + return strings.NewReader(opts.Stdin) +} + func (p *Provider) Diagnostics(ctx context.Context, instance provider.Instance) (provider.Diagnostics, error) { present, err := p.assertIdentity(ctx, instance) if err != nil { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 715f8b8..e64ceb6 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "os" "strings" "time" @@ -83,6 +84,7 @@ type RunningProcess struct { type ExecOptions struct { Stdin string + StdinReader io.Reader Env map[string]string SensitiveValues []string LogPath string @@ -249,6 +251,28 @@ func CopyTextAtomic(ctx context.Context, p Provider, vmName, path, mode, content return err } +// CopyFile streams one regular host file into a guest without loading the +// complete payload into memory. The provider command installs through a +// temporary file and removes it on both success and failure. +func CopyFile(ctx context.Context, p Provider, vmName, source, destination, mode string) error { + file, err := os.Open(source) + if err != nil { + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("copy source %s must be a regular file", source) + } + tmp := "/tmp/epar-copy" + cmd := []string{"bash", "-lc", fmt.Sprintf("trap 'rm -f %s' EXIT; cat > %s && if command -v sudo >/dev/null 2>&1; then sudo install -m %s %s %s; else install -m %s %s %s; fi", shellQuote(tmp), shellQuote(tmp), shellQuote(mode), shellQuote(tmp), shellQuote(destination), shellQuote(mode), shellQuote(tmp), shellQuote(destination))} + _, err = p.Exec(ctx, vmName, cmd, ExecOptions{StdinReader: file}) + return err +} + func ShellCommand(script string) []string { return []string{"bash", "-lc", script} } diff --git a/internal/provider/tart/tart.go b/internal/provider/tart/tart.go index ae73c26..bc3bb6d 100644 --- a/internal/provider/tart/tart.go +++ b/internal/provider/tart/tart.go @@ -69,12 +69,16 @@ func (p *Provider) Start(ctx context.Context, name string, opts provider.StartOp func (p *Provider) Exec(ctx context.Context, name string, command []string, opts provider.ExecOptions) (provider.ExecResult, error) { full := []string{"exec"} - if opts.Stdin != "" { + if opts.Stdin != "" || opts.StdinReader != nil { full = append(full, "-i") } full = append(full, name) full = append(full, provider.EnvCommand(opts.Env, command)...) - return p.runWithSensitiveLog(ctx, strings.NewReader(opts.Stdin), opts.Stdout, opts.Stderr, opts.SensitiveValues, full...) + stdin := opts.StdinReader + if stdin == nil && opts.Stdin != "" { + stdin = strings.NewReader(opts.Stdin) + } + return p.runWithSensitiveLog(ctx, stdin, opts.Stdout, opts.Stderr, opts.SensitiveValues, full...) } func (p *Provider) IP(ctx context.Context, name string, waitSeconds int) (string, error) { diff --git a/internal/provider/wsl/wsl.go b/internal/provider/wsl/wsl.go index fa9353d..da7d16a 100644 --- a/internal/provider/wsl/wsl.go +++ b/internal/provider/wsl/wsl.go @@ -101,7 +101,9 @@ func (p *Provider) Start(ctx context.Context, name string, opts provider.StartOp func (p *Provider) Exec(ctx context.Context, name string, command []string, opts provider.ExecOptions) (provider.ExecResult, error) { var stdin io.Reader - if opts.Stdin != "" { + if opts.StdinReader != nil { + stdin = opts.StdinReader + } else if opts.Stdin != "" { stdin = strings.NewReader(opts.Stdin) } return p.runWithSensitiveLog(ctx, stdin, opts.LogPath, opts.Stdout, opts.Stderr, opts.SensitiveValues, p.execArgs(name, command, opts.Env)...) diff --git a/scripts/ci/core-runner-controller.sh b/scripts/ci/core-runner-controller.sh index 7c8161c..75b0365 100644 --- a/scripts/ci/core-runner-controller.sh +++ b/scripts/ci/core-runner-controller.sh @@ -248,6 +248,8 @@ image: upstreamDir: third_party/runner-images upstreamLock: third_party/runner-images.lock runnerVersion: latest + updateFrequency: weekly + updateTime: "07:00" customInstallScripts: EOF if [[ -n "${EPAR_TRUSTED_CA_CERTIFICATE_PATH:-}" ]]; then diff --git a/scripts/docker-sandboxes/validate-assets.ps1 b/scripts/docker-sandboxes/validate-assets.ps1 index f795853..181fd02 100644 --- a/scripts/docker-sandboxes/validate-assets.ps1 +++ b/scripts/docker-sandboxes/validate-assets.ps1 @@ -61,7 +61,6 @@ Assert-Equal 'SBOM generator index' $lock.sbomGenerator.indexDigest 'sha256:79e7 Assert-Equal 'Go builder version' $lock.goBuilder.version '1.25.12' Assert-Equal 'Go builder index' $lock.goBuilder.indexDigest 'sha256:9006890ecba0a168034d99516084099ae3114d9f2b7d6572c77f2dde57ebc980' Assert-Equal 'hook launcher source checksum' $lock.hookLauncher.sha256 '7fe07f10f484fa6888481a4165e81570187c0aeff422738d3ea5add6b95dd9b7' -Assert-Equal 'Actions runner version' $lock.actionsRunner.version '2.332.0' Assert-Equal 'Tini version' $lock.tini.version '0.19.0' $expectedPlatforms = [ordered]@{ 'linux/amd64' = [ordered]@{ @@ -69,8 +68,6 @@ $expectedPlatforms = [ordered]@{ frontendManifest = 'sha256:b5f3b260a9678e1d83d2fce86eeddf79420b79147eaba2a25986f47133d73720' goBuilderManifest = 'sha256:12e171e33ce7ade87ac8ab2bbe65cea9371527285bdab43ca02780a9e6ac60e5' sbomManifest = 'sha256:13864237fb990943433f89d698590aad1de38d4a7e13d38e7b12f2488c1952e7' - runnerUrl = 'https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-x64-2.332.0.tar.gz' - runnerSha256 = 'f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a' tiniUrl = 'https://github.com/krallin/tini/releases/download/v0.19.0/tini-amd64' tiniSha256 = '93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c' } @@ -79,8 +76,6 @@ $expectedPlatforms = [ordered]@{ frontendManifest = 'sha256:c8678869a83fab70232869ba24acc1c0be661f4d65135c0eeacb6a8e78420fdd' goBuilderManifest = 'sha256:afe53a4752b49f57ddebc97501a99394e2f7715236b4241efa830d54efb44434' sbomManifest = 'sha256:860305b3d1667c35142f11f6e9485e322c1c6173702a0831dc68739a34847f2d' - runnerUrl = 'https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-arm64-2.332.0.tar.gz' - runnerSha256 = 'b72f0599cdbd99dd9513ab64fcb59e424fc7359c93b849e8f5efdd5a72f743a6' tiniUrl = 'https://github.com/krallin/tini/releases/download/v0.19.0/tini-arm64' tiniSha256 = '07952557df20bfd2a95f9bef198b445e006171969499a1d361bd9e6f8e5e0e81' } @@ -94,8 +89,6 @@ foreach ($platformName in $expectedPlatforms.Keys) { Assert-Equal "$platformName Go builder reference" $platformRecord.goBuilderReference ("docker.io/library/golang@{0}" -f $expectedPlatform.goBuilderManifest) Assert-Equal "$platformName SBOM generator manifest" $platformRecord.sbomGeneratorManifestDigest $expectedPlatform.sbomManifest Assert-Equal "$platformName SBOM generator reference" $platformRecord.sbomGeneratorReference ("docker.io/docker/buildkit-syft-scanner@{0}" -f $expectedPlatform.sbomManifest) - Assert-Equal "$platformName Actions runner URL" $platformRecord.actionsRunner.url $expectedPlatform.runnerUrl - Assert-Equal "$platformName Actions runner checksum" $platformRecord.actionsRunner.sha256 $expectedPlatform.runnerSha256 Assert-Equal "$platformName Tini URL" $platformRecord.tini.url $expectedPlatform.tiniUrl Assert-Equal "$platformName Tini checksum" $platformRecord.tini.sha256 $expectedPlatform.tiniSha256 } @@ -188,7 +181,10 @@ foreach ($required in @( 'com.docker.sandboxes.start-docker=true', 'USER agent', 'ENTRYPOINT ["/usr/local/bin/tini", "-g", "--", "/opt/epar/template-entrypoint.sh"]', - 'sha256:f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a', + 'ARG ACTIONS_RUNNER_VERSION', + 'ARG ACTIONS_RUNNER_SHA256', + 'COPY inputs/actions-runner.tar.gz /tmp/actions-runner.tar.gz', + 'echo "${ACTIONS_RUNNER_SHA256#sha256:} /tmp/actions-runner.tar.gz" | sha256sum --check -', 'sha256:93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c' )) { if (-not $dockerfile.Contains($required)) { @@ -300,7 +296,7 @@ if ($DockerfileCheck) { foreach ($profileName in $expectedProfiles.Keys) { $profile = $lock.profiles.PSObject.Properties[$profileName].Value $profilePlatform = $profile.platforms.PSObject.Properties[$Platform].Value - & docker buildx build --builder $Builder --call check --platform $Platform --build-arg ("TEMPLATE_PLATFORM={0}" -f $Platform) --build-arg ("SOURCE_IMAGE={0}" -f $profile.immutableReference) --build-arg ("GO_BUILDER_IMAGE={0}" -f $platformLock.goBuilderReference) --build-arg ("HOOK_LAUNCHER_SHA256={0}" -f $lock.hookLauncher.sha256) --build-arg ("SOURCE_PROFILE={0}" -f $profileName) --build-arg ("SOURCE_INDEX_DIGEST={0}" -f $profile.indexDigest) --build-arg ("SOURCE_MANIFEST_DIGEST={0}" -f $profilePlatform.manifestDigest) --build-arg ("SOURCE_REVISION={0}" -f $profile.sourceRevision) --build-arg ("TEMPLATE_VERSION={0}" -f (($profilePlatform.templateTag -split ':', 2)[1])) --build-arg ("COMPATIBILITY_FILE={0}" -f $profilePlatform.compatibilityFile) --build-arg ("ACTIONS_RUNNER_URL={0}" -f $platformLock.actionsRunner.url) --build-arg ("ACTIONS_RUNNER_SHA256=sha256:{0}" -f $platformLock.actionsRunner.sha256) --build-arg ("TINI_URL={0}" -f $platformLock.tini.url) --build-arg ("TINI_SHA256=sha256:{0}" -f $platformLock.tini.sha256) --file $dockerfilePath $templateDirectory + & docker buildx build --builder $Builder --call check --platform $Platform --build-arg ("TEMPLATE_PLATFORM={0}" -f $Platform) --build-arg ("SOURCE_IMAGE={0}" -f $profile.immutableReference) --build-arg ("GO_BUILDER_IMAGE={0}" -f $platformLock.goBuilderReference) --build-arg ("HOOK_LAUNCHER_SHA256={0}" -f $lock.hookLauncher.sha256) --build-arg ("SOURCE_PROFILE={0}" -f $profileName) --build-arg ("SOURCE_INDEX_DIGEST={0}" -f $profile.indexDigest) --build-arg ("SOURCE_MANIFEST_DIGEST={0}" -f $profilePlatform.manifestDigest) --build-arg ("SOURCE_REVISION={0}" -f $profile.sourceRevision) --build-arg ("TEMPLATE_VERSION={0}" -f (($profilePlatform.templateTag -split ':', 2)[1])) --build-arg ("COMPATIBILITY_FILE={0}" -f $profilePlatform.compatibilityFile) --build-arg 'ACTIONS_RUNNER_VERSION=0.0.0' --build-arg ('ACTIONS_RUNNER_SHA256=sha256:' + ('0' * 64)) --build-arg ("TINI_SHA256=sha256:{0}" -f $platformLock.tini.sha256) --file $dockerfilePath $templateDirectory if ($LASTEXITCODE -ne 0) { throw "Dockerfile frontend check failed for $profileName" } diff --git a/scripts/guest/ubuntu/install-runner.sh b/scripts/guest/ubuntu/install-runner.sh index de40c66..a6e1d71 100755 --- a/scripts/guest/ubuntu/install-runner.sh +++ b/scripts/guest/ubuntu/install-runner.sh @@ -1,7 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -RUNNER_VERSION="${1:-latest}" +RUNNER_VERSION="${1:-}" +RUNNER_PACKAGE="${2:-}" +RUNNER_SHA256="${3:-}" +if [[ -z "${RUNNER_VERSION}" || -z "${RUNNER_PACKAGE}" || ! -f "${RUNNER_PACKAGE}" || ! "${RUNNER_SHA256}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Usage: install-runner.sh " >&2 + exit 1 +fi ARCH="$(uname -m)" case "${ARCH}" in aarch64|arm64) RUNNER_ARCH="arm64" ;; @@ -14,22 +20,24 @@ export NEEDRESTART_MODE=l export NEEDRESTART_SUSPEND=1 bash /opt/epar/wait-apt-ready.sh apt-get update -apt-get install -y --no-install-recommends ca-certificates curl jq sudo tar - -if [[ "${RUNNER_VERSION}" == "latest" ]]; then - RUNNER_VERSION="$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed 's/^v//')" -fi +apt-get install -y --no-install-recommends ca-certificates sudo tar id -u runner >/dev/null 2>&1 || useradd --create-home --shell /bin/bash runner usermod -aG docker runner 2>/dev/null || true install -d -o runner -g runner /opt/actions-runner cd /opt/actions-runner -RUNNER_TGZ="actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz" -curl -fL "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/${RUNNER_TGZ}" -o "/tmp/${RUNNER_TGZ}" -tar xzf "/tmp/${RUNNER_TGZ}" +echo "${RUNNER_SHA256#sha256:} ${RUNNER_PACKAGE}" | sha256sum --check - +tar xzf "${RUNNER_PACKAGE}" +rm -f "${RUNNER_PACKAGE}" chown -R runner:runner /opt/actions-runner +INSTALLED_RUNNER_VERSION="$(./bin/Runner.Listener --version | tr -d '\r' | tail -n 1)" +if [[ "${INSTALLED_RUNNER_VERSION}" != "${RUNNER_VERSION}" ]]; then + echo "Actions runner package version ${INSTALLED_RUNNER_VERSION:-} does not match expected version ${RUNNER_VERSION}" >&2 + exit 1 +fi + ./bin/installdependencies.sh install -d /var/log/actions-runner diff --git a/scripts/host-trust/wrapper-lib.ps1 b/scripts/host-trust/wrapper-lib.ps1 index a75a617..eaeb254 100644 --- a/scripts/host-trust/wrapper-lib.ps1 +++ b/scripts/host-trust/wrapper-lib.ps1 @@ -98,7 +98,7 @@ function Start-EparHostTrustBridge { } $subcommand = if ($Arguments -and $Arguments.Count -gt 1) { [string]$Arguments[1] } else { "" } $needsBridge = $Command -eq "start" -or - ($Command -eq "image" -and $subcommand -eq "build") -or + ($Command -eq "image" -and $subcommand -in @("build", "update")) -or ($Command -eq "pool" -and $subcommand -in @("up", "verify")) if (-not $needsBridge) { return [pscustomobject]@{ FeedDir = $null; BuildFeedDir = $null; RunnerFeedDir = $null; WatchProcess = $null; WatchProcesses = @(); Config = $config; PostInit = $false } diff --git a/scripts/host-trust/wrapper-lib.sh b/scripts/host-trust/wrapper-lib.sh index a20b47d..1585b14 100644 --- a/scripts/host-trust/wrapper-lib.sh +++ b/scripts/host-trust/wrapper-lib.sh @@ -91,7 +91,7 @@ epar_host_trust_prepare() { return 0 ;; start) ;; - image) [[ "$subcommand" == build ]] || return 0 ;; + image) [[ "$subcommand" == build || "$subcommand" == update ]] || return 0 ;; pool) [[ "$subcommand" == up || "$subcommand" == verify ]] || return 0 ;; *) return 0 ;; esac diff --git a/scripts/test/start-command-forwarding.sh b/scripts/test/start-command-forwarding.sh index 47be385..d92c96e 100644 --- a/scripts/test/start-command-forwarding.sh +++ b/scripts/test/start-command-forwarding.sh @@ -22,6 +22,8 @@ export PATH="$test_root:$PATH" export EPAR_GO_BIN=go export EPAR_USE_DOCKER_RUN=0 export EPAR_START_FORWARD_LOG="$test_root/arguments" +export EPAR_CONFIG="$test_root/config.yml" +printf 'image:\n hostTrustMode: disabled\n' >"$EPAR_CONFIG" "$repo_root/start" actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" @@ -47,6 +49,14 @@ if [[ "$actual" != "$expected" ]]; then exit 1 fi +"$repo_root/start" image update --config .local/custom-config.yml +actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" +expected="run ./cmd/ephemeral-action-runner image update --config .local/custom-config.yml " +if [[ "$actual" != "$expected" ]]; then + echo "image update forwarding mismatch: got '$actual', want '$expected'" >&2 + exit 1 +fi + "$repo_root/start" version actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" expected="run ./cmd/ephemeral-action-runner version " diff --git a/templates/docker-sandboxes/Dockerfile b/templates/docker-sandboxes/Dockerfile index 5e1dd2c..e5f0b98 100644 --- a/templates/docker-sandboxes/Dockerfile +++ b/templates/docker-sandboxes/Dockerfile @@ -28,7 +28,8 @@ ARG SOURCE_MANIFEST_DIGEST=sha256:f3d493b10df1582ce631e0213bd90aa5f8196287c8a9f8 ARG SOURCE_REVISION=e2f8efe464c82732f78e967ee709c00b6af53643 ARG TEMPLATE_VERSION=20260723-r4-amd64 ARG COMPATIBILITY_FILE=act-22.04.amd64.compatibility.json -ARG ACTIONS_RUNNER_SHA256=sha256:f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a +ARG ACTIONS_RUNNER_VERSION +ARG ACTIONS_RUNNER_SHA256 ARG TINI_SHA256=sha256:93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c USER root @@ -59,10 +60,11 @@ RUN [[ "${TARGETPLATFORM}" == "${TEMPLATE_PLATFORM}" ]] \ && install -d -m 0755 -o agent -g agent /opt/actions-runner /opt/actions-runner/_work /opt/actions-runner/_work/_tool /var/log/actions-runner \ && tar -xzf /tmp/actions-runner.tar.gz -C /opt/actions-runner \ && rm -f /tmp/actions-runner.tar.gz \ + && printf '%s\n' "${ACTIONS_RUNNER_VERSION}" > /opt/epar/actions-runner-version \ && chown -R agent:agent /opt/actions-runner /var/log/actions-runner \ && chmod 0555 /opt/epar/custom-install/run.sh \ && /opt/epar/custom-install/run.sh \ - && sudo -u agent -H /opt/actions-runner/bin/Runner.Listener --version | grep -Fx '2.332.0' \ + && sudo -u agent -H /opt/actions-runner/bin/Runner.Listener --version | grep -Fx "${ACTIONS_RUNNER_VERSION}" \ && /usr/local/bin/tini --version 2>&1 | grep -F 'version 0.19.0' ENV HOME=/home/agent \ @@ -83,7 +85,7 @@ LABEL com.docker.sandboxes.start-docker=true \ io.solutionforest.epar.template.expected-docker-daemon-count="1" \ io.solutionforest.epar.template.profile="${SOURCE_PROFILE}" \ io.solutionforest.epar.template.platform="${TEMPLATE_PLATFORM}" \ - io.solutionforest.epar.template.runner-version="2.332.0" \ + io.solutionforest.epar.template.runner-version="${ACTIONS_RUNNER_VERSION}" \ io.solutionforest.epar.template.source-index-digest="${SOURCE_INDEX_DIGEST}" \ io.solutionforest.epar.template.source-manifest-digest="${SOURCE_MANIFEST_DIGEST}" \ org.opencontainers.image.base.name="${SOURCE_IMAGE}" \ diff --git a/templates/docker-sandboxes/guest/verify-template.sh b/templates/docker-sandboxes/guest/verify-template.sh index 1b069e2..cebcada 100644 --- a/templates/docker-sandboxes/guest/verify-template.sh +++ b/templates/docker-sandboxes/guest/verify-template.sh @@ -16,7 +16,8 @@ docker info >/dev/null [[ -x /opt/epar/hook-bin/bash ]] [[ "$(PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin command -v bash)" == "/opt/epar/hook-bin/bash" ]] [[ -x /usr/bin/python3 ]] -[[ "$(sudo -u agent -H /opt/actions-runner/bin/Runner.Listener --version)" == "2.332.0" ]] +[[ -s /opt/epar/actions-runner-version ]] +[[ "$(sudo -u agent -H /opt/actions-runner/bin/Runner.Listener --version)" == "$(cat /opt/epar/actions-runner-version)" ]] case "${EPAR_TEMPLATE_PLATFORM}" in linux/amd64) [[ "$(uname -m)" == "x86_64" ]] diff --git a/templates/docker-sandboxes/helpers.sha256 b/templates/docker-sandboxes/helpers.sha256 index 4ed1128..ef99b03 100644 --- a/templates/docker-sandboxes/helpers.sha256 +++ b/templates/docker-sandboxes/helpers.sha256 @@ -7,4 +7,4 @@ a32f6bdb3b883272172e2d7318feb40d88e3ebfaffa909527ca57a3c06773ae1 ./prepare-template.sh 647fd19d18e608cb794f2b08be3019bd8e6243009aea8b9308703a465f0089a3 ./run-runner.sh 0746da2abc0a1033ecdad83ec6e432c7a006c83fefd2ce5424d3932c8f38d9aa ./template-entrypoint.sh -d1e3b6808118e4c04bfff8db2b42228cb7a376910874b4a1fb486eaaa55d5d5f ./verify-template.sh +f50c9cf84021b8c29cbee19e6e78c6bc07101b3aad812a25fb6e3ca02a409ca0 ./verify-template.sh diff --git a/templates/docker-sandboxes/sources.lock.json b/templates/docker-sandboxes/sources.lock.json index ea7ad16..ffd2ad9 100644 --- a/templates/docker-sandboxes/sources.lock.json +++ b/templates/docker-sandboxes/sources.lock.json @@ -19,9 +19,6 @@ "hookLauncher": { "sha256": "7fe07f10f484fa6888481a4165e81570187c0aeff422738d3ea5add6b95dd9b7" }, - "actionsRunner": { - "version": "2.332.0" - }, "tini": { "version": "0.19.0" }, @@ -33,10 +30,6 @@ "goBuilderReference": "docker.io/library/golang@sha256:12e171e33ce7ade87ac8ab2bbe65cea9371527285bdab43ca02780a9e6ac60e5", "sbomGeneratorManifestDigest": "sha256:13864237fb990943433f89d698590aad1de38d4a7e13d38e7b12f2488c1952e7", "sbomGeneratorReference": "docker.io/docker/buildkit-syft-scanner@sha256:13864237fb990943433f89d698590aad1de38d4a7e13d38e7b12f2488c1952e7", - "actionsRunner": { - "url": "https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-x64-2.332.0.tar.gz", - "sha256": "f2094522a6b9afeab07ffb586d1eb3f190b6457074282796c497ce7dce9e0f2a" - }, "tini": { "url": "https://github.com/krallin/tini/releases/download/v0.19.0/tini-amd64", "sha256": "93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c" @@ -49,10 +42,6 @@ "goBuilderReference": "docker.io/library/golang@sha256:afe53a4752b49f57ddebc97501a99394e2f7715236b4241efa830d54efb44434", "sbomGeneratorManifestDigest": "sha256:860305b3d1667c35142f11f6e9485e322c1c6173702a0831dc68739a34847f2d", "sbomGeneratorReference": "docker.io/docker/buildkit-syft-scanner@sha256:860305b3d1667c35142f11f6e9485e322c1c6173702a0831dc68739a34847f2d", - "actionsRunner": { - "url": "https://github.com/actions/runner/releases/download/v2.332.0/actions-runner-linux-arm64-2.332.0.tar.gz", - "sha256": "b72f0599cdbd99dd9513ab64fcb59e424fc7359c93b849e8f5efdd5a72f743a6" - }, "tini": { "url": "https://github.com/krallin/tini/releases/download/v0.19.0/tini-arm64", "sha256": "07952557df20bfd2a95f9bef198b445e006171969499a1d361bd9e6f8e5e0e81" From 5707d0dcd53bd2fe86302905b27ef215726f7949 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 31 Jul 2026 02:13:51 +0800 Subject: [PATCH 10/22] docs: simplify Docker prerequisites --- README.md | 5 +++-- docs/advanced/docker-registry-mirrors.md | 6 +++--- docs/advanced/macos-startup.md | 2 +- docs/advanced/no-go-install.md | 4 ++-- docs/advanced/windows-startup.md | 4 ++-- docs/providers/docker-container.md | 4 ++-- docs/providers/docker-sandboxes.md | 2 +- docs/providers/wsl.md | 4 ++-- docs/troubleshooting.md | 8 ++++---- docs/usage.md | 6 ++++-- internal/image/build.go | 2 +- scripts/build-native-controller.ps1 | 2 +- scripts/build-native-controller.sh | 2 +- scripts/run-with-docker.ps1 | 2 +- scripts/run-with-docker.sh | 2 +- 15 files changed, 29 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 80ae9c5..02655d1 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ The normal path is a source archive plus Docker. EPAR's first run opens a guided ### 1. Install the host tools -Install a Docker-compatible daemon: [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Windows or macOS, [OrbStack](https://orbstack.dev/) on macOS, or [Docker Engine](https://docs.docker.com/engine/) on Linux. Docker Sandboxes also needs the `sbx` CLI to pass its diagnostics; the wizard provisions its EPAR runner template. See [Docker Sandboxes](docs/providers/docker-sandboxes.md). +- Install and start Docker. +- For stronger isolation, also install [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) to enable the Docker Sandboxes provider. ### 2. Download EPAR @@ -67,7 +68,7 @@ Use labels that describe the environment your job actually needs. In particular, ## Trusted jobs only -EPAR reduces stale state after a job; it is not a hostile-code sandbox. Use it only for workflows and repositories you trust, restrict access with [runner groups](docs/runner-groups.md), and do not expose it to unreviewed public or fork pull-request code. Read [Security](docs/security.md) before choosing a provider. +Use it only for workflows and repositories you trust, restrict access with [runner groups](docs/runner-groups.md), and do not expose it to unreviewed public or fork pull-request code. Read [Security](docs/security.md) before choosing a provider. ## Find the right guide diff --git a/docs/advanced/docker-registry-mirrors.md b/docs/advanced/docker-registry-mirrors.md index bfd8913..ed01828 100644 --- a/docs/advanced/docker-registry-mirrors.md +++ b/docs/advanced/docker-registry-mirrors.md @@ -22,7 +22,7 @@ Before enabling this config, provide one of these: - a mirror service running on another machine in the same LAN or intranet; - a managed registry cache, such as a cloud registry pull-through cache. -For a local mirror on the EPAR host, Docker Engine, Docker Desktop, or OrbStack is enough to run the mirror container. No extra EPAR package is required. +For a local mirror on the EPAR host, Docker is enough to run the mirror container. No extra EPAR package is required. For an intranet mirror, runners should use the mirror's LAN DNS name or IP address. This is often better for multiple office machines because all EPAR hosts can share one warm cache. @@ -62,7 +62,7 @@ If the mirror is not running or is not reachable from the runner, Docker falls b ## Local Docker Hub Cache -For local development, a Docker Hub pull-through cache can run on the same host as EPAR. Docker, Docker Desktop, or OrbStack is enough to run the mirror container; no extra EPAR dependency is required. +For local development, a Docker Hub pull-through cache can run on the same host as EPAR. Docker is enough to run the mirror container; no extra EPAR dependency is required. For a quick public-image cache: @@ -107,7 +107,7 @@ docker: - http://host.docker.internal:5050 ``` -For Docker Container, EPAR adds Docker's `host.docker.internal:host-gateway` alias when any configured mirror uses `host.docker.internal`. On macOS Docker Desktop and OrbStack this name is usually already available; on Linux Docker Engine the alias helps runner containers reach a host-published mirror. +For Docker Container, EPAR adds Docker's `host.docker.internal:host-gateway` alias when any configured mirror uses `host.docker.internal`. Some host runtimes already provide this name; others support Docker's `host-gateway` token and can use the added alias. If the host runtime supports neither behavior, use a LAN address or DNS name reachable from the runner instead. For Tart and WSL, `host.docker.internal` may not resolve the way it does in Docker containers. Use a LAN address, DNS name, or other route that is reachable from the guest. diff --git a/docs/advanced/macos-startup.md b/docs/advanced/macos-startup.md index 68eb07d..5764b97 100644 --- a/docs/advanced/macos-startup.md +++ b/docs/advanced/macos-startup.md @@ -124,6 +124,6 @@ launchctl bootout "gui/$(id -u)" ~/Library/LaunchAgents/com.example.epar.plist - `start` cleans up prefixed instances when it exits. Use `--keep-on-exit` only for debugging. - The first run can take a while because `start` may build or refresh the configured image before starting runners. -- If Docker Desktop, OrbStack, or Docker Engine cannot start, the script exits before EPAR starts. +- If Docker cannot start, the script exits before EPAR starts. - For Docker Container, the host Docker runtime must support privileged containers. - For Tart-only pools that do not use host Docker or a local registry mirror, disable the Docker wait in your local copy. diff --git a/docs/advanced/no-go-install.md b/docs/advanced/no-go-install.md index a0cbee3..a8f79d6 100644 --- a/docs/advanced/no-go-install.md +++ b/docs/advanced/no-go-install.md @@ -49,10 +49,10 @@ The compiler container mounts the source read-only and writes only the temporary ### Windows: WSL versus native PowerShell -- From WSL2 or Git Bash, use `./start`. It behaves like the Linux case and needs Docker Desktop's WSL2 integration when run from WSL2. +- From WSL2 or Git Bash, use `./start`. It behaves like the Linux case and needs Docker to be available and working in that environment. - From native PowerShell or cmd, use `./start.ps1` or `start.cmd`, which use `scripts/run-with-docker.ps1` instead of the Bash script. -`start.ps1` and `scripts/run-with-docker.ps1` are less exercised than the Bash/macOS path. If you hit an issue, check whether Docker Desktop file sharing is enabled for the drive that holds the source folder. +`start.ps1` and `scripts/run-with-docker.ps1` are less exercised than the Bash/macOS path. If you hit an issue, check whether the host runtime can bind-mount the drive that holds the source folder. ## macOS Login Item Startup diff --git a/docs/advanced/windows-startup.md b/docs/advanced/windows-startup.md index 976072f..acb3705 100644 --- a/docs/advanced/windows-startup.md +++ b/docs/advanced/windows-startup.md @@ -47,13 +47,13 @@ Create a user logon task: 1. Open **Task Scheduler**. 2. Choose **Create Task**. -3. On **Triggers**, add **At log on**. Add a short delay if Docker Desktop or another Docker daemon needs time to start. +3. On **Triggers**, add **At log on**. Add a short delay if Docker needs time to start. 4. On **Actions**, choose **Start a program**. 5. Set **Program/script** to `D:\path\to\ephemeral-action-runner\bin\ephemeral-action-runner.exe`. 6. Set **Add arguments** to `start --config .local\config.yml`. 7. Set **Start in** to `D:\path\to\ephemeral-action-runner`. -For Docker Desktop, keep the task as a user logon task. Docker Desktop is usually tied to the user session, so a boot-time system task may start too early or without the expected Docker context. +If the host runtime is tied to the user session, keep the task as a user logon task. A boot-time system task may start too early or without the expected Docker context. PowerShell equivalent: diff --git a/docs/providers/docker-container.md b/docs/providers/docker-container.md index 3b58bd2..9d30ec4 100644 --- a/docs/providers/docker-container.md +++ b/docs/providers/docker-container.md @@ -12,7 +12,7 @@ This is a supported provider on hosts whose Docker runtime can run privileged Li ## Prerequisites -- A working Docker-compatible daemon that permits `docker run --privileged`. +- Docker installed and running with support for `docker run --privileged`. - Enough host Docker storage for the reusable image and the requested disposable runners. - A GitHub App and runner group configured as described in [Runner Group Security](../runner-groups.md). @@ -50,7 +50,7 @@ The outer container has no host Docker socket mount and does not publish host po - The inner Docker daemon is private, but its CPU, memory, and disk use still comes from the host. - The runner's inner image cache disappears with the instance. -- Docker Desktop, OrbStack, and Linux Docker Engine can differ in privileged-container and foreign-architecture behavior. +- Docker-compatible host runtimes can differ in privileged-container and foreign-architecture behavior; verify both on the host you intend to use. - Registry mirrors and proxy services are external infrastructure; EPAR configures the runner daemon but does not operate or secure those services. ## Verification diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index 9d28541..992eedf 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -33,7 +33,7 @@ EPAR selects this provider by capability, not by an operating-system allowlist: ## Prerequisites -- A working Docker CLI and daemon. +- Docker installed and running. - Docker Sandboxes CLI whose `sbx diagnose --output json` result reports at least one passing check and no failed checks. Before the first-run provider assessment, the wizard runs `sbx daemon start --detach` when the `sbx` executable is installed, then runs diagnostics. Warnings and skipped checks do not make the provider unavailable. - A native `amd64` or `arm64` controller with matching `linux/amd64` or `linux/arm64` image support. EPAR does not use emulation to admit a mismatched template. - Enough capacity to resolve, build, export, import, and retain the selected runner template. diff --git a/docs/providers/wsl.md b/docs/providers/wsl.md index 67e34c1..d067c85 100644 --- a/docs/providers/wsl.md +++ b/docs/providers/wsl.md @@ -13,7 +13,7 @@ WSL2 is supported only on native Windows with WSL default version 2. It is not e ## Prerequisites - Native Windows, `wsl.exe --status` reporting default version 2, and an Ubuntu-compatible WSL environment. -- A working Docker daemon for the default Catthehacker Docker-image source during `image build`; later runner startup does not require Docker Desktop unless jobs use Docker. +- Docker installed and running for the default Catthehacker Docker-image source during `image build`; later runner startup does not require Docker on the host. - Enough storage for the pulled source image, intermediate rootfs tar, temporary WSL build distro, reusable tar, and active pool. ## Minimal Configuration @@ -47,7 +47,7 @@ EPAR imports each runner from `provider.sourceImage`, enables systemd in the reu ## Limitations -- The default full image needs Docker only for source conversion; an image build can fail when Docker Desktop/Engine storage is full even if Windows has free disk space. +- The default full image needs Docker only for source conversion; an image build can fail when the host daemon's storage is full even if Windows has free disk space. - EPAR does not install cross-architecture emulation. An x64 WSL runner can pull an ARM64 image but cannot execute it natively. - The default Docker-enabled runner uses Docker Engine inside WSL, not a mounted Windows Docker socket. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 769c508..5d71fb9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -34,7 +34,7 @@ docker info docker system df ``` -Without local Go, use `./start --help`; the wrapper selects the containerized toolchain. For Windows WSL2, Docker Desktop's WSL2 backend, or the WSL provider, also run: +Without local Go, use `./start --help`; the wrapper selects the containerized toolchain. For Windows WSL2, a WSL-backed Docker daemon, or the WSL provider, also run: ```powershell wsl --version @@ -43,7 +43,7 @@ docker context ls docker run --rm ghcr.io/catthehacker/ubuntu:full-latest df -h / ``` -Container-visible free space is the relevant value for Docker builds. Windows Explorer or Finder free space does not necessarily equal the free space in Docker Desktop, OrbStack, or another Linux VM backing the daemon. +Container-visible free space is the relevant value for Docker builds. Windows Explorer or Finder free space does not necessarily equal the free space in a Linux VM backing the daemon. ## Windows no-Go startup prints an HTTP/2 named-pipe diagnostic @@ -207,7 +207,7 @@ Get-Content .local/storage/buildx.json Get-Content .local/storage/buildkitd.toml ``` -The owned metadata records the exact registry set, configuration digest, certificate bundle, and trust generation. Rerunning the same command reconciles that exact builder and preserves its BuildKit state; EPAR never changes Docker's shared/default builder. If the source-image `docker pull` itself fails before Buildx starts, configure the authorized CA in Docker Desktop, OrbStack, or Docker Engine because builder trust cannot repair host-daemon trust. +The owned metadata records the exact registry set, configuration digest, certificate bundle, and trust generation. Rerunning the same command reconciles that exact builder and preserves its BuildKit state; EPAR never changes Docker's shared/default builder. If the source-image `docker pull` itself fails before Buildx starts, configure the authorized CA in the host daemon because builder trust cannot repair host-daemon trust. Configure runner overlay only when jobs inside an ephemeral runner must inherit host roots: @@ -274,7 +274,7 @@ docker pull ghcr.io/catthehacker/ubuntu:full-latest wsl -l -v ``` -For `Wsl/Service/CreateInstance/E_UNEXPECTED`, `Catastrophic failure`, or import exit `0xffffffff`, stop EPAR, save work in other distros, then run `wsl --shutdown`. This stops every running WSL distro, including Docker Desktop's backend. Restart Docker Desktop, verify a normal distro command returns `0`, then rerun `./start`; a matching cached source rootfs is reused. If it persists, update WSL, shut it down again, reboot, and consult [Microsoft's WSL troubleshooting guidance](https://learn.microsoft.com/windows/wsl/troubleshooting#error-code-0x8000ffff-unexpected-failure). +For `Wsl/Service/CreateInstance/E_UNEXPECTED`, `Catastrophic failure`, or import exit `0xffffffff`, stop EPAR, save work in other distros, then run `wsl --shutdown`. This stops every running WSL distro, including any Docker backend using WSL. Restart the affected Docker host runtime, verify a normal distro command returns `0`, then rerun `./start`; a matching cached source rootfs is reused. If it persists, update WSL, shut it down again, reboot, and consult [Microsoft's WSL troubleshooting guidance](https://learn.microsoft.com/windows/wsl/troubleshooting#error-code-0x8000ffff-unexpected-failure). If a guest exists but systemd does not become ready, inspect `work/logs/builds/.wsl-build.log` and `work/logs/builds/.guest.log`. Do not unregister a distro until you have identified the exact EPAR-owned target and accepted that unregistration is irreversible. diff --git a/docs/usage.md b/docs/usage.md index d6e7116..5feda9d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -8,13 +8,15 @@ Use this page for normal EPAR tasks. Start with the host and provider you alread | --- | --- | | Run a source archive | Go 1.25 or newer, or Docker for the no-Go controller builder | | Register or inspect GitHub runners | A GitHub App with organization self-hosted runner read/write permission | -| Docker Container | Docker Engine, Docker Desktop, or OrbStack with privileged Linux-container support | +| Docker Container | Docker with privileged Linux-container support | | Docker Sandboxes | Docker and the `sbx` CLI with at least one diagnostic pass and zero failures; the wizard builds and imports the selected EPAR template | | WSL | Native Windows, WSL2, and Docker when preparing the default WSL image | | Tart | Native Apple Silicon macOS and Tart | Get the source from the [EPAR releases page](https://github.com/solutionforest/ephemeral-action-runner/releases), extract the source archive, and work from that folder. You do not need Packer, GitHub CLI, or `sshpass`. +EPAR works with any Docker installation that supports the selected provider. + ## Start a pool On macOS, Linux, WSL, or Git Bash, run: @@ -35,7 +37,7 @@ The wrapper uses local Go when available, otherwise it uses Docker to build and go run ./cmd/ephemeral-action-runner start ``` -When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group, shows every provider with its tooling and daemon prerequisite result, and refuses unavailable selections. Storage does not make a provider unavailable. Docker Container, Docker Sandboxes, and WSL share one Catthehacker image/profile and custom-script flow, followed by an informational physical-growth estimate and confirmation. The wizard writes the desired configuration first; direct `init` then exits, while embedded `./start` continues through the ordinary image/template provisioning and pool startup path. When `sbx` is installed, the wizard runs `sbx daemon start --detach` before Docker Sandboxes diagnostics so a stopped daemon does not require a manual retry. +When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group, shows every provider with its prerequisite status, and refuses unavailable selections. Storage does not make a provider unavailable. Docker Container, Docker Sandboxes, and WSL share one Catthehacker image/profile and custom-script flow, followed by an informational physical-growth estimate and confirmation. The wizard writes the desired configuration first; direct `init` then exits, while embedded `./start` continues through the ordinary image/template provisioning and pool startup path. When `sbx` is installed, the wizard runs `sbx daemon start --detach` before Docker Sandboxes diagnostics so a stopped daemon does not require a manual retry. See [Docker Sandboxes](providers/docker-sandboxes.md) for source profiles, capacity, local receipts, and platform validation status. diff --git a/internal/image/build.go b/internal/image/build.go index 23c3381..a453b94 100644 --- a/internal/image/build.go +++ b/internal/image/build.go @@ -776,7 +776,7 @@ func (m *Coordinator) prepareWSLDockerSourceRootfs(ctx context.Context, outputPa Platform: platform, LogPath: buildLogPath, }); err != nil { - return "", "", fmt.Errorf("wsl image.sourceType=docker-image requires Docker Desktop, Docker Engine, or another reachable Docker daemon; alternatively set image.sourceType=rootfs-tar and provide a prepared rootfs tar: %w", err) + return "", "", fmt.Errorf("could not pull the WSL Docker image source; alternatively set image.sourceType=rootfs-tar and provide a prepared rootfs tar: %w", err) } if err := m.runHostLogged(ctx, buildLogPath, "docker", createArgs...); err != nil { return "", "", err diff --git a/scripts/build-native-controller.ps1 b/scripts/build-native-controller.ps1 index 6dda43b..1deaeaa 100644 --- a/scripts/build-native-controller.ps1 +++ b/scripts/build-native-controller.ps1 @@ -40,7 +40,7 @@ if ($env:EPAR_BOOTSTRAP_MIN_FREE_BYTES) { } if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { - Write-Error 'docker command not found. Install Docker Desktop or another working Docker host.' + Write-Error 'docker command not found. Install Docker and make sure it is available on PATH.' exit 1 } diff --git a/scripts/build-native-controller.sh b/scripts/build-native-controller.sh index 1f99109..c290a57 100644 --- a/scripts/build-native-controller.sh +++ b/scripts/build-native-controller.sh @@ -13,7 +13,7 @@ abandoned_build_grace_seconds=$((24 * 60 * 60)) bootstrap_minimum_free_bytes="${EPAR_BOOTSTRAP_MIN_FREE_BYTES:-$((1 * 1024 * 1024 * 1024))}" go_cache_limit_bytes="${EPAR_GO_CACHE_LIMIT_BYTES:-$((10 * 1024 * 1024 * 1024))}" -command -v docker >/dev/null 2>&1 || { echo "docker command not found. Install Docker Desktop, Docker Engine, or a compatible Docker host." >&2; exit 1; } +command -v docker >/dev/null 2>&1 || { echo "docker command not found. Install Docker and make sure it is available on PATH." >&2; exit 1; } command -v shasum >/dev/null 2>&1 || { echo "shasum is required to build the native EPAR cache key." >&2; exit 1; } [[ "$bootstrap_minimum_free_bytes" =~ ^[1-9][0-9]*$ ]] || { echo "EPAR_BOOTSTRAP_MIN_FREE_BYTES must be a positive integer byte count." >&2; exit 1; } [[ "$go_cache_limit_bytes" =~ ^[1-9][0-9]*$ ]] || { echo "EPAR_GO_CACHE_LIMIT_BYTES must be a positive integer byte count." >&2; exit 1; } diff --git a/scripts/run-with-docker.ps1 b/scripts/run-with-docker.ps1 index 586346e..72eb382 100644 --- a/scripts/run-with-docker.ps1 +++ b/scripts/run-with-docker.ps1 @@ -61,7 +61,7 @@ if ($HostName) { } if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { - Write-Error "docker command not found. Install Docker Desktop or another working Docker host." + Write-Error "docker command not found. Install Docker and make sure it is available on PATH." exit 1 } diff --git a/scripts/run-with-docker.sh b/scripts/run-with-docker.sh index cf28cbd..f5f8155 100755 --- a/scripts/run-with-docker.sh +++ b/scripts/run-with-docker.sh @@ -45,7 +45,7 @@ if [[ -n "${host_name}" ]]; then fi if ! command -v docker >/dev/null 2>&1; then - echo "docker command not found. Install Docker Desktop, Docker Engine, or a compatible Docker host." >&2 + echo "docker command not found. Install Docker and make sure it is available on PATH." >&2 exit 1 fi From d106c44a90131cce95980020a6b58674bd42e860 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 31 Jul 2026 02:57:21 +0800 Subject: [PATCH 11/22] docs: clarify provider sandbox boundaries --- README.md | 4 ++-- docs/README.md | 2 +- docs/configuration.md | 2 +- docs/providers/docker-sandboxes.md | 12 ++++++------ docs/providers/tart.md | 2 +- docs/runner-groups.md | 2 +- docs/security.md | 6 +++--- docs/troubleshooting.md | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 02655d1..b66db9e 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ runs-on: [self-hosted, linux, epar-docker-sandboxes] Use labels that describe the environment your job actually needs. In particular, an ARM64 Tart runner is not a replacement for GitHub-hosted `ubuntu-latest` or an x64-only workload. -## Trusted jobs only +## Security depends on the provider -Use it only for workflows and repositories you trust, restrict access with [runner groups](docs/runner-groups.md), and do not expose it to unreviewed public or fork pull-request code. Read [Security](docs/security.md) before choosing a provider. +Docker Sandboxes places each runner inside a dedicated microVM sandbox and provides EPAR's strongest host-isolation boundary. Docker Container and WSL remain trusted-workflow providers; Tart is VM-isolated but experimental. With every provider, restrict access with [runner groups](docs/runner-groups.md) and expose only the secrets and services each workflow needs. Read [Security](docs/security.md) before choosing a provider. ## Find the right guide diff --git a/docs/README.md b/docs/README.md index d927229..19a1148 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,7 @@ Use these guides after the short [README quick start](../README.md). Start with ## Safety and support -- [Security](security.md): trusted-job boundary, secrets, provider caveats, and private vulnerability reporting. +- [Security](security.md): provider-dependent isolation, secrets, provider caveats, and private vulnerability reporting. - [Support](../SUPPORT.md): information to collect before opening an issue. ## Contribute diff --git a/docs/configuration.md b/docs/configuration.md index 2b2f0b8..3bc79bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,7 +161,7 @@ If the complete subsection is absent, EPAR warns and uses the strict recommended | Property | Type and default | Required or applies when | Effect and caution | | --- | --- | --- | --- | | `image.sourceImage` | `ghcr.io/catthehacker/ubuntu:full-latest` | Required with Docker Sandboxes. | Desired Catthehacker source selector; EPAR builds and imports the runnable template automatically. | -| `policyGeneration` | lowercase `sha256:<64-hex>`; no default | Required with Docker Sandboxes. | Fingerprint of the verified host-global Balanced policy. Policy drift blocks admission. | +| `policyGeneration` | lowercase `sha256:<64-hex>`; no default | Required with Docker Sandboxes. | Recorded fingerprint of the host-global Balanced policy. | | `networkBaseline` | `open` or `balanced`; `open` | Docker Sandboxes. | `open` adds a sandbox-scoped public-egress rule while denying host aliases; it does not change the host-global policy. | | `additionalAllow` | unique hostname or `*.domain`, optional port; empty | Docker Sandboxes. | Adds sandbox-scoped allow resources. With `open`, it cannot re-allow EPAR's host-alias deny guardrails. | | `additionalDeny` | unique hostname or `*.domain`, optional port; empty | Docker Sandboxes. | Adds sandbox-scoped deny resources. A resource cannot be in both allow and deny lists. | diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index 992eedf..62b3ea8 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -1,6 +1,6 @@ # Docker Sandboxes Provider -Docker Sandboxes runs each GitHub Actions listener in a separate microVM with a private filesystem, network boundary, and Docker daemon. It is EPAR's strongest current host boundary, but it remains trusted-job infrastructure rather than a sandbox for arbitrary hostile workflows. +Docker Sandboxes places each GitHub Actions listener inside a dedicated microVM sandbox with a private guest filesystem and Docker daemon. This is EPAR's strongest current host-isolation boundary. Its protection still depends on the installed Docker Sandboxes runtime, the host platform, EPAR's configuration, and the resources deliberately exposed to the workflow. ```mermaid flowchart TB @@ -25,11 +25,11 @@ flowchart TB ## When To Use It -Choose Docker Sandboxes when its local checks pass and you want a microVM boundary around the runner and its Docker workload. The first-run wizard makes it the default only when its exact admission checks pass. A configured Docker Sandboxes pool never silently falls back to Docker Container or another provider. +Choose Docker Sandboxes when its local checks pass and you want a microVM boundary around the runner and its Docker workload. The first-run wizard recommends it when the supported-platform, Docker, and machine-readable `sbx` readiness checks pass. Startup then performs the remaining storage, template, policy-rule, runtime, and registration admission checks and fails closed. A configured Docker Sandboxes pool never silently falls back to Docker Container or another provider. ## Support Status -EPAR selects this provider by capability, not by an operating-system allowlist: Docker must work, `sbx diagnose --output json` must report at least one passing check and no failed checks, the controller architecture must have a matching native guest template, and capacity/template admission must pass. Windows x86_64 has the recorded real-host lifecycle evidence. The ARM64 implementation is architecture-complete, but equivalent real-host build, load, lifecycle, and independent-certification evidence has not yet been recorded. macOS and Linux also lack equivalent EPAR real-host evidence in this repository. +EPAR recommends this provider in the wizard by capability, not by an operating-system allowlist: Docker must work, `sbx diagnose --output json` must report at least one passing check and no failed checks, and the controller architecture must have a matching native guest template. After configuration is saved, ordinary startup additionally requires storage and template admission before any runner starts. Windows x86_64 has the recorded real-host lifecycle evidence. The ARM64 implementation is architecture-complete, but equivalent real-host build, load, lifecycle, and independent-certification evidence has not yet been recorded. macOS and Linux also lack equivalent EPAR real-host evidence in this repository. ## Prerequisites @@ -88,7 +88,7 @@ dockerSandboxes: 4. Start the pool with `./start`. EPAR reuses the verified imported template without a registry check until the configured update schedule is due; local input changes and missing templates still rebuild immediately. -Each allocation receives an empty owner-restricted staging directory, but Actions `_work` stays on the guest filesystem. EPAR verifies the guest, policy, private daemon, and runner trust policy before requesting a short-lived registration token. With `image.hostTrustMode: overlay`, the common pool lifecycle installs the selected roots, verifies the immutable generation, and maintains the job-start lease. With the setting omitted or disabled, the template carries an explicit disabled-policy marker and does not install the trust hook. The token remains on the native host except for registration through `sbx exec` standard input. +Each allocation receives an empty owner-restricted staging directory, but Actions `_work` stays on the guest filesystem. EPAR verifies the guest, confirms that the configured sandbox-scoped policy rules are present, and verifies the private daemon and runner trust policy before requesting a short-lived registration token. With `image.hostTrustMode: overlay`, the common pool lifecycle installs the selected roots, verifies the immutable generation, and maintains the job-start lease. With the setting omitted or disabled, the template carries an explicit disabled-policy marker and does not install the trust hook. The token remains on the native host except for registration through `sbx exec` standard input. Template construction uses two independent trust paths. EPAR's project-owned BuildKit builder automatically receives host system roots for Docker Hub, GHCR, and the other pinned registries used by the build. The native controller downloads the locked Actions runner and `tini`, verifies their SHA-256 values, and then supplies them as local build inputs; the Dockerfile does not perform remote HTTPS downloads. @@ -96,7 +96,7 @@ BuildKit streams the runner template directly to an attestation-free, verified a ## Limitations -- Public egress remains an exfiltration path for workflow code and secrets. Use only trusted repositories and runner groups. +- `networkBaseline: open` permits public egress, which can exfiltrate secrets or data exposed to the workflow. Use least-privilege runner groups and secrets, and choose `balanced` with narrow allow rules for higher-risk workloads. - Docker Sandboxes template cache storage is shared host state; it is not a per-sandbox root-disk measurement. - A stopped sandbox is diagnostic state, not proof of deletion. Unknown state consumes capacity and blocks replacement. - `EPAR_DISABLE_DOCKER_SANDBOXES=1` fails admission closed during an incident or compatibility problem. @@ -109,7 +109,7 @@ Use the prewarm command above for an unregistered lifecycle check. To include Gi ephemeral-action-runner pool verify --config .local/docker-sandboxes.yml --instances 1 --register-only --cleanup ``` -The shared pool treats provisioning, ready, draining, quarantined, and cleanup-pending instances as capacity-consuming states. Cleanup uses the durable exact-identity ledger, including the exact sandbox, policy rules, GitHub runner record, and staging directory; it never uses an `sbx` reset or broad prefix deletion. +The shared pool treats provisioning, ready, draining, quarantined, and cleanup-pending instances as capacity-consuming states. Cleanup uses durable exact sandbox, GitHub runner, and staging-directory identities; it never uses an `sbx` reset or broad prefix deletion. ## Troubleshooting diff --git a/docs/providers/tart.md b/docs/providers/tart.md index 6aa0f5c..b200f19 100644 --- a/docs/providers/tart.md +++ b/docs/providers/tart.md @@ -45,7 +45,7 @@ Tart clones the reusable image, starts the VM headless, uses the guest agent for ## Limitations -- This provider is experimental and intended for trusted jobs. +- This provider is experimental. It uses a per-runner VM boundary, but workflows still control the guest and any secrets or services exposed to the job. - The default image is runner-only. Add only the dependencies your workflows need, or maintain a fuller source image yourself. - `provider.network: softnet` may require additional host privileges; NAT is the default. - Rosetta can translate some Linux amd64 user-space workloads in an ARM64 guest, but it does not turn the VM into an x64 VM or guarantee every amd64 workload. diff --git a/docs/runner-groups.md b/docs/runner-groups.md index 9f3ad4c..83cc36a 100644 --- a/docs/runner-groups.md +++ b/docs/runner-groups.md @@ -12,7 +12,7 @@ GitHub decides which repositories can route jobs to a self-hosted runner through See GitHub’s [runner-group access documentation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access) for the organization and enterprise controls. -Any repository with access to the group can route a matching job to its runners. Broad access also applies to repositories created later, and public repositories can expose self-hosted runners to untrusted pull request or fork workflows. EPAR is intended for trusted jobs and is not a hostile-code sandbox. +Any repository with access to the group can route a matching job to its runners. Broad access also applies to repositories created later, and public repositories can expose self-hosted runners to untrusted pull request or fork workflows. Provider isolation does not replace this authorization boundary: Docker Sandboxes isolates each runner inside a dedicated microVM, but a job can still access its assigned secrets and reachable services; Docker Container and WSL should remain limited to trusted workflows. ## Wizard Decisions diff --git a/docs/security.md b/docs/security.md index b731bc5..0e63fc8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,6 +1,6 @@ # Security -EPAR is intended for trusted jobs by default. It adds cleanup and isolation around GitHub self-hosted runners, but it does not make an existing host safe for arbitrary untrusted workflows. +EPAR provides disposable GitHub self-hosted runners with provider-dependent isolation. Docker Sandboxes places each runner inside a dedicated microVM sandbox and provides EPAR's strongest current host-isolation boundary. Docker Container and WSL remain trusted-workflow infrastructure; Tart uses a VM but is experimental. No provider is guaranteed to be universally safe for arbitrary hostile workflows. GitHub's self-hosted runner warning still applies: GitHub recommends using self-hosted runners only with private repositories because public repository forks can run code on the runner machine through pull request workflows. Read the official GitHub guidance before exposing any self-hosted runner to public or untrusted workflows: [Adding self-hosted runners](https://docs.github.com/actions/hosting-your-own-runners/adding-self-hosted-runners). @@ -18,7 +18,7 @@ Disposable instances reduce host pollution, stale runner state, and accidental c ## What EPAR Does Not Guarantee -A workflow controls the runner environment while it runs and can access any secrets exposed to that workflow. Ephemeral cleanup reduces persistence risk after the job, but it is not a hostile-code sandbox. +A workflow controls its runner environment while it runs and can access any secrets and reachable services exposed to that workflow. Ephemeral cleanup alone is not a sandbox boundary: Docker Sandboxes supplies a dedicated microVM boundary, while the other providers use their documented isolation models. Do not mount host source directories, Docker sockets, private keys, or long-lived cloud credentials into runner instances unless that is inside your trust boundary. @@ -30,7 +30,7 @@ EPAR intentionally does not implement a Docker-socket provider. A runner that co Docker Container uses a privileged outer container with a private inner Docker daemon. That gives good cleanup and Docker resource separation for each job, but it is still trusted-job infrastructure because `--privileged` weakens container isolation. -Docker Sandboxes places the listener, guest filesystem, network boundary, and private Docker daemon inside a dedicated microVM. It provides EPAR's strongest current host boundary and materially strengthens host isolation relative to Docker Container. The first-run wizard makes it the capability-driven default on any OS when Docker works and `sbx diagnose --output json` reports at least one pass and zero failures; this selection rule is separate from independent platform certification and does not claim that every host combination has received the same real-host validation. EPAR does not market any provider as universally safe for arbitrary hostile workflows. +Docker Sandboxes places the listener, guest filesystem, and private Docker daemon inside a dedicated microVM sandbox. It provides EPAR's strongest current host boundary and materially strengthens host isolation relative to Docker Container. The first-run wizard recommends it when the supported-platform, Docker, and machine-readable `sbx` readiness checks pass; startup then performs the remaining storage, template, policy-rule, runtime, and registration admission checks and fails closed. This selection rule is separate from independent platform certification and does not claim that every host combination has received the same real-host validation. Tart runs jobs inside VMs on Apple Silicon macOS. That is a stronger host boundary than Docker Container, but workflows still control the guest and any secrets exposed to the job. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5d71fb9..85c6116 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,6 @@ # Troubleshooting -Start with the symptom that most closely matches the failure. EPAR is trusted-job infrastructure: keep TLS verification enabled, preserve the first relevant log, and do not use broad Docker/WSL resets or prune commands as a first response. +Start with the symptom that most closely matches the failure. Regardless of provider, keep TLS verification enabled, preserve the first relevant log, and do not use broad Docker/WSL resets or prune commands as a first response. ## Contents From b485f8e8a28cbdf4bcb0cf46d9d0449a443e2436 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 31 Jul 2026 09:22:26 +0800 Subject: [PATCH 12/22] fix: stabilize cross-platform CI verification --- .github/workflows/host-trust-verification.yml | 7 +- cmd/ephemeral-action-runner/init_test.go | 5 ++ internal/image/runner_release_test.go | 2 +- .../provider/dockersandboxes/provider_test.go | 20 ++++- .../dockersandboxes/staging/identity_other.go | 4 + .../staging/identity_windows.go | 29 ++++++++ .../dockersandboxes/staging/staging.go | 20 ++++- .../staging/staging_windows_test.go | 73 +++++++++++++++++++ internal/provider/wsl/wsl_test.go | 7 ++ internal/storage/filesystem.go | 9 ++- internal/storage/filesystem_unix.go | 2 + internal/storage/filesystem_windows.go | 19 +++++ internal/storage/filesystem_windows_test.go | 54 ++++++++++++++ scripts/guest/ubuntu/install-runner.sh | 2 +- 14 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 internal/provider/dockersandboxes/staging/staging_windows_test.go create mode 100644 internal/storage/filesystem_windows_test.go diff --git a/.github/workflows/host-trust-verification.yml b/.github/workflows/host-trust-verification.yml index 19dd832..cfb1641 100644 --- a/.github/workflows/host-trust-verification.yml +++ b/.github/workflows/host-trust-verification.yml @@ -61,7 +61,12 @@ jobs: - name: Run Go tests shell: bash - run: go test ./... + run: | + set -euo pipefail + if [[ "${RUNNER_OS}" == "macOS" ]]; then + export TMPDIR="$(cd "${RUNNER_TEMP}" && pwd -P)" + fi + go test ./... - name: Smoke-test native host root collection without trust-store mutations shell: bash diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index dd79c89..7c5f957 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -2134,13 +2134,18 @@ func stubWSL2Available(t *testing.T) { stubDockerSandboxesUnavailable(t) oldGOOS := initGOOS oldWSLStatus := initWSLStatus + oldPlatform := initSandboxPromotionPlatform initGOOS = "windows" initWSLStatus = func(context.Context) ([]byte, error) { return []byte("Default Distribution: Ubuntu\nDefault Version: 2\n"), nil } + initSandboxPromotionPlatform = func() sandboxpromotion.Platform { + return sandboxpromotion.WindowsAMD64 + } t.Cleanup(func() { initGOOS = oldGOOS initWSLStatus = oldWSLStatus + initSandboxPromotionPlatform = oldPlatform }) } diff --git a/internal/image/runner_release_test.go b/internal/image/runner_release_test.go index 54c3526..2336f04 100644 --- a/internal/image/runner_release_test.go +++ b/internal/image/runner_release_test.go @@ -73,7 +73,7 @@ func TestRunnerInstallScriptRequiresVerifiedLocalPackage(t *testing.T) { t.Fatalf("install-runner.sh still performs guest-side remote resolution: found %q", forbidden) } } - for _, required := range []string{"", "sha256sum --check", "Runner.Listener --version"} { + for _, required := range []string{"", "sha256sum --check", "sudo -u runner -H ./bin/Runner.Listener --version"} { if !strings.Contains(text, required) { t.Fatalf("install-runner.sh omitted %q", required) } diff --git a/internal/provider/dockersandboxes/provider_test.go b/internal/provider/dockersandboxes/provider_test.go index 1861f83..c709183 100644 --- a/internal/provider/dockersandboxes/provider_test.go +++ b/internal/provider/dockersandboxes/provider_test.go @@ -7,7 +7,10 @@ import ( "io" "os" "os/exec" + "path/filepath" "reflect" + "runtime" + "strconv" "strings" "sync" "testing" @@ -19,17 +22,26 @@ import ( const ( testName = "epar-sandbox-1" testID = "9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b" - testWorkspace = "/var/lib/epar/staging/job-1" testTemplate = "docker.io/docker/sandbox-templates:shell-docker" testDigest = "sha256:39cf20eca8610000000000000000000000000000000000000000000000000000" - readyListJSON = `{"sandboxes":[{"id":"9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b","name":"epar-sandbox-1","status":"running","workspaces":["/var/lib/epar/staging/job-1"],"agent":"shell","additive_field":true}]}` emptyPortsJSON = `[]` templateListJSON = `{"images":[{"id":"39cf20eca861","repository":"docker.io/docker/sandbox-templates","tag":"shell-docker","flavor":"shell-docker","created_at":"2026-07-22T07:13:19Z","size":599103243}]}` - inspectionJSON = `{"name":"epar-sandbox-1","agent":"shell","kits":[],"state":"running","image":"docker.io/docker/sandbox-templates:shell-docker","image_digest":"sha256:39cf20eca8610000000000000000000000000000000000000000000000000000","workspace":"/var/lib/epar/staging/job-1","network":"epar-sandbox-1","network_policy":{"scope":"global"},"proxy":"172.17.0.1:3128","mcp_gateway":false,"sessions":0,"daemon_version":"fixture-current","daemon_uptime":"1h"}` healthyDiagnoseJSON = `{"version":"1.0","checks":[{"name":"daemon","status":"pass","message":"healthy","detail":"","hint":""}],"summary":{"pass":1,"warn":0,"fail":0,"skip":0}}` ) -var testInstance = provider.Instance{Name: testName, ProviderID: testID, Source: "shell", State: "running"} +var ( + testWorkspace = providerTestWorkspace() + readyListJSON = `{"sandboxes":[{"id":"9b6dbdf3-2ef4-47cb-8f55-55b26a790c8b","name":"epar-sandbox-1","status":"running","workspaces":[` + strconv.Quote(testWorkspace) + `],"agent":"shell","additive_field":true}]}` + inspectionJSON = `{"name":"epar-sandbox-1","agent":"shell","kits":[],"state":"running","image":"docker.io/docker/sandbox-templates:shell-docker","image_digest":"sha256:39cf20eca8610000000000000000000000000000000000000000000000000000","workspace":` + strconv.Quote(testWorkspace) + `,"network":"epar-sandbox-1","network_policy":{"scope":"global"},"proxy":"172.17.0.1:3128","mcp_gateway":false,"sessions":0,"daemon_version":"fixture-current","daemon_uptime":"1h"}` + testInstance = provider.Instance{Name: testName, ProviderID: testID, Source: "shell", State: "running"} +) + +func providerTestWorkspace() string { + if runtime.GOOS == "windows" { + return filepath.Join(filepath.VolumeName(os.TempDir())+string(filepath.Separator), "var", "lib", "epar", "staging", "job-1") + } + return filepath.Join(string(filepath.Separator), "var", "lib", "epar", "staging", "job-1") +} func TestCreateDryRunFailsBeforeProviderSideEffects(t *testing.T) { p := NewWithDryRun("sbx", true) diff --git a/internal/provider/dockersandboxes/staging/identity_other.go b/internal/provider/dockersandboxes/staging/identity_other.go index 2f6c6c4..f448d6a 100644 --- a/internal/provider/dockersandboxes/staging/identity_other.go +++ b/internal/provider/dockersandboxes/staging/identity_other.go @@ -19,3 +19,7 @@ func platformDirectoryIdentity(path string) (string, error) { } return fmt.Sprintf("unix:%x:%x", uint64(stat.Dev), uint64(stat.Ino)), nil } + +func isPlatformRedirect(info os.FileInfo) bool { return info.Mode()&os.ModeSymlink != 0 } + +func platformCanonicalPathSpelling(path string) (string, error) { return path, nil } diff --git a/internal/provider/dockersandboxes/staging/identity_windows.go b/internal/provider/dockersandboxes/staging/identity_windows.go index 584bb82..27c7876 100644 --- a/internal/provider/dockersandboxes/staging/identity_windows.go +++ b/internal/provider/dockersandboxes/staging/identity_windows.go @@ -4,6 +4,8 @@ package staging import ( "fmt" + "os" + "syscall" "golang.org/x/sys/windows" ) @@ -24,3 +26,30 @@ func platformDirectoryIdentity(path string) (string, error) { } return fmt.Sprintf("windows:%08x:%08x%08x", information.VolumeSerialNumber, information.FileIndexHigh, information.FileIndexLow), nil } + +func isPlatformRedirect(info os.FileInfo) bool { + if info.Mode()&os.ModeSymlink != 0 { + return true + } + data, ok := info.Sys().(*syscall.Win32FileAttributeData) + return ok && data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +func platformCanonicalPathSpelling(path string) (string, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + size := uint32(len(path) + 1) + for { + buffer := make([]uint16, size) + length, err := windows.GetLongPathName(pointer, &buffer[0], size) + if err != nil { + return "", err + } + if length < size { + return windows.UTF16ToString(buffer[:length]), nil + } + size = length + 1 + } +} diff --git a/internal/provider/dockersandboxes/staging/staging.go b/internal/provider/dockersandboxes/staging/staging.go index 67f8680..69716b0 100644 --- a/internal/provider/dockersandboxes/staging/staging.go +++ b/internal/provider/dockersandboxes/staging/staging.go @@ -51,7 +51,11 @@ func Open(root string) (*Staging, error) { if err := validateDirectory(absRoot, false); err != nil { return nil, fmt.Errorf("validate Docker Sandboxes staging root: %w", err) } - return &Staging{root: absRoot}, nil + canonicalRoot, err := platformCanonicalPathSpelling(absRoot) + if err != nil { + return nil, fmt.Errorf("normalize Docker Sandboxes staging root: %w", err) + } + return &Staging{root: filepath.Clean(canonicalRoot)}, nil } func (s *Staging) Root() string { @@ -265,7 +269,11 @@ func validateDirectory(path string, requireEmpty bool) error { if err != nil { return fmt.Errorf("resolve Docker Sandboxes staging path %q: %w", path, err) } - if !samePath(filepath.Clean(absPath), filepath.Clean(absEvaluated)) { + canonicalPath, err := platformCanonicalPathSpelling(filepath.Clean(absPath)) + if err != nil { + return fmt.Errorf("normalize Docker Sandboxes staging path %q: %w", path, err) + } + if !samePath(filepath.Clean(canonicalPath), filepath.Clean(absEvaluated)) { return fmt.Errorf("Docker Sandboxes staging path %q contains a symlink, junction, or reparse redirection", path) } if requireEmpty { @@ -318,7 +326,7 @@ func validateNoRedirectDirectory(path string) error { if err != nil { return err } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + if isPlatformRedirect(info) || !info.IsDir() { return fmt.Errorf("Docker Sandboxes staging path %q is not a real directory", path) } evaluated, err := filepath.EvalSymlinks(path) @@ -333,7 +341,11 @@ func validateNoRedirectDirectory(path string) error { if err != nil { return fmt.Errorf("resolve Docker Sandboxes staging path %q: %w", path, err) } - if !samePath(filepath.Clean(absPath), filepath.Clean(absEvaluated)) { + canonicalPath, err := platformCanonicalPathSpelling(filepath.Clean(absPath)) + if err != nil { + return fmt.Errorf("normalize Docker Sandboxes staging path %q: %w", path, err) + } + if !samePath(filepath.Clean(canonicalPath), filepath.Clean(absEvaluated)) { return fmt.Errorf("Docker Sandboxes staging path %q contains a symlink, junction, or reparse redirection", path) } return nil diff --git a/internal/provider/dockersandboxes/staging/staging_windows_test.go b/internal/provider/dockersandboxes/staging/staging_windows_test.go new file mode 100644 index 0000000..7903c20 --- /dev/null +++ b/internal/provider/dockersandboxes/staging/staging_windows_test.go @@ -0,0 +1,73 @@ +//go:build windows + +package staging + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "golang.org/x/sys/windows" +) + +type windowsReparseDirectoryInfo struct { + os.FileInfo +} + +func (windowsReparseDirectoryInfo) Mode() os.FileMode { + return os.ModeDir +} + +func (windowsReparseDirectoryInfo) Sys() any { + return &syscall.Win32FileAttributeData{FileAttributes: syscall.FILE_ATTRIBUTE_DIRECTORY | syscall.FILE_ATTRIBUTE_REPARSE_POINT} +} + +func TestPlatformRedirectRejectsWindowsReparseDirectory(t *testing.T) { + if !isPlatformRedirect(windowsReparseDirectoryInfo{}) { + t.Fatal("Windows reparse directory was not classified as a redirect") + } +} + +func TestOpenAcceptsWindowsShortPathAlias(t *testing.T) { + longParent := filepath.Join(t.TempDir(), "Long Directory Name") + if err := os.Mkdir(longParent, 0o700); err != nil { + t.Fatal(err) + } + if err := restrictPlatformPermissions(longParent); err != nil { + t.Fatal(err) + } + shortParent := stagingWindowsShortPath(t, longParent) + if strings.EqualFold(shortParent, longParent) { + t.Skip("filesystem did not provide a distinct short path alias") + } + staging, err := Open(filepath.Join(shortParent, "staging")) + if err != nil { + t.Fatalf("Open() rejected a Windows short path alias: %v", err) + } + want := filepath.Join(longParent, "staging") + if !strings.EqualFold(staging.Root(), want) { + t.Fatalf("Root() = %q, want canonical spelling %q", staging.Root(), want) + } +} + +func stagingWindowsShortPath(t *testing.T, path string) string { + t.Helper() + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + size := uint32(len(path) + 1) + for { + buffer := make([]uint16, size) + length, err := windows.GetShortPathName(pointer, &buffer[0], size) + if err != nil { + t.Skipf("Windows short paths unavailable: %v", err) + } + if length < size { + return windows.UTF16ToString(buffer[:length]) + } + size = length + 1 + } +} diff --git a/internal/provider/wsl/wsl_test.go b/internal/provider/wsl/wsl_test.go index bca0bf4..5240996 100644 --- a/internal/provider/wsl/wsl_test.go +++ b/internal/provider/wsl/wsl_test.go @@ -125,6 +125,13 @@ func TestParseListParsesVerboseOutput(t *testing.T) { func TestNoInstalledDistrosReturnsEmptyList(t *testing.T) { p := New("wsl.exe", t.TempDir(), t.TempDir(), true) + p.runCommand = func(_ context.Context, _ io.Reader, _ string, _, _ io.Writer, args ...string) (provider.ExecResult, error) { + if !reflect.DeepEqual(args, []string{"--list", "--verbose"}) { + t.Fatalf("args = %#v", args) + } + message := "Windows Subsystem for Linux has no installed distributions." + return provider.ExecResult{Stderr: message}, errors.New(message) + } out, err := p.List(context.Background()) if err != nil { t.Fatalf("dry-run list failed: %v", err) diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index 4c4396a..29f8840 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -101,10 +101,15 @@ func inspectFilesystemPath(path string) (string, os.FileInfo, error) { if err != nil { return "", nil, err } - if !sameFilesystemPath(absolute, filepath.Clean(evaluated)) { + canonicalSpelling, err := platformCanonicalFilesystemPath(absolute) + if err != nil { + return "", nil, fmt.Errorf("normalize storage filesystem path %q: %w", absolute, err) + } + canonicalSpelling = filepath.Clean(canonicalSpelling) + if !sameFilesystemPath(canonicalSpelling, filepath.Clean(evaluated)) { return "", nil, fmt.Errorf("storage filesystem path %q contains a symlink, junction, or reparse redirection", absolute) } - return absolute, info, nil + return canonicalSpelling, info, nil } func rejectRedirectedAncestors(path string) error { diff --git a/internal/storage/filesystem_unix.go b/internal/storage/filesystem_unix.go index 78eec89..e1f980f 100644 --- a/internal/storage/filesystem_unix.go +++ b/internal/storage/filesystem_unix.go @@ -36,3 +36,5 @@ func platformFilesystemIdentity(path string, _ bool) (string, error) { } func isFilesystemRedirect(info os.FileInfo) bool { return info.Mode()&os.ModeSymlink != 0 } + +func platformCanonicalFilesystemPath(path string) (string, error) { return path, nil } diff --git a/internal/storage/filesystem_windows.go b/internal/storage/filesystem_windows.go index 172db15..04a858a 100644 --- a/internal/storage/filesystem_windows.go +++ b/internal/storage/filesystem_windows.go @@ -50,3 +50,22 @@ func isFilesystemRedirect(info os.FileInfo) bool { data, ok := info.Sys().(*syscall.Win32FileAttributeData) return ok && data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 } + +func platformCanonicalFilesystemPath(path string) (string, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + size := uint32(len(path) + 1) + for { + buffer := make([]uint16, size) + length, err := windows.GetLongPathName(pointer, &buffer[0], size) + if err != nil { + return "", err + } + if length < size { + return windows.UTF16ToString(buffer[:length]), nil + } + size = length + 1 + } +} diff --git a/internal/storage/filesystem_windows_test.go b/internal/storage/filesystem_windows_test.go new file mode 100644 index 0000000..d6a8ee2 --- /dev/null +++ b/internal/storage/filesystem_windows_test.go @@ -0,0 +1,54 @@ +//go:build windows + +package storage + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/windows" +) + +func TestSnapshotFilesystemTargetAcceptsWindowsShortPathAlias(t *testing.T) { + longRoot := filepath.Join(t.TempDir(), "Long Directory Name") + if err := os.Mkdir(longRoot, 0o700); err != nil { + t.Fatal(err) + } + shortRoot := windowsShortPath(t, longRoot) + if strings.EqualFold(shortRoot, longRoot) { + t.Skip("filesystem did not provide a distinct short path alias") + } + longPath := filepath.Join(longRoot, "artifact.bin") + if err := os.WriteFile(longPath, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + target, err := SnapshotFilesystemTarget(filepath.Join(shortRoot, "artifact.bin")) + if err != nil { + t.Fatalf("SnapshotFilesystemTarget() rejected a Windows short path alias: %v", err) + } + if !strings.EqualFold(target.Locator, longPath) { + t.Fatalf("SnapshotFilesystemTarget() locator = %q, want canonical spelling %q", target.Locator, longPath) + } +} + +func windowsShortPath(t *testing.T, path string) string { + t.Helper() + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + size := uint32(len(path) + 1) + for { + buffer := make([]uint16, size) + length, err := windows.GetShortPathName(pointer, &buffer[0], size) + if err != nil { + t.Skipf("Windows short paths unavailable: %v", err) + } + if length < size { + return windows.UTF16ToString(buffer[:length]) + } + size = length + 1 + } +} diff --git a/scripts/guest/ubuntu/install-runner.sh b/scripts/guest/ubuntu/install-runner.sh index a6e1d71..c78929d 100755 --- a/scripts/guest/ubuntu/install-runner.sh +++ b/scripts/guest/ubuntu/install-runner.sh @@ -32,7 +32,7 @@ tar xzf "${RUNNER_PACKAGE}" rm -f "${RUNNER_PACKAGE}" chown -R runner:runner /opt/actions-runner -INSTALLED_RUNNER_VERSION="$(./bin/Runner.Listener --version | tr -d '\r' | tail -n 1)" +INSTALLED_RUNNER_VERSION="$(sudo -u runner -H ./bin/Runner.Listener --version | tr -d '\r' | tail -n 1)" if [[ "${INSTALLED_RUNNER_VERSION}" != "${RUNNER_VERSION}" ]]; then echo "Actions runner package version ${INSTALLED_RUNNER_VERSION:-} does not match expected version ${RUNNER_VERSION}" >&2 exit 1 From bd68253d4ec84e5ef1f118fe2ba55574b3b85021 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 31 Jul 2026 10:07:41 +0800 Subject: [PATCH 13/22] Fix hosted CI lifecycle and path regressions --- internal/image/storage_catalog_test.go | 15 +++- internal/pool/manager.go | 10 +-- internal/pool/manager_test.go | 75 ++++++++++++++++++- .../staging/permissions_windows.go | 48 ++++++------ .../staging/staging_windows_test.go | 5 +- internal/storage/filesystem_windows_test.go | 8 +- internal/storage/inventory/native_test.go | 6 +- scripts/test/host-trust-wrapper-smoke.sh | 2 +- 8 files changed, 132 insertions(+), 37 deletions(-) diff --git a/internal/image/storage_catalog_test.go b/internal/image/storage_catalog_test.go index 27eda6c..f428af7 100644 --- a/internal/image/storage_catalog_test.go +++ b/internal/image/storage_catalog_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/storage" storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" ) @@ -138,8 +139,16 @@ func TestSandboxWorkspaceIsCatalogedBeforeBuildAndSupersededExactly(t *testing.T if err != nil { t.Fatal(err) } - firstResource := findCatalogResourceByLocator(t, value.Resources, first) - secondResource := findCatalogResourceByLocator(t, value.Resources, second) + firstTarget, err := storage.SnapshotFilesystemTarget(first) + if err != nil { + t.Fatal(err) + } + secondTarget, err := storage.SnapshotFilesystemTarget(second) + if err != nil { + t.Fatal(err) + } + firstResource := findCatalogResourceByLocator(t, value.Resources, firstTarget.Locator) + secondResource := findCatalogResourceByLocator(t, value.Resources, secondTarget.Locator) if firstResource.State != storagecatalog.StateSuperseded || len(firstResource.References) != 0 || firstResource.SupersededAt == nil { t.Fatalf("replaced staging workspace = %#v", firstResource) } @@ -153,7 +162,7 @@ func TestSandboxWorkspaceIsCatalogedBeforeBuildAndSupersededExactly(t *testing.T if err != nil { t.Fatal(err) } - secondResource = findCatalogResourceByLocator(t, value.Resources, second) + secondResource = findCatalogResourceByLocator(t, value.Resources, secondTarget.Locator) if secondResource.State != storagecatalog.StateSuperseded || len(secondResource.References) != 0 || secondResource.SupersededAt == nil { t.Fatalf("completed staging workspace = %#v", secondResource) } diff --git a/internal/pool/manager.go b/internal/pool/manager.go index 29f9109..5b88d5c 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -1328,11 +1328,6 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register vm.Phase = LifecycleReady return } - if listenerMayBeRunning { - m.quarantineLifecycle(context.Background(), name, err) - vm.Phase = LifecycleQuarantined - return - } remoteKnownAbsent := !configureAttempted if configureAttempted && m.GitHub != nil { runner, found, lookupErr := m.GitHub.RunnerByName(context.Background(), name) @@ -1351,6 +1346,11 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register } } } + if listenerMayBeRunning { + m.quarantineLifecycle(context.Background(), name, err) + vm.Phase = LifecycleQuarantined + return + } if m.LifecycleState == nil { if vm.RunnerID != 0 && m.GitHub != nil { if deleteErr := m.GitHub.DeleteRunnerIfExists(context.Background(), vm.RunnerID); deleteErr != nil { diff --git a/internal/pool/manager_test.go b/internal/pool/manager_test.go index 112ee19..9fd7522 100644 --- a/internal/pool/manager_test.go +++ b/internal/pool/manager_test.go @@ -19,6 +19,7 @@ import ( gh "github.com/solutionforest/ephemeral-action-runner/internal/github" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" "github.com/solutionforest/ephemeral-action-runner/internal/logging" + poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" "github.com/solutionforest/ephemeral-action-runner/internal/provider" ) @@ -872,6 +873,68 @@ func TestProvisionOneCapturesReadinessTimeoutAndPreservesCause(t *testing.T) { } } +func TestRunPoolCancellationAfterListenerStartCleansExactRunner(t *testing.T) { + state, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + var deleted atomic.Bool + var lookupCalls atomic.Int32 + fake := &fakeProvider{ip: "127.0.0.1"} + fake.execFunc = func(_ context.Context, _ string, command []string, _ provider.ExecOptions) (provider.ExecResult, error) { + if strings.Contains(strings.Join(command, " "), "run-runner.sh") { + cancel() + } + return provider.ExecResult{}, nil + } + github := &fakeGitHub{ + runnerByNameFunc: func(_ context.Context, name string) (gh.Runner, bool, error) { + if lookupCalls.Add(1) == 1 || deleted.Load() { + return gh.Runner{}, false, nil + } + return gh.Runner{Name: name, ID: 718}, true, nil + }, + deleteFunc: func(context.Context, int64) error { + deleted.Store(true) + return nil + }, + waitFunc: func(ctx context.Context, _ string, _ time.Duration) (gh.Runner, error) { + <-ctx.Done() + return gh.Runner{}, ctx.Err() + }, + } + manager := newRegisteredTestManager(t, fake, github) + manager.Lifecycle = provider.AdaptLegacy(fake) + manager.LifecycleState = state + + if err := manager.RunPool(ctx, RunOptions{Instances: 1, Register: true, ReplaceCompleted: true, PoolLockHeld: true, HostTrustLockHeld: true}); err != nil { + t.Fatalf("RunPool() cancellation error = %v, want exact cleanup", err) + } + records, err := state.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("lifecycle records = %#v, want one tombstoned candidate", records) + } + record := records[0] + if record.Phase != poolstate.PhaseTombstoned || record.GitHub.RunnerID != 718 { + t.Fatalf("lifecycle after cancellation = phase %q runner id %d, want tombstoned with id 718", record.Phase, record.GitHub.RunnerID) + } + if got := atomic.LoadInt32(&github.deleteCalls); got != 1 { + t.Fatalf("remote delete calls = %d, want 1", got) + } + github.mu.Lock() + defer github.mu.Unlock() + if len(github.deletedIDs) != 1 || github.deletedIDs[0] != 718 { + t.Fatalf("deleted remote IDs = %v, want [718]", github.deletedIDs) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 1 { + t.Fatalf("local delete calls = %d, want 1", got) + } +} + func TestCommonLifecycleOwnsGuestTranscriptPath(t *testing.T) { fake := &fakeProvider{instances: []provider.Instance{{Name: "epar-test-1", State: "running"}}} manager := newRegisteredTestManager(t, fake, nil) @@ -1522,12 +1585,14 @@ func (p *fakeProvider) List(ctx context.Context) ([]provider.Instance, error) { type fakeGitHub struct { runner gh.Runner + runnerByNameFunc func(context.Context, string) (gh.Runner, bool, error) waitRunner gh.Runner waitErr error waitFunc func(context.Context, string, time.Duration) (gh.Runner, error) found bool runnerErr error deleteErr error + deleteFunc func(context.Context, int64) error waitOnlineCalls int32 waitOnlineIdleCalls int32 listRunners []gh.Runner @@ -1572,8 +1637,11 @@ func (g *fakeGitHub) ListRunners(ctx context.Context) ([]gh.Runner, error) { return append([]gh.Runner(nil), g.listRunners...), g.listErr } -func (g *fakeGitHub) RunnerByName(context.Context, string) (gh.Runner, bool, error) { +func (g *fakeGitHub) RunnerByName(ctx context.Context, name string) (gh.Runner, bool, error) { atomic.AddInt32(&g.runnerByNameCalls, 1) + if g.runnerByNameFunc != nil { + return g.runnerByNameFunc(ctx, name) + } return g.runner, g.found, g.runnerErr } @@ -1600,11 +1668,14 @@ func (g *fakeGitHub) waitReady(ctx context.Context, name string, timeout time.Du return g.runner, nil } -func (g *fakeGitHub) DeleteRunnerIfExists(_ context.Context, id int64) error { +func (g *fakeGitHub) DeleteRunnerIfExists(ctx context.Context, id int64) error { atomic.AddInt32(&g.deleteCalls, 1) g.mu.Lock() g.deletedIDs = append(g.deletedIDs, id) g.mu.Unlock() + if g.deleteFunc != nil { + return g.deleteFunc(ctx, id) + } return g.deleteErr } diff --git a/internal/provider/dockersandboxes/staging/permissions_windows.go b/internal/provider/dockersandboxes/staging/permissions_windows.go index d25f1de..c6a7122 100644 --- a/internal/provider/dockersandboxes/staging/permissions_windows.go +++ b/internal/provider/dockersandboxes/staging/permissions_windows.go @@ -5,11 +5,13 @@ package staging import ( "fmt" "os" - "strings" + "unsafe" "golang.org/x/sys/windows" ) +const windowsFileAllAccess = windows.ACCESS_MASK(windows.STANDARD_RIGHTS_REQUIRED | windows.SYNCHRONIZE | 0x1ff) + func restrictPlatformPermissions(path string) error { user, err := windows.GetCurrentProcessToken().GetTokenUser() if err != nil { @@ -43,39 +45,41 @@ func validatePlatformPermissions(path string, _ os.FileInfo) error { if control&windows.SE_DACL_PROTECTED == 0 { return fmt.Errorf("DACL inherits access from a parent") } + dacl, _, err := descriptor.DACL() + if err != nil { + return fmt.Errorf("read staging DACL: %w", err) + } + if dacl == nil { + return fmt.Errorf("DACL is absent") + } user, err := windows.GetCurrentProcessToken().GetTokenUser() if err != nil { return fmt.Errorf("get current process user: %w", err) } - sddl := descriptor.String() - currentSID := user.User.Sid.String() - remaining := sddl + systemSID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return fmt.Errorf("create SYSTEM SID: %w", err) + } seenCurrent := false seenSystem := false - aceCount := 0 - for { - start := strings.IndexByte(remaining, '(') - if start < 0 { - break - } - remaining = remaining[start+1:] - end := strings.IndexByte(remaining, ')') - if end < 0 { - return fmt.Errorf("DACL contains malformed SDDL") + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &ace); err != nil { + return fmt.Errorf("read DACL access rule %d: %w", index, err) } - fields := strings.Split(remaining[:end], ";") - remaining = remaining[end+1:] - if len(fields) != 6 || fields[0] != "A" || fields[2] != "FA" { + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || + ace.Header.AceFlags != windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE || + ace.Mask != windowsFileAllAccess { return fmt.Errorf("DACL contains an unexpected access rule") } - aceCount++ - switch fields[5] { - case currentSID: + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + switch { + case sid.Equals(user.User.Sid): if seenCurrent { return fmt.Errorf("DACL contains duplicate current-user access") } seenCurrent = true - case "SY", "S-1-5-18": + case sid.Equals(systemSID): if seenSystem { return fmt.Errorf("DACL contains duplicate SYSTEM access") } @@ -84,7 +88,7 @@ func validatePlatformPermissions(path string, _ os.FileInfo) error { return fmt.Errorf("DACL grants an unexpected trustee") } } - if aceCount != 2 || !seenCurrent || !seenSystem { + if dacl.AceCount != 2 || !seenCurrent || !seenSystem { return fmt.Errorf("DACL must grant full access only to the current process identity and SYSTEM") } return nil diff --git a/internal/provider/dockersandboxes/staging/staging_windows_test.go b/internal/provider/dockersandboxes/staging/staging_windows_test.go index 7903c20..336b54b 100644 --- a/internal/provider/dockersandboxes/staging/staging_windows_test.go +++ b/internal/provider/dockersandboxes/staging/staging_windows_test.go @@ -46,7 +46,10 @@ func TestOpenAcceptsWindowsShortPathAlias(t *testing.T) { if err != nil { t.Fatalf("Open() rejected a Windows short path alias: %v", err) } - want := filepath.Join(longParent, "staging") + want, err := platformCanonicalPathSpelling(filepath.Join(longParent, "staging")) + if err != nil { + t.Fatal(err) + } if !strings.EqualFold(staging.Root(), want) { t.Fatalf("Root() = %q, want canonical spelling %q", staging.Root(), want) } diff --git a/internal/storage/filesystem_windows_test.go b/internal/storage/filesystem_windows_test.go index d6a8ee2..b57f144 100644 --- a/internal/storage/filesystem_windows_test.go +++ b/internal/storage/filesystem_windows_test.go @@ -28,8 +28,12 @@ func TestSnapshotFilesystemTargetAcceptsWindowsShortPathAlias(t *testing.T) { if err != nil { t.Fatalf("SnapshotFilesystemTarget() rejected a Windows short path alias: %v", err) } - if !strings.EqualFold(target.Locator, longPath) { - t.Fatalf("SnapshotFilesystemTarget() locator = %q, want canonical spelling %q", target.Locator, longPath) + want, err := platformCanonicalFilesystemPath(longPath) + if err != nil { + t.Fatal(err) + } + if !strings.EqualFold(target.Locator, want) { + t.Fatalf("SnapshotFilesystemTarget() locator = %q, want canonical spelling %q", target.Locator, want) } } diff --git a/internal/storage/inventory/native_test.go b/internal/storage/inventory/native_test.go index 8a22500..d101b38 100644 --- a/internal/storage/inventory/native_test.go +++ b/internal/storage/inventory/native_test.go @@ -75,8 +75,12 @@ func TestCollectNativeRecognizesStableControllerLayout(t *testing.T) { if len(warnings) != 0 || len(artifacts) != 1 { t.Fatalf("collectNative() artifacts=%+v warnings=%v", artifacts, warnings) } + expectedTarget, err := storage.SnapshotFilesystemTarget(executable) + if err != nil { + t.Fatal(err) + } stable := findArtifact(t, artifacts, "native-controller-stable:"+fingerprint) - if !stable.Current || stable.Ownership.Kind != storage.OwnershipExact || !hasProtection(stable, storage.ProtectionCurrent) || stable.Target.Locator != executable { + if !stable.Current || stable.Ownership.Kind != storage.OwnershipExact || !hasProtection(stable, storage.ProtectionCurrent) || stable.Target.Locator != expectedTarget.Locator { t.Fatalf("stable native controller = %+v", stable) } } diff --git a/scripts/test/host-trust-wrapper-smoke.sh b/scripts/test/host-trust-wrapper-smoke.sh index db7f100..fa2f4be 100644 --- a/scripts/test/host-trust-wrapper-smoke.sh +++ b/scripts/test/host-trust-wrapper-smoke.sh @@ -212,7 +212,7 @@ fake_go="$temporary/fake-go" fake_go_log="$temporary/fake-go.log" mkdir -p "$native_project/scripts/host-trust" cp "$project_root/start" "$native_project/start" -cp "$project_root/scripts/host-trust/wrapper-lib.sh" "$project_root/scripts/host-trust/host-trust-feed.sh" "$native_project/scripts/host-trust/" +cp "$project_root/scripts/host-trust/wrapper-lib.sh" "$project_root/scripts/host-trust/host-trust-feed.sh" "$project_root/scripts/host-trust/macos-trust-settings.js" "$native_project/scripts/host-trust/" cat >"$fake_go" <<'SH' #!/usr/bin/env bash set -euo pipefail From 1764cff4c4b52d5cc7ec6b3cfc77f914f668d9ba Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 31 Jul 2026 10:22:46 +0800 Subject: [PATCH 14/22] Fix macOS forwarding smoke host detection --- scripts/test/start-command-forwarding.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/test/start-command-forwarding.sh b/scripts/test/start-command-forwarding.sh index d92c96e..e620d0b 100644 --- a/scripts/test/start-command-forwarding.sh +++ b/scripts/test/start-command-forwarding.sh @@ -5,9 +5,13 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" test_root="$(mktemp -d)" trap 'rm -rf "$test_root"' EXIT +test_uname="$(uname -s)" +case "$test_uname" in + MINGW*|MSYS*|CYGWIN*) test_uname=Linux ;; +esac cat >"$test_root/uname" <<'SCRIPT' #!/usr/bin/env bash -printf 'Linux\n' +printf '%s\n' "$EPAR_TEST_UNAME" SCRIPT cat >"$test_root/go" <<'SCRIPT' #!/usr/bin/env bash @@ -19,6 +23,7 @@ SCRIPT chmod +x "$test_root/uname" "$test_root/go" export PATH="$test_root:$PATH" +export EPAR_TEST_UNAME="$test_uname" export EPAR_GO_BIN=go export EPAR_USE_DOCKER_RUN=0 export EPAR_START_FORWARD_LOG="$test_root/arguments" From 0d4ad24d7e03ebb9bc317743605448fa86e81e8c Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 31 Jul 2026 13:43:48 +0800 Subject: [PATCH 15/22] fix: isolate native controller trust feeds --- scripts/build-native-controller.sh | 18 ++- scripts/test/host-trust-wrapper-smoke.sh | 4 +- .../test/native-controller-cache-retention.sh | 111 ++++++++++++++++++ start | 21 ++-- 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/scripts/build-native-controller.sh b/scripts/build-native-controller.sh index c290a57..9504847 100644 --- a/scripts/build-native-controller.sh +++ b/scripts/build-native-controller.sh @@ -517,15 +517,25 @@ printf '%s\n' \ "pid=$$" \ "startedAtUnix=$(date +%s)" >"$lease_file" export EPAR_NATIVE_CONTROLLER=1 -export EPAR_CONTROLLER_HOST_OS="$goos" export DOCKER_CLI_HINTS="${DOCKER_CLI_HINTS:-false}" export EPAR_HOST_NAME="${EPAR_HOST_NAME:-$(hostname 2>/dev/null || true)}" controller_command="${1:-start}" -epar_host_trust_prepare "$repo_root" "$controller_command" "$@" -if [[ -n "${EPAR_BUILD_TRUST_FEED_DIR}" ]]; then export EPAR_BUILD_TRUST_FEED="${EPAR_BUILD_TRUST_FEED_DIR}/current.json"; fi -if [[ -n "${EPAR_RUNNER_TRUST_FEED_DIR}" ]]; then export EPAR_HOST_TRUST_FEED="${EPAR_RUNNER_TRUST_FEED_DIR}/current.json"; fi + +# Bootstrap trust is mounted only into the compiler container above. The cached +# native controller reads the host stores directly, so legacy bridge state must +# not survive into first-run continuation or suppress its native preflight. +unset EPAR_BUILD_TRUST_FEED EPAR_HOST_TRUST_FEED EPAR_CONTROLLER_HOST_OS EPAR_HOST_TRUST_INIT_DEFERRED + +# Keep explicit-init's post-write verification. Ordinary starts must not create +# a pre-wizard feed because the native controller resolves the new config itself. +if [[ "$controller_command" == "init" ]]; then + epar_host_trust_prepare "$repo_root" "$controller_command" "$@" +fi status=0 "$binary" "$@" || status=$? +if [[ "$status" == "0" && "$controller_command" == "init" ]]; then + epar_host_trust_post_init "$repo_root" || status=$? +fi epar_host_trust_cleanup cleanup_build trap - EXIT INT TERM diff --git a/scripts/test/host-trust-wrapper-smoke.sh b/scripts/test/host-trust-wrapper-smoke.sh index fa2f4be..a5fcf92 100644 --- a/scripts/test/host-trust-wrapper-smoke.sh +++ b/scripts/test/host-trust-wrapper-smoke.sh @@ -221,10 +221,12 @@ if [[ "${1:-}" == version ]]; then exit 0 fi printf '%s\n' "$*" >>"$FAKE_GO_LOG" +printf 'trust build=<%s> runner=<%s> os=<%s> deferred=<%s>\n' "${EPAR_BUILD_TRUST_FEED:-}" "${EPAR_HOST_TRUST_FEED:-}" "${EPAR_CONTROLLER_HOST_OS:-}" "${EPAR_HOST_TRUST_INIT_DEFERRED:-}" >>"$FAKE_GO_LOG" SH chmod +x "$fake_go" -(cd "$native_project" && EPAR_GO_BIN="$fake_go" FAKE_GO_LOG="$fake_go_log" ./start) +(cd "$native_project" && EPAR_GO_BIN="$fake_go" FAKE_GO_LOG="$fake_go_log" EPAR_BUILD_TRUST_FEED=stale-build EPAR_HOST_TRUST_FEED=stale-runner EPAR_CONTROLLER_HOST_OS=darwin EPAR_HOST_TRUST_INIT_DEFERRED=1 ./start) grep -Fxq 'run ./cmd/ephemeral-action-runner start' "$fake_go_log" +grep -Fxq 'trust build=<> runner=<> os=<> deferred=<>' "$fake_go_log" nested_root="$temporary/nested-project" mkdir -p "$nested_root/.local" diff --git a/scripts/test/native-controller-cache-retention.sh b/scripts/test/native-controller-cache-retention.sh index 20f5726..7358b6c 100644 --- a/scripts/test/native-controller-cache-retention.sh +++ b/scripts/test/native-controller-cache-retention.sh @@ -150,4 +150,115 @@ for required in 'golang:latest' 'ephemeral-action-runner.manifest' 'schemaVersio [[ "$builder_source" == *"$required"* ]] || { echo "stable native-controller wrapper contract is missing: ${required}" >&2; exit 1; } done +native_smoke_root="${temporary}/native-runtime-smoke" +native_smoke_project="${native_smoke_root}/project" +native_smoke_bin="${native_smoke_root}/bin" +mkdir -p "${native_smoke_project}/scripts/host-trust" "${native_smoke_project}/scripts/docker" "${native_smoke_project}/scripts/bootstrap-trust" "${native_smoke_project}/cmd" "${native_smoke_project}/internal" "$native_smoke_bin" +cp "$builder" "${native_smoke_project}/scripts/build-native-controller.sh" +: >"${native_smoke_project}/scripts/docker/dev.Dockerfile" +: >"${native_smoke_project}/go.mod" +: >"${native_smoke_project}/go.sum" +cat >"${native_smoke_project}/scripts/host-trust/wrapper-lib.sh" <<'SH' +#!/usr/bin/env bash +EPAR_HOST_TRUST_POST_INIT_CONFIG="" +EPAR_BUILD_TRUST_FEED_DIR="" +EPAR_RUNNER_TRUST_FEED_DIR="" +epar_host_trust_config_path() { printf '%s/.local/config.yml\n' "$1"; } +epar_host_trust_prepare() { EPAR_HOST_TRUST_POST_INIT_CONFIG="$(epar_host_trust_config_path "$1")"; } +epar_host_trust_post_init() { [[ -n "$EPAR_HOST_TRUST_POST_INIT_CONFIG" ]] && "$EPAR_HOST_TRUST_HELPER" sync --project-root "$1" --config "$EPAR_HOST_TRUST_POST_INIT_CONFIG" >/dev/null; } +epar_host_trust_cleanup() { :; } +epar_host_trust_host_os() { printf '%s\n' linux; } +SH +cat >"${native_smoke_project}/scripts/host-trust/host-trust-feed.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + sync) + if [[ " $* " == *" --purpose build "* ]]; then + printf 'bootstrap\n' >>"$FAKE_HELPER_LOG" + printf '{}\n' >"$FAKE_BOOTSTRAP_FEED" + printf '%s\n' "$FAKE_BOOTSTRAP_FEED" + else + printf 'post-init\n' >>"$FAKE_HELPER_LOG" + fi + ;; + watch) + trap 'exit 0' INT TERM + while :; do sleep 1; done + ;; + *) + echo "unexpected host-trust helper command: $*" >&2 + exit 1 + ;; +esac +SH +cat >"${native_smoke_bin}/docker" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'CALL' >>"$FAKE_DOCKER_LOG" +printf ' <%s>' "$@" >>"$FAKE_DOCKER_LOG" +printf '\n' >>"$FAKE_DOCKER_LOG" +case "${1:-}" in + image) + printf 'sha256:%064d\n' 0 + ;; + pull|build) + ;; + run) + output_directory="" + for argument in "$@"; do + case "$argument" in + *:/out) output_directory="${argument%:/out}" ;; + esac + done + if [[ " $* " == *' /bootstrap/main.go '* ]]; then + printf 'bootstrap trust\n' + printf 'fake bootstrap trust\n' >"${output_directory}/ca.pem" + elif [[ " $* " == *' go build '* ]]; then + cat >"${output_directory}/ephemeral-action-runner" <<'NATIVE' +#!/usr/bin/env bash +set -euo pipefail +printf 'runtime build=<%s> runner=<%s> os=<%s> deferred=<%s> args=<%s>\n' "${EPAR_BUILD_TRUST_FEED:-}" "${EPAR_HOST_TRUST_FEED:-}" "${EPAR_CONTROLLER_HOST_OS:-}" "${EPAR_HOST_TRUST_INIT_DEFERRED:-}" "$*" >>"${FAKE_NATIVE_LOG:?}" +if [[ "${1:-}" == init ]]; then + mkdir -p .local + printf '%s\n' 'image:' ' hostTrustMode: overlay' ' hostTrustScopes: [system, user]' >.local/config.yml +fi +NATIVE + chmod +x "${output_directory}/ephemeral-action-runner" + fi + ;; + *) + echo "unexpected fake Docker command: $*" >&2 + exit 1 + ;; +esac +SH +chmod +x "${native_smoke_project}/scripts/build-native-controller.sh" "${native_smoke_project}/scripts/host-trust/host-trust-feed.sh" "${native_smoke_bin}/docker" + +native_smoke_env=( + "PATH=${native_smoke_bin}:$PATH" + "EPAR_GOMOD_VOLUME=native-smoke-gomod" + "EPAR_GOCACHE_VOLUME=native-smoke-gocache" + 'EPAR_BOOTSTRAP_MIN_FREE_BYTES=1' + "FAKE_HELPER_LOG=${native_smoke_root}/helper.log" + "FAKE_BOOTSTRAP_FEED=${native_smoke_root}/bootstrap-feed.json" + "FAKE_DOCKER_LOG=${native_smoke_root}/docker.log" + "FAKE_NATIVE_LOG=${native_smoke_root}/native.log" + 'EPAR_BUILD_TRUST_FEED=stale-build' + 'EPAR_HOST_TRUST_FEED=stale-runner' + 'EPAR_CONTROLLER_HOST_OS=darwin' + 'EPAR_HOST_TRUST_INIT_DEFERRED=1' +) +(cd "$native_smoke_project" && env "${native_smoke_env[@]}" scripts/build-native-controller.sh start) +grep -Fxq 'runtime build=<> runner=<> os=<> deferred=<> args=' "${native_smoke_root}/native.log" +grep -Fxq 'bootstrap' "${native_smoke_root}/helper.log" +[[ "$(wc -l <"${native_smoke_root}/helper.log" | tr -d ' ')" == 1 ]] || { echo 'ordinary cached-native start unexpectedly used a runtime trust bridge' >&2; exit 1; } +grep -Fq ':/feed/current.json:ro>' "${native_smoke_root}/docker.log" +grep -Fq ' ' "${native_smoke_root}/docker.log" + +: >"${native_smoke_root}/helper.log" +(cd "$native_smoke_project" && env "${native_smoke_env[@]}" scripts/build-native-controller.sh init) +grep -Fxq 'runtime build=<> runner=<> os=<> deferred=<> args=' "${native_smoke_root}/native.log" +grep -Fxq 'post-init' "${native_smoke_root}/helper.log" + echo "Unix native-controller cache retention contract passed" diff --git a/start b/start index 51ebd71..652cbca 100755 --- a/start +++ b/start @@ -51,16 +51,23 @@ if ! go_usable "${GO_BIN}"; then exit 1 fi -epar_host_trust_prepare "${script_dir}" "${controller_command}" "${epar_args[@]}" -trap epar_host_trust_cleanup EXIT INT TERM -if [[ -n "${EPAR_BUILD_TRUST_FEED_DIR}" || -n "${EPAR_RUNNER_TRUST_FEED_DIR}" ]]; then - export EPAR_CONTROLLER_HOST_OS="$(epar_host_trust_host_os)" +# The local controller reads the host trust stores itself. Feed variables are +# exclusively for the legacy controller-in-Docker bridge, so do not let a +# caller's bridge state bypass native first-run preflight or stale its build. +unset EPAR_BUILD_TRUST_FEED EPAR_HOST_TRUST_FEED EPAR_CONTROLLER_HOST_OS EPAR_HOST_TRUST_INIT_DEFERRED + +# The native controller resolves operational and runner trust directly. Keep +# the established explicit-init post-write verification, but never prepare a +# pre-wizard feed for the embedded first-run start continuation. +if [[ "$controller_command" != "init" ]]; then + exec "${GO_BIN}" run ./cmd/ephemeral-action-runner "${epar_args[@]}" fi -if [[ -n "${EPAR_BUILD_TRUST_FEED_DIR}" ]]; then export EPAR_BUILD_TRUST_FEED="${EPAR_BUILD_TRUST_FEED_DIR}/current.json"; fi -if [[ -n "${EPAR_RUNNER_TRUST_FEED_DIR}" ]]; then export EPAR_HOST_TRUST_FEED="${EPAR_RUNNER_TRUST_FEED_DIR}/current.json"; fi + +epar_host_trust_prepare "${script_dir}" "$controller_command" "${epar_args[@]}" +trap epar_host_trust_cleanup EXIT INT TERM status=0 "${GO_BIN}" run ./cmd/ephemeral-action-runner "${epar_args[@]}" || status=$? -if [[ "$status" == "0" && "$controller_command" == "init" ]]; then +if [[ "$status" == "0" ]]; then epar_host_trust_post_init "${script_dir}" || status=$? fi epar_host_trust_cleanup From efdf3874c797b73ca4d79a9b846171ead4d0da50 Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 1 Aug 2026 01:19:59 +0800 Subject: [PATCH 16/22] fix: harden sandbox identity and multi-config state --- README.md | 2 + cmd/ephemeral-action-runner/main.go | 10 +- cmd/ephemeral-action-runner/storage.go | 2 +- .../storage_external.go | 34 ++- docs/advanced/docker-registry-mirrors.md | 2 +- docs/configuration.md | 4 +- docs/operations.md | 6 +- docs/providers/docker-sandboxes.md | 31 +- docs/security.md | 4 +- docs/storage.md | 2 +- docs/troubleshooting.md | 56 +++- docs/usage.md | 2 + internal/filelock/filelock.go | 24 ++ internal/filelock/filelock_test.go | 23 ++ internal/image/build.go | 56 +++- internal/image/buildx.go | 169 +++++++++-- internal/image/buildx_test.go | 165 ++++++++-- .../image/docker_output_tag_claim_test.go | 185 ++++++++++++ internal/image/docker_sandboxes.go | 13 +- internal/image/docker_sandboxes_scope_test.go | 31 ++ internal/image/docker_sandboxes_test.go | 131 ++++++++ internal/image/storage_catalog.go | 256 ++++++++++++++-- internal/pool/controller_lock.go | 148 +++++++-- internal/pool/controller_lock_test.go | 284 ++++++++++++++++-- internal/pool/host_trust_test.go | 1 + internal/pool/lifecycle_cleanup.go | 26 +- internal/pool/lifecycle_cleanup_test.go | 108 +++++++ internal/pool/lifecycle_state.go | 81 ++++- .../pool/lifecycle_state_identity_test.go | 78 +++++ internal/pool/manager.go | 47 ++- internal/pool/manager_test.go | 53 ++++ internal/pool/runner_script_test.go | 9 +- internal/pool/state/store.go | 3 +- internal/pool/state/store_test.go | 35 +++ .../provider/dockersandboxes/live_test.go | 92 ++++++ internal/provider/dockersandboxes/provider.go | 26 +- .../provider/dockersandboxes/provider_test.go | 58 +++- internal/storage/catalog/catalog.go | 17 +- internal/storage/catalog/catalog_test.go | 23 ++ templates/docker-sandboxes/Dockerfile | 9 + .../guest/configure-runner.sh | 36 ++- .../guest/prepare-template.sh | 29 +- .../docker-sandboxes/guest/run-runner.sh | 32 +- .../guest/template-entrypoint.sh | 16 +- .../docker-sandboxes/guest/verify-template.sh | 20 ++ templates/docker-sandboxes/helpers.sha256 | 10 +- 46 files changed, 2248 insertions(+), 201 deletions(-) create mode 100644 internal/image/docker_output_tag_claim_test.go create mode 100644 internal/image/docker_sandboxes_scope_test.go create mode 100644 internal/pool/lifecycle_state_identity_test.go diff --git a/README.md b/README.md index b66db9e..5f1072c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ The normal path is a source archive plus Docker. EPAR's first run opens a guided - Install and start Docker. - For stronger isolation, also install [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) to enable the Docker Sandboxes provider. +On macOS or Linux, the first Docker Sandboxes runner may trigger operating-system or security-tool prompts for runtime helpers such as `mkfs.ext4`, `mkfs.erofs`, and `containerd-shim-nerdbox-v1`; macOS may say the helper “is an app downloaded from the Internet.” These are used to create the runner's private Docker filesystem, unpack its read-only template filesystem, and launch the sandbox VM. Confirm that each executable belongs to the installed Docker Sandboxes runtime and that any displayed file target is sandbox-owned before approving it. Denying a required helper prevents that sandbox from starting, and EPAR fails closed without registering it; preserve and clean any diagnostic runtime state through EPAR's exact cleanup path. See the [Docker Sandboxes provider guide](docs/providers/docker-sandboxes.md#private-filesystem-and-vm-helper-approval) and [troubleshooting](docs/troubleshooting.md#docker-sandboxes-creation-fails-after-a-runtime-helper-prompt). + ### 2. Download EPAR From the [EPAR releases page](https://github.com/solutionforest/ephemeral-action-runner/releases), download GitHub's **Source code (zip)** or **Source code (tar.gz)** for the release you want. Extract it and open a terminal in the extracted folder. diff --git a/cmd/ephemeral-action-runner/main.go b/cmd/ephemeral-action-runner/main.go index b436e2a..fa42c90 100644 --- a/cmd/ephemeral-action-runner/main.go +++ b/cmd/ephemeral-action-runner/main.go @@ -17,7 +17,6 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/invocation" "github.com/solutionforest/ephemeral-action-runner/internal/logging" "github.com/solutionforest/ephemeral-action-runner/internal/pool" - poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" "github.com/solutionforest/ephemeral-action-runner/internal/provider" "github.com/solutionforest/ephemeral-action-runner/internal/provider/registry" "github.com/solutionforest/ephemeral-action-runner/internal/storage" @@ -504,13 +503,6 @@ func newManagerWithLifecycleState(configPath, projectRoot string, dryRun bool, g } client = gh.New(cfg.GitHub) } - var lifecycleState *poolstate.Store - if !dryRun && openLifecycleState { - lifecycleState, err = pool.OpenLifecycleState(projectRoot, resolvedConfigPath) - if err != nil { - return nil, err - } - } runtime, err := logging.NewRuntime(logging.Options{ Directory: config.ProjectPath(projectRoot, cfg.Logging.Directory), ManagerSinks: loggingSinks(cfg.Logging.ManagerSinks), @@ -535,7 +527,7 @@ func newManagerWithLifecycleState(configPath, projectRoot string, dryRun bool, g Lifecycle: providerRuntime.Lifecycle, PolicyManager: providerRuntime.PolicyManager, Storage: providerRuntime.Storage, - LifecycleState: lifecycleState, + LifecycleStateEnabled: !dryRun && openLifecycleState, GitHub: client, ProjectRoot: projectRoot, ConfigPath: resolvedConfigPath, diff --git a/cmd/ephemeral-action-runner/storage.go b/cmd/ephemeral-action-runner/storage.go index 6dac7ba..d809efc 100644 --- a/cmd/ephemeral-action-runner/storage.go +++ b/cmd/ephemeral-action-runner/storage.go @@ -132,7 +132,7 @@ func runStorage(args []string) error { if staleTemplateReceiptWarning != "" { snapshot.Warnings = append(snapshot.Warnings, staleTemplateReceiptWarning) } - collectExternalStorage(&snapshot, *providerFlag) + collectExternalStorage(&snapshot, *providerFlag, configPath) protectConfiguredSandboxTemplates(&snapshot, selections) catalogValue, catalogErr := addCatalogStorage(&snapshot, *providerFlag, now) if catalogErr != nil { diff --git a/cmd/ephemeral-action-runner/storage_external.go b/cmd/ephemeral-action-runner/storage_external.go index fe63b58..4e47f99 100644 --- a/cmd/ephemeral-action-runner/storage_external.go +++ b/cmd/ephemeral-action-runner/storage_external.go @@ -24,9 +24,9 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/storage/inventory" ) -func collectExternalStorage(snapshot *inventory.Snapshot, providerFilter string) { +func collectExternalStorage(snapshot *inventory.Snapshot, providerFilter, configPath string) { if providerFilter == "" || providerFilter == "docker-container" || providerFilter == "docker-sandboxes" || providerFilter == "wsl" { - collectDockerStorage(snapshot, providerFilter) + collectDockerStorage(snapshot, providerFilter, configPath) } if providerFilter == "" || providerFilter == "docker-sandboxes" { collectDockerSandboxesStorage(snapshot) @@ -39,7 +39,7 @@ func collectExternalStorage(snapshot *inventory.Snapshot, providerFilter string) } } -func collectDockerStorage(snapshot *inventory.Snapshot, providerFilter string) { +func collectDockerStorage(snapshot *inventory.Snapshot, providerFilter, configPath string) { const surfaceID = "docker-engine" snapshot.Surfaces = append(snapshot.Surfaces, storage.Surface{ ID: surfaceID, @@ -93,7 +93,7 @@ func collectDockerStorage(snapshot *inventory.Snapshot, providerFilter string) { collectDockerVolumeRecords(snapshot, surfaceID, volumes) } } - collectDedicatedBuildxStorage(snapshot, surfaceID) + collectDedicatedBuildxStorage(snapshot, surfaceID, configPath) } type dockerDiskUsageVolume struct { @@ -196,14 +196,27 @@ func sameStorageProjectRoot(labelRoot, projectRoot string) bool { return left == right } -func collectDedicatedBuildxStorage(snapshot *inventory.Snapshot, surfaceID string) { - metadata, err := artifactimage.LoadBuildxMetadata(snapshot.ProjectRoot) +func collectDedicatedBuildxStorage(snapshot *inventory.Snapshot, surfaceID, configPath string) { + metadata, err := artifactimage.LoadBuildxMetadataForConfig(snapshot.ProjectRoot, configPath) if err != nil { if !os.IsNotExist(err) { snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR Buildx ownership metadata is invalid; no builder cache is trusted: %v", err)) } + } else { + collectBuildxMetadataStorage(snapshot, surfaceID, metadata, metadata.ConfigID, metadata.EPARConfigPath, false) + } + legacy, legacyErr := artifactimage.LoadLegacyBuildxMetadata(snapshot.ProjectRoot) + if legacyErr != nil { + if !os.IsNotExist(legacyErr) { + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Legacy EPAR Buildx ownership metadata is invalid and was left untouched: %v", legacyErr)) + } return } + snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("Legacy project-scoped EPAR Buildx builder %q is retained for explicit cleanup and is not reused by config-scoped controllers", legacy.Builder)) + collectBuildxMetadataStorage(snapshot, surfaceID, legacy, legacy.ProjectRoot, artifactimage.LegacyBuildxMetadataPath(snapshot.ProjectRoot), true) +} + +func collectBuildxMetadataStorage(snapshot *inventory.Snapshot, surfaceID string, metadata artifactimage.BuildxMetadata, ownerID, evidence string, legacy bool) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() output, err := exec.CommandContext(ctx, "docker", "buildx", "du", "--builder", metadata.Builder, "--format", "json").Output() @@ -221,15 +234,18 @@ func collectDedicatedBuildxStorage(snapshot *inventory.Snapshot, surfaceID strin snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR Buildx cache limit for %q is invalid: %v", metadata.Builder, limitErr)) } artifact := storage.Artifact{ - ID: externalStorageID("buildx-cache", metadata.Builder, metadata.ProjectRoot), + ID: externalStorageID("buildx-cache", metadata.Builder, ownerID), SurfaceID: surfaceID, Kind: storage.ArtifactBuildKitCache, - Target: storage.Target{Kind: storage.TargetBuildKitRecord, Locator: metadata.Builder, Identity: metadata.Builder, Fingerprint: metadata.ProjectRoot + "\x00" + metadata.CacheLimit, Match: storage.MatchExact}, - Ownership: storage.Ownership{Kind: storage.OwnershipExact, OwnerID: metadata.ProjectRoot, Evidence: artifactimage.BuildxMetadataPath(snapshot.ProjectRoot)}, + Target: storage.Target{Kind: storage.TargetBuildKitRecord, Locator: metadata.Builder, Identity: metadata.Builder, Fingerprint: ownerID + "\x00" + metadata.CacheLimit, Match: storage.MatchExact}, + Ownership: storage.Ownership{Kind: storage.OwnershipExact, OwnerID: ownerID, Evidence: evidence}, SizeBytes: total, LastUsedAt: snapshot.CollectedAt, Protections: []storage.Protection{{Kind: storage.ProtectionLock, Detail: "dedicated BuildKit enforces its configured garbage-collection ceiling"}}, } + if legacy { + artifact.Protections = append(artifact.Protections, storage.Protection{Kind: storage.ProtectionOperator, Detail: "legacy project-scoped builder requires explicit operator cleanup"}) + } snapshot.Artifacts = append(snapshot.Artifacts, artifact) if limitErr == nil && total > uint64(cacheLimit) { snapshot.Warnings = append(snapshot.Warnings, fmt.Sprintf("EPAR Buildx cache %q currently uses %d bytes above its %d-byte configured ceiling; BuildKit garbage collection is authoritative", metadata.Builder, total, uint64(cacheLimit))) diff --git a/docs/advanced/docker-registry-mirrors.md b/docs/advanced/docker-registry-mirrors.md index ed01828..a33e470 100644 --- a/docs/advanced/docker-registry-mirrors.md +++ b/docs/advanced/docker-registry-mirrors.md @@ -141,7 +141,7 @@ docker: A mirror cannot bypass registry authorization. -For Docker Hub private images, keep doing `docker login` inside the GitHub Actions job with repository or organization secrets. Host-side `docker login` is not copied into EPAR runners, and EPAR does not bake Docker credentials into images. +For Docker Hub private images, keep doing `docker login` inside the GitHub Actions job with repository or organization secrets when the provider honors guest-scoped Docker credentials. Host-side `docker login` is not copied into EPAR runners, and EPAR does not bake Docker credentials into images. Docker Sandboxes' host security proxy may instead replace the guest's Docker Hub authorization with the host `sbx login` identity; see [Docker Hub Credentials and the Host Proxy](../providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). Private pulls can use a mirror in two common ways: diff --git a/docs/configuration.md b/docs/configuration.md index 3bc79bb..aafdcda 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,8 @@ EPAR reads a small, strict YAML subset: indentation uses spaces, unknown section EPAR chooses the first available configuration path in this order: `--config `, `EPAR_CONFIG`, `./.local/config.yml`, then `~/.config/ephemeral-action-runner/config.yml`. A relative file path in configuration is resolved from the project root when EPAR consumes it. `~` and `~/...` are expanded for the configuration path, `github.privateKeyPath`, and each `image.trustedCaCertificatePaths` entry; do not assume they expand in other configuration properties. +The canonical config path owns its lifecycle-state namespace and may have only one active controller, even if its contents change while that controller runs. A separate host-wide prefix reservation prevents another config or project from using the same normalized `pool.namePrefix`. Distinct configs with distinct prefixes may run concurrently; use unique routing labels and separate log directories so jobs and diagnostics remain unambiguous. + `github`, `image`, `pool`, `storage`, `logging`, `runner`, `security`, `provider`, `docker`, `dockerSandboxes`, and `timeouts` are the only accepted top-level sections. `security` contains only the `runnerGroup` subsection. Values are strings unless this reference says integer, number, boolean, or list. Quote a value when it needs YAML-like punctuation; EPAR removes one matching pair of single or double quotes. `pool.logDir` is a deprecated compatibility input. If `logging.directory` is absent, EPAR uses it and emits a warning; using both is rejected. `pool.vmPrefix` is an accepted alias for `pool.namePrefix`. `image.profile` and the old `docker-socket` provider are rejected rather than silently migrated. @@ -187,7 +189,7 @@ If the complete subsection is absent, EPAR warns and uses the strict recommended - Docker Sandboxes requires `runner.ephemeral: true`, `security.runnerGroup.enforcement: enforce`, a valid desired Catthehacker image, policy generation, resource values, and a lowercase-compatible pool prefix. - `image.sourcePlatform` requires `image.sourceType: docker-image`; all byte-size fields require a positive `B`, `KiB`, `MiB`, `GiB`, or `TiB` value. - Host-trust overlay requires a non-empty, duplicate-free scope list and `runner.ephemeral: true`; `user` is not supported on Linux. -- `pool.namePrefix` is an ownership boundary. Tart, WSL, and Docker Container use the configured prefix to select legacy owned resources; Docker Sandboxes uses its durable ledger of exact owned identities. Do not share a prefix between controllers or assume broad prefix cleanup is safe. +- `pool.namePrefix` is a host-wide controller and ownership boundary. Tart, WSL, and Docker Container use the configured prefix to select legacy owned resources; Docker Sandboxes uses its durable ledger of exact owned identities. EPAR rejects concurrent reuse across configs, projects, and providers; do not assume broad prefix cleanup is safe. - `runner.labels` must never be empty, even when `runner.noDefaultLabels` is false. ## Provider defaults diff --git a/docs/operations.md b/docs/operations.md index 465e098..9c9e5af 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -27,7 +27,9 @@ The supervisor reports when GitHub assigns a job and when the ephemeral runner f `pool.instances` is a strict cap on local physical instances, not just ready GitHub runners. Provisioning, ready, draining, quarantined, and cleanup-pending instances all consume a slot. A busy runner retained during a trust-generation change also keeps its slot until it finishes or can be safely removed. -Only one controller may manage a canonical configuration, provider, and `pool.namePrefix` on a host. Use a distinct unique prefix for an intentionally independent pool; never start a second controller with the same identity. +Only one controller may manage a canonical configuration path on a host, even if that file is edited to select a different provider or prefix while the first controller is running. A second host-wide lock also reserves the normalized `pool.namePrefix`, so separate configs and projects can run concurrently only when every independent pool has a distinct prefix. Lock failures report the current owner metadata without exposing configuration contents. + +Before upgrading an existing checkout to a release that introduces or changes controller locking or lifecycle-state identity, stop the older controller for that same project/config and wait for its normal shutdown to finish. A pre-change process cannot participate in a lock protocol it does not implement, so starting the new binary concurrently could migrate state beneath it. This restriction is per managed config and prefix; an unrelated controller in another checkout with a distinct prefix does not need to be stopped. At startup and before a replacement, EPAR compares provider inventory with exact GitHub runner records. Healthy pairs are adopted. Proven stopped or unregistered resources are removed. An ambiguous resource is quarantined and consumes capacity instead of being deleted or replaced. @@ -43,6 +45,8 @@ ephemeral-action-runner logs list Add `--no-github` to `status` when you need a local-only view. By default, host logs live under `work/logs`; manager events are console-first and instance/build transcripts are file artifacts. A failed launch or readiness check appends bounded guest diagnostics to the relevant instance log. See [Logging](logging.md) for locations, formats, retention, and shipping. +When multiple configs run concurrently, give each one a distinct `logging.directory` as well as a distinct prefix and workflow-routing label. Config-specific lifecycle state and build workspaces remain isolated, while the host resource catalog retains exact shared-artifact references. + ## Clean up safely ```bash diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index 62b3ea8..83d28fb 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -42,6 +42,12 @@ EPAR recommends this provider in the wizard by capability, not by an operating-s The wizard builds and imports the template. The recipes in `templates/docker-sandboxes` are build inputs, not prebuilt images. +## Private Filesystem and VM Helper Approval + +Every sandbox receives a private Docker daemon backed by its own Linux filesystem image and a read-only template filesystem. During first creation on macOS or Linux, Docker Sandboxes may launch helpers including `mkfs.ext4` to format the private Docker disk image, `mkfs.erofs` to construct an EROFS template snapshot, and `containerd-shim-nerdbox-v1` to launch and manage the sandbox VM. The operating system, endpoint-security software, or application-control policy may ask the signed-in user to approve each helper; macOS Gatekeeper may say that the executable “is an app downloaded from the Internet.” These commands are launched by the Docker Sandboxes runtime, not by a workflow and not directly by EPAR. With the current Homebrew `sbx` package on macOS, the runtime and shim are installed beneath `/opt/homebrew/Caskroom/sbx//`, sandbox state is beneath Docker Sandboxes' user data directories, the ext4 target resembles `~/.sbx/run/d/containerd/.../images/-docker.img`, and the EROFS target resembles `~/.sbx/run/d/containerd/.../snapshots//layer.erofs`. + +Review the complete executable and any target shown by every prompt. Approve it only when the executable belongs to the Docker Sandboxes installation you intentionally installed and any file target is beneath that runtime's sandbox data directory. A formatter must not target a physical device such as `/dev/disk*`, another user-data path, or an unrelated file. The configured `dockerSandboxes.dockerDisk` value is the sparse logical maximum for the private Docker filesystem, not an immediate allocation of that entire size. Denying or blocking any required helper prevents that sandbox from starting and commonly surfaces through `sbx` as `500 Internal Server Error: failed to run sandbox container`; EPAR then fails closed without registering the runner. Correct the host approval policy and retry the exact EPAR command rather than running a formatter or shim manually. + ## Minimal Configuration Start with `./start` or [`configs/docker-sandboxes.example.yml`](../../configs/docker-sandboxes.example.yml). Configuration expresses the desired source; EPAR stores immutable build and cache identities in its local receipt. @@ -79,7 +85,7 @@ dockerSandboxes: ## Normal Workflow 1. Run `./start` with no config and select Docker Sandboxes when its tooling and diagnostics pass. Choose a Catthehacker profile or tag and optional custom install scripts; review the non-blocking physical-growth estimate, sparse logical limits, reserve, confidence, and expected duration. -2. The wizard writes the desired configuration. Embedded `./start` then enters the ordinary provisioning path, performs authoritative storage admission, builds and imports the template, and activates it only after exact readback. +2. The wizard writes the desired configuration. Embedded `./start` then enters the ordinary provisioning path, performs authoritative storage admission, builds and imports the template, and activates it only after exact readback. On macOS or Linux, review the narrowly scoped helper prompts described in [Private Filesystem and VM Helper Approval](#private-filesystem-and-vm-helper-approval) if the host presents them. 3. Prewarm the selected template without GitHub registration: ```powershell @@ -90,14 +96,37 @@ dockerSandboxes: Each allocation receives an empty owner-restricted staging directory, but Actions `_work` stays on the guest filesystem. EPAR verifies the guest, confirms that the configured sandbox-scoped policy rules are present, and verifies the private daemon and runner trust policy before requesting a short-lived registration token. With `image.hostTrustMode: overlay`, the common pool lifecycle installs the selected roots, verifies the immutable generation, and maintains the job-start lease. With the setting omitted or disabled, the template carries an explicit disabled-policy marker and does not install the trust hook. The token remains on the native host except for registration through `sbx exec` standard input. +The listener identity is explicit and self-consistent: `agent` owns its home, XDG, runtime, and Docker configuration directories, and every workflow action and shell command inherits those exact paths. Template construction removes Docker credentials inherited from source-image user homes and verification rejects reusable artifacts that retain registry authentication or point identity-derived paths at another user. A workflow login can therefore write only to the disposable sandbox's Docker client configuration, and that file disappears with the sandbox. Registry authorization can still be changed by Docker Sandboxes' host-side credential proxy as described below. + +Docker Sandboxes can automatically forward the host SSH agent when its shared daemon inherits `SSH_AUTH_SOCK`. That would expose a host credential capability to every sandbox created by that daemon, so EPAR rejects any guest containing `SSH_AUTH_SOCK`, `SSH_AUTH_SOCK_GATEWAY`, `SSH_AGENT_PID`, or `/run/ssh-agent.sock`. EPAR removes these variables when it launches Docker Sandboxes commands, but it cannot repair an already-running daemon that another shell or tool started with forwarding enabled. Coordinate with other Docker Sandboxes users on the host, stop the shared daemon, and restart it from a sanitized environment before retrying EPAR: + +```sh +sbx daemon stop +env -u SSH_AUTH_SOCK -u SSH_AUTH_SOCK_GATEWAY -u SSH_AGENT_PID sbx daemon start --detach +``` + +Do not relax the verification or merely delete the relay socket: the gateway setting is itself a forwarding capability and the daemon's inherited environment is authoritative for subsequently created sandboxes. + +## Docker Hub Credentials and the Host Proxy + +Docker Sandboxes interposes a host-side security proxy on registry traffic. Current Docker Sandboxes releases may replace an `Authorization` header created by `docker login` inside the guest with the host's Docker Sandboxes credential. Docker's credential documentation describes this as intentional isolation: the credential is injected by the proxy and does not enter the sandbox. For Docker Hub, the proxy uses the host `sbx login` session; registry credentials stored with `sbx secret set -g --registry ...` are likewise global and apply to every new sandbox. + +This means a job can report `Login Succeeded`, have a correctly owned `/home/agent/.docker/config.json`, and still receive `insufficient_scope: authorization failed` when pulling a private Docker Hub image. Passing `docker --config /home/agent/.docker` does not bypass the proxy. The decisive host-side diagnostic is a Docker Sandboxes daemon message that it is overriding the client-supplied registry credential with a host credential. This behavior is independent of CA copying, TLS, network reachability, and CPU emulation when the request reaches Docker Hub and returns an authorization response. + +EPAR deliberately rejects Docker Sandboxes global secrets because they would expose one shared credential to unrelated workflow sandboxes. It also does not copy a workflow secret back to the host or silently weaken the proxy. For workflows that require repository-scoped Docker Hub credentials, use Docker Container or another provider that honors the disposable runner's Docker client configuration. If all Docker Sandboxes on a trusted single-tenant host are intentionally allowed to share one least-privilege Docker Hub identity, an operator may instead authenticate the host with `sbx login`; coordinate that change across every controller using the same Docker Sandboxes runtime. Do not make this host-wide change merely to repair one job, and do not configure a global registry secret while EPAR's global-secret isolation check is enabled. + Template construction uses two independent trust paths. EPAR's project-owned BuildKit builder automatically receives host system roots for Docker Hub, GHCR, and the other pinned registries used by the build. The native controller downloads the locked Actions runner and `tini`, verifies their SHA-256 values, and then supplies them as local build inputs; the Dockerfile does not perform remote HTTPS downloads. BuildKit streams the runner template directly to an attestation-free, verified archive; Docker Sandboxes does not require or retain a Docker staging image. Separate cache-backed BuildKit targets produce the max-mode provenance, SBOM, and software inventory without loading the runner image into Docker Engine. After `sbx template load` succeeds and EPAR reads back the exact imported template, startup housekeeping removes the transient archive workspace while retaining the active template and compact receipt evidence. Initial creation and every replacement trust the authoritative Sandbox cache readback, so the expected absence of a Docker image does not block a runner. Superseded templates are removed only after no configuration, lease, or live sandbox references them. +The opt-in `TestLiveRunnerTemplateIsolation` proof also exercises authenticated Docker-client state across separate commands. Set `EPAR_LIVE_DOCKER_SANDBOXES_REGISTRY_IMAGE` to an immutable Distribution Registry image reference and `EPAR_LIVE_DOCKER_SANDBOXES_HTPASSWD_IMAGE` to an immutable image containing `htpasswd`, in addition to the existing live-test template, digest, and staging variables. The test generates credentials in memory, creates a registry inside the sandbox-private daemon, logs in through standard input, pushes and separately pulls a private image, logs out, verifies the registry auth entry is absent, and exactly removes its registry container and images. + ## Limitations - `networkBaseline: open` permits public egress, which can exfiltrate secrets or data exposed to the workflow. Use least-privilege runner groups and secrets, and choose `balanced` with narrow allow rules for higher-risk workloads. - Docker Sandboxes template cache storage is shared host state; it is not a per-sandbox root-disk measurement. +- macOS ARM64 remains preview-only until lifecycle, replacement, exact-cleanup, and an authenticated-registry path compatible with Docker Sandboxes' host credential proxy are recorded for the current template and runtime. +- Per-job Docker Hub credentials may be overridden by Docker Sandboxes' host credential proxy. Use a provider that honors guest-scoped Docker credentials when jobs must authenticate with different Docker Hub identities. - A stopped sandbox is diagnostic state, not proof of deletion. Unknown state consumes capacity and blocks replacement. - `EPAR_DISABLE_DOCKER_SANDBOXES=1` fails admission closed during an incident or compatibility problem. diff --git a/docs/security.md b/docs/security.md index 0e63fc8..3bcec27 100644 --- a/docs/security.md +++ b/docs/security.md @@ -32,6 +32,8 @@ Docker Container uses a privileged outer container with a private inner Docker d Docker Sandboxes places the listener, guest filesystem, and private Docker daemon inside a dedicated microVM sandbox. It provides EPAR's strongest current host boundary and materially strengthens host isolation relative to Docker Container. The first-run wizard recommends it when the supported-platform, Docker, and machine-readable `sbx` readiness checks pass; startup then performs the remaining storage, template, policy-rule, runtime, and registration admission checks and fails closed. This selection rule is separate from independent platform certification and does not claim that every host combination has received the same real-host validation. +Docker Sandboxes may forward a host SSH agent when its shared daemon inherits `SSH_AUTH_SOCK`. EPAR strips SSH-agent variables from child commands and rejects any sandbox exposing the socket, gateway, or agent PID; operators must restart an already-running daemon with those variables unset rather than weakening the check. + Tart runs jobs inside VMs on Apple Silicon macOS. That is a stronger host boundary than Docker Container, but workflows still control the guest and any secrets exposed to the job. WSL2 has a weaker isolation story than one full VM per job. Treat the WSL provider as trusted-job infrastructure unless your environment has reviewed and accepted that model. @@ -54,4 +56,4 @@ Docker registry mirrors are optional infrastructure outside EPAR. Treat them as Do not assume a mirror makes private image pulls safe or anonymous. A private image still needs authorization from the workflow's `docker login` or from credentials configured on the mirror itself. If the mirror is configured with upstream registry credentials, secure the mirror because it may be able to serve private images that credential can access. -Host-side Docker login state is not copied into EPAR instances. Keep Docker Hub, cloud registry, and package registry credentials in GitHub secrets or in a deliberately secured mirror service. +Host-side Docker login state is not copied into EPAR instances. Keep Docker Hub, cloud registry, and package registry credentials in GitHub secrets or in a deliberately secured mirror service. Docker Sandboxes is an exception at the authorization boundary: its host security proxy may replace a guest's Docker Hub authorization with the host `sbx login` identity without copying that credential into the guest. Use Docker Container when each job must supply an independent Docker Hub identity; see the [Docker Sandboxes provider guide](providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). diff --git a/docs/storage.md b/docs/storage.md index 238f7a5..987963f 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -31,7 +31,7 @@ The exact host resource catalog lives in the platform's per-user state directory Older prefix-era resources are never adopted automatically. Use `storage prune --legacy` to produce their exact preview; execution requires the preview plan hash. The Docker Sandboxes base template `docker/sandbox-templates:shell-docker` is always protected. -EPAR image builds use a project-scoped Buildx builder with persisted ownership metadata, an exact registry/trust configuration digest, and BuildKit garbage collection capped by `storage.buildCacheLimit`. Its running BuildKit control container is intentional reusable build infrastructure, not a runner. EPAR prunes only that exact builder; it never selects, modifies, or prunes Docker's shared/default builder. Active CA material lives under `.local/storage/buildkit-certs/`. The no-Go controller similarly uses project-scoped Go module and build-cache volumes, bounded by `storage.goCacheLimit`; shared or explicitly overridden caches are report-only. +EPAR image builds use a config-scoped Buildx builder with persisted ownership metadata, an exact registry/trust configuration digest, and BuildKit garbage collection capped by `storage.buildCacheLimit`. Its running BuildKit control container is intentional reusable build infrastructure, not a runner. EPAR prunes only that exact builder; it never selects, modifies, or prunes Docker's shared/default builder. Metadata, BuildKit configuration, and active CA material live under `.local/storage/buildx/`, `.local/storage/buildkit/`, and `.local/storage/buildkit-certs//`. Project-scoped builders recorded by metadata schema 1–3 are retained, reported by storage inventory, and never silently adopted by a config-scoped controller; remove them only through explicit storage cleanup after confirming no older EPAR controller uses them. The no-Go controller similarly uses project-scoped Go module and build-cache volumes, bounded by `storage.goCacheLimit`; shared or explicitly overridden caches are report-only. Docker Sandboxes builds directly to one transient archive, so no Docker staging image is expected. After the archive is imported and the exact Sandbox cache identity is read back, EPAR removes the archive workspace while retaining the active imported template and compact receipt evidence. A completely verified interrupted archive can resume at import; partial archives remain inactive and are reclaimed as owned temporary work. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 85c6116..5b7080c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,6 +9,9 @@ Start with the symptom that most closely matches the failure. Regardless of prov - [A Docker workload fails with an architecture error](#a-docker-workload-fails-with-an-architecture-error) - [Docker Sandboxes is unavailable or its preflight fails](#docker-sandboxes-is-unavailable-or-its-preflight-fails) - [Docker Sandboxes rejects template, policy, or capacity](#docker-sandboxes-rejects-template-policy-or-capacity) +- [Docker Sandboxes creation fails after a runtime-helper prompt](#docker-sandboxes-creation-fails-after-a-runtime-helper-prompt) +- [Docker Sandboxes rejects a staging workspace because SSH-agent forwarding is present](#docker-sandboxes-rejects-a-staging-workspace-because-ssh-agent-forwarding-is-present) +- [Docker Hub login succeeds but a private pull is denied in Docker Sandboxes](#docker-hub-login-succeeds-but-a-private-pull-is-denied-in-docker-sandboxes) - [An idle runner reports GitHub or Sandbox health warnings](#an-idle-runner-reports-github-or-sandbox-health-warnings) - [A scheduled image check or update fails](#a-scheduled-image-check-or-update-fails) - [A runner is held for diagnostics or an acknowledgement](#a-runner-is-held-for-diagnostics-or-an-acknowledgement) @@ -119,6 +122,53 @@ An imported Docker Sandboxes template does not require a matching Docker image. Capacity admission accounts for estimated incremental physical growth on each measurable backing filesystem plus the fixed `storage.minimumFree` reserve. Docker Sandboxes root and inner-Docker sizes are independent sparse logical maxima and are not added as immediate host usage. Inspect the reported physical surface, run the matching `storage status` and prune-preview commands, or deliberately retry only that invocation with `--allow-insufficient-storage`. Avoid broad cleanup commands: they can delete stopped containers and intentionally retained resources. +## Docker Sandboxes creation fails after a runtime-helper prompt + +### Symptom + +On macOS or Linux, host security asks whether to allow a Docker Sandboxes helper such as `mkfs.ext4`, `mkfs.erofs`, or `containerd-shim-nerdbox-v1`; macOS may say that the helper “is an app downloaded from the Internet.” After a required prompt is denied or blocked, EPAR reports `create docker sandbox failed`, and `sbx` may report `500 Internal Server Error: failed to run sandbox container`. The runner is neither registered nor marked ready. + +### Diagnosis and remediation + +Docker Sandboxes uses `mkfs.ext4` to create an ext4 filesystem inside each sandbox's private Docker disk-image file, `mkfs.erofs` to construct the read-only template snapshot, and `containerd-shim-nerdbox-v1` to launch and manage the sandbox VM. Expected file targets are regular sandbox-owned files beneath the Docker Sandboxes runtime data directory—for example, current macOS releases may use `~/.sbx/run/d/containerd/.../images/-docker.img` and `~/.sbx/run/d/containerd/.../snapshots//layer.erofs`. With the current Homebrew `sbx` package, the runtime and shim are beneath `/opt/homebrew/Caskroom/sbx//`. A formatter must not target a physical device such as `/dev/disk*`, an EPAR checkout, a home-directory document, or another unrelated path. + +If each executable belongs to the Docker Sandboxes installation you intentionally installed and any displayed target is the expected sandbox-owned file, allow the operation through the host's security or application-control prompt, then rerun the same EPAR start or verification command if creation already failed. Do not invoke a formatter or shim yourself, disable host security broadly, or approve a command with an unfamiliar target. The configured private Docker disk is sparse, so its logical maximum does not mean the formatter immediately consumes that amount of physical storage. + +If no prompt appeared, or approval still produces the 500 error, preserve the failed runner evidence and inspect the Docker Sandboxes daemon/client logs and `sbx diagnose --output json`; the same top-level error can also represent a runtime, capacity, or host-policy failure. See [Private Filesystem and VM Helper Approval](providers/docker-sandboxes.md#private-filesystem-and-vm-helper-approval) for the provider contract. + +## Docker Sandboxes rejects a staging workspace because SSH-agent forwarding is present + +### Symptom + +Sandbox creation reaches `verify dedicated docker sandbox staging workspace` and fails with a message that host SSH-agent forwarding is not permitted. Diagnostics may show `SSH_AUTH_SOCK=/run/ssh-agent.sock` or `SSH_AUTH_SOCK_GATEWAY=...` inside the guest even though the imported template does not define them. + +### Diagnosis and remediation + +Docker Sandboxes may forward the host SSH agent when its shared daemon inherits the host's agent environment. EPAR rejects the resulting sandbox because the forwarded socket or gateway could let a workflow use host SSH credentials. This is not evidence that the staging mount is missing or read-only, and deleting only `/run/ssh-agent.sock` is insufficient when the forwarding gateway remains configured. + +Coordinate the interruption with every process using the shared Docker Sandboxes daemon, then restart it with all forwarding variables removed and retry EPAR: + +```sh +sbx daemon stop +env -u SSH_AUTH_SOCK -u SSH_AUTH_SOCK_GATEWAY -u SSH_AGENT_PID sbx daemon start --detach +``` + +EPAR strips these variables from Docker Sandboxes commands it launches, but an already-running daemon retains the environment with which another shell or tool started it. Do not disable this admission check or forward an agent into a reusable runner template. If the failed creation predates the immutable-receipt fix, preserve its reported sandbox UUID and use exact provider cleanup; never delete a same-name resource by prefix alone. + +## Docker Hub login succeeds but a private pull is denied in Docker Sandboxes + +### Symptom + +A workflow's Docker login step reports `Login Succeeded`, but a later pull of a private Docker Hub image fails with `insufficient_scope: authorization failed`, `pull access denied`, or an equivalent authorization response. The same workflow and credentials may succeed with Docker Container or a GitHub-hosted runner. Using `docker --config /home/agent/.docker pull ...` produces the same denial. + +### Diagnosis and remediation + +First verify only metadata, never credential contents: the listener should run as `agent` with `HOME=/home/agent` and `DOCKER_CONFIG=/home/agent/.docker`, and a post-login config should be owned by `agent` with restrictive permissions. If Docker reaches the registry and returns an authorization response, do not investigate CA copying unless an `x509` or TLS error is also present. + +Inspect the host Docker Sandboxes daemon log for a message that the proxy is overriding a client-supplied registry credential with a host credential. When that message is present, the workflow credential was written correctly but cannot control the pull: Docker Sandboxes intentionally substitutes the host-side credential. An explicit guest config path cannot bypass that policy. + +Use Docker Container for workflows that require independent, per-job Docker Hub credentials. On a trusted single-tenant Docker Sandboxes host, an operator can instead choose one least-privilege Docker Hub identity with access to every required image and authenticate Docker Sandboxes with `sbx login`; this changes shared host runtime behavior, so coordinate it with every controller that uses the same `sbx` daemon. EPAR rejects global `sbx` secrets and never copies GitHub Actions secrets back to the host. See [Docker Hub Credentials and the Host Proxy](providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). + ## An idle runner reports GitHub or Sandbox health warnings A GitHub 429/5xx response or an `sbx` command timeout makes runner health temporarily unknown; it does not prove that the Actions listener stopped. EPAR keeps the exact runner, lets a trust lease expire closed when it cannot refresh it, and retries. Cleanup for an inactive listener requires two consecutive guest probes that successfully execute and explicitly report the process stopped. Review the instance guest transcript when warnings repeat; do not delete the runner merely because one API or Sandbox inspection failed. @@ -199,12 +249,12 @@ Do not disable certificate verification. First identify which trust boundary fai For a no-Go native-controller build, `./start` automatically reads host system roots, excludes explicitly distrusted certificates, validates the short-lived feed in an offline container, and mounts only the resulting CA bundle into the Go compiler container. Runner CA inheritance remains independent. If the build still reports an unknown issuer, inspect `work/logs/epar-native-controller-build.log`: the wrapper prints the requested host, presented certificate subject and issuer, SHA-256 fingerprint, validity, and verification result, and on Windows it lists matching roots from `LocalMachine\Root` and `CurrentUser\Root`. A remaining failure means the expected issuer was absent, distrusted, malformed, expired, or not the certificate actually presented; EPAR never disables TLS verification or retries insecurely. -For an EPAR Buildx failure, leave `image.hostTrustMode` unchanged. EPAR automatically supplies host system roots to its project-owned builder and prints the full build transcript path before `docker buildx build`. The console and error report include a bounded redacted tail. Inspect the underlying `x509` line together with the registry host, builder identity, and active trust generation: +For an EPAR Buildx failure, leave `image.hostTrustMode` unchanged. EPAR automatically supplies host system roots to its config-owned builder and prints the full build transcript path before `docker buildx build`. The console and error report include a bounded redacted tail. Inspect the underlying `x509` line together with the registry host, builder identity, and active trust generation: ```powershell docker buildx ls -Get-Content .local/storage/buildx.json -Get-Content .local/storage/buildkitd.toml +Get-ChildItem .local/storage/buildx -Recurse -Filter metadata.json | Get-Content +Get-ChildItem .local/storage/buildkit -Recurse -Filter buildkitd.toml | Get-Content ``` The owned metadata records the exact registry set, configuration digest, certificate bundle, and trust generation. Rerunning the same command reconciles that exact builder and preserves its BuildKit state; EPAR never changes Docker's shared/default builder. If the source-image `docker pull` itself fails before Buildx starts, configure the authorized CA in the host daemon because builder trust cannot repair host-daemon trust. diff --git a/docs/usage.md b/docs/usage.md index 5feda9d..e86fc0f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -70,6 +70,8 @@ go run ./cmd/ephemeral-action-runner start --config .local\ci.yml --instances 2 If `--instances` is omitted, `start`, `pool up`, and `pool verify` use `pool.instances` from the selected config. EPAR resolves configuration from `--config`, `EPAR_CONFIG`, `.local/config.yml`, then `~/.config/ephemeral-action-runner/config.yml`. Tracked files in `configs/` are examples; keep App values and key paths in an ignored local file. See [Configuration](configuration.md) for every setting and [Runner Group Security](runner-groups.md) before broadening repository access. +Multiple configs from the same checkout may run concurrently when they use different canonical config paths, unique `pool.namePrefix` values, unique workflow-routing labels, and preferably separate log directories. EPAR rejects a second controller for the same config path or prefix before provisioning or cleanup can mutate provider state. Config-scoped BuildKit builders and transient workspaces keep divergent registry, trust, and cache settings isolated. + Storage-consuming commands fail before their provider side effects when an authoritative physical surface cannot retain `storage.minimumFree`. The one-invocation `--allow-insufficient-storage` option keeps all probes and warnings but permits only storage admission to continue; provider diagnostics, GitHub policy, ownership, lifecycle, and cleanup protections remain enforced. The option is available on `start`, `pool up`, `pool verify`, `image update`, `image build`, and `image update-upstream`, including the equivalent `./start ...` wrapper forms. Each normal start also reconciles interrupted exact-owned work and retires unreferenced superseded artifacts after replacement readback. Use `./start storage status` to inspect the result. `./start storage prune --legacy` previews prefix-era resources, which remain manual and require the displayed plan hash before execution. diff --git a/internal/filelock/filelock.go b/internal/filelock/filelock.go index 40bd1b2..0b9c58e 100644 --- a/internal/filelock/filelock.go +++ b/internal/filelock/filelock.go @@ -4,6 +4,7 @@ package filelock import ( "errors" "fmt" + "io" "os" "sync" ) @@ -38,6 +39,29 @@ func Acquire(path string) (*Lock, error) { return &Lock{file: file}, nil } +// ReplaceContent atomically with respect to this lock replaces the lock-file +// payload while retaining ownership of the platform lock. This matters on +// Windows, where writing the locked byte range through a second file handle +// can fail even when both handles belong to the same process. +func (lock *Lock) ReplaceContent(content []byte) error { + if lock == nil || lock.file == nil { + return errors.New("file lock is not open") + } + if err := lock.file.Truncate(0); err != nil { + return fmt.Errorf("truncate lock file: %w", err) + } + if _, err := lock.file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek lock file: %w", err) + } + if _, err := lock.file.Write(content); err != nil { + return fmt.Errorf("write lock file: %w", err) + } + if err := lock.file.Sync(); err != nil { + return fmt.Errorf("sync lock file: %w", err) + } + return nil +} + // Close releases the lock. func (lock *Lock) Close() error { if lock == nil { diff --git a/internal/filelock/filelock_test.go b/internal/filelock/filelock_test.go index 94c4efb..b6a0e4f 100644 --- a/internal/filelock/filelock_test.go +++ b/internal/filelock/filelock_test.go @@ -57,3 +57,26 @@ func TestCloseAllowsReacquireAndIsIdempotent(t *testing.T) { t.Fatalf("Close second: %v", err) } } + +func TestReplaceContentUsesHeldLockFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "metadata.lock") + lock, err := Acquire(path) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + defer lock.Close() + + if err := lock.ReplaceContent([]byte("first payload that is longer\n")); err != nil { + t.Fatalf("ReplaceContent first: %v", err) + } + if err := lock.ReplaceContent([]byte("short\n")); err != nil { + t.Fatalf("ReplaceContent second: %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if got, want := string(content), "short\n"; got != want { + t.Fatalf("lock metadata = %q, want %q", got, want) + } +} diff --git a/internal/image/build.go b/internal/image/build.go index a453b94..22f57c4 100644 --- a/internal/image/build.go +++ b/internal/image/build.go @@ -154,31 +154,75 @@ func (m *Coordinator) buildDockerContainerImageUntimed(ctx context.Context, opts value.HostTrust = hostTrustMetadata(snapshot) manifest = &value } + manifestHash, err := ManifestHash(*manifest) + if err != nil { + return err + } + var releaseOutputClaim func() error + claimContext := ctx + if !m.DryRun { + claimContext, releaseOutputClaim, err = m.claimDockerOutputTag(ctx, m.Config.Image.OutputImage, manifestHash) + if err != nil { + return err + } + } + releaseClaim := func() error { + if releaseOutputClaim == nil { + return nil + } + err := releaseOutputClaim() + releaseOutputClaim = nil + return err + } targetImage := m.Config.Image.OutputImage if m.hostTrustEnabled() { targetImage = temporaryDockerImageTag(targetImage, snapshot.Generation, attempt) } - if err := m.buildDockerContainerImageAttempt(ctx, upstreamDir, buildLogPath, builder, targetImage, *manifest, snapshot); err != nil { - return err + if err := m.buildDockerContainerImageAttempt(claimContext, upstreamDir, buildLogPath, builder, targetImage, *manifest, snapshot); err != nil { + return errors.Join(err, releaseClaim()) + } + if cause := context.Cause(claimContext); cause != nil { + return errors.Join(cause, releaseClaim()) } if m.DryRun || !m.hostTrustEnabled() { + if !m.DryRun { + if err := m.recordCurrentArtifact(claimContext, manifestHash); err != nil { + return errors.Join(fmt.Errorf("record current Docker Container artifact ownership: %w", err), releaseClaim()) + } + } + if err := releaseClaim(); err != nil { + return fmt.Errorf("release Docker output image tag claim: %w", err) + } return nil } - current, err := m.resolveHostTrust(ctx) + current, err := m.resolveHostTrust(claimContext) if err != nil { _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) - return err + return errors.Join(err, releaseClaim()) } if current.Generation != snapshot.Generation { m.infof("host trust changed during image build (%s -> %s); discarding attempt %d/%d\n", snapshot.Generation, current.Generation, attempt, attempts) _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) + if err := releaseClaim(); err != nil { + return fmt.Errorf("release Docker output image tag claim: %w", err) + } continue } - if err := m.runHost(ctx, "docker", "image", "tag", targetImage, m.Config.Image.OutputImage); err != nil { + if cause := context.Cause(claimContext); cause != nil { _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) - return err + return errors.Join(cause, releaseClaim()) + } + if err := m.runHost(claimContext, "docker", "image", "tag", targetImage, m.Config.Image.OutputImage); err != nil { + _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) + return errors.Join(err, releaseClaim()) } _ = m.runHostQuiet(context.Background(), "docker", "image", "rm", "-f", targetImage) + if err := m.recordCurrentArtifact(claimContext, manifestHash); err != nil { + return errors.Join(fmt.Errorf("record current Docker Container artifact ownership: %w", err), releaseClaim()) + } + if err := releaseClaim(); err != nil { + return fmt.Errorf("release Docker output image tag claim: %w", err) + } m.infof("image build complete: %s is available in `docker image ls`\n", m.Config.Image.OutputImage) return nil } diff --git a/internal/image/buildx.go b/internal/image/buildx.go index 355df77..28526cf 100644 --- a/internal/image/buildx.go +++ b/internal/image/buildx.go @@ -19,11 +19,13 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/config" "github.com/solutionforest/ephemeral-action-runner/internal/filelock" "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" ) const ( - buildxMetadataSchemaVersion = 3 - buildkitImageReference = "moby/buildkit:buildx-stable-1" + buildxMetadataSchemaVersion = 4 + legacyBuildxMaxSchemaVersion = 3 + buildkitImageReference = "moby/buildkit:buildx-stable-1" ) type BuildxMetadata struct { @@ -31,6 +33,8 @@ type BuildxMetadata struct { Builder string `json:"builder"` Driver string `json:"driver"` ProjectRoot string `json:"projectRoot"` + ConfigID string `json:"configId,omitempty"` + EPARConfigPath string `json:"eparConfigPath,omitempty"` CacheLimit string `json:"cacheLimit"` ConfigPath string `json:"configPath"` ConfigSHA256 string `json:"configSha256,omitempty"` @@ -43,12 +47,67 @@ type BuildxMetadata struct { LastReconciledAt time.Time `json:"lastReconciledAt,omitempty"` } +// BuildxMetadataPath returns the dedicated Buildx metadata path for the +// project's default configuration. Callers that accept --config must use +// BuildxMetadataPathForConfig so independent controllers never share mutable +// BuildKit state. func BuildxMetadataPath(projectRoot string) string { + path, err := BuildxMetadataPathForConfig(projectRoot, filepath.Join(projectRoot, ".local", "config.yml")) + if err != nil { + return filepath.Join(projectRoot, ".local", "storage", "buildx", "invalid", "metadata.json") + } + return path +} + +func BuildxMetadataPathForConfig(projectRoot, configPath string) (string, error) { + scope, err := resolveBuildxScope(projectRoot, configPath) + if err != nil { + return "", err + } + return scope.metadataPath, nil +} + +// LegacyBuildxMetadataPath identifies the project-scoped metadata used before +// Buildx resources became config-scoped. It remains discoverable for exact +// inventory and operator-directed cleanup; current controllers never mutate +// or adopt the legacy builder implicitly. +func LegacyBuildxMetadataPath(projectRoot string) string { return filepath.Join(projectRoot, ".local", "storage", "buildx.json") } +func LoadLegacyBuildxMetadata(projectRoot string) (BuildxMetadata, error) { + content, err := os.ReadFile(LegacyBuildxMetadataPath(projectRoot)) + if err != nil { + return BuildxMetadata{}, err + } + var metadata BuildxMetadata + if err := json.Unmarshal(content, &metadata); err != nil { + return BuildxMetadata{}, err + } + canonicalRoot, err := storagecatalog.CanonicalPath(projectRoot) + if err != nil { + return BuildxMetadata{}, err + } + metadataRoot, err := storagecatalog.CanonicalPath(metadata.ProjectRoot) + if err != nil { + return BuildxMetadata{}, fmt.Errorf("invalid legacy EPAR Buildx ownership metadata") + } + if metadata.SchemaVersion < 1 || metadata.SchemaVersion > legacyBuildxMaxSchemaVersion || metadata.Builder != legacyBuildxBuilderName(metadata.ProjectRoot) || metadata.Driver != "docker-container" || metadataRoot != canonicalRoot || strings.TrimSpace(metadata.ConfigPath) == "" { + return BuildxMetadata{}, fmt.Errorf("invalid legacy EPAR Buildx ownership metadata") + } + return metadata, nil +} + func LoadBuildxMetadata(projectRoot string) (BuildxMetadata, error) { - content, err := os.ReadFile(BuildxMetadataPath(projectRoot)) + return LoadBuildxMetadataForConfig(projectRoot, filepath.Join(projectRoot, ".local", "config.yml")) +} + +func LoadBuildxMetadataForConfig(projectRoot, configPath string) (BuildxMetadata, error) { + scope, err := resolveBuildxScope(projectRoot, configPath) + if err != nil { + return BuildxMetadata{}, err + } + content, err := os.ReadFile(scope.metadataPath) if err != nil { return BuildxMetadata{}, err } @@ -56,13 +115,29 @@ func LoadBuildxMetadata(projectRoot string) (BuildxMetadata, error) { if err := json.Unmarshal(content, &metadata); err != nil { return BuildxMetadata{}, err } - if (metadata.SchemaVersion < 1 || metadata.SchemaVersion > buildxMetadataSchemaVersion) || metadata.Builder == "" || metadata.Driver != "docker-container" || metadata.ProjectRoot == "" || metadata.ConfigPath == "" { + if metadata.SchemaVersion != buildxMetadataSchemaVersion || metadata.Builder != "epar-"+scope.configID || metadata.Driver != "docker-container" || filepath.Clean(metadata.ProjectRoot) != scope.projectRoot || metadata.ConfigID != scope.configID || filepath.Clean(metadata.EPARConfigPath) != scope.configPath || filepath.Clean(metadata.ConfigPath) != scope.buildkitConfig { return BuildxMetadata{}, fmt.Errorf("invalid EPAR Buildx ownership metadata") } return metadata, nil } func buildxBuilderName(projectRoot string) string { + builder, err := buildxBuilderNameForConfig(projectRoot, filepath.Join(projectRoot, ".local", "config.yml")) + if err != nil { + return "epar-invalid" + } + return builder +} + +func buildxBuilderNameForConfig(projectRoot, configPath string) (string, error) { + scope, err := resolveBuildxScope(projectRoot, configPath) + if err != nil { + return "", err + } + return "epar-" + scope.configID, nil +} + +func legacyBuildxBuilderName(projectRoot string) string { canonical := filepath.Clean(projectRoot) if runtime.GOOS == "windows" { canonical = strings.ToLower(canonical) @@ -71,8 +146,60 @@ func buildxBuilderName(projectRoot string) string { return "epar-" + hex.EncodeToString(sum[:6]) } +type buildxScope struct { + configID string + projectRoot string + configPath string + metadataPath string + lockPath string + buildkitConfig string + certificateDir string +} + +func resolveBuildxScope(projectRoot, configPath string) (buildxScope, error) { + if strings.TrimSpace(configPath) == "" { + configPath = filepath.Join(projectRoot, ".local", "config.yml") + } + configID, err := storagecatalog.ConfigID(projectRoot, configPath) + if err != nil { + return buildxScope{}, err + } + canonicalRoot, err := canonicalBuildxPath(projectRoot) + if err != nil { + return buildxScope{}, err + } + canonicalConfig, err := canonicalBuildxPath(configPath) + if err != nil { + return buildxScope{}, err + } + storageRoot := filepath.Join(canonicalRoot, ".local", "storage") + buildxRoot := filepath.Join(storageRoot, "buildx", configID) + return buildxScope{ + configID: configID, + projectRoot: canonicalRoot, + configPath: canonicalConfig, + metadataPath: filepath.Join(buildxRoot, "metadata.json"), + lockPath: filepath.Join(buildxRoot, "reconcile.lock"), + buildkitConfig: filepath.Join(storageRoot, "buildkit", configID, "buildkitd.toml"), + certificateDir: filepath.Join(storageRoot, "buildkit-certs", configID), + }, nil +} + +func canonicalBuildxPath(path string) (string, error) { + return storagecatalog.CanonicalPath(path) +} + func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReferences []string) (string, error) { - builder := buildxBuilderName(m.ProjectRoot) + scope, err := resolveBuildxScope(m.ProjectRoot, m.effectiveConfigPath()) + if err != nil { + return "", fmt.Errorf("resolve Buildx configuration scope: %w", err) + } + builder := "epar-" + scope.configID + if legacy, legacyErr := LoadLegacyBuildxMetadata(m.ProjectRoot); legacyErr == nil { + m.warnf("Legacy project-scoped EPAR Buildx builder %q remains recorded at %s; it is not reused by config-scoped controllers and remains visible to storage inventory for explicit cleanup.\n", legacy.Builder, LegacyBuildxMetadataPath(m.ProjectRoot)) + } else if !os.IsNotExist(legacyErr) { + m.warnf("Legacy EPAR Buildx metadata at %s is invalid and was left untouched: %v\n", LegacyBuildxMetadataPath(m.ProjectRoot), legacyErr) + } cacheLimit := strings.TrimSpace(m.Config.Storage.BuildCacheLimit) if cacheLimit == "" { cacheLimit = "20GiB" @@ -81,14 +208,6 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference if err != nil { return "", fmt.Errorf("parse storage.buildCacheLimit: %w", err) } - effectiveLimit, err := m.effectiveProjectBuildCacheLimit() - if err != nil { - return "", fmt.Errorf("resolve shared EPAR BuildKit cache limit: %w", err) - } - if effectiveLimit < uint64(limitBytes) { - limitBytes = int64(effectiveLimit) - cacheLimit = strconv.FormatUint(effectiveLimit, 10) + "B" - } registryHosts, err := buildRegistryHosts(registryReferences) if err != nil { return "", err @@ -138,15 +257,17 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference return "", err } bundleSHA := sha256.Sum256(bundle) - certificatePath := filepath.Join(m.ProjectRoot, ".local", "storage", "buildkit-certs", trust.Generation, "ca.pem") - configPath := filepath.Join(m.ProjectRoot, ".local", "storage", "buildkitd.toml") + certificatePath := filepath.Join(scope.certificateDir, trust.Generation, "ca.pem") + configPath := scope.buildkitConfig configContent := buildkitConfig(uint64(limitBytes), trust.Generation, certificatePath, registryHosts) configSHA := sha256.Sum256(configContent) expected := BuildxMetadata{ SchemaVersion: buildxMetadataSchemaVersion, Builder: builder, Driver: "docker-container", - ProjectRoot: filepath.Clean(m.ProjectRoot), + ProjectRoot: scope.projectRoot, + ConfigID: scope.configID, + EPARConfigPath: scope.configPath, CacheLimit: cacheLimit, ConfigPath: configPath, ConfigSHA256: hex.EncodeToString(configSHA[:]), @@ -156,11 +277,17 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference RegistryHosts: registryHosts, BuildKitImageID: buildKitImageID, } - storageDirectory := filepath.Join(m.ProjectRoot, ".local", "storage") + storageDirectory := filepath.Join(scope.projectRoot, ".local", "storage") if err := validateRegularParent(storageDirectory, m.ProjectRoot); err != nil { return "", fmt.Errorf("validate EPAR storage directory: %w", err) } - reconcileLock, err := filelock.Acquire(filepath.Join(storageDirectory, "buildx.lock")) + if err := validateRegularParent(filepath.Dir(scope.lockPath), storageDirectory); err != nil { + return "", fmt.Errorf("validate EPAR Buildx lock directory: %w", err) + } + if err := validateRegularParent(filepath.Dir(configPath), storageDirectory); err != nil { + return "", fmt.Errorf("validate EPAR BuildKit configuration directory: %w", err) + } + reconcileLock, err := filelock.Acquire(scope.lockPath) if err != nil { if errors.Is(err, filelock.ErrLocked) { return "", fmt.Errorf("another EPAR process is reconciling the project Buildx builder") @@ -179,7 +306,7 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference return "", fmt.Errorf("write EPAR BuildKit configuration: %w", err) } - metadata, metadataErr := LoadBuildxMetadata(m.ProjectRoot) + metadata, metadataErr := LoadBuildxMetadataForConfig(m.ProjectRoot, m.effectiveConfigPath()) _, inspectErr := m.runHostOutput(ctx, "docker", "buildx", "inspect", builder) if inspectErr == nil { if metadataErr != nil { @@ -221,7 +348,7 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference if err != nil { return "", err } - if err := writeAtomicFile(BuildxMetadataPath(m.ProjectRoot), append(content, '\n'), 0o600); err != nil { + if err := writeAtomicFile(scope.metadataPath, append(content, '\n'), 0o600); err != nil { return "", fmt.Errorf("publish EPAR Buildx ownership metadata: %w", err) } return builder, nil @@ -231,6 +358,8 @@ func buildxOwnershipMatches(actual, expected BuildxMetadata) bool { return actual.Builder == expected.Builder && actual.Driver == expected.Driver && filepath.Clean(actual.ProjectRoot) == expected.ProjectRoot && + actual.ConfigID == expected.ConfigID && + filepath.Clean(actual.EPARConfigPath) == expected.EPARConfigPath && filepath.Clean(actual.ConfigPath) == expected.ConfigPath } diff --git a/internal/image/buildx_test.go b/internal/image/buildx_test.go index 1b24408..d8be3aa 100644 --- a/internal/image/buildx_test.go +++ b/internal/image/buildx_test.go @@ -10,34 +10,111 @@ import ( ) func TestBuildxBuilderNameIsStableAndProjectScoped(t *testing.T) { - first := buildxBuilderName(filepath.Join("one", "project")) - second := buildxBuilderName(filepath.Join("one", "project")) - other := buildxBuilderName(filepath.Join("two", "project")) + project := filepath.Join("one", "project") + config := filepath.Join(project, ".local", "config.yml") + first, err := buildxBuilderNameForConfig(project, config) + if err != nil { + t.Fatal(err) + } + second, err := buildxBuilderNameForConfig(project, config) + if err != nil { + t.Fatal(err) + } + other, err := buildxBuilderNameForConfig(filepath.Join("two", "project"), filepath.Join("two", "project", ".local", "config.yml")) + if err != nil { + t.Fatal(err) + } if first != second { t.Fatalf("builder names are not stable: %q != %q", first, second) } if first == other { t.Fatalf("different projects share builder name %q", first) } - if !strings.HasPrefix(first, "epar-") || len(first) != len("epar-")+12 { + if !strings.HasPrefix(first, "epar-") || len(first) != len("epar-")+24 { t.Fatalf("builder name %q does not use the bounded EPAR identity", first) } } +func TestBuildxScopeSeparatesConfigurationsInOneProject(t *testing.T) { + root := t.TempDir() + firstConfig := filepath.Join(root, ".local", "config.yml") + secondConfig := filepath.Join(root, ".local", "config.docker-container.yml") + first, err := resolveBuildxScope(root, firstConfig) + if err != nil { + t.Fatal(err) + } + second, err := resolveBuildxScope(root, secondConfig) + if err != nil { + t.Fatal(err) + } + firstAgain, err := resolveBuildxScope(root, firstConfig) + if err != nil { + t.Fatal(err) + } + if first.configID != firstAgain.configID || first.metadataPath != firstAgain.metadataPath || first.lockPath != firstAgain.lockPath { + t.Fatalf("same configuration scope is not stable: first=%+v again=%+v", first, firstAgain) + } + for _, pair := range [][2]string{{first.configID, second.configID}, {first.metadataPath, second.metadataPath}, {first.lockPath, second.lockPath}, {first.buildkitConfig, second.buildkitConfig}, {first.certificateDir, second.certificateDir}} { + if pair[0] == pair[1] { + t.Fatalf("distinct configuration scopes unexpectedly share %q", pair[0]) + } + } +} + +func TestBuildxScopeUsesCanonicalConfigurationIdentity(t *testing.T) { + root := t.TempDir() + realConfig := filepath.Join(root, ".local", "config.yml") + linkConfig := filepath.Join(root, ".local", "config-link.yml") + if err := os.MkdirAll(filepath.Dir(realConfig), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(realConfig, []byte("provider: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realConfig, linkConfig); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + realScope, err := resolveBuildxScope(root, realConfig) + if err != nil { + t.Fatal(err) + } + linkScope, err := resolveBuildxScope(root, linkConfig) + if err != nil { + t.Fatal(err) + } + if realScope != linkScope { + t.Fatalf("Buildx scope split through a config symlink: real=%+v link=%+v", realScope, linkScope) + } +} + func TestLoadBuildxMetadataRequiresExactOwnershipFields(t *testing.T) { root := t.TempDir() - path := BuildxMetadataPath(root) + configPath := filepath.Join(root, ".local", "config.yml") + path, err := BuildxMetadataPathForConfig(root, configPath) + if err != nil { + t.Fatal(err) + } if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } + scope, err := resolveBuildxScope(root, configPath) + if err != nil { + t.Fatal(err) + } + builder, err := buildxBuilderNameForConfig(root, configPath) + if err != nil { + t.Fatal(err) + } metadata := BuildxMetadata{ - SchemaVersion: buildxMetadataSchemaVersion, - Builder: buildxBuilderName(root), - Driver: "docker-container", - ProjectRoot: root, - CacheLimit: "64GiB", - ConfigPath: filepath.Join(root, ".local", "storage", "buildkitd.toml"), - CreatedAt: time.Now().UTC(), + SchemaVersion: buildxMetadataSchemaVersion, + Builder: builder, + Driver: "docker-container", + ProjectRoot: scope.projectRoot, + ConfigID: scope.configID, + EPARConfigPath: scope.configPath, + CacheLimit: "64GiB", + ConfigPath: scope.buildkitConfig, + CreatedAt: time.Now().UTC(), } content, err := json.Marshal(metadata) if err != nil { @@ -46,7 +123,7 @@ func TestLoadBuildxMetadataRequiresExactOwnershipFields(t *testing.T) { if err := os.WriteFile(path, content, 0644); err != nil { t.Fatal(err) } - loaded, err := LoadBuildxMetadata(root) + loaded, err := LoadBuildxMetadataForConfig(root, configPath) if err != nil { t.Fatal(err) } @@ -59,11 +136,50 @@ func TestLoadBuildxMetadataRequiresExactOwnershipFields(t *testing.T) { if err := os.WriteFile(path, content, 0644); err != nil { t.Fatal(err) } - if _, err := LoadBuildxMetadata(root); err == nil { + if _, err := LoadBuildxMetadataForConfig(root, configPath); err == nil { t.Fatal("LoadBuildxMetadata accepted a shared Docker driver") } } +func TestLoadLegacyBuildxMetadataRetainsExactInventoryEvidence(t *testing.T) { + root := t.TempDir() + metadata := BuildxMetadata{ + SchemaVersion: legacyBuildxMaxSchemaVersion, + Builder: legacyBuildxBuilderName(root), + Driver: "docker-container", + ProjectRoot: root, + CacheLimit: "20GiB", + ConfigPath: filepath.Join(root, ".local", "storage", "buildkitd.toml"), + CreatedAt: time.Now().UTC(), + } + content, err := json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + path := LegacyBuildxMetadataPath(root) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + loaded, err := LoadLegacyBuildxMetadata(root) + if err != nil { + t.Fatal(err) + } + if loaded.Builder != metadata.Builder || loaded.ProjectRoot != root { + t.Fatalf("legacy metadata = %+v, want %+v", loaded, metadata) + } + metadata.Builder = "epar-unowned" + content, _ = json.Marshal(metadata) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadLegacyBuildxMetadata(root); err == nil { + t.Fatal("legacy metadata accepted a builder not derived from its project root") + } +} + func TestBuildRegistryHostsUsesDockerHubForUnqualifiedImages(t *testing.T) { got, err := buildRegistryHosts([]string{ "source:latest", @@ -120,13 +236,24 @@ func TestParseBuildxUsageBytesUsesSummaryOrSumsRecords(t *testing.T) { func TestBuildxSchemaOneMetadataIsOwnedButRequiresUpgrade(t *testing.T) { root := t.TempDir() + configPath := filepath.Join(root, ".local", "config.yml") + scope, err := resolveBuildxScope(root, configPath) + if err != nil { + t.Fatal(err) + } + builder, err := buildxBuilderNameForConfig(root, configPath) + if err != nil { + t.Fatal(err) + } expected := BuildxMetadata{ SchemaVersion: buildxMetadataSchemaVersion, - Builder: buildxBuilderName(root), + Builder: builder, Driver: "docker-container", - ProjectRoot: root, + ProjectRoot: scope.projectRoot, + ConfigID: scope.configID, + EPARConfigPath: scope.configPath, CacheLimit: "64GiB", - ConfigPath: filepath.Join(root, ".local", "storage", "buildkitd.toml"), + ConfigPath: scope.buildkitConfig, ConfigSHA256: strings.Repeat("a", 64), TrustGeneration: strings.Repeat("b", 64), CertificateBundle: filepath.Join(root, ".local", "storage", "buildkit-certs", "g", "ca.pem"), @@ -134,14 +261,14 @@ func TestBuildxSchemaOneMetadataIsOwnedButRequiresUpgrade(t *testing.T) { RegistryHosts: []string{"docker.io"}, } legacy := expected - legacy.SchemaVersion = 1 + legacy.SchemaVersion = buildxMetadataSchemaVersion - 1 legacy.ConfigSHA256 = "" legacy.TrustGeneration = "" legacy.CertificateBundle = "" legacy.CertificateSHA256 = "" legacy.RegistryHosts = nil if !buildxOwnershipMatches(legacy, expected) { - t.Fatal("schema-one EPAR metadata lost exact ownership") + t.Fatal("previous EPAR metadata lost exact ownership") } if buildxMetadataMatches(legacy, expected) { t.Fatal("schema-one EPAR metadata did not require an owned builder upgrade") diff --git a/internal/image/docker_output_tag_claim_test.go b/internal/image/docker_output_tag_claim_test.go new file mode 100644 index 0000000..5118d79 --- /dev/null +++ b/internal/image/docker_output_tag_claim_test.go @@ -0,0 +1,185 @@ +package image + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" +) + +func TestDockerOutputTagClaimAllowsSameManifestAndRejectsDivergence(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "host-state")) + project := t.TempDir() + firstPath := filepath.Join(project, ".local", "config.yml") + secondPath := filepath.Join(project, ".local", "config.docker-container.yml") + if err := os.MkdirAll(filepath.Dir(firstPath), 0o700); err != nil { + t.Fatal(err) + } + for _, path := range []string{firstPath, secondPath} { + if err := os.WriteFile(path, []byte("provider:\n type: docker-container\n"), 0o600); err != nil { + t.Fatal(err) + } + } + first := Coordinator{Config: config.Default(), ProjectRoot: project, ConfigPath: firstPath} + second := Coordinator{Config: config.Default(), ProjectRoot: project, ConfigPath: secondPath} + now := time.Date(2026, 7, 31, 2, 3, 4, 0, time.UTC) + const backendID = "docker:test-daemon" + const tag = "example/runner:current" + const firstManifest = "manifest-one" + if err := first.claimDockerOutputTagLocked(backendID, tag, firstManifest, now); err != nil { + t.Fatal(err) + } + if err := first.claimDockerOutputTagLocked(backendID, tag, firstManifest, now.Add(time.Second)); err != nil { + t.Fatalf("same configuration could not renew its stable tag claim: %v", err) + } + if err := second.claimDockerOutputTagLocked(backendID, tag, firstManifest, now.Add(2*time.Second)); err != nil { + t.Fatalf("identical manifests could not share output tag intent: %v", err) + } + err := second.claimDockerOutputTagLocked(backendID, tag, "manifest-two", now.Add(3*time.Second)) + if err == nil { + t.Fatal("divergent configuration claimed a shared mutable Docker output tag") + } + if !strings.Contains(err.Error(), secondPath) && !strings.Contains(err.Error(), firstPath) { + t.Fatalf("conflict error omitted actionable configuration path: %v", err) + } + if !strings.Contains(err.Error(), tag) || !strings.Contains(err.Error(), firstManifest) || !strings.Contains(err.Error(), "manifest-two") { + t.Fatalf("conflict error omitted tag or manifest evidence: %v", err) + } + + store, err := storagecatalog.Open("") + if err != nil { + t.Fatal(err) + } + value, err := store.Load(now.Add(4 * time.Second)) + if err != nil { + t.Fatal(err) + } + var claim storagecatalog.Resource + for _, resource := range value.Resources { + if resource.Kind == catalogDockerOutputTagClaimKind { + claim = resource + break + } + } + if claim.Key == "" || len(claim.References) != 2 || claim.ManifestHash != firstManifest { + t.Fatalf("shared exact tag claim = %#v, want one stable claim with both references", claim) + } + if err := first.releaseDockerOutputTagClaim(backendID, tag, now.Add(5*time.Second)); err != nil { + t.Fatal(err) + } + if err := second.releaseDockerOutputTagClaim(backendID, tag, now.Add(6*time.Second)); err != nil { + t.Fatal(err) + } + value, err = store.Load(now.Add(7 * time.Second)) + if err != nil { + t.Fatal(err) + } + claim = storagecatalog.Resource{} + for _, resource := range value.Resources { + if resource.Kind == catalogDockerOutputTagClaimKind { + claim = resource + break + } + } + if claim.State != storagecatalog.StateSuperseded || len(claim.References) != 0 { + t.Fatalf("released tag claim was not retained only as exact superseded cleanup evidence: %#v", claim) + } +} + +func TestDockerOutputTagClaimRejectsOtherActiveArtifactManifest(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "host-state")) + project := t.TempDir() + firstPath := filepath.Join(project, ".local", "config.yml") + secondPath := filepath.Join(project, ".local", "config.docker-container.yml") + if err := os.MkdirAll(filepath.Dir(firstPath), 0o700); err != nil { + t.Fatal(err) + } + for _, path := range []string{firstPath, secondPath} { + if err := os.WriteFile(path, []byte("provider:\n type: docker-container\n"), 0o600); err != nil { + t.Fatal(err) + } + } + second := Coordinator{Config: config.Default(), ProjectRoot: project, ConfigPath: secondPath} + now := time.Date(2026, 7, 31, 3, 4, 5, 0, time.UTC) + const backendID = "docker:test-daemon" + const tag = "example/runner:current" + store, err := storagecatalog.Open("") + if err != nil { + t.Fatal(err) + } + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + record, err := storagecatalog.RegisterConfig(value, project, firstPath, now) + if err != nil { + return err + } + resource := storagecatalog.Resource{ + BackendID: backendID, Kind: catalogDockerImageKind, Provider: "docker-container", Role: "runtime-image", + Locator: tag, Identity: "sha256:active", Custody: storagecatalog.CustodyGenerated, ManifestHash: "manifest-one", + State: storagecatalog.StateCurrent, CreatedAt: now, LastSeenAt: now, + } + if err := storagecatalog.UpsertResource(value, resource); err != nil { + return err + } + storagecatalog.ReplaceConfigRoleReferences(value, record.ID, "provider-artifact", map[string]storagecatalog.Reference{ + storagecatalog.ResourceKey(backendID, catalogDockerImageKind, resource.Identity): {ManifestHash: "manifest-one"}, + }, now) + return nil + }) + if err != nil { + t.Fatal(err) + } + err = second.claimDockerOutputTagLocked(backendID, tag, "manifest-two", now.Add(time.Second)) + if err == nil || !strings.Contains(err.Error(), firstPath) { + t.Fatalf("active artifact conflict = %v, want first configuration path", err) + } +} + +func TestDockerOutputTagClaimExpiresAfterPublisherCrash(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "host-state")) + project := t.TempDir() + firstPath := filepath.Join(project, ".local", "config.yml") + secondPath := filepath.Join(project, ".local", "config.other.yml") + if err := os.MkdirAll(filepath.Dir(firstPath), 0o700); err != nil { + t.Fatal(err) + } + for _, path := range []string{firstPath, secondPath} { + if err := os.WriteFile(path, []byte("provider:\n type: docker-container\n"), 0o600); err != nil { + t.Fatal(err) + } + } + first := Coordinator{Config: config.Default(), ProjectRoot: project, ConfigPath: firstPath} + second := Coordinator{Config: config.Default(), ProjectRoot: project, ConfigPath: secondPath} + now := time.Date(2026, 7, 31, 4, 5, 6, 0, time.UTC) + const backendID = "docker:test-daemon" + const tag = "example/runner:current" + if err := first.claimDockerOutputTagLocked(backendID, tag, "manifest-one", now); err != nil { + t.Fatal(err) + } + if err := second.claimDockerOutputTagLocked(backendID, tag, "manifest-two", now.Add(dockerOutputTagClaimLifetime-time.Second)); err == nil { + t.Fatal("fresh claim did not block a divergent manifest") + } + if err := second.claimDockerOutputTagLocked(backendID, tag, "manifest-two", now.Add(dockerOutputTagClaimLifetime)); err != nil { + t.Fatalf("expired claim still blocked a divergent manifest after publisher crash: %v", err) + } +} + +func TestNormalizedDockerTagCollapsesEquivalentNames(t *testing.T) { + tests := map[string]string{ + "epar-runner": "docker.io/library/epar-runner:latest", + "epar-runner:latest": "docker.io/library/epar-runner:latest", + "solutionforest/epar-runner": "docker.io/solutionforest/epar-runner:latest", + "index.docker.io/epar-runner:Current": "docker.io/library/epar-runner:Current", + "registry-1.docker.io/epar-runner:edge": "docker.io/library/epar-runner:edge", + "ghcr.io/SolutionForest/EPAR:edge": "ghcr.io/solutionforest/epar:edge", + "localhost:5000/epar-runner": "localhost:5000/epar-runner:latest", + } + for input, want := range tests { + if got := normalizedDockerTag(input); got != want { + t.Fatalf("normalizedDockerTag(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/image/docker_sandboxes.go b/internal/image/docker_sandboxes.go index 86dc534..92374c1 100644 --- a/internal/image/docker_sandboxes.go +++ b/internal/image/docker_sandboxes.go @@ -730,7 +730,10 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest architecture := strings.TrimPrefix(source.Platform, "linux/") tagProfile := sanitizeTemplateTag(profile) templateTag := fmt.Sprintf("epar-docker-sandboxes-catthehacker-%s:%s-%s", tagProfile, manifestHash[:16], architecture) - artifactRoot := filepath.Join(m.ProjectRoot, "work", "template-builds", "docker-sandboxes", manifestHash) + artifactRoot, err := m.dockerSandboxesArtifactRoot(manifestHash) + if err != nil { + return fmt.Errorf("resolve Docker Sandboxes template workspace: %w", err) + } if err := os.MkdirAll(artifactRoot, 0o755); err != nil { return err } @@ -1057,6 +1060,14 @@ func (m *Coordinator) buildDockerSandboxesTemplate(ctx context.Context, manifest return m.activateDockerSandboxesTemplate(ctx, manifest, source, manifestHash, artifact, metadataPath, metadataSHA, archivePath, archiveSHA, runtime) } +func (m *Coordinator) dockerSandboxesArtifactRoot(manifestHash string) (string, error) { + configID, err := storagecatalog.ConfigID(m.ProjectRoot, m.effectiveConfigPath()) + if err != nil { + return "", err + } + return filepath.Join(m.ProjectRoot, "work", "template-builds", "docker-sandboxes", configID, manifestHash), nil +} + func readVerifiedBuildEvidence(path string, maximumBytes uint64) ([]byte, error) { if maximumBytes == 0 || maximumBytes > uint64(^uint(0)>>1) { return nil, fmt.Errorf("invalid build-evidence size limit %d", maximumBytes) diff --git a/internal/image/docker_sandboxes_scope_test.go b/internal/image/docker_sandboxes_scope_test.go new file mode 100644 index 0000000..ab8573d --- /dev/null +++ b/internal/image/docker_sandboxes_scope_test.go @@ -0,0 +1,31 @@ +package image + +import ( + "path/filepath" + "testing" +) + +func TestDockerSandboxesArtifactWorkspaceIsConfigurationScoped(t *testing.T) { + project := t.TempDir() + first := &Coordinator{ProjectRoot: project, ConfigPath: filepath.Join(project, ".local", "config.yml")} + second := &Coordinator{ProjectRoot: project, ConfigPath: filepath.Join(project, ".local", "config.docker-sandboxes.yml")} + const manifestHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + firstPath, err := first.dockerSandboxesArtifactRoot(manifestHash) + if err != nil { + t.Fatal(err) + } + firstAgain, err := first.dockerSandboxesArtifactRoot(manifestHash) + if err != nil { + t.Fatal(err) + } + secondPath, err := second.dockerSandboxesArtifactRoot(manifestHash) + if err != nil { + t.Fatal(err) + } + if firstPath != firstAgain { + t.Fatalf("same configuration workspace is unstable: %q != %q", firstPath, firstAgain) + } + if firstPath == secondPath { + t.Fatalf("different configs share Docker Sandboxes workspace %q", firstPath) + } +} diff --git a/internal/image/docker_sandboxes_test.go b/internal/image/docker_sandboxes_test.go index 18afb48..62bdaad 100644 --- a/internal/image/docker_sandboxes_test.go +++ b/internal/image/docker_sandboxes_test.go @@ -102,6 +102,137 @@ func TestDockerSandboxesDockerfileUsesVerifiedLocalDownloadsAndInstallsTrustBefo } } +func TestDockerSandboxesRunnerIdentityAndCredentialHygieneContract(t *testing.T) { + templateRoot := filepath.Join("..", "..", "templates", "docker-sandboxes") + readTemplateFile := func(t *testing.T, relativePath string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join(templateRoot, relativePath)) + if err != nil { + t.Fatal(err) + } + return string(content) + } + + t.Run("environment normalization", func(t *testing.T) { + dockerfile := readTemplateFile(t, "Dockerfile") + for _, required := range []string{ + "HOME=/home/agent", + "USER=agent", + "LOGNAME=agent", + "SSH_AUTH_SOCK=", + "SSH_AUTH_SOCK_GATEWAY=", + "SSH_AGENT_PID=", + "XDG_CONFIG_HOME=/home/agent/.config", + "XDG_CACHE_HOME=/home/agent/.cache", + "XDG_DATA_HOME=/home/agent/.local/share", + "XDG_STATE_HOME=/home/agent/.local/state", + "XDG_RUNTIME_DIR=/run/user/1000", + "DOCKER_CONFIG=/home/agent/.docker", + } { + if !strings.Contains(dockerfile, required) { + t.Fatalf("Docker Sandboxes Dockerfile omitted normalized environment %q", required) + } + } + + runner := readTemplateFile(t, filepath.Join("guest", "run-runner.sh")) + for _, required := range []string{ + "unset SSH_AUTH_SOCK SSH_AUTH_SOCK_GATEWAY SSH_AGENT_PID", + "env -i", + `"HOME=${agent_home}"`, + `"USER=agent"`, + `"LOGNAME=agent"`, + `"XDG_CONFIG_HOME=${agent_home}/.config"`, + `"XDG_CACHE_HOME=${agent_home}/.cache"`, + `"XDG_DATA_HOME=${agent_home}/.local/share"`, + `"XDG_STATE_HOME=${agent_home}/.local/state"`, + `"XDG_RUNTIME_DIR=${agent_runtime_dir}"`, + `"DOCKER_CONFIG=${agent_home}/.docker"`, + } { + if !strings.Contains(runner, required) { + t.Fatalf("Docker Sandboxes listener environment omitted %q", required) + } + } + + entrypoint := readTemplateFile(t, filepath.Join("guest", "template-entrypoint.sh")) + for _, required := range []string{ + `-n "${SSH_AUTH_SOCK:-}"`, + `-n "${SSH_AUTH_SOCK_GATEWAY:-}"`, + "-e /run/ssh-agent.sock", + "host SSH-agent forwarding is not permitted", + } { + if !strings.Contains(entrypoint, required) { + t.Fatalf("Docker Sandboxes entrypoint omitted SSH-agent isolation contract %q", required) + } + } + verify := readTemplateFile(t, filepath.Join("guest", "verify-template.sh")) + for _, required := range []string{`[[ -z "${SSH_AUTH_SOCK:-}" ]]`, `[[ -z "${SSH_AUTH_SOCK_GATEWAY:-}" ]]`, `[[ -z "${SSH_AGENT_PID:-}" ]]`, `[[ ! -e /run/ssh-agent.sock && ! -L /run/ssh-agent.sock ]]`} { + if !strings.Contains(verify, required) { + t.Fatalf("Docker Sandboxes template verification omitted SSH-agent isolation contract %q", required) + } + } + }) + + t.Run("private directory ownership", func(t *testing.T) { + prepare := readTemplateFile(t, filepath.Join("guest", "prepare-template.sh")) + for _, required := range []string{ + "install -d -m 0700 -o agent -g agent", + "/home/agent/.docker", + "/home/agent/.config", + "/home/agent/.cache", + "/home/agent/.local/share", + "/home/agent/.local/state", + "/run/user/1000", + } { + if !strings.Contains(prepare, required) { + t.Fatalf("Docker Sandboxes template preparation omitted private-directory contract %q", required) + } + } + + verify := readTemplateFile(t, filepath.Join("guest", "verify-template.sh")) + if !strings.Contains(verify, `stat -c '%U:%G:%a'`) || !strings.Contains(verify, `"agent:agent:700"`) { + t.Fatal("Docker Sandboxes template verification does not enforce restrictive agent directory ownership and mode") + } + entrypoint := readTemplateFile(t, filepath.Join("guest", "template-entrypoint.sh")) + if !strings.Contains(entrypoint, "sudo -n install -d -m 0700 -o agent -g agent /run/user/1000") { + t.Fatal("Docker Sandboxes entrypoint does not recreate the agent runtime directory after a boot-time /run reset") + } + configure := readTemplateFile(t, filepath.Join("guest", "configure-runner.sh")) + if !strings.Contains(configure, "install -d -m 0700 -o agent -g agent") || !strings.Contains(configure, "/run/user/1000") { + t.Fatal("Docker Sandboxes runner configuration does not ensure the agent runtime directory exists") + } + }) + + t.Run("source credential scrubbing", func(t *testing.T) { + prepare := readTemplateFile(t, filepath.Join("guest", "prepare-template.sh")) + for _, required := range []string{ + "rm -rf -- /root/.docker /home/runner/.docker /home/agent/.docker", + "failed to scrub source Docker client configuration", + } { + if !strings.Contains(prepare, required) { + t.Fatalf("Docker Sandboxes template preparation omitted credential-scrubbing contract %q", required) + } + } + for _, forbidden := range []string{"cat /root/.docker", "cat /home/runner/.docker", "cat /home/agent/.docker", "cp /root/.docker", "cp /home/runner/.docker"} { + if strings.Contains(prepare, forbidden) { + t.Fatalf("Docker Sandboxes credential hygiene exposes or copies source credential material via %q", forbidden) + } + } + }) + + t.Run("foreign runner config rejection", func(t *testing.T) { + for _, relativePath := range []string{ + filepath.Join("guest", "template-entrypoint.sh"), + filepath.Join("guest", "run-runner.sh"), + filepath.Join("guest", "verify-template.sh"), + } { + content := readTemplateFile(t, relativePath) + if !strings.Contains(content, "/home/runner/.docker") { + t.Fatalf("%s does not reject stale foreign Docker client configuration", relativePath) + } + } + }) +} + func TestDockerSandboxesDisabledTrustPolicyIsExplicit(t *testing.T) { root := t.TempDir() coordinator := &Coordinator{ProjectRoot: root} diff --git a/internal/image/storage_catalog.go b/internal/image/storage_catalog.go index c43fc89..5fc57df 100644 --- a/internal/image/storage_catalog.go +++ b/internal/image/storage_catalog.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/solutionforest/ephemeral-action-runner/internal/config" @@ -25,11 +26,15 @@ import ( var buildxUsageSizePattern = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)([kMGTPE]?i?B)$`) const ( - catalogDockerImageKind = "docker-image" - catalogSandboxTemplateKind = "sandbox-template" - catalogWSLArtifactKind = "provider-image" - catalogTartImageKind = "tart-image" - catalogTemplateStagingKind = "template-staging-directory" + catalogDockerImageKind = "docker-image" + catalogSandboxTemplateKind = "sandbox-template" + catalogWSLArtifactKind = "provider-image" + catalogTartImageKind = "tart-image" + catalogTemplateStagingKind = "template-staging-directory" + catalogDockerOutputTagClaimKind = "docker-output-tag-claim" + dockerOutputTagClaimLifetime = 2 * time.Minute + dockerOutputTagClaimRefresh = 30 * time.Second + dockerOutputTagClaimLockTimeout = 15 * time.Second ) func (m *Coordinator) effectiveConfigPath() string { @@ -116,6 +121,211 @@ func (m *Coordinator) acquireDockerBackendLock(ctx context.Context) (string, fun }, nil } +// claimDockerOutputTag records a short-lived, exact intent to publish a Docker +// image tag. The catalog claim closes the window between checking active +// configuration references and mutating Docker's globally shared tag. A +// different configuration may share the tag only when it requests the exact +// same immutable manifest. +func (m *Coordinator) claimDockerOutputTag(ctx context.Context, tag, manifestHash string) (context.Context, func() error, error) { + tag = normalizedDockerTag(tag) + if tag == "" || strings.TrimSpace(manifestHash) == "" { + return nil, nil, errors.New("Docker output tag and manifest hash are required") + } + backendID, releaseBackend, err := m.acquireDockerBackendLock(ctx) + if err != nil { + return nil, nil, err + } + claimErr := m.claimDockerOutputTagLocked(backendID, tag, manifestHash, time.Now().UTC()) + releaseBackend() + if claimErr != nil { + return nil, nil, claimErr + } + claimContext, cancelClaim := context.WithCancelCause(ctx) + stopRefresh := make(chan struct{}) + refreshDone := make(chan struct{}) + var refreshOnce sync.Once + var refreshErr error + go func() { + defer close(refreshDone) + ticker := time.NewTicker(dockerOutputTagClaimRefresh) + defer ticker.Stop() + for { + select { + case <-stopRefresh: + return + case now := <-ticker.C: + refreshLockContext, cancelRefreshLock := context.WithTimeout(claimContext, dockerOutputTagClaimLockTimeout) + refreshBackendID, releaseRefreshLock, refreshLockErr := m.acquireDockerBackendLock(refreshLockContext) + cancelRefreshLock() + if refreshLockErr != nil { + refreshErr = fmt.Errorf("refresh Docker output-tag claim lock: %w", refreshLockErr) + cancelClaim(refreshErr) + return + } + if refreshBackendID != backendID { + releaseRefreshLock() + refreshErr = fmt.Errorf("Docker backend changed while publishing output tag %s", tag) + cancelClaim(refreshErr) + return + } + refreshErr = m.claimDockerOutputTagLocked(backendID, tag, manifestHash, now.UTC()) + releaseRefreshLock() + if refreshErr != nil { + refreshErr = fmt.Errorf("refresh Docker output-tag claim: %w", refreshErr) + cancelClaim(refreshErr) + return + } + } + } + }() + return claimContext, func() error { + refreshOnce.Do(func() { close(stopRefresh) }) + <-refreshDone + cancelClaim(nil) + releaseContext, cancelRelease := context.WithTimeout(context.Background(), dockerOutputTagClaimLockTimeout) + defer cancelRelease() + backendID, releaseBackend, err := m.acquireDockerBackendLock(releaseContext) + if err != nil { + return errors.Join(refreshErr, err) + } + defer releaseBackend() + return errors.Join(refreshErr, m.releaseDockerOutputTagClaim(backendID, tag, time.Now().UTC())) + }, nil +} + +func (m *Coordinator) claimDockerOutputTagLocked(backendID, tag, manifestHash string, now time.Time) error { + tag = normalizedDockerTag(tag) + if tag == "" || strings.TrimSpace(manifestHash) == "" { + return errors.New("Docker output tag and manifest hash are required") + } + store, err := m.hostCatalog() + if err != nil { + return err + } + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, err := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if err != nil { + return err + } + if err := m.applyCatalogConfigSettings(value, configRecord.ID); err != nil { + return err + } + for _, resource := range value.Resources { + if resource.BackendID != backendID || resource.Kind != catalogDockerImageKind || normalizedDockerTag(resource.Locator) != tag { + continue + } + for _, reference := range resource.References { + if reference.ConfigID == configRecord.ID || reference.Role != "provider-artifact" { + continue + } + referencedManifest := reference.ManifestHash + if referencedManifest == "" { + referencedManifest = resource.ManifestHash + } + if referencedManifest != "" && referencedManifest != manifestHash { + return dockerOutputTagConflict(value, reference.ConfigID, tag, referencedManifest, manifestHash) + } + } + } + claimIdentity := "tag:" + tag + claimKey := storagecatalog.ResourceKey(backendID, catalogDockerOutputTagClaimKind, claimIdentity) + var existingReferences []storagecatalog.Reference + for resourceIndex := range value.Resources { + resource := &value.Resources[resourceIndex] + if resource.Key != claimKey { + continue + } + activeReferences := resource.References[:0] + for _, reference := range resource.References { + if !reference.UpdatedAt.Add(dockerOutputTagClaimLifetime).After(now) { + continue + } + activeReferences = append(activeReferences, reference) + if reference.ConfigID == configRecord.ID || reference.ManifestHash == manifestHash { + continue + } + return dockerOutputTagConflict(value, reference.ConfigID, tag, reference.ManifestHash, manifestHash) + } + resource.References = activeReferences + existingReferences = append(existingReferences, activeReferences...) + } + claim := storagecatalog.Resource{ + Key: claimKey, BackendID: backendID, Kind: catalogDockerOutputTagClaimKind, + Provider: "docker-container", Role: "runtime-image-tag-claim", Locator: tag, Identity: claimIdentity, + Custody: storagecatalog.CustodyGenerated, ManifestHash: manifestHash, State: storagecatalog.StateCurrent, + References: existingReferences, CreatedAt: now, LastSeenAt: now, + } + if err := storagecatalog.UpsertResource(value, claim); err != nil { + return err + } + storagecatalog.ReplaceConfigRoleReferences(value, configRecord.ID, "docker-output-tag-claim", map[string]storagecatalog.Reference{ + claimKey: {ManifestHash: manifestHash}, + }, now) + return nil + }) + return err +} + +func (m *Coordinator) releaseDockerOutputTagClaim(backendID, tag string, now time.Time) error { + if strings.TrimSpace(backendID) == "" || normalizedDockerTag(tag) == "" { + return errors.New("Docker backend and output tag are required to release an output-tag claim") + } + store, err := m.hostCatalog() + if err != nil { + return err + } + _, err = store.WithLock(now, func(value *storagecatalog.Catalog) error { + configRecord, err := storagecatalog.RegisterConfig(value, m.ProjectRoot, m.effectiveConfigPath(), now) + if err != nil { + return err + } + storagecatalog.ReplaceConfigRoleReferences(value, configRecord.ID, "docker-output-tag-claim", nil, now) + return nil + }) + return err +} + +func normalizedDockerTag(tag string) string { + tag = strings.TrimSpace(tag) + if tag == "" || strings.Contains(tag, "@") { + return tag + } + if strings.LastIndex(tag, ":") <= strings.LastIndex(tag, "/") { + tag += ":latest" + } + tagSeparator := strings.LastIndex(tag, ":") + name, suffix := tag[:tagSeparator], tag[tagSeparator:] + parts := strings.Split(name, "/") + hasRegistry := len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":") || parts[0] == "localhost") + if !hasRegistry { + if len(parts) == 1 { + name = "docker.io/library/" + name + } else { + name = "docker.io/" + name + } + } else { + if parts[0] == "index.docker.io" || parts[0] == "registry-1.docker.io" { + parts[0] = "docker.io" + } + if parts[0] == "docker.io" && len(parts) == 2 { + parts = []string{"docker.io", "library", parts[1]} + } + name = strings.Join(parts, "/") + } + return strings.ToLower(name) + suffix +} + +func dockerOutputTagConflict(value *storagecatalog.Catalog, configID, tag, existingManifest, requestedManifest string) error { + path := configID + for _, configRecord := range value.Configs { + if configRecord.ID == configID { + path = configRecord.Path + break + } + } + return fmt.Errorf("Docker output image tag %q is actively claimed by configuration %s with manifest %s; requested manifest %s differs. Configure a unique image.outputImage or use matching immutable image inputs", tag, path, existingManifest, requestedManifest) +} + func backendPathID(kind, path string) (string, error) { absolute, err := filepath.Abs(path) if err != nil { @@ -929,6 +1139,10 @@ func (m *Coordinator) catalogResourceExists(ctx context.Context, resource storag return false, err } return target.Identity == resource.Identity && target.Fingerprint == resource.Fingerprint, nil + case catalogDockerOutputTagClaimKind: + // Claims are catalog-only intent records. They have no Docker object to + // inspect and are removed exactly when their reference is released. + return true, nil case catalogTartImageKind: if m.Config.Provider.Type != "tart" { return true, nil @@ -949,14 +1163,14 @@ func (m *Coordinator) catalogResourceExists(ctx context.Context, resource storag } func (m *Coordinator) enforceDedicatedBuildxCache(ctx context.Context) error { - metadata, err := LoadBuildxMetadata(m.ProjectRoot) + metadata, err := LoadBuildxMetadataForConfig(m.ProjectRoot, m.effectiveConfigPath()) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } - limitBytes, err := m.effectiveProjectBuildCacheLimit() + limitBytes, err := m.effectiveBuildCacheLimit() if err != nil { return err } @@ -977,7 +1191,7 @@ func (m *Coordinator) enforceDedicatedBuildxCache(ctx context.Context) error { return m.runHostQuiet(ctx, "docker", "buildx", "prune", "--builder", metadata.Builder, "--force", "--max-used-space", strconv.FormatUint(limitBytes, 10)+"B") } -func (m *Coordinator) effectiveProjectBuildCacheLimit() (uint64, error) { +func (m *Coordinator) effectiveBuildCacheLimit() (uint64, error) { configured := strings.TrimSpace(m.Config.Storage.BuildCacheLimit) if configured == "" { configured = "20GiB" @@ -986,29 +1200,7 @@ func (m *Coordinator) effectiveProjectBuildCacheLimit() (uint64, error) { if err != nil { return 0, err } - limit := uint64(current) - store, err := m.hostCatalog() - if err != nil { - return 0, err - } - value, err := store.Load(time.Now().UTC()) - if err != nil { - return 0, err - } - root, err := filepath.Abs(m.ProjectRoot) - if err != nil { - return 0, err - } - for _, configRecord := range value.Configs { - sameRoot := filepath.Clean(configRecord.ProjectRoot) == filepath.Clean(root) - if runtime.GOOS == "windows" { - sameRoot = strings.EqualFold(filepath.Clean(configRecord.ProjectRoot), filepath.Clean(root)) - } - if sameRoot && configRecord.BuildCacheLimitBytes > 0 && configRecord.BuildCacheLimitBytes < limit { - limit = configRecord.BuildCacheLimitBytes - } - } - return limit, nil + return uint64(current), nil } func parseBuildxUsageBytes(content []byte) (uint64, error) { @@ -1201,6 +1393,8 @@ func (m *Coordinator) removeCatalogResource(ctx context.Context, resource storag return errors.New("Tart image is running") } return m.Lifecycle.Delete(ctx, *exact) + case catalogDockerOutputTagClaimKind: + return nil default: return fmt.Errorf("automatic cleanup is not implemented for catalog resource kind %q", resource.Kind) } diff --git a/internal/pool/controller_lock.go b/internal/pool/controller_lock.go index c0c259a..820aada 100644 --- a/internal/pool/controller_lock.go +++ b/internal/pool/controller_lock.go @@ -3,45 +3,159 @@ package pool import ( "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" "fmt" "io" "os" "path/filepath" - "runtime" "strings" + "time" - "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + "github.com/solutionforest/ephemeral-action-runner/internal/filelock" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" ) -// AcquirePoolControllerLock excludes another mutating controller for the same -// canonical configuration, provider, and pool prefix on this host. +const poolControllerLockDirectory = "pool-controller-locks" + +type poolControllerLockOwner struct { + ConfigPath string `json:"configPath"` + Provider string `json:"provider"` + NamePrefix string `json:"namePrefix"` + PID int `json:"pid"` + StartedAt time.Time `json:"startedAt"` +} + +type poolControllerLocks struct { + config *filelock.Lock + prefix *filelock.Lock +} + +func (locks *poolControllerLocks) Close() error { + if locks == nil { + return nil + } + var result error + if locks.prefix != nil { + result = errors.Join(result, locks.prefix.Close()) + } + if locks.config != nil { + result = errors.Join(result, locks.config.Close()) + } + return result +} + +// AcquirePoolControllerLock excludes another mutating controller that uses +// either this canonical configuration path or this normalized pool prefix. +// Config locking prevents a second controller from bypassing an active one by +// editing provider or prefix fields in place; prefix locking protects global +// provider and GitHub runner identities across configurations and projects. func (m *Manager) AcquirePoolControllerLock() (io.Closer, error) { providerType := strings.TrimSpace(strings.ToLower(m.Config.Provider.Type)) namePrefix := strings.TrimSpace(strings.ToLower(m.Config.Pool.NamePrefix)) if providerType == "" || namePrefix == "" { return nil, fmt.Errorf("acquire pool controller lock: provider.type and pool.namePrefix are required") } + canonicalConfig, err := m.canonicalPoolControllerConfigPath() + if err != nil { + return nil, err + } + root, err := storagecatalog.DefaultRoot() + if err != nil { + return nil, fmt.Errorf("resolve EPAR state root for pool controller lock: %w", err) + } + lockRoot := filepath.Join(root, poolControllerLockDirectory) + if err := os.MkdirAll(lockRoot, 0o700); err != nil { + return nil, fmt.Errorf("create pool controller lock directory: %w", err) + } + configPath := poolControllerLockPath(lockRoot, "config", canonicalConfig) + prefixPath := poolControllerLockPath(lockRoot, "prefix", namePrefix) + configLock, err := filelock.Acquire(configPath) + if err != nil { + return nil, poolControllerLockError("configuration", canonicalConfig, configPath, err) + } + prefixLock, err := filelock.Acquire(prefixPath) + if err != nil { + _ = configLock.Close() + return nil, poolControllerLockError("pool.namePrefix", namePrefix, prefixPath, err) + } + locks := &poolControllerLocks{config: configLock, prefix: prefixLock} + owner := poolControllerLockOwner{ + ConfigPath: canonicalConfig, + Provider: providerType, + NamePrefix: namePrefix, + PID: os.Getpid(), + StartedAt: time.Now().UTC(), + } + if err := writePoolControllerLockOwner(configLock, owner); err != nil { + _ = locks.Close() + return nil, err + } + if err := writePoolControllerLockOwner(prefixLock, owner); err != nil { + _ = locks.Close() + return nil, err + } + if m.LifecycleStateEnabled && m.LifecycleState == nil { + lifecycleState, err := OpenLifecycleState(m.ProjectRoot, m.ConfigPath) + if err != nil { + _ = locks.Close() + return nil, fmt.Errorf("open lifecycle state after acquiring pool controller locks: %w", err) + } + m.LifecycleState = lifecycleState + } + return locks, nil +} + +func (m *Manager) canonicalPoolControllerConfigPath() (string, error) { configPath := strings.TrimSpace(m.ConfigPath) if configPath == "" { configPath = filepath.Join(m.ProjectRoot, ".local", "config.yml") } - canonicalConfig, err := filepath.Abs(configPath) + canonicalConfig, err := storagecatalog.CanonicalPath(configPath) + if err != nil { + return "", fmt.Errorf("acquire pool controller lock: resolve config path: %w", err) + } + return canonicalConfig, nil +} + +func poolControllerLockPath(root, kind, identity string) string { + sum := sha256.Sum256([]byte(identity)) + return filepath.Join(root, kind+"-"+hex.EncodeToString(sum[:])+".lock") +} + +func writePoolControllerLockOwner(lock *filelock.Lock, owner poolControllerLockOwner) error { + content, err := json.Marshal(owner) if err != nil { - return nil, fmt.Errorf("acquire pool controller lock: resolve config path: %w", err) + return fmt.Errorf("encode pool controller lock owner: %w", err) } - if resolved, resolveErr := filepath.EvalSymlinks(canonicalConfig); resolveErr == nil { - canonicalConfig = resolved + if err := lock.ReplaceContent(append(content, '\n')); err != nil { + return fmt.Errorf("write pool controller lock owner metadata: %w", err) } - canonicalConfig = filepath.Clean(canonicalConfig) - if runtime.GOOS == "windows" { - canonicalConfig = strings.ToLower(canonicalConfig) + return nil +} + +func poolControllerLockError(kind, identity, path string, lockErr error) error { + if !errors.Is(lockErr, filelock.ErrLocked) { + return fmt.Errorf("acquire pool controller %s lock for %q: %w", kind, identity, lockErr) } - identity := canonicalConfig + "\x00" + providerType + "\x00" + namePrefix - sum := sha256.Sum256([]byte(identity)) - syntheticPath := filepath.Join(os.TempDir(), "ephemeral-action-runner", "pool-controller", hex.EncodeToString(sum[:])+".identity") - lock, err := hosttrust.AcquireConfigLock(syntheticPath) + message := fmt.Sprintf("pool controller %s lock is already held for %q", kind, identity) + if owner, err := readPoolControllerLockOwner(path); err == nil { + message += fmt.Sprintf(" (owner config=%q provider=%q prefix=%q pid=%d startedAt=%s)", owner.ConfigPath, owner.Provider, owner.NamePrefix, owner.PID, owner.StartedAt.UTC().Format(time.RFC3339)) + } + return errors.New(message) +} + +func readPoolControllerLockOwner(path string) (poolControllerLockOwner, error) { + content, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("acquire pool controller lock for provider %q prefix %q: %w", providerType, namePrefix, err) + return poolControllerLockOwner{}, err + } + var owner poolControllerLockOwner + if err := json.Unmarshal(content, &owner); err != nil { + return poolControllerLockOwner{}, err + } + if owner.ConfigPath == "" || owner.Provider == "" || owner.NamePrefix == "" || owner.PID <= 0 || owner.StartedAt.IsZero() { + return poolControllerLockOwner{}, errors.New("invalid pool controller lock owner metadata") } - return lock, nil + return owner, nil } diff --git a/internal/pool/controller_lock_test.go b/internal/pool/controller_lock_test.go index a7565e4..8b991ff 100644 --- a/internal/pool/controller_lock_test.go +++ b/internal/pool/controller_lock_test.go @@ -2,15 +2,23 @@ package pool import ( "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" "strings" + "sync" "testing" + "time" "github.com/solutionforest/ephemeral-action-runner/internal/config" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" ) -func TestPoolControllerLockConflictsForSameProviderAndPrefix(t *testing.T) { - t.Setenv("LOCALAPPDATA", t.TempDir()) - manager := Manager{ConfigPath: "config.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-same"}}} +func TestPoolControllerLockConflictsForSameConfig(t *testing.T) { + setPoolControllerLockStateHome(t) + manager := testPoolControllerManager(t, t.TempDir(), "config.yml", "docker-container", "epar-lock-same") first, err := manager.AcquirePoolControllerLock() if err != nil { t.Fatal(err) @@ -18,12 +26,228 @@ func TestPoolControllerLockConflictsForSameProviderAndPrefix(t *testing.T) { defer first.Close() if second, err := manager.AcquirePoolControllerLock(); err == nil { _ = second.Close() - t.Fatal("second controller acquired the same provider/prefix lock") + t.Fatal("second controller acquired the same configuration lock") + } +} + +func TestPoolControllerLockConflictsWhenSameConfigChangesProviderOrPrefix(t *testing.T) { + setPoolControllerLockStateHome(t) + projectRoot := t.TempDir() + firstManager := testPoolControllerManager(t, projectRoot, "config.yml", "docker-container", "epar-lock-before") + secondManager := testPoolControllerManager(t, projectRoot, "config.yml", "docker-sandboxes", "epar-lock-after") + first, err := firstManager.AcquirePoolControllerLock() + if err != nil { + t.Fatal(err) + } + defer first.Close() + if second, err := secondManager.AcquirePoolControllerLock(); err == nil { + _ = second.Close() + t.Fatal("in-place configuration mutation bypassed the configuration lock") + } +} + +func TestPoolControllerLockConflictsForSamePrefixAcrossConfigProviderAndProject(t *testing.T) { + setPoolControllerLockStateHome(t) + firstManager := testPoolControllerManager(t, t.TempDir(), "first.yml", "docker-container", "epar-lock-shared-prefix") + secondManager := testPoolControllerManager(t, t.TempDir(), "second.yml", "docker-sandboxes", "epar-lock-shared-prefix") + first, err := firstManager.AcquirePoolControllerLock() + if err != nil { + t.Fatal(err) + } + defer first.Close() + if second, err := secondManager.AcquirePoolControllerLock(); err == nil { + _ = second.Close() + t.Fatal("second controller acquired a shared pool prefix across projects/providers") + } else if !strings.Contains(err.Error(), "pool.namePrefix") || !strings.Contains(err.Error(), "owner config=") { + t.Fatalf("prefix conflict error = %v, want owner diagnostic", err) + } +} + +func TestPoolControllerLockAllowsDistinctConfigAndPrefix(t *testing.T) { + setPoolControllerLockStateHome(t) + projectRoot := t.TempDir() + firstManager := testPoolControllerManager(t, projectRoot, "first.yml", "docker-container", "epar-lock-first") + secondManager := testPoolControllerManager(t, projectRoot, "second.yml", "docker-sandboxes", "epar-lock-second") + first, err := firstManager.AcquirePoolControllerLock() + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := secondManager.AcquirePoolControllerLock() + if err != nil { + t.Fatalf("independent pool identity was blocked: %v", err) + } + defer second.Close() +} + +func TestPoolControllerLockReleaseAllowsReacquire(t *testing.T) { + setPoolControllerLockStateHome(t) + manager := testPoolControllerManager(t, t.TempDir(), "config.yml", "docker-container", "epar-lock-release") + first, err := manager.AcquirePoolControllerLock() + if err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + second, err := manager.AcquirePoolControllerLock() + if err != nil { + t.Fatalf("reacquire after release: %v", err) + } + defer second.Close() +} + +func TestPoolControllerLockOverwritesStaleMetadataAfterBothLocksAreAcquired(t *testing.T) { + stateHome := setPoolControllerLockStateHome(t) + manager := testPoolControllerManager(t, t.TempDir(), "config.yml", "docker-container", "epar-lock-stale") + canonicalConfig, err := manager.canonicalPoolControllerConfigPath() + if err != nil { + t.Fatal(err) + } + lockRoot := filepath.Join(stateHome, poolControllerLockDirectory) + if err := os.MkdirAll(lockRoot, 0o700); err != nil { + t.Fatal(err) + } + stale := poolControllerLockOwner{ConfigPath: "stale.yml", Provider: "tart", NamePrefix: "stale-prefix", PID: 1, StartedAt: time.Unix(1, 0).UTC()} + for _, path := range []string{ + poolControllerLockPath(lockRoot, "config", canonicalConfig), + poolControllerLockPath(lockRoot, "prefix", "epar-lock-stale"), + } { + if err := os.WriteFile(path, mustJSON(t, stale), 0o600); err != nil { + t.Fatal(err) + } + } + lock, err := manager.AcquirePoolControllerLock() + if err != nil { + t.Fatal(err) + } + defer lock.Close() + owner, err := readPoolControllerLockOwner(poolControllerLockPath(lockRoot, "prefix", "epar-lock-stale")) + if err != nil { + t.Fatal(err) + } + if owner.ConfigPath != canonicalConfig || owner.NamePrefix != "epar-lock-stale" || owner.PID != os.Getpid() { + t.Fatalf("stale metadata was not replaced after lock acquisition: %#v", owner) + } +} + +func TestPoolControllerLockRaceAllowsOneOwner(t *testing.T) { + setPoolControllerLockStateHome(t) + manager := testPoolControllerManager(t, t.TempDir(), "config.yml", "docker-container", "epar-lock-race") + const contenders = 8 + locks := make(chan io.Closer, contenders) + errs := make(chan error, contenders) + var start sync.WaitGroup + start.Add(1) + for range contenders { + go func() { + start.Wait() + lock, err := manager.AcquirePoolControllerLock() + if err != nil { + errs <- err + return + } + locks <- lock + }() + } + start.Done() + var winners []io.Closer + for range contenders { + select { + case lock := <-locks: + winners = append(winners, lock) + case <-errs: + } + } + for _, lock := range winners { + defer lock.Close() + } + if len(winners) != 1 { + t.Fatalf("concurrent lock winners = %d, want 1", len(winners)) + } +} + +func TestPoolControllerLockMigratesLifecycleStateOnlyAfterExclusiveOwnership(t *testing.T) { + setPoolControllerLockStateHome(t) + root := t.TempDir() + project := filepath.Join(root, "project") + alias := filepath.Join(root, "project-alias") + if err := os.MkdirAll(filepath.Join(project, ".local"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(project, alias); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + configPath := filepath.Join(alias, ".local", "config.yml") + if err := os.WriteFile(filepath.Join(project, ".local", "config.yml"), []byte("provider: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + legacyConfig, err := filepath.Abs(configPath) + if err != nil { + t.Fatal(err) + } + legacyDirectory := lifecycleStateDirectory(project, filepath.Clean(legacyConfig)) + canonicalConfig, err := storagecatalog.CanonicalPath(configPath) + if err != nil { + t.Fatal(err) + } + canonicalDirectory := lifecycleStateDirectory(project, canonicalConfig) + if legacyDirectory == canonicalDirectory { + t.Fatal("test requires distinct legacy and canonical lifecycle namespaces") + } + if err := os.MkdirAll(legacyDirectory, 0o700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(legacyDirectory, "migration-marker") + if err := os.WriteFile(marker, []byte("preserve"), 0o600); err != nil { + t.Fatal(err) + } + + blocker := testPoolControllerManager(t, project, configPath, "docker-container", "epar-lock-migration") + held, err := blocker.AcquirePoolControllerLock() + if err != nil { + t.Fatal(err) + } + candidate := testPoolControllerManager(t, project, configPath, "docker-container", "epar-lock-migration") + candidate.LifecycleStateEnabled = true + if lock, err := candidate.AcquirePoolControllerLock(); err == nil { + _ = lock.Close() + t.Fatal("candidate acquired controller locks while blocker was active") + } + if candidate.LifecycleState != nil { + t.Fatal("lifecycle state opened before controller locks were acquired") + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("failed lock attempt moved legacy lifecycle state: %v", err) + } + if _, err := os.Stat(canonicalDirectory); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("failed lock attempt created canonical lifecycle state: %v", err) + } + if err := held.Close(); err != nil { + t.Fatal(err) + } + + lock, err := candidate.AcquirePoolControllerLock() + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if candidate.LifecycleState == nil { + t.Fatal("lifecycle state was not opened after acquiring controller locks") + } + if filepath.Dir(candidate.LifecycleState.Path()) != canonicalDirectory { + t.Fatalf("lifecycle state path = %q, want canonical directory %q", candidate.LifecycleState.Path(), canonicalDirectory) + } + if _, err := os.Stat(filepath.Join(canonicalDirectory, "migration-marker")); err != nil { + t.Fatalf("legacy lifecycle content was not migrated under the controller locks: %v", err) + } + if _, err := os.Stat(legacyDirectory); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("legacy lifecycle namespace still exists after locked migration: %v", err) } } func TestVerifyRequiresPoolControllerLock(t *testing.T) { - t.Setenv("LOCALAPPDATA", t.TempDir()) + setPoolControllerLockStateHome(t) manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "verify.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container", SourceImage: "image"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-verify"}}, Provider: &fakeProvider{}} held, err := manager.AcquirePoolControllerLock() if err != nil { @@ -37,7 +261,7 @@ func TestVerifyRequiresPoolControllerLock(t *testing.T) { } func TestCleanupRequiresPoolControllerLock(t *testing.T) { - t.Setenv("LOCALAPPDATA", t.TempDir()) + setPoolControllerLockStateHome(t) manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "cleanup.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-cleanup"}}, Provider: &fakeProvider{}} held, err := manager.AcquirePoolControllerLock() if err != nil { @@ -51,7 +275,7 @@ func TestCleanupRequiresPoolControllerLock(t *testing.T) { } func TestProvisionPoolRequiresPoolControllerLock(t *testing.T) { - t.Setenv("LOCALAPPDATA", t.TempDir()) + setPoolControllerLockStateHome(t) manager := Manager{ProjectRoot: t.TempDir(), ConfigPath: "provision.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container", SourceImage: "image"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-provision"}}, Provider: &fakeProvider{}} held, err := manager.AcquirePoolControllerLock() if err != nil { @@ -64,34 +288,34 @@ func TestProvisionPoolRequiresPoolControllerLock(t *testing.T) { } } -func TestPoolControllerLockIsIndependentAcrossPoolIdentity(t *testing.T) { - t.Setenv("LOCALAPPDATA", t.TempDir()) - firstManager := Manager{ConfigPath: "first.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-first"}}} - secondManager := Manager{ConfigPath: "second.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-second"}}} - first, err := firstManager.AcquirePoolControllerLock() - if err != nil { - t.Fatal(err) +func setPoolControllerLockStateHome(t *testing.T) string { + t.Helper() + root := t.TempDir() + t.Setenv("EPAR_STATE_HOME", root) + return root +} + +func testPoolControllerManager(t *testing.T, projectRoot, configName, providerType, prefix string) Manager { + t.Helper() + manager := Manager{ + ProjectRoot: projectRoot, + ConfigPath: configName, + Config: config.Config{ + Provider: config.ProviderConfig{Type: providerType}, + Pool: config.PoolConfig{NamePrefix: prefix}, + }, } - defer first.Close() - second, err := secondManager.AcquirePoolControllerLock() - if err != nil { - t.Fatalf("independent pool identity was blocked: %v", err) + if !filepath.IsAbs(configName) { + manager.ConfigPath = filepath.Join(projectRoot, configName) } - defer second.Close() + return manager } -func TestPoolControllerLockIncludesCanonicalConfigIdentity(t *testing.T) { - t.Setenv("LOCALAPPDATA", t.TempDir()) - firstManager := Manager{ConfigPath: "first.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-shared-prefix"}}} - secondManager := Manager{ConfigPath: "second.yml", Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-lock-shared-prefix"}}} - first, err := firstManager.AcquirePoolControllerLock() +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + content, err := json.Marshal(value) if err != nil { t.Fatal(err) } - defer first.Close() - second, err := secondManager.AcquirePoolControllerLock() - if err != nil { - t.Fatalf("distinct canonical config identity was blocked: %v", err) - } - defer second.Close() + return append(content, '\n') } diff --git a/internal/pool/host_trust_test.go b/internal/pool/host_trust_test.go index 7159024..b703ba4 100644 --- a/internal/pool/host_trust_test.go +++ b/internal/pool/host_trust_test.go @@ -487,6 +487,7 @@ func hostTrustLeaseInputs(provider *fakeProvider) []string { } func TestHostTrustImageBuildRetriesChangedGenerationBeforePublishing(t *testing.T) { + t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "host-state")) root := t.TempDir() for _, dir := range []string{ filepath.Join(root, "scripts", "guest", "ubuntu"), diff --git a/internal/pool/lifecycle_cleanup.go b/internal/pool/lifecycle_cleanup.go index 9d420df..4e11d42 100644 --- a/internal/pool/lifecycle_cleanup.go +++ b/internal/pool/lifecycle_cleanup.go @@ -118,7 +118,31 @@ func (m *Manager) cleanupLifecycleRecordWithRemoteAbsence(ctx context.Context, i if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionResumeCleanup}); err != nil { return err } - case poolstate.PhaseCreated, poolstate.PhaseValidating, poolstate.PhaseStandby, poolstate.PhaseRegistering, poolstate.PhaseReady, poolstate.PhaseBusy, poolstate.PhaseDraining, poolstate.PhaseQuarantined: + case poolstate.PhaseQuarantined: + if record.ProviderID == "" { + if len(sameName) != 0 { + for _, item := range sameName { + if reportErr := m.reportUnknownInventory(ctx, item); reportErr != nil { + return reportErr + } + } + return fmt.Errorf("unidentified same-name instance is quarantined and was not deleted") + } + if m.GitHub == nil { + return fmt.Errorf("record has no immutable provider identity and GitHub absence cannot be verified") + } + if _, found, err := m.GitHub.RunnerByName(ctx, record.GitHub.ExactName); err != nil { + return err + } else if found { + return fmt.Errorf("unidentified same-name GitHub runner is quarantined and was not deleted") + } + _, err = m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionAbandonCreate}) + return err + } + if _, err := m.LifecycleState.Transition(ctx, record.Name, poolstate.Transition{Action: poolstate.ActionFenceIntent}); err != nil { + return err + } + case poolstate.PhaseCreated, poolstate.PhaseValidating, poolstate.PhaseStandby, poolstate.PhaseRegistering, poolstate.PhaseReady, poolstate.PhaseBusy, poolstate.PhaseDraining: if record.ProviderID == "" { return fmt.Errorf("record has no immutable provider identity and remains report-only") } diff --git a/internal/pool/lifecycle_cleanup_test.go b/internal/pool/lifecycle_cleanup_test.go index ed6a344..20aa9d4 100644 --- a/internal/pool/lifecycle_cleanup_test.go +++ b/internal/pool/lifecycle_cleanup_test.go @@ -97,6 +97,114 @@ func TestLifecycleCleanupRefusesActiveLeaseBeforeSideEffects(t *testing.T) { } } +func TestLifecycleCleanupTombstonesIdentitylessQuarantineAfterExactAbsence(t *testing.T) { + store, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const name = "epar-test-identityless" + if _, err := store.Reserve(context.Background(), poolstate.CreateSpec{Name: name, ProviderType: "docker-container", GitHub: poolstate.GitHubIdentity{ExactName: name}}); err != nil { + t.Fatal(err) + } + if _, err := store.Transition(context.Background(), name, poolstate.Transition{Action: poolstate.ActionQuarantine, Reason: "post-create identity was lost"}); err != nil { + t.Fatal(err) + } + fake := &fakeProvider{} + manager := Manager{ + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}}, + Provider: fake, + Lifecycle: provider.AdaptLegacy(fake), + LifecycleState: store, + GitHub: &fakeGitHub{}, + ProjectRoot: t.TempDir(), + } + if err := manager.cleanupOwnedLifecycle(context.Background()); err != nil { + t.Fatal(err) + } + record, err := store.Read(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if record.Phase != poolstate.PhaseTombstoned || record.Cleanup.RemoteAbsentAt == nil || record.Cleanup.LocalAbsentAt == nil { + t.Fatalf("cleanup record = %#v, want exact absence tombstone", record) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 0 { + t.Fatalf("delete calls = %d, want no name-only deletion", got) + } +} + +func TestLifecycleCleanupKeepsIdentitylessQuarantineWhenSameNameExists(t *testing.T) { + store, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const name = "epar-test-identityless-present" + if _, err := store.Reserve(context.Background(), poolstate.CreateSpec{Name: name, ProviderType: "docker-container", GitHub: poolstate.GitHubIdentity{ExactName: name}}); err != nil { + t.Fatal(err) + } + if _, err := store.Transition(context.Background(), name, poolstate.Transition{Action: poolstate.ActionQuarantine, Reason: "post-create identity was lost"}); err != nil { + t.Fatal(err) + } + fake := &fakeProvider{instances: []provider.Instance{{Name: name, ProviderID: "docker:unknown-same-name", State: "running"}}} + manager := Manager{ + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}}, + Provider: fake, + Lifecycle: provider.AdaptLegacy(fake), + LifecycleState: store, + GitHub: &fakeGitHub{}, + ProjectRoot: t.TempDir(), + } + err = manager.cleanupOwnedLifecycle(context.Background()) + if err == nil || !strings.Contains(err.Error(), "same-name instance is quarantined") { + t.Fatalf("cleanup error = %v, want same-name refusal", err) + } + record, readErr := store.Read(context.Background(), name) + if readErr != nil { + t.Fatal(readErr) + } + if record.Phase != poolstate.PhaseQuarantined { + t.Fatalf("phase = %s, want %s", record.Phase, poolstate.PhaseQuarantined) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 0 { + t.Fatalf("delete calls = %d, want no name-only deletion", got) + } +} + +func TestIdentitylessQuarantineAlwaysVerifiesGitHubAbsence(t *testing.T) { + store, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const name = "epar-test-identityless-remote" + if _, err := store.Reserve(context.Background(), poolstate.CreateSpec{Name: name, ProviderType: "docker-container", GitHub: poolstate.GitHubIdentity{ExactName: name}}); err != nil { + t.Fatal(err) + } + record, err := store.Transition(context.Background(), name, poolstate.Transition{Action: poolstate.ActionQuarantine, Reason: "post-create identity was lost"}) + if err != nil { + t.Fatal(err) + } + fake := &fakeProvider{} + manager := Manager{ + Config: config.Config{Provider: config.ProviderConfig{Type: "docker-container"}, Pool: config.PoolConfig{NamePrefix: "epar-test"}, Logging: config.LoggingConfig{Directory: t.TempDir()}}, + Provider: fake, + Lifecycle: provider.AdaptLegacy(fake), + LifecycleState: store, + GitHub: &fakeGitHub{runner: gh.Runner{Name: name, ID: 9123}, found: true}, + ProjectRoot: t.TempDir(), + } + err = manager.cleanupLifecycleRecordWithRemoteAbsence(context.Background(), record, nil, true) + if err == nil || !strings.Contains(err.Error(), "same-name GitHub runner") { + t.Fatalf("cleanup error = %v, want GitHub absence refusal", err) + } + record, readErr := store.Read(context.Background(), name) + if readErr != nil { + t.Fatal(readErr) + } + if record.Phase != poolstate.PhaseQuarantined { + t.Fatalf("phase = %s, want %s", record.Phase, poolstate.PhaseQuarantined) + } +} + func TestCleanupRecoversInterruptedProvisionLeaseAfterExclusiveLock(t *testing.T) { store, err := poolstate.Open(t.TempDir()) if err != nil { diff --git a/internal/pool/lifecycle_state.go b/internal/pool/lifecycle_state.go index 1f02cfb..931a866 100644 --- a/internal/pool/lifecycle_state.go +++ b/internal/pool/lifecycle_state.go @@ -7,26 +7,99 @@ import ( "encoding/json" "errors" "fmt" + "os" "path/filepath" "strconv" "time" + "github.com/solutionforest/ephemeral-action-runner/internal/filelock" gh "github.com/solutionforest/ephemeral-action-runner/internal/github" poolstate "github.com/solutionforest/ephemeral-action-runner/internal/pool/state" "github.com/solutionforest/ephemeral-action-runner/internal/provider" + storagecatalog "github.com/solutionforest/ephemeral-action-runner/internal/storage/catalog" ) // OpenLifecycleState opens the provider-neutral state namespace for one exact // configuration file. The namespace hash prevents two configurations in the -// same checkout from claiming each other's instances. +// same checkout from claiming each other's instances. Production callers must +// hold the canonical configuration and normalized prefix controller locks so a +// legacy namespace cannot be renamed beneath another active controller. func OpenLifecycleState(projectRoot, configPath string) (*poolstate.Store, error) { - absoluteConfig, err := filepath.Abs(configPath) + legacyConfig, err := filepath.Abs(configPath) + if err != nil { + return nil, fmt.Errorf("resolve legacy lifecycle config path: %w", err) + } + legacyConfig = filepath.Clean(legacyConfig) + canonicalConfig, err := storagecatalog.CanonicalPath(configPath) if err != nil { return nil, fmt.Errorf("resolve lifecycle config path: %w", err) } - sum := sha256.Sum256([]byte(filepath.Clean(absoluteConfig))) + legacyDirectory := lifecycleStateDirectory(projectRoot, legacyConfig) + canonicalDirectory := lifecycleStateDirectory(projectRoot, canonicalConfig) + if err := os.MkdirAll(filepath.Dir(canonicalDirectory), 0o700); err != nil { + return nil, fmt.Errorf("create lifecycle state root: %w", err) + } + migrationLock, err := acquireLifecycleMigrationLock(canonicalDirectory + ".migration.lock") + if err != nil { + return nil, err + } + defer migrationLock.Close() + if legacyDirectory != canonicalDirectory { + if err := migrateLegacyLifecycleState(legacyDirectory, canonicalDirectory); err != nil { + return nil, err + } + } + return poolstate.Open(canonicalDirectory) +} + +func acquireLifecycleMigrationLock(path string) (*filelock.Lock, error) { + deadline := time.Now().Add(15 * time.Second) + for { + lock, err := filelock.Acquire(path) + if err == nil { + return lock, nil + } + if !errors.Is(err, filelock.ErrLocked) { + return nil, fmt.Errorf("acquire lifecycle migration lock %s: %w", path, err) + } + if !time.Now().Before(deadline) { + return nil, fmt.Errorf("timed out waiting for lifecycle migration lock %s", path) + } + time.Sleep(50 * time.Millisecond) + } +} + +func lifecycleStateDirectory(projectRoot, canonicalConfig string) string { + sum := sha256.Sum256([]byte(canonicalConfig)) namespace := hex.EncodeToString(sum[:8]) - return poolstate.Open(filepath.Join(projectRoot, ".local", "state", "pools", namespace)) + return filepath.Join(projectRoot, ".local", "state", "pools", namespace) +} + +func migrateLegacyLifecycleState(legacyDirectory, canonicalDirectory string) error { + legacyInfo, legacyErr := os.Lstat(legacyDirectory) + if errors.Is(legacyErr, os.ErrNotExist) { + return nil + } + if legacyErr != nil { + return fmt.Errorf("inspect legacy lifecycle state %s: %w", legacyDirectory, legacyErr) + } + if !legacyInfo.IsDir() || legacyInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("legacy lifecycle state is not a real directory: %s", legacyDirectory) + } + if _, canonicalErr := os.Lstat(canonicalDirectory); canonicalErr == nil { + return fmt.Errorf("both legacy and canonical lifecycle state exist; refusing to choose between %s and %s", legacyDirectory, canonicalDirectory) + } else if !errors.Is(canonicalErr, os.ErrNotExist) { + return fmt.Errorf("inspect canonical lifecycle state %s: %w", canonicalDirectory, canonicalErr) + } + if err := os.Rename(legacyDirectory, canonicalDirectory); err != nil { + _, legacyRetryErr := os.Lstat(legacyDirectory) + _, canonicalRetryErr := os.Lstat(canonicalDirectory) + if errors.Is(legacyRetryErr, os.ErrNotExist) && canonicalRetryErr == nil { + return nil + } + return fmt.Errorf("migrate legacy lifecycle state %s to %s: %w", legacyDirectory, canonicalDirectory, err) + } + return nil } func (m *Manager) reserveLifecycle(ctx context.Context, name string) error { diff --git a/internal/pool/lifecycle_state_identity_test.go b/internal/pool/lifecycle_state_identity_test.go new file mode 100644 index 0000000..479c44e --- /dev/null +++ b/internal/pool/lifecycle_state_identity_test.go @@ -0,0 +1,78 @@ +package pool + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestLifecycleStateUsesCanonicalConfigurationIdentity(t *testing.T) { + project := t.TempDir() + realConfig := filepath.Join(project, ".local", "config.yml") + linkConfig := filepath.Join(project, ".local", "config-link.yml") + if err := os.MkdirAll(filepath.Dir(realConfig), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(realConfig, []byte("provider: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realConfig, linkConfig); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + realStore, err := OpenLifecycleState(project, realConfig) + if err != nil { + t.Fatal(err) + } + linkStore, err := OpenLifecycleState(project, linkConfig) + if err != nil { + t.Fatal(err) + } + if realStore.Path() != linkStore.Path() { + t.Fatalf("one configuration received split lifecycle state through a symlink: %q != %q", realStore.Path(), linkStore.Path()) + } +} + +func TestLifecycleStateMigratesAncestorSymlinkNamespace(t *testing.T) { + root := t.TempDir() + project := filepath.Join(root, "project") + alias := filepath.Join(root, "project-alias") + if err := os.MkdirAll(filepath.Join(project, ".local"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(project, alias); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + configPath := filepath.Join(alias, ".local", "config.yml") + if err := os.WriteFile(filepath.Join(project, ".local", "config.yml"), []byte("provider: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + legacyAbsolute, err := filepath.Abs(configPath) + if err != nil { + t.Fatal(err) + } + legacySum := sha256.Sum256([]byte(filepath.Clean(legacyAbsolute))) + legacyDirectory := filepath.Join(project, ".local", "state", "pools", hex.EncodeToString(legacySum[:8])) + if err := os.MkdirAll(legacyDirectory, 0o700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(legacyDirectory, "migration-marker") + if err := os.WriteFile(marker, []byte("preserve"), 0o600); err != nil { + t.Fatal(err) + } + store, err := OpenLifecycleState(project, configPath) + if err != nil { + t.Fatal(err) + } + if store.Path() == filepath.Join(legacyDirectory, "state-v1.json") { + t.Fatal("ancestor-symlink lifecycle state remained in the legacy namespace") + } + if _, err := os.Stat(filepath.Join(filepath.Dir(store.Path()), "migration-marker")); err != nil { + t.Fatalf("legacy lifecycle state content was not migrated: %v", err) + } + if _, err := os.Stat(legacyDirectory); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("legacy lifecycle namespace still exists after migration: %v", err) + } +} diff --git a/internal/pool/manager.go b/internal/pool/manager.go index 5b88d5c..26482d5 100644 --- a/internal/pool/manager.go +++ b/internal/pool/manager.go @@ -33,6 +33,7 @@ type Manager struct { PolicyManager provider.PolicyManager Storage provider.StorageContribution LifecycleState *poolstate.Store + LifecycleStateEnabled bool GitHub GitHubClient ProjectRoot string ConfigPath string @@ -1402,25 +1403,47 @@ func (m *Manager) provisionOneAttempt(ctx context.Context, name string, register } m.logger().Info("cloning instance", "provider", m.Config.Provider.Type, "instance", name, "operation", "clone", "sourceImage", m.Config.Provider.SourceImage, "logPath", logPath) var created provider.Instance - if err := m.timeFirstInstanceStage(name, "instance_container_create", func() error { + createStageErr := m.timeFirstInstanceStage(name, "instance_container_create", func() error { var createErr error created, createErr = m.createProviderInstance(ctx, name) return createErr - }); err != nil { - return vm, err - } - if created.Name != name || created.ProviderID == "" { - return vm, fmt.Errorf("provider create returned no immutable identity for %q", name) - } - vm.ProviderID = created.ProviderID - if created.ReceiptVersion != "" && len(created.Receipt) != 0 { + }) + if created.Name != "" || created.ProviderID != "" || created.ReceiptVersion != "" || len(created.Receipt) != 0 { + if created.Name != name || created.ProviderID == "" { + identityErr := fmt.Errorf("provider create returned an incomplete immutable identity for %q", name) + if createStageErr != nil { + return vm, errors.Join(createStageErr, identityErr) + } + return vm, identityErr + } + if created.ReceiptVersion == "" || len(created.Receipt) == 0 { + receiptErr := fmt.Errorf("provider create returned an incomplete versioned receipt for %q", name) + if createStageErr != nil { + return vm, errors.Join(createStageErr, receiptErr) + } + return vm, receiptErr + } var providerReceipt map[string]any if json.Unmarshal(created.Receipt, &providerReceipt) != nil || providerReceipt == nil { - return vm, fmt.Errorf("provider create returned an invalid versioned receipt for %q", name) + receiptErr := fmt.Errorf("provider create returned an invalid versioned receipt for %q", name) + if createStageErr != nil { + return vm, errors.Join(createStageErr, receiptErr) + } + return vm, receiptErr + } + vm.ProviderID = created.ProviderID + if recordErr := m.recordLifecycleCreated(context.WithoutCancel(ctx), created); recordErr != nil { + if createStageErr != nil { + return vm, errors.Join(createStageErr, recordErr) + } + return vm, recordErr } } - if err := m.recordLifecycleCreated(ctx, created); err != nil { - return vm, err + if createStageErr != nil { + return vm, createStageErr + } + if created.Name != name || created.ProviderID == "" { + return vm, fmt.Errorf("provider create returned no immutable identity for %q", name) } if err := m.recordLifecycleValidationIntent(ctx, name); err != nil { return vm, fmt.Errorf("record runtime validation intent: %w", err) diff --git a/internal/pool/manager_test.go b/internal/pool/manager_test.go index 9fd7522..3d1ffb7 100644 --- a/internal/pool/manager_test.go +++ b/internal/pool/manager_test.go @@ -1044,6 +1044,59 @@ func TestProvisioningFailureRollbackBoundary(t *testing.T) { } } +func TestProvisioningRecordsPartialCreateIdentityBeforeExactRollback(t *testing.T) { + const name = "epar-test-partial-create" + partial := provider.Instance{ + Name: name, + ProviderID: "fake:partial-create-id", + Source: "image", + State: "running", + ReceiptVersion: "v1", + Receipt: json.RawMessage(`{"providerId":"fake:partial-create-id","source":"image"}`), + } + fake := &fakeProvider{} + baseLifecycle := provider.AdaptLegacy(fake) + lifecycle := &partialCreateLifecycle{ + Lifecycle: baseLifecycle, + create: func() (provider.Instance, error) { + fake.mu.Lock() + fake.instances = append(fake.instances, partial) + fake.mu.Unlock() + return partial, errors.New("post-create verification failed") + }, + } + manager := newRegisteredTestManager(t, fake, nil) + manager.Lifecycle = lifecycle + store, err := poolstate.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + manager.LifecycleState = store + + if _, err := manager.provisionOne(context.Background(), name, false, false); err == nil || !strings.Contains(err.Error(), "post-create verification failed") { + t.Fatalf("provisionOne() error = %v", err) + } + if got := atomic.LoadInt32(&fake.deleteCalls); got != 1 { + t.Fatalf("exact provider delete calls = %d, want 1", got) + } + record, err := store.Read(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if record.ProviderID != partial.ProviderID || record.Phase != poolstate.PhaseTombstoned { + t.Fatalf("lifecycle record = %#v, want exact provider identity tombstoned", record) + } +} + +type partialCreateLifecycle struct { + provider.Lifecycle + create func() (provider.Instance, error) +} + +func (l *partialCreateLifecycle) Create(context.Context, provider.CreateRequest) (provider.Instance, error) { + return l.create() +} + func TestConfigureFailureDeletesExactLocalAndRemoteCandidate(t *testing.T) { p := &fakeProvider{ip: "127.0.0.1"} p.execFunc = func(_ context.Context, _ string, command []string, _ provider.ExecOptions) (provider.ExecResult, error) { diff --git a/internal/pool/runner_script_test.go b/internal/pool/runner_script_test.go index cb3b329..2fb79eb 100644 --- a/internal/pool/runner_script_test.go +++ b/internal/pool/runner_script_test.go @@ -431,7 +431,10 @@ func TestHostTrustGenerationHookAcceptsCurrentLease(t *testing.T) { t.Skip("the guest hook requires the Linux image's python3 runtime") } marker := `{"schemaVersion":1,"generation":"g1","hostOS":"windows","mode":"overlay","scopes":["system","user"]}` - lease := fmt.Sprintf(`{"schemaVersion":1,"generation":"g1","hostOS":"windows","mode":"overlay","scopes":["system","user"],"expiresAt":%q}`, time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano)) + // The host test invokes macOS's system Python, whose fromisoformat support is + // older than the Python shipped in the Linux runner image. Whole-second + // RFC3339 still exercises the production timezone and expiry checks. + lease := fmt.Sprintf(`{"schemaVersion":1,"generation":"g1","hostOS":"windows","mode":"overlay","scopes":["system","user"],"expiresAt":%q}`, time.Now().Add(time.Minute).UTC().Format(time.RFC3339)) output, err := runHostTrustGenerationHook(t, marker, lease) if err != nil { t.Fatalf("current host-trust lease rejected: %v\n%s", err, output) @@ -501,8 +504,8 @@ func TestHostTrustGenerationHookRejectsMismatchAndExpiry(t *testing.T) { for _, tc := range []struct { name, lease, want string }{ - {name: "generation mismatch", lease: fmt.Sprintf(`{"generation":"g2","hostOS":"linux","mode":"overlay","scopes":["system"],"expiresAt":%q}`, time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano)), want: "generation mismatch"}, - {name: "expired", lease: fmt.Sprintf(`{"generation":"g1","hostOS":"linux","mode":"overlay","scopes":["system"],"expiresAt":%q}`, time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano)), want: "lease expired"}, + {name: "generation mismatch", lease: fmt.Sprintf(`{"generation":"g2","hostOS":"linux","mode":"overlay","scopes":["system"],"expiresAt":%q}`, time.Now().Add(time.Minute).UTC().Format(time.RFC3339)), want: "generation mismatch"}, + {name: "expired", lease: fmt.Sprintf(`{"generation":"g1","hostOS":"linux","mode":"overlay","scopes":["system"],"expiresAt":%q}`, time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)), want: "lease expired"}, } { t.Run(tc.name, func(t *testing.T) { output, err := runHostTrustGenerationHook(t, marker, tc.lease) diff --git a/internal/pool/state/store.go b/internal/pool/state/store.go index 2f46bf6..25c98ba 100644 --- a/internal/pool/state/store.go +++ b/internal/pool/state/store.go @@ -388,7 +388,8 @@ func applyTransition(record *Record, transition Transition, now time.Time) error case ActionCreateIntent: return move(record, PhaseReserved, PhaseCreating, transition.Action) case ActionAbandonCreate: - if record.Phase != PhaseReserved && record.Phase != PhaseCreating { + identitylessQuarantine := record.Phase == PhaseQuarantined && record.ProviderID == "" && emptyReceipt(record.Receipt) + if record.Phase != PhaseReserved && record.Phase != PhaseCreating && !identitylessQuarantine { return invalid(record, transition.Action) } if record.ProviderID != "" || !emptyReceipt(record.Receipt) || len(activeLeases(record.Leases, now)) != 0 { diff --git a/internal/pool/state/store_test.go b/internal/pool/state/store_test.go index 06acf5c..f4a21f0 100644 --- a/internal/pool/state/store_test.go +++ b/internal/pool/state/store_test.go @@ -112,6 +112,41 @@ func TestAbandonCreateRequiresNoProviderIdentityAndRecordsExactAbsence(t *testin } } +func TestAbandonCreateAllowsOnlyIdentitylessQuarantine(t *testing.T) { + store, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + record := reserve(t, store, "quarantined-create") + record, err = store.Transition(context.Background(), record.Name, Transition{Action: ActionQuarantine, Reason: "create outcome was uncertain"}) + if err != nil { + t.Fatal(err) + } + record, err = store.Transition(context.Background(), record.Name, Transition{Action: ActionAbandonCreate}) + if err != nil { + t.Fatalf("abandon identityless quarantine: %v", err) + } + if record.Phase != PhaseTombstoned || record.Cleanup.RemoteAbsentAt == nil || record.Cleanup.LocalAbsentAt == nil { + t.Fatalf("abandoned quarantine = %#v", record) + } + + identified := reserve(t, store, "identified-quarantine") + if _, err := store.Transition(context.Background(), identified.Name, Transition{Action: ActionCreateIntent}); err != nil { + t.Fatal(err) + } + identified, err = store.Transition(context.Background(), identified.Name, Transition{Action: ActionCreated, ProviderID: "provider:identified", Receipt: receipt(t)}) + if err != nil { + t.Fatal(err) + } + identified, err = store.Transition(context.Background(), identified.Name, Transition{Action: ActionQuarantine, Reason: "identified quarantine"}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Transition(context.Background(), identified.Name, Transition{Action: ActionAbandonCreate}); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("identified quarantine abandon error = %v, want invalid transition", err) + } +} + func TestProviderReceiptAndUnknownDiscoveryRoundTrip(t *testing.T) { store, err := Open(t.TempDir()) if err != nil { diff --git a/internal/provider/dockersandboxes/live_test.go b/internal/provider/dockersandboxes/live_test.go index 4b7ca0c..2fce8bf 100644 --- a/internal/provider/dockersandboxes/live_test.go +++ b/internal/provider/dockersandboxes/live_test.go @@ -2,6 +2,9 @@ package dockersandboxes import ( "context" + "crypto/rand" + "encoding/hex" + "errors" "fmt" "os" "os/exec" @@ -105,6 +108,9 @@ printf '\n'`}, provider.ExecOptions{}) if _, err := p.Exec(ctx, instance, []string{"bash", "/opt/epar/verify-template.sh"}, provider.ExecOptions{}); err != nil { t.Fatal(err) } + if err := verifyAuthenticatedRegistryLifecycle(ctx, p, instance); err != nil { + t.Fatal(err) + } diskUsage, err := p.Exec(ctx, instance, []string{"df", "-B1", "--output=used,target", "/", "/var/lib/docker"}, provider.ExecOptions{}) if err != nil { t.Fatal(err) @@ -198,3 +204,89 @@ rm -rf -- "$workdir" t.Fatal(err) } } + +func verifyAuthenticatedRegistryLifecycle(ctx context.Context, p *Provider, instance provider.Instance) error { + registryImage := strings.TrimSpace(os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES_REGISTRY_IMAGE")) + htpasswdImage := strings.TrimSpace(os.Getenv("EPAR_LIVE_DOCKER_SANDBOXES_HTPASSWD_IMAGE")) + if !strings.Contains(registryImage, "@sha256:") || !strings.Contains(htpasswdImage, "@sha256:") { + return errors.New("EPAR_LIVE_DOCKER_SANDBOXES_REGISTRY_IMAGE and EPAR_LIVE_DOCKER_SANDBOXES_HTPASSWD_IMAGE must be immutable digest references") + } + username, err := randomLiveCredential("epar-") + if err != nil { + return err + } + password, err := randomLiveCredential("") + if err != nil { + return err + } + script := `set -euo pipefail +registry_image="$1" +htpasswd_image="$2" +IFS= read -r username +IFS= read -r password +auth_dir="$(mktemp -d)" +registry_name="epar-auth-registry-$$" +registry_ref="" +probe_image="" +cleanup() { + if [[ -n "${registry_ref}" ]]; then docker logout "${registry_ref}" >/dev/null 2>&1 || true; fi + docker rm -f "${registry_name}" >/dev/null 2>&1 || true + if [[ -n "${probe_image}" ]]; then docker image rm "${probe_image}" >/dev/null 2>&1 || true; fi + rm -rf -- "${auth_dir}" +} +trap cleanup EXIT +printf '%s\n' "${password}" | docker run --rm -i -v "${auth_dir}:/auth" "${htpasswd_image}" htpasswd -B -i -c /auth/htpasswd "${username}" >/dev/null +docker run --name "${registry_name}" --detach --publish 127.0.0.1::5000 --env REGISTRY_AUTH=htpasswd --env REGISTRY_AUTH_HTPASSWD_REALM='EPAR live proof' --env REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd --volume "${auth_dir}:/auth:ro" "${registry_image}" >/dev/null +registry_port="$(docker port "${registry_name}" 5000/tcp | awk -F: 'NR == 1 { print $NF }')" +[[ "${registry_port}" =~ ^[0-9]+$ ]] +registry_ref="127.0.0.1:${registry_port}" +probe_image="${registry_ref}/epar/private-pull-proof:latest" +login_succeeded=false +for attempt in $(seq 1 30); do + if printf '%s\n' "${password}" | docker login --username "${username}" --password-stdin "${registry_ref}" >/dev/null 2>&1; then login_succeeded=true; break; fi + if [[ "${attempt}" == 30 ]]; then echo 'authenticated registry did not become ready' >&2; exit 1; fi + sleep 1 +done +[[ "${login_succeeded}" == true ]] +python3 - "${DOCKER_CONFIG}/config.json" "${registry_ref}" <<'PY' +import json +import pathlib +import sys +config = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +if sys.argv[2] not in config.get("auths", {}): + raise SystemExit("login did not create the expected Docker auth entry") +PY +docker pull docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce >/dev/null +docker tag docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce "${probe_image}" +docker push "${probe_image}" >/dev/null +docker image rm "${probe_image}" >/dev/null +docker pull "${probe_image}" >/dev/null +docker logout "${registry_ref}" >/dev/null +python3 - "${DOCKER_CONFIG}/config.json" "${registry_ref}" <<'PY' +import json +import pathlib +import sys +path = pathlib.Path(sys.argv[1]) +config = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} +if sys.argv[2] in config.get("auths", {}): + raise SystemExit("Docker auth entry survived logout") +PY +printf 'authenticated registry login, separate-command pull, and credential cleanup passed\n' +` + result, err := p.Exec(ctx, instance, []string{"bash", "-lc", script, "--", registryImage, htpasswdImage}, provider.ExecOptions{ + Stdin: username + "\n" + password + "\n", + SensitiveValues: []string{username, password}, + }) + if err != nil { + return fmt.Errorf("authenticated local registry proof: %w: %s", err, strings.TrimSpace(result.Stderr)) + } + return nil +} + +func randomLiveCredential(prefix string) (string, error) { + value := make([]byte, 24) + if _, err := rand.Read(value); err != nil { + return "", fmt.Errorf("generate live registry credential: %w", err) + } + return prefix + hex.EncodeToString(value), nil +} diff --git a/internal/provider/dockersandboxes/provider.go b/internal/provider/dockersandboxes/provider.go index 69d72b1..5283030 100644 --- a/internal/provider/dockersandboxes/provider.go +++ b/internal/provider/dockersandboxes/provider.go @@ -28,8 +28,10 @@ const ( ) const directWorkspaceVerificationScript = `set -euo pipefail -test -z "${SSH_AUTH_SOCK:-}" -test -z "${SSH_AGENT_PID:-}" +if test -n "${SSH_AUTH_SOCK:-}" || test -n "${SSH_AUTH_SOCK_GATEWAY:-}" || test -n "${SSH_AGENT_PID:-}" || test -e /run/ssh-agent.sock || test -L /run/ssh-agent.sock; then + echo "Docker Sandboxes exposed host SSH-agent forwarding; stop the daemon and restart it with SSH_AUTH_SOCK, SSH_AUTH_SOCK_GATEWAY, and SSH_AGENT_PID unset" >&2 + exit 1 +fi workspace="$(pwd -P)" test -n "${workspace}" source_options="$(findmnt -T "${workspace}" -n -o OPTIONS)" @@ -217,15 +219,6 @@ func (p *Provider) Create(ctx context.Context, request provider.CreateRequest) ( if item.Source != "shell" || !containsExactWorkspace(item.Workspaces, request.StagingPath) { return provider.Instance{}, fmt.Errorf("docker sandbox inventory did not bind the exact shell workspace") } - if err := p.verifyNoPublishedPorts(ctx, item.Instance); err != nil { - return provider.Instance{}, err - } - if err := p.verifyInspection(ctx, item.Instance, &request); err != nil { - return provider.Instance{}, err - } - if err := p.verifyDirectWorkspace(ctx, item.Instance); err != nil { - return provider.Instance{}, err - } receipt, encodeErr := json.Marshal(instanceReceipt{ SchemaVersion: 1, StagingPath: ownedStaging.Path, @@ -239,6 +232,15 @@ func (p *Provider) Create(ctx context.Context, request provider.CreateRequest) ( instance := item.Instance instance.ReceiptVersion = "v1" instance.Receipt = receipt + if err := p.verifyNoPublishedPorts(ctx, item.Instance); err != nil { + return instance, err + } + if err := p.verifyInspection(ctx, item.Instance, &request); err != nil { + return instance, err + } + if err := p.verifyDirectWorkspace(ctx, item.Instance); err != nil { + return instance, err + } return instance, nil } } @@ -955,7 +957,7 @@ func childEnvironment(additions map[string]string) []string { for _, item := range os.Environ() { key, _, _ := strings.Cut(item, "=") upperKey := strings.ToUpper(key) - if strings.HasPrefix(upperKey, "DOCKER_SANDBOXES_") || upperKey == "SSH_AUTH_SOCK" || upperKey == "SSH_AGENT_PID" { + if strings.HasPrefix(upperKey, "DOCKER_SANDBOXES_") || upperKey == "SSH_AUTH_SOCK" || upperKey == "SSH_AUTH_SOCK_GATEWAY" || upperKey == "SSH_AGENT_PID" { continue } environment = append(environment, item) diff --git a/internal/provider/dockersandboxes/provider_test.go b/internal/provider/dockersandboxes/provider_test.go index c709183..7fbb202 100644 --- a/internal/provider/dockersandboxes/provider_test.go +++ b/internal/provider/dockersandboxes/provider_test.go @@ -3,6 +3,7 @@ package dockersandboxes import ( "bytes" "context" + "encoding/json" "errors" "io" "os" @@ -216,6 +217,35 @@ func TestCreateUsesHealthyDiagnosticsAndExactArgv(t *testing.T) { done() } +func TestCreateReturnsExactReceiptWhenPostCreateVerificationFails(t *testing.T) { + p, done := scriptedProvider(t, + commandStep{args: []string{"diagnose", "--output", "json"}, result: provider.ExecResult{Stdout: healthyDiagnoseJSON}}, + commandStep{args: []string{"secret", "ls", "-g"}, result: provider.ExecResult{Stdout: `No secrets found for scope "(global)".`}}, + commandStep{args: []string{"template", "ls", "--json"}, result: provider.ExecResult{Stdout: templateListJSON}}, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: `{"sandboxes":[]}`}}, + commandStep{args: []string{"create", "--name", testName, "--cpus", "4", "--memory", "8g", "--template", testTemplate, "shell", testWorkspace}, environment: map[string]string{}}, + commandStep{args: []string{"ls", "--json"}, result: provider.ExecResult{Stdout: readyListJSON}}, + commandStep{args: []string{"ports", testName, "--json"}, result: provider.ExecResult{Stdout: emptyPortsJSON}}, + commandStep{args: []string{"inspect", "--json", testName}, result: provider.ExecResult{Stdout: inspectionJSON}}, + commandStep{args: []string{"exec", "-i", testName, "--", "bash", "-lc", directWorkspaceVerificationScript}, err: errors.New("workspace verification failed")}, + ) + instance, err := p.Create(context.Background(), validCreateRequest()) + if err == nil || !strings.Contains(err.Error(), "workspace verification failed") { + t.Fatalf("Create() error = %v", err) + } + if instance.Name != testName || instance.ProviderID != testID || instance.ReceiptVersion != "v1" || len(instance.Receipt) == 0 { + t.Fatalf("Create() partial instance = %#v, want exact receipted identity", instance) + } + var receipt instanceReceipt + if err := json.Unmarshal(instance.Receipt, &receipt); err != nil { + t.Fatal(err) + } + if receipt.StagingPath != testWorkspace || receipt.StagingIdentity == "" || receipt.Template != testTemplate || receipt.TemplateDigest != testDigest { + t.Fatalf("Create() receipt = %#v", receipt) + } + done() +} + func TestSplitTemplateReferenceCanonicalizesDockerHubNames(t *testing.T) { tests := map[string]string{ "epar-template:version": "docker.io/library/epar-template", @@ -343,16 +373,42 @@ func TestInstanceAdmissionRejectsPublishedPortInventory(t *testing.T) { func TestChildEnvironmentStripsHostSSHAgent(t *testing.T) { t.Setenv("SSH_AUTH_SOCK", "/host/agent.sock") + t.Setenv("SSH_AUTH_SOCK_GATEWAY", "gateway.example.test:3129") t.Setenv("SSH_AGENT_PID", "4242") environment := childEnvironment(nil) for _, item := range environment { key, _, _ := strings.Cut(item, "=") - if strings.EqualFold(key, "SSH_AUTH_SOCK") || strings.EqualFold(key, "SSH_AGENT_PID") { + if strings.EqualFold(key, "SSH_AUTH_SOCK") || strings.EqualFold(key, "SSH_AUTH_SOCK_GATEWAY") || strings.EqualFold(key, "SSH_AGENT_PID") { t.Fatalf("host SSH agent variable survived child environment filtering: %q", key) } } } +func TestDirectWorkspaceVerificationRejectsSSHAgentForwardingWithRemediation(t *testing.T) { + for _, required := range []string{"SSH_AUTH_SOCK", "SSH_AUTH_SOCK_GATEWAY", "SSH_AGENT_PID", "restart it with SSH_AUTH_SOCK"} { + if !strings.Contains(directWorkspaceVerificationScript, required) { + t.Fatalf("direct workspace verification omitted SSH-agent guardrail %q", required) + } + } + for name, environment := range map[string]string{ + "socket": "SSH_AUTH_SOCK=/tmp/host-agent.sock", + "gateway": "SSH_AUTH_SOCK_GATEWAY=gateway.example.test:3129", + "pid": "SSH_AGENT_PID=4242", + } { + t.Run(name, func(t *testing.T) { + command := exec.Command("bash", "-c", directWorkspaceVerificationScript) + command.Env = append(childEnvironment(nil), environment) + output, err := command.CombinedOutput() + if err == nil { + t.Fatal("workspace verification accepted host SSH-agent forwarding") + } + if !strings.Contains(string(output), "Docker Sandboxes exposed host SSH-agent forwarding") { + t.Fatalf("workspace verification output = %q, want actionable SSH-agent diagnostic", output) + } + }) + } +} + func TestTemplateInventorySchemaFailsClosed(t *testing.T) { for _, fixture := range []string{ `[]`, diff --git a/internal/storage/catalog/catalog.go b/internal/storage/catalog/catalog.go index e138c57..1cab1e3 100644 --- a/internal/storage/catalog/catalog.go +++ b/internal/storage/catalog/catalog.go @@ -275,11 +275,11 @@ func (s *Store) writeUnlocked(value Catalog) error { } func ConfigID(projectRoot, configPath string) (string, error) { - root, err := canonicalPath(projectRoot) + root, err := CanonicalPath(projectRoot) if err != nil { return "", err } - path, err := canonicalPath(configPath) + path, err := CanonicalPath(configPath) if err != nil { return "", err } @@ -297,11 +297,11 @@ func RegisterConfig(value *Catalog, projectRoot, configPath string, now time.Tim if err != nil { return Config{}, err } - root, err := canonicalPath(projectRoot) + root, err := CanonicalPath(projectRoot) if err != nil { return Config{}, err } - path, err := canonicalPath(configPath) + path, err := CanonicalPath(configPath) if err != nil { return Config{}, err } @@ -497,11 +497,18 @@ func normalize(value *Catalog) { sort.Slice(value.Journals, func(i, j int) bool { return value.Journals[i].ID < value.Journals[j].ID }) } -func canonicalPath(path string) (string, error) { +// CanonicalPath returns the stable identity used by controller locks, catalog +// records, lifecycle state, and build workspaces. Existing symlinks are +// resolved so alternate spellings of the same configuration cannot split +// ownership state. +func CanonicalPath(path string) (string, error) { absolute, err := filepath.Abs(path) if err != nil { return "", err } + if resolved, resolveErr := filepath.EvalSymlinks(absolute); resolveErr == nil { + absolute = resolved + } absolute = filepath.Clean(absolute) if runtime.GOOS == "windows" { absolute = strings.ToLower(absolute) diff --git a/internal/storage/catalog/catalog_test.go b/internal/storage/catalog/catalog_test.go index e5e7ffa..41c083c 100644 --- a/internal/storage/catalog/catalog_test.go +++ b/internal/storage/catalog/catalog_test.go @@ -9,6 +9,29 @@ import ( "time" ) +func TestConfigIDResolvesConfigurationSymlinks(t *testing.T) { + project := t.TempDir() + realPath := filepath.Join(project, "config.yml") + linkPath := filepath.Join(project, "config-link.yml") + if err := os.WriteFile(realPath, []byte("provider: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realPath, linkPath); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + realID, err := ConfigID(project, realPath) + if err != nil { + t.Fatal(err) + } + linkID, err := ConfigID(project, linkPath) + if err != nil { + t.Fatal(err) + } + if realID != linkID { + t.Fatalf("one configuration received split identities through a symlink: %q != %q", realID, linkID) + } +} + func TestMultipleConfigsShareResourceUntilLastReferenceIsRemoved(t *testing.T) { root := t.TempDir() project := filepath.Join(root, "project") diff --git a/templates/docker-sandboxes/Dockerfile b/templates/docker-sandboxes/Dockerfile index e5f0b98..76d1799 100644 --- a/templates/docker-sandboxes/Dockerfile +++ b/templates/docker-sandboxes/Dockerfile @@ -70,6 +70,15 @@ RUN [[ "${TARGETPLATFORM}" == "${TEMPLATE_PLATFORM}" ]] \ ENV HOME=/home/agent \ USER=agent \ LOGNAME=agent \ + SSH_AUTH_SOCK= \ + SSH_AUTH_SOCK_GATEWAY= \ + SSH_AGENT_PID= \ + XDG_CONFIG_HOME=/home/agent/.config \ + XDG_CACHE_HOME=/home/agent/.cache \ + XDG_DATA_HOME=/home/agent/.local/share \ + XDG_STATE_HOME=/home/agent/.local/state \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DOCKER_CONFIG=/home/agent/.docker \ RUNNER_TOOL_CACHE=/opt/actions-runner/_work/_tool \ AGENT_TOOLSDIRECTORY=/opt/actions-runner/_work/_tool \ DOTNET_INSTALL_DIR=/opt/actions-runner/_work/_tool/dotnet \ diff --git a/templates/docker-sandboxes/guest/configure-runner.sh b/templates/docker-sandboxes/guest/configure-runner.sh index 61d3506..a41db61 100644 --- a/templates/docker-sandboxes/guest/configure-runner.sh +++ b/templates/docker-sandboxes/guest/configure-runner.sh @@ -2,6 +2,12 @@ set -euo pipefail umask 077 +if [[ "$(id -u)" != "0" ]]; then + echo "configure-runner.sh must run as root" >&2 + exit 1 +fi +unset SSH_AUTH_SOCK SSH_AUTH_SOCK_GATEWAY SSH_AGENT_PID + : "${RUNNER_URL:?RUNNER_URL is required}" : "${RUNNER_NAME:?RUNNER_NAME is required}" : "${RUNNER_LABELS:?RUNNER_LABELS is required}" @@ -10,6 +16,15 @@ RUNNER_GROUP="${RUNNER_GROUP:-}" RUNNER_NO_DEFAULT_LABELS="${RUNNER_NO_DEFAULT_LABELS:-false}" runner_dir="${EPAR_ACTIONS_RUNNER_DIR:-/opt/actions-runner}" +install -d -m 0700 -o agent -g agent \ + /home/agent/.docker \ + /home/agent/.config \ + /home/agent/.cache \ + /home/agent/.local \ + /home/agent/.local/share \ + /home/agent/.local/state \ + /run/user/1000 + if ! IFS= read -r runner_token || [[ -z "${runner_token}" ]]; then echo "RUNNER_TOKEN must be provided as one nonempty line on stdin" >&2 exit 1 @@ -43,5 +58,24 @@ if [[ "${RUNNER_NO_DEFAULT_LABELS}" == "true" ]]; then args+=(--no-default-labels) fi -sudo -u agent -H ./config.sh "${args[@]}" +configuration_environment=( + "HOME=/home/agent" + "USER=agent" + "LOGNAME=agent" + "XDG_CONFIG_HOME=/home/agent/.config" + "XDG_CACHE_HOME=/home/agent/.cache" + "XDG_DATA_HOME=/home/agent/.local/share" + "XDG_STATE_HOME=/home/agent/.local/state" + "XDG_RUNTIME_DIR=/run/user/1000" + "DOCKER_CONFIG=/home/agent/.docker" + "PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + "LANG=C.UTF-8" +) +for environment_name in http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS NODE_USE_ENV_PROXY; do + if [[ -n "${!environment_name+x}" ]]; then + configuration_environment+=("${environment_name}=${!environment_name}") + fi +done + +sudo -u agent -H env -i "${configuration_environment[@]}" ./config.sh "${args[@]}" unset runner_token args diff --git a/templates/docker-sandboxes/guest/prepare-template.sh b/templates/docker-sandboxes/guest/prepare-template.sh index 995a03b..cd18ff6 100644 --- a/templates/docker-sandboxes/guest/prepare-template.sh +++ b/templates/docker-sandboxes/guest/prepare-template.sh @@ -6,7 +6,7 @@ if [[ "$(id -u)" != "0" ]]; then exit 1 fi -for command_name in bash cut docker dockerd dpkg-query find getent grep groupadd groupmod head install nohup pgrep ps readlink seq sha256sum sort sudo tar tr useradd usermod wc; do +for command_name in bash cut docker dockerd dpkg-query find getent grep groupadd groupmod head install nohup pgrep ps readlink seq sha256sum sort stat sudo tar tr useradd usermod wc; do command -v "${command_name}" >/dev/null 2>&1 || { echo "pinned source image is missing required command: ${command_name}" >&2 exit 1 @@ -50,6 +50,10 @@ if [[ "$(id -u agent)" != "1000" || "$(id -g agent)" != "1000" ]]; then echo "agent identity must resolve to UID/GID 1000" >&2 exit 1 fi +if [[ "$(getent passwd agent | cut -d: -f6)" != "/home/agent" ]]; then + echo "agent home must resolve to /home/agent" >&2 + exit 1 +fi getent group docker >/dev/null 2>&1 || groupadd docker getent group sudo >/dev/null 2>&1 || { @@ -58,7 +62,28 @@ getent group sudo >/dev/null 2>&1 || { } usermod --append --groups docker,sudo agent -install -d -m 0755 -o agent -g agent /home/agent /home/agent/.docker /home/agent/.docker/sandbox /home/agent/.docker/sandbox/locks +# Never carry registry credentials from the pinned source image into a reusable +# runner template. Remove complete Docker client directories at the explicit +# source identities so symlinked or helper-backed configuration cannot survive. +rm -rf -- /root/.docker /home/runner/.docker /home/agent/.docker +for stale_docker_config in /root/.docker /home/runner/.docker /home/agent/.docker; do + if [[ -e "${stale_docker_config}" || -L "${stale_docker_config}" ]]; then + echo "failed to scrub source Docker client configuration at ${stale_docker_config}" >&2 + exit 1 + fi +done + +install -d -m 0755 -o agent -g agent /home/agent +install -d -m 0700 -o agent -g agent \ + /home/agent/.docker \ + /home/agent/.docker/sandbox \ + /home/agent/.docker/sandbox/locks \ + /home/agent/.config \ + /home/agent/.cache \ + /home/agent/.local \ + /home/agent/.local/share \ + /home/agent/.local/state \ + /run/user/1000 install -d -m 0755 /etc/sudoers.d /etc/apt/apt.conf.d printf '%s\n' 'agent ALL=(ALL:ALL) NOPASSWD:ALL' > /etc/sudoers.d/epar-agent printf '%s\n' 'Defaults:agent env_keep += "http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS"' > /etc/sudoers.d/epar-proxy diff --git a/templates/docker-sandboxes/guest/run-runner.sh b/templates/docker-sandboxes/guest/run-runner.sh index aaef7fa..65b3059 100644 --- a/templates/docker-sandboxes/guest/run-runner.sh +++ b/templates/docker-sandboxes/guest/run-runner.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +unset SSH_AUTH_SOCK SSH_AUTH_SOCK_GATEWAY SSH_AGENT_PID runner_dir="${EPAR_RUNNER_WORK_DIR:-/opt/actions-runner}" tool_cache="${EPAR_RUNNER_TOOL_CACHE:-${runner_dir}/_work/_tool}" @@ -7,6 +8,8 @@ pid_file="${EPAR_RUNNER_PID_FILE:-/var/run/actions-runner.pid}" pid_start_file="${EPAR_RUNNER_PID_START_FILE:-${pid_file}.start}" log_file="${EPAR_RUNNER_LOG_FILE:-/var/log/actions-runner/run.log}" startup_check_seconds="${EPAR_RUNNER_STARTUP_CHECK_SECONDS:-1}" +agent_home="/home/agent" +agent_runtime_dir="/run/user/1000" process_start_time() { local pid="$1" @@ -21,6 +24,18 @@ process_start_time() { } install -d -m 0755 -o agent -g agent "$(dirname "${log_file}")" "${tool_cache}" "${tool_cache}/dotnet" +install -d -m 0700 -o agent -g agent \ + "${agent_home}/.docker" \ + "${agent_home}/.config" \ + "${agent_home}/.cache" \ + "${agent_home}/.local" \ + "${agent_home}/.local/share" \ + "${agent_home}/.local/state" \ + "${agent_runtime_dir}" +if [[ -e /home/runner/.docker || -L /home/runner/.docker ]]; then + echo "refusing to start the runner with stale Docker client configuration under /home/runner" >&2 + exit 1 +fi old_pid="$(cat "${pid_file}" 2>/dev/null || true)" if [[ "${old_pid}" =~ ^[1-9][0-9]*$ ]] && kill -0 "${old_pid}" >/dev/null 2>&1; then echo "actions-runner is already running as PID ${old_pid}" >&2 @@ -58,16 +73,31 @@ print(mode) PY )" runner_environment=( + "HOME=${agent_home}" + "USER=agent" + "LOGNAME=agent" + "XDG_CONFIG_HOME=${agent_home}/.config" + "XDG_CACHE_HOME=${agent_home}/.cache" + "XDG_DATA_HOME=${agent_home}/.local/share" + "XDG_STATE_HOME=${agent_home}/.local/state" + "XDG_RUNTIME_DIR=${agent_runtime_dir}" + "DOCKER_CONFIG=${agent_home}/.docker" "EPAR_RUNNER_WORK_DIR=${runner_dir}" "RUNNER_TOOL_CACHE=${tool_cache}" "AGENT_TOOLSDIRECTORY=${tool_cache}" "DOTNET_INSTALL_DIR=${tool_cache}/dotnet" "PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + "LANG=C.UTF-8" ) +for environment_name in http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS NODE_USE_ENV_PROXY; do + if [[ -n "${!environment_name+x}" ]]; then + runner_environment+=("${environment_name}=${!environment_name}") + fi +done if [[ "${trust_mode}" == "overlay" ]]; then runner_environment+=("ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/epar/check-host-trust-generation.sh") fi -sudo -u agent -H env "${runner_environment[@]}" /bin/bash -c 'cd "$1" || exit 1; nohup ./run.sh >>"$2" 2>&1 "${pid_file}" +sudo -u agent -H env -i "${runner_environment[@]}" /bin/bash -c 'cd "$1" || exit 1; nohup ./run.sh >>"$2" 2>&1 "${pid_file}" sleep "${startup_check_seconds}" pid="$(cat "${pid_file}" 2>/dev/null || true)" if [[ ! "${pid}" =~ ^[1-9][0-9]*$ ]] || ! kill -0 "${pid}" >/dev/null 2>&1; then diff --git a/templates/docker-sandboxes/guest/template-entrypoint.sh b/templates/docker-sandboxes/guest/template-entrypoint.sh index d6c3085..dc3d4c6 100644 --- a/templates/docker-sandboxes/guest/template-entrypoint.sh +++ b/templates/docker-sandboxes/guest/template-entrypoint.sh @@ -1,10 +1,24 @@ #!/usr/bin/env bash set -euo pipefail -if [[ "$(id -u)" != "1000" || "$(id -g)" != "1000" || "${HOME}" != "/home/agent" ]]; then +if [[ "$(id -u)" != "1000" || "$(id -g)" != "1000" || "${HOME:-}" != "/home/agent" || "${USER:-}" != "agent" || "${LOGNAME:-}" != "agent" ]]; then echo "EPAR Docker Sandboxes template: agent identity contract is not satisfied" >&2 exit 1 fi +if [[ -n "${SSH_AUTH_SOCK:-}" || -n "${SSH_AUTH_SOCK_GATEWAY:-}" || -n "${SSH_AGENT_PID:-}" || -e /run/ssh-agent.sock || -L /run/ssh-agent.sock ]]; then + echo "EPAR Docker Sandboxes template: host SSH-agent forwarding is not permitted; restart the Sandboxes daemon without SSH-agent variables" >&2 + exit 1 +fi +unset SSH_AUTH_SOCK SSH_AUTH_SOCK_GATEWAY SSH_AGENT_PID +sudo -n install -d -m 0700 -o agent -g agent /run/user/1000 +if [[ "${XDG_CONFIG_HOME:-}" != "/home/agent/.config" || "${XDG_CACHE_HOME:-}" != "/home/agent/.cache" || "${XDG_DATA_HOME:-}" != "/home/agent/.local/share" || "${XDG_STATE_HOME:-}" != "/home/agent/.local/state" || "${XDG_RUNTIME_DIR:-}" != "/run/user/1000" || "${DOCKER_CONFIG:-}" != "/home/agent/.docker" ]]; then + echo "EPAR Docker Sandboxes template: agent configuration-path contract is not satisfied" >&2 + exit 1 +fi +if [[ -e /home/runner/.docker || -L /home/runner/.docker ]]; then + echo "EPAR Docker Sandboxes template: stale Docker client configuration exists under /home/runner" >&2 + exit 1 +fi if [[ "${EPAR_SKIP_DOCKER_READY_CHECK:-0}" != "1" ]]; then echo "EPAR Docker Sandboxes template: waiting for the sandbox-private Docker daemon" diff --git a/templates/docker-sandboxes/guest/verify-template.sh b/templates/docker-sandboxes/guest/verify-template.sh index cebcada..0d4233d 100644 --- a/templates/docker-sandboxes/guest/verify-template.sh +++ b/templates/docker-sandboxes/guest/verify-template.sh @@ -7,6 +7,26 @@ sha256sum --check helpers.sha256 >/dev/null [[ "$(id -u agent)" == "1000" ]] [[ "$(id -g agent)" == "1000" ]] [[ "$(getent passwd agent | cut -d: -f6)" == "/home/agent" ]] +[[ "${HOME:-}" == "/home/agent" ]] +[[ "${USER:-}" == "agent" ]] +[[ "${LOGNAME:-}" == "agent" ]] +[[ -z "${SSH_AUTH_SOCK:-}" ]] +[[ -z "${SSH_AUTH_SOCK_GATEWAY:-}" ]] +[[ -z "${SSH_AGENT_PID:-}" ]] +[[ ! -e /run/ssh-agent.sock && ! -L /run/ssh-agent.sock ]] +[[ "${XDG_CONFIG_HOME:-}" == "/home/agent/.config" ]] +[[ "${XDG_CACHE_HOME:-}" == "/home/agent/.cache" ]] +[[ "${XDG_DATA_HOME:-}" == "/home/agent/.local/share" ]] +[[ "${XDG_STATE_HOME:-}" == "/home/agent/.local/state" ]] +[[ "${XDG_RUNTIME_DIR:-}" == "/run/user/1000" ]] +[[ "${DOCKER_CONFIG:-}" == "/home/agent/.docker" ]] +for private_directory in /home/agent/.docker /home/agent/.config /home/agent/.cache /home/agent/.local /home/agent/.local/share /home/agent/.local/state /run/user/1000; do + [[ "$(stat -c '%U:%G:%a' "${private_directory}")" == "agent:agent:700" ]] +done +[[ ! -e /home/agent/.docker/config.json && ! -L /home/agent/.docker/config.json ]] +[[ ! -e /home/runner/.docker && ! -L /home/runner/.docker ]] +sudo -n test ! -e /root/.docker +sudo -n test ! -L /root/.docker id -nG agent | tr ' ' '\n' | grep -Fx docker >/dev/null sudo -u agent -H sudo -n true [[ "$(pgrep -x dockerd | wc -l | tr -d '[:space:]')" == "1" ]] diff --git a/templates/docker-sandboxes/helpers.sha256 b/templates/docker-sandboxes/helpers.sha256 index ef99b03..74f827a 100644 --- a/templates/docker-sandboxes/helpers.sha256 +++ b/templates/docker-sandboxes/helpers.sha256 @@ -2,9 +2,9 @@ 3f1cee32d05ffad64004377f0276ef8aa46a2848b32bca74ac35efb3d71fd1bf ./check-runner.sh 4fe8e512539f97d00db3c2856f452f017c4de6a04832f1c1af86f1994862df59 ./collect-runner-diagnostics.sh 9d659942a7a0958c07cb0f0f069c699ec865020f0f9302bc005680a88c10c8e6 ./collect-software-inventory.sh -90a13d73f74e7628f1b7c4c768d300d2a4620387061c056dd67ca42692180e2b ./configure-runner.sh +d88d1b579e8455c853374c93848f4c244b80a7822f86d90bb0a196f925083eb4 ./configure-runner.sh 32bc68fe28dbbe9eb6947a1023606c32fc9b20088efcb90b5c543d0fa3db1218 ./install-trusted-ca-certificates.sh -a32f6bdb3b883272172e2d7318feb40d88e3ebfaffa909527ca57a3c06773ae1 ./prepare-template.sh -647fd19d18e608cb794f2b08be3019bd8e6243009aea8b9308703a465f0089a3 ./run-runner.sh -0746da2abc0a1033ecdad83ec6e432c7a006c83fefd2ce5424d3932c8f38d9aa ./template-entrypoint.sh -f50c9cf84021b8c29cbee19e6e78c6bc07101b3aad812a25fb6e3ca02a409ca0 ./verify-template.sh +aef419e89e7f39ec82648282928f1236a0121d007733083f693005705772ae36 ./prepare-template.sh +8c49af7539b77e84fdec2d3d1ed03c02f86b49fd25cefe76684d324f7458770f ./run-runner.sh +f46d7c62ebc0260b1dd578017073655ba2e7a6931dd1ad3b7c53d33910be2041 ./template-entrypoint.sh +67a64b3e7f080c3ba83ebd9dab7af89d779e00719929f80c2012a44b158a73e7 ./verify-template.sh From efdd1a14f0854802388540cc67030e071ebb6c26 Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 1 Aug 2026 10:22:48 +0800 Subject: [PATCH 17/22] docs: explain Docker Sandboxes host credential proxy --- README.md | 2 ++ docs/providers/docker-sandboxes.md | 16 +++++++++++++--- docs/troubleshooting.md | 14 +++++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5f1072c..2273407 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ The normal path is a source archive plus Docker. EPAR's first run opens a guided On macOS or Linux, the first Docker Sandboxes runner may trigger operating-system or security-tool prompts for runtime helpers such as `mkfs.ext4`, `mkfs.erofs`, and `containerd-shim-nerdbox-v1`; macOS may say the helper “is an app downloaded from the Internet.” These are used to create the runner's private Docker filesystem, unpack its read-only template filesystem, and launch the sandbox VM. Confirm that each executable belongs to the installed Docker Sandboxes runtime and that any displayed file target is sandbox-owned before approving it. Denying a required helper prevents that sandbox from starting, and EPAR fails closed without registering it; preserve and clean any diagnostic runtime state through EPAR's exact cleanup path. See the [Docker Sandboxes provider guide](docs/providers/docker-sandboxes.md#private-filesystem-and-vm-helper-approval) and [troubleshooting](docs/troubleshooting.md#docker-sandboxes-creation-fails-after-a-runtime-helper-prompt). +Docker Sandboxes also controls Docker Hub authorization at its host proxy. A workflow's guest `docker login` can report success while the proxy replaces that credential with the host `sbx login` identity. Use an authorized least-privilege host identity on a trusted single-tenant Sandbox host, or choose Docker Container when each job must control its own Docker Hub identity. See [Docker Hub Credentials and the Host Proxy](docs/providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). + ### 2. Download EPAR From the [EPAR releases page](https://github.com/solutionforest/ephemeral-action-runner/releases), download GitHub's **Source code (zip)** or **Source code (tar.gz)** for the release you want. Extract it and open a terminal in the extracted folder. diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index 83d28fb..1ecab53 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -109,11 +109,21 @@ Do not relax the verification or merely delete the relay socket: the gateway set ## Docker Hub Credentials and the Host Proxy -Docker Sandboxes interposes a host-side security proxy on registry traffic. Current Docker Sandboxes releases may replace an `Authorization` header created by `docker login` inside the guest with the host's Docker Sandboxes credential. Docker's credential documentation describes this as intentional isolation: the credential is injected by the proxy and does not enter the sandbox. For Docker Hub, the proxy uses the host `sbx login` session; registry credentials stored with `sbx secret set -g --registry ...` are likewise global and apply to every new sandbox. +Docker Sandboxes interposes a host-side security proxy on registry traffic. Docker Sandboxes v0.37.1 replaces an `Authorization` header created by `docker login` inside the guest with the host's Docker Sandboxes credential. Docker's credential documentation describes host-side injection as intentional isolation: the credential is injected by the proxy and does not enter the sandbox. For Docker Hub, the proxy uses the host `sbx login` session; registry credentials stored with `sbx secret set -g --registry ...` are likewise global and apply to every new sandbox. Treat this as a Docker Sandboxes capability contract, not a macOS networking limitation. This means a job can report `Login Succeeded`, have a correctly owned `/home/agent/.docker/config.json`, and still receive `insufficient_scope: authorization failed` when pulling a private Docker Hub image. Passing `docker --config /home/agent/.docker` does not bypass the proxy. The decisive host-side diagnostic is a Docker Sandboxes daemon message that it is overriding the client-supplied registry credential with a host credential. This behavior is independent of CA copying, TLS, network reachability, and CPU emulation when the request reaches Docker Hub and returns an authorization response. -EPAR deliberately rejects Docker Sandboxes global secrets because they would expose one shared credential to unrelated workflow sandboxes. It also does not copy a workflow secret back to the host or silently weaken the proxy. For workflows that require repository-scoped Docker Hub credentials, use Docker Container or another provider that honors the disposable runner's Docker client configuration. If all Docker Sandboxes on a trusted single-tenant host are intentionally allowed to share one least-privilege Docker Hub identity, an operator may instead authenticate the host with `sbx login`; coordinate that change across every controller using the same Docker Sandboxes runtime. Do not make this host-wide change merely to repair one job, and do not configure a global registry secret while EPAR's global-secret isolation check is enabled. +EPAR deliberately rejects Docker Sandboxes global secrets because they would expose one shared credential capability to unrelated workflow sandboxes. It also does not copy a workflow secret back to the host or silently weaken the proxy. For workflows that require repository-scoped Docker Hub credentials, use Docker Container or another provider that honors the disposable runner's Docker client configuration. If all Docker Sandboxes on a trusted single-tenant host are intentionally allowed to share one least-privilege Docker Hub identity, an operator may instead authenticate the host with `sbx login`; coordinate that change across every controller using the same Docker Sandboxes runtime. Do not make this host-wide change merely to repair one job, and do not configure a global registry secret while EPAR's global-secret isolation check is enabled. + +Run `sbx login` without credentials to display the current Docker Sandboxes username when a session already exists. A passing `sbx diagnose --output json` authentication check proves only that the session is valid; it does not prove that the identity can read a particular private repository. If the displayed identity is not the intended least-privilege account, stop every EPAR controller and sandbox using the shared runtime, then authenticate through standard input and restart the daemon before creating a fresh runner: + +```sh +printf '%s' "$DOCKERHUB_TOKEN" | sbx login --username "$DOCKERHUB_USERNAME" --password-stdin +sbx daemon stop +env -u SSH_AUTH_SOCK -u SSH_AUTH_SOCK_GATEWAY -u SSH_AGENT_PID sbx daemon start --detach +``` + +Source those variables from a local secret manager or protected prompt; never put a token literal in the command line, configuration, repository, or workflow output. Prove repository scope with a fresh Sandbox job that runs `docker manifest inspect ` without a guest login before downloading image layers. Then run the normal workflow. A successful no-login probe proves that host proxy authorization is operative; it does not make the job's later guest login authoritative. Template construction uses two independent trust paths. EPAR's project-owned BuildKit builder automatically receives host system roots for Docker Hub, GHCR, and the other pinned registries used by the build. The native controller downloads the locked Actions runner and `tini`, verifies their SHA-256 values, and then supplies them as local build inputs; the Dockerfile does not perform remote HTTPS downloads. @@ -125,7 +135,7 @@ The opt-in `TestLiveRunnerTemplateIsolation` proof also exercises authenticated - `networkBaseline: open` permits public egress, which can exfiltrate secrets or data exposed to the workflow. Use least-privilege runner groups and secrets, and choose `balanced` with narrow allow rules for higher-risk workloads. - Docker Sandboxes template cache storage is shared host state; it is not a per-sandbox root-disk measurement. -- macOS ARM64 remains preview-only until lifecycle, replacement, exact-cleanup, and an authenticated-registry path compatible with Docker Sandboxes' host credential proxy are recorded for the current template and runtime. +- macOS ARM64 remains preview-only while Docker Sandboxes and its host-authentication contract continue to evolve. Current v0.37.1 live evidence confirms host-credential substitution for private Docker Hub requests, AMD64 image pulls from an ARM64 guest daemon, ephemeral replacement, and exact sandbox cleanup; it does not establish that a workflow-provided Docker Hub identity can control the pull. - Per-job Docker Hub credentials may be overridden by Docker Sandboxes' host credential proxy. Use a provider that honors guest-scoped Docker credentials when jobs must authenticate with different Docker Hub identities. - A stopped sandbox is diagnostic state, not proof of deletion. Unknown state consumes capacity and blocks replacement. - `EPAR_DISABLE_DOCKER_SANDBOXES=1` fails admission closed during an incident or compatibility problem. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5b7080c..eb1ff88 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -167,7 +167,19 @@ First verify only metadata, never credential contents: the listener should run a Inspect the host Docker Sandboxes daemon log for a message that the proxy is overriding a client-supplied registry credential with a host credential. When that message is present, the workflow credential was written correctly but cannot control the pull: Docker Sandboxes intentionally substitutes the host-side credential. An explicit guest config path cannot bypass that policy. -Use Docker Container for workflows that require independent, per-job Docker Hub credentials. On a trusted single-tenant Docker Sandboxes host, an operator can instead choose one least-privilege Docker Hub identity with access to every required image and authenticate Docker Sandboxes with `sbx login`; this changes shared host runtime behavior, so coordinate it with every controller that uses the same `sbx` daemon. EPAR rejects global `sbx` secrets and never copies GitHub Actions secrets back to the host. See [Docker Hub Credentials and the Host Proxy](providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). +Run `sbx login` on the host to display the current Docker Sandboxes username when it is already signed in. Do not assume that a successful host Docker CLI pull means `sbx` uses the same account: Docker's ordinary credential store and the Docker Sandboxes login session can contain different identities. Likewise, `sbx diagnose --output json` can prove that authentication is valid but cannot prove private-repository scope. + +On a trusted single-tenant host, stop every controller and sandbox that shares the runtime, authenticate `sbx` as one least-privilege account that can read every required image, restart the daemon, and create a fresh runner. Pass the token only through standard input from a protected local source: + +```sh +printf '%s' "$DOCKERHUB_TOKEN" | sbx login --username "$DOCKERHUB_USERNAME" --password-stdin +sbx daemon stop +env -u SSH_AUTH_SOCK -u SSH_AUTH_SOCK_GATEWAY -u SSH_AGENT_PID sbx daemon start --detach +``` + +First run a fresh Sandbox job that performs only `docker manifest inspect ` without a guest login. If it succeeds, the host proxy identity has repository scope; run the original workflow next. A manual guest `docker login`, putting login and pull in one shell step, or changing `DOCKER_CONFIG` cannot bypass the proxy override. + +Use Docker Container for workflows that require independent, per-job Docker Hub credentials. EPAR rejects global `sbx` secrets and never copies GitHub Actions secrets back to the host. See [Docker Hub Credentials and the Host Proxy](providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). ## An idle runner reports GitHub or Sandbox health warnings From 6245d319f2b3b7e3d47b6ba1e4aac8a706a0de6e Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 1 Aug 2026 14:02:40 +0800 Subject: [PATCH 18/22] fix: preserve sandbox guest registry credentials --- README.md | 6 +- docs/advanced/docker-registry-mirrors.md | 2 +- docs/providers/docker-sandboxes.md | 22 ++--- docs/security.md | 2 +- docs/troubleshooting.md | 16 ++-- internal/image/docker_sandboxes_test.go | 87 ++++++++++++++++++- .../guest/configure-runner.sh | 2 +- .../docker-sandboxes/guest/docker-daemon.json | 7 ++ .../guest/prepare-template.sh | 59 +++++++++++-- .../docker-sandboxes/guest/run-runner.sh | 2 +- .../guest/template-entrypoint.sh | 5 ++ .../docker-sandboxes/guest/verify-template.sh | 37 +++++++- templates/docker-sandboxes/helpers.sha256 | 11 +-- 13 files changed, 209 insertions(+), 49 deletions(-) create mode 100644 templates/docker-sandboxes/guest/docker-daemon.json diff --git a/README.md b/README.md index 2273407..e16b628 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ flowchart LR ## Why EPAR -- Keep ready capacity available for private-repository CI without a long-lived runner workspace. -- Give each runner its own disposable container, WSL distribution, or microVM, depending on the provider. +- Keep private-repository CI ready without maintaining a long-lived runner workspace. +- Protect the host with [Docker Sandboxes](docs/providers/docker-sandboxes.md): each runner gets a dedicated microVM and private Docker daemon, then is removed after one job. - Run Docker-friendly Linux jobs from a Windows, macOS, Linux, or other Docker-capable host. ## Quick Start @@ -29,7 +29,7 @@ The normal path is a source archive plus Docker. EPAR's first run opens a guided On macOS or Linux, the first Docker Sandboxes runner may trigger operating-system or security-tool prompts for runtime helpers such as `mkfs.ext4`, `mkfs.erofs`, and `containerd-shim-nerdbox-v1`; macOS may say the helper “is an app downloaded from the Internet.” These are used to create the runner's private Docker filesystem, unpack its read-only template filesystem, and launch the sandbox VM. Confirm that each executable belongs to the installed Docker Sandboxes runtime and that any displayed file target is sandbox-owned before approving it. Denying a required helper prevents that sandbox from starting, and EPAR fails closed without registering it; preserve and clean any diagnostic runtime state through EPAR's exact cleanup path. See the [Docker Sandboxes provider guide](docs/providers/docker-sandboxes.md#private-filesystem-and-vm-helper-approval) and [troubleshooting](docs/troubleshooting.md#docker-sandboxes-creation-fails-after-a-runtime-helper-prompt). -Docker Sandboxes also controls Docker Hub authorization at its host proxy. A workflow's guest `docker login` can report success while the proxy replaces that credential with the host `sbx login` identity. Use an authorized least-privilege host identity on a trusted single-tenant Sandbox host, or choose Docker Container when each job must control its own Docker Hub identity. See [Docker Hub Credentials and the Host Proxy](docs/providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). +Docker Sandboxes has a host credential-injecting forward proxy. EPAR's Docker Sandboxes template keeps the private Docker daemon and Actions listener on Docker Sandboxes' policy-enforced transparent egress path by default, so a workflow's own `docker login` remains authoritative instead of being replaced by the host `sbx login` identity. Rebuild older templates after upgrading EPAR. See [Docker Hub Credentials and Transparent Egress](docs/providers/docker-sandboxes.md#docker-hub-credentials-and-transparent-egress). ### 2. Download EPAR diff --git a/docs/advanced/docker-registry-mirrors.md b/docs/advanced/docker-registry-mirrors.md index a33e470..14cc0f2 100644 --- a/docs/advanced/docker-registry-mirrors.md +++ b/docs/advanced/docker-registry-mirrors.md @@ -141,7 +141,7 @@ docker: A mirror cannot bypass registry authorization. -For Docker Hub private images, keep doing `docker login` inside the GitHub Actions job with repository or organization secrets when the provider honors guest-scoped Docker credentials. Host-side `docker login` is not copied into EPAR runners, and EPAR does not bake Docker credentials into images. Docker Sandboxes' host security proxy may instead replace the guest's Docker Hub authorization with the host `sbx login` identity; see [Docker Hub Credentials and the Host Proxy](../providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). +For Docker Hub private images, keep doing `docker login` inside the GitHub Actions job with repository or organization secrets. Host-side `docker login` is not copied into EPAR runners, and EPAR does not bake Docker credentials into images. EPAR's current Docker Sandboxes template routes its private Docker daemon transparently so the host forward proxy cannot replace the job's registry authorization during normal operation; see [Docker Hub Credentials and Transparent Egress](../providers/docker-sandboxes.md#docker-hub-credentials-and-transparent-egress). Private pulls can use a mirror in two common ways: diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index 1ecab53..ddc8282 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -107,23 +107,17 @@ env -u SSH_AUTH_SOCK -u SSH_AUTH_SOCK_GATEWAY -u SSH_AGENT_PID sbx daemon start Do not relax the verification or merely delete the relay socket: the gateway setting is itself a forwarding capability and the daemon's inherited environment is authoritative for subsequently created sandboxes. -## Docker Hub Credentials and the Host Proxy +## Docker Hub Credentials and Transparent Egress -Docker Sandboxes interposes a host-side security proxy on registry traffic. Docker Sandboxes v0.37.1 replaces an `Authorization` header created by `docker login` inside the guest with the host's Docker Sandboxes credential. Docker's credential documentation describes host-side injection as intentional isolation: the credential is injected by the proxy and does not enter the sandbox. For Docker Hub, the proxy uses the host `sbx login` session; registry credentials stored with `sbx secret set -g --registry ...` are likewise global and apply to every new sandbox. Treat this as a Docker Sandboxes capability contract, not a macOS networking limitation. +Docker Sandboxes v0.37.1 provides three HTTP(S) egress paths. Its `forward` path can terminate TLS and replace a guest registry `Authorization` header with the host `sbx login` credential; `forward-bypass` and `transparent` do not inject credentials. All three remain subject to Docker Sandboxes network policy. A runner using the forward path can therefore report `Login Succeeded`, have a correct `/home/agent/.docker/config.json`, and still receive `insufficient_scope: authorization failed` because the host identity, not the workflow identity, performs the private pull. -This means a job can report `Login Succeeded`, have a correctly owned `/home/agent/.docker/config.json`, and still receive `insufficient_scope: authorization failed` when pulling a private Docker Hub image. Passing `docker --config /home/agent/.docker` does not bypass the proxy. The decisive host-side diagnostic is a Docker Sandboxes daemon message that it is overriding the client-supplied registry credential with a host credential. This behavior is independent of CA copying, TLS, network reachability, and CPU emulation when the request reaches Docker Hub and returns an authorization response. +EPAR configures the sandbox-private Docker daemon with Docker Engine's normal daemon proxy object and `no-proxy: "*"`. The forward proxy address remains explicit, but the wildcard makes dockerd use Docker Sandboxes' transparent interception for every registry and changing CDN hostname. EPAR also starts runner registration and the Actions listener from an allowlisted clean environment that does not inherit `HTTP_PROXY`, `HTTPS_PROXY`, or their lowercase forms. Ordinary workflow and nested-Docker traffic therefore defaults to policy-enforced transparent egress, where the workflow's own `docker login` remains authoritative. Template verification requires `/etc/docker/daemon.json` to be root-owned and non-symlinked, preserves the exact proxy object while permitting only EPAR's validated optional `registry-mirrors` merge, and requires the running daemon to report `NoProxy=*`. -EPAR deliberately rejects Docker Sandboxes global secrets because they would expose one shared credential capability to unrelated workflow sandboxes. It also does not copy a workflow secret back to the host or silently weaken the proxy. For workflows that require repository-scoped Docker Hub credentials, use Docker Container or another provider that honors the disposable runner's Docker client configuration. If all Docker Sandboxes on a trusted single-tenant host are intentionally allowed to share one least-privilege Docker Hub identity, an operator may instead authenticate the host with `sbx login`; coordinate that change across every controller using the same Docker Sandboxes runtime. Do not make this host-wide change merely to repair one job, and do not configure a global registry secret while EPAR's global-secret isolation check is enabled. +The host variables `DOCKER_SANDBOXES_NO_PROXY` and `NO_PROXY` described in Docker Sandboxes' architecture documentation are not substitutes for this guest configuration. They only choose whether the host-side Sandbox proxy reaches its next hop directly or through an optional upstream proxy; they do not bypass the Sandbox proxy or its credential interceptor. Likewise, changing `DOCKER_CONFIG`, combining `docker login` and `docker pull` in one step, or replacing `docker/login-action` with a shell command cannot repair an old template that still sends dockerd through the forward path. -Run `sbx login` without credentials to display the current Docker Sandboxes username when a session already exists. A passing `sbx diagnose --output json` authentication check proves only that the session is valid; it does not prove that the identity can read a particular private repository. If the displayed identity is not the intended least-privilege account, stop every EPAR controller and sandbox using the shared runtime, then authenticate through standard input and restart the daemon before creating a fresh runner: +EPAR continues to reject global Docker Sandboxes service and registry secrets and host SSH-agent forwarding. It never copies workflow secrets back to the host. The transparent default is an operational isolation control, not a hard boundary against a hostile root-capable workflow: a job with root inside the microVM can deliberately configure a client to reconnect to the Sandbox forward proxy, and Docker Sandboxes v0.37.1 documents no per-sandbox switch that disables the credential interceptor itself. Keep the mandatory host `sbx login` identity least-privileged. Use Docker Container or another provider if even deliberate use of that residual host credential capability is outside the workflow trust boundary. -```sh -printf '%s' "$DOCKERHUB_TOKEN" | sbx login --username "$DOCKERHUB_USERNAME" --password-stdin -sbx daemon stop -env -u SSH_AUTH_SOCK -u SSH_AUTH_SOCK_GATEWAY -u SSH_AGENT_PID sbx daemon start --detach -``` - -Source those variables from a local secret manager or protected prompt; never put a token literal in the command line, configuration, repository, or workflow output. Prove repository scope with a fresh Sandbox job that runs `docker manifest inspect ` without a guest login before downloading image layers. Then run the normal workflow. A successful no-login probe proves that host proxy authorization is operative; it does not make the job's later guest login authoritative. +For diagnosis, `sbx policy log ` should report `transparent` for `registry-1.docker.io`, `auth.docker.io`, and current Docker Hub blob hosts. A daemon log saying it is overriding a client-supplied registry credential, or policy entries showing `forward` for Docker Hub, identifies an old or altered template. Aligning `sbx login` with a workflow account can prove the interception diagnosis, but it establishes only shared-host authorization and is not EPAR's remediation for per-job credentials. Template construction uses two independent trust paths. EPAR's project-owned BuildKit builder automatically receives host system roots for Docker Hub, GHCR, and the other pinned registries used by the build. The native controller downloads the locked Actions runner and `tini`, verifies their SHA-256 values, and then supplies them as local build inputs; the Dockerfile does not perform remote HTTPS downloads. @@ -135,8 +129,8 @@ The opt-in `TestLiveRunnerTemplateIsolation` proof also exercises authenticated - `networkBaseline: open` permits public egress, which can exfiltrate secrets or data exposed to the workflow. Use least-privilege runner groups and secrets, and choose `balanced` with narrow allow rules for higher-risk workloads. - Docker Sandboxes template cache storage is shared host state; it is not a per-sandbox root-disk measurement. -- macOS ARM64 remains preview-only while Docker Sandboxes and its host-authentication contract continue to evolve. Current v0.37.1 live evidence confirms host-credential substitution for private Docker Hub requests, AMD64 image pulls from an ARM64 guest daemon, ephemeral replacement, and exact sandbox cleanup; it does not establish that a workflow-provided Docker Hub identity can control the pull. -- Per-job Docker Hub credentials may be overridden by Docker Sandboxes' host credential proxy. Use a provider that honors guest-scoped Docker credentials when jobs must authenticate with different Docker Hub identities. +- macOS ARM64 remains preview-only while Docker Sandboxes and its host-authentication contract continue to evolve. Current v0.37.1 evidence includes three consecutive private Docker Hub pulls with intentionally different host and workflow identities, transparent registry/auth/blob routing, AMD64 image pulls from an ARM64 guest daemon, ephemeral replacement, and exact sandbox cleanup. +- Transparent egress preserves ordinary per-job Docker Hub credentials, but a root-capable workflow can deliberately opt back into the Docker Sandboxes forward proxy. Use Docker Container when that residual host credential capability is outside the trust boundary. - A stopped sandbox is diagnostic state, not proof of deletion. Unknown state consumes capacity and blocks replacement. - `EPAR_DISABLE_DOCKER_SANDBOXES=1` fails admission closed during an incident or compatibility problem. diff --git a/docs/security.md b/docs/security.md index 3bcec27..4e3f8bb 100644 --- a/docs/security.md +++ b/docs/security.md @@ -56,4 +56,4 @@ Docker registry mirrors are optional infrastructure outside EPAR. Treat them as Do not assume a mirror makes private image pulls safe or anonymous. A private image still needs authorization from the workflow's `docker login` or from credentials configured on the mirror itself. If the mirror is configured with upstream registry credentials, secure the mirror because it may be able to serve private images that credential can access. -Host-side Docker login state is not copied into EPAR instances. Keep Docker Hub, cloud registry, and package registry credentials in GitHub secrets or in a deliberately secured mirror service. Docker Sandboxes is an exception at the authorization boundary: its host security proxy may replace a guest's Docker Hub authorization with the host `sbx login` identity without copying that credential into the guest. Use Docker Container when each job must supply an independent Docker Hub identity; see the [Docker Sandboxes provider guide](providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). +Host-side Docker login state is not copied into EPAR instances. Keep Docker Hub, cloud registry, and package registry credentials in GitHub secrets or in a deliberately secured mirror service. Docker Sandboxes has a host forward proxy that can replace a guest's Docker Hub authorization with the host `sbx login` identity without copying that credential into the guest. EPAR configures its private Docker daemon and Actions listener to use Docker Sandboxes' policy-enforced transparent egress path by default, where credential injection is unavailable. This preserves ordinary per-job registry authentication but is not a hard boundary against a root-capable workflow deliberately reconnecting to the forward proxy; use a least-privilege `sbx` account and choose Docker Container when that residual host credential capability is outside the trust boundary. See the [Docker Sandboxes provider guide](providers/docker-sandboxes.md#docker-hub-credentials-and-transparent-egress). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index eb1ff88..17b3dc0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -165,21 +165,15 @@ A workflow's Docker login step reports `Login Succeeded`, but a later pull of a First verify only metadata, never credential contents: the listener should run as `agent` with `HOME=/home/agent` and `DOCKER_CONFIG=/home/agent/.docker`, and a post-login config should be owned by `agent` with restrictive permissions. If Docker reaches the registry and returns an authorization response, do not investigate CA copying unless an `x509` or TLS error is also present. -Inspect the host Docker Sandboxes daemon log for a message that the proxy is overriding a client-supplied registry credential with a host credential. When that message is present, the workflow credential was written correctly but cannot control the pull: Docker Sandboxes intentionally substitutes the host-side credential. An explicit guest config path cannot bypass that policy. +Inspect the host Docker Sandboxes daemon log for a message that the proxy is overriding a client-supplied registry credential with a host credential. When that message is present, the workflow credential was written correctly but dockerd used the credential-injecting forward path. An explicit guest config path cannot bypass that path. -Run `sbx login` on the host to display the current Docker Sandboxes username when it is already signed in. Do not assume that a successful host Docker CLI pull means `sbx` uses the same account: Docker's ordinary credential store and the Docker Sandboxes login session can contain different identities. Likewise, `sbx diagnose --output json` can prove that authentication is valid but cannot prove private-repository scope. +On a current EPAR template, `docker info --format '{{.NoProxy}}'` must print `*`, `/etc/docker/daemon.json` must be a root-owned regular file, and `sbx policy log ` must report `transparent` for `registry-1.docker.io`, `auth.docker.io`, and the blob host used by the pull. Docker Sandboxes documents transparent traffic as policy-enforced without credential injection. If dockerd reports another no-proxy value or the policy log reports `forward`, stop the workspace controller, let its exact cleanup finish, rerun `./start` to build and import the changed template, and test on a newly created runner. Do not reuse the old sandbox. -On a trusted single-tenant host, stop every controller and sandbox that shares the runtime, authenticate `sbx` as one least-privilege account that can read every required image, restart the daemon, and create a fresh runner. Pass the token only through standard input from a protected local source: +Do not set `DOCKER_SANDBOXES_NO_PROXY` expecting it to disable credential injection. That host variable only excludes destinations from an optional upstream proxy used after traffic reaches the mandatory Sandbox proxy. Replacing `docker/login-action` with `docker login`, combining login and pull in one shell step, or changing `DOCKER_CONFIG` also leaves an old daemon's forward route unchanged. -```sh -printf '%s' "$DOCKERHUB_TOKEN" | sbx login --username "$DOCKERHUB_USERNAME" --password-stdin -sbx daemon stop -env -u SSH_AUTH_SOCK -u SSH_AUTH_SOCK_GATEWAY -u SSH_AGENT_PID sbx daemon start --detach -``` - -First run a fresh Sandbox job that performs only `docker manifest inspect ` without a guest login. If it succeeds, the host proxy identity has repository scope; run the original workflow next. A manual guest `docker login`, putting login and pull in one shell step, or changing `DOCKER_CONFIG` cannot bypass the proxy override. +Keep the host `sbx login` identity intentionally different from the workflow identity when proving this fix. A successful private pull together with transparent policy-log entries proves that the guest credential is authoritative. Changing the host login to match the workflow can diagnose the old interception behavior, but it is a shared-identity workaround rather than the fix. -Use Docker Container for workflows that require independent, per-job Docker Hub credentials. EPAR rejects global `sbx` secrets and never copies GitHub Actions secrets back to the host. See [Docker Hub Credentials and the Host Proxy](providers/docker-sandboxes.md#docker-hub-credentials-and-the-host-proxy). +EPAR rejects global `sbx` secrets and removes inherited proxy variables from runner registration and the Actions listener. A root-capable workflow can still deliberately reconnect a client to Docker Sandboxes' forward proxy, and v0.37.1 has no documented per-sandbox switch that disables the interceptor. Use a least-privilege host `sbx` account and choose Docker Container if that residual capability is outside the trust boundary. See [Docker Hub Credentials and Transparent Egress](providers/docker-sandboxes.md#docker-hub-credentials-and-transparent-egress). ## An idle runner reports GitHub or Sandbox health warnings diff --git a/internal/image/docker_sandboxes_test.go b/internal/image/docker_sandboxes_test.go index 62bdaad..90847cc 100644 --- a/internal/image/docker_sandboxes_test.go +++ b/internal/image/docker_sandboxes_test.go @@ -152,6 +152,18 @@ func TestDockerSandboxesRunnerIdentityAndCredentialHygieneContract(t *testing.T) t.Fatalf("Docker Sandboxes listener environment omitted %q", required) } } + for _, forbidden := range []string{"http_proxy", "https_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"} { + if strings.Contains(runner, forbidden) { + t.Fatalf("Docker Sandboxes listener still inherits host forward-proxy variables via %q", forbidden) + } + } + + configure := readTemplateFile(t, filepath.Join("guest", "configure-runner.sh")) + for _, forbidden := range []string{"http_proxy", "https_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"} { + if strings.Contains(configure, forbidden) { + t.Fatalf("Docker Sandboxes registration still inherits host forward-proxy variables via %q", forbidden) + } + } entrypoint := readTemplateFile(t, filepath.Join("guest", "template-entrypoint.sh")) for _, required := range []string{ @@ -159,6 +171,9 @@ func TestDockerSandboxesRunnerIdentityAndCredentialHygieneContract(t *testing.T) `-n "${SSH_AUTH_SOCK_GATEWAY:-}"`, "-e /run/ssh-agent.sock", "host SSH-agent forwarding is not permitted", + "unset http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY", + `docker info --format '{{.NoProxy}}'`, + "policy-enforced transparent egress", } { if !strings.Contains(entrypoint, required) { t.Fatalf("Docker Sandboxes entrypoint omitted SSH-agent isolation contract %q", required) @@ -205,7 +220,12 @@ func TestDockerSandboxesRunnerIdentityAndCredentialHygieneContract(t *testing.T) t.Run("source credential scrubbing", func(t *testing.T) { prepare := readTemplateFile(t, filepath.Join("guest", "prepare-template.sh")) for _, required := range []string{ - "rm -rf -- /root/.docker /home/runner/.docker /home/agent/.docker", + `passwd_entries="$(getent passwd)"`, + `[[ -z "${passwd_entries}" ]]`, + `done <<<"${passwd_entries}"`, + "for root_home_docker_config in /.docker /.dockercfg", + `rm -rf -- "${credential_home}/.docker"`, + `rm -f -- "${credential_home}/.dockercfg"`, "failed to scrub source Docker client configuration", } { if !strings.Contains(prepare, required) { @@ -217,6 +237,17 @@ func TestDockerSandboxesRunnerIdentityAndCredentialHygieneContract(t *testing.T) t.Fatalf("Docker Sandboxes credential hygiene exposes or copies source credential material via %q", forbidden) } } + verify := readTemplateFile(t, filepath.Join("guest", "verify-template.sh")) + for _, required := range []string{ + `passwd_entries="$(getent passwd)"`, + `done <<<"${passwd_entries}"`, + `sudo -n test ! -e "${normalized_home}/.dockercfg"`, + `sudo -n test ! -e "${normalized_home}/.docker"`, + } { + if !strings.Contains(verify, required) { + t.Fatalf("Docker Sandboxes template verification omitted foreign-home credential check %q", required) + } + } }) t.Run("foreign runner config rejection", func(t *testing.T) { @@ -233,6 +264,60 @@ func TestDockerSandboxesRunnerIdentityAndCredentialHygieneContract(t *testing.T) }) } +func TestDockerSandboxesDockerDaemonUsesPolicyEnforcedTransparentRegistryPath(t *testing.T) { + templateRoot := filepath.Join("..", "..", "templates", "docker-sandboxes") + content, err := os.ReadFile(filepath.Join(templateRoot, "guest", "docker-daemon.json")) + if err != nil { + t.Fatal(err) + } + var configuration struct { + Proxies struct { + HTTPProxy string `json:"http-proxy"` + HTTPSProxy string `json:"https-proxy"` + NoProxy string `json:"no-proxy"` + } `json:"proxies"` + } + if err := json.Unmarshal(content, &configuration); err != nil { + t.Fatalf("parse Docker daemon proxy configuration: %v", err) + } + if configuration.Proxies.HTTPProxy != "http://gateway.docker.internal:3128" || configuration.Proxies.HTTPSProxy != "http://gateway.docker.internal:3128" || configuration.Proxies.NoProxy != "*" { + t.Fatalf("Docker daemon proxy configuration = %#v, want Sandbox forward proxy with daemon-wide transparent routing", configuration.Proxies) + } + prepareContent, err := os.ReadFile(filepath.Join(templateRoot, "guest", "prepare-template.sh")) + if err != nil { + t.Fatal(err) + } + prepare := string(prepareContent) + for _, required := range []string{ + "pinned source image unexpectedly supplies /etc/docker/daemon.json", + "install -m 0644 -o root -g root /opt/epar/docker-daemon.json /etc/docker/daemon.json", + "cmp -s /opt/epar/docker-daemon.json /etc/docker/daemon.json", + "rm -f /etc/sudoers.d/epar-proxy", + } { + if !strings.Contains(prepare, required) { + t.Fatalf("Docker Sandboxes template preparation omitted daemon proxy contract %q", required) + } + } + verifyContent, err := os.ReadFile(filepath.Join(templateRoot, "guest", "verify-template.sh")) + if err != nil { + t.Fatal(err) + } + verify := string(verifyContent) + for _, required := range []string{ + "test ! -L /etc/docker/daemon.json", + `stat -c '%U:%G:%a' /etc/docker/daemon.json`, + `"root:root:644"`, + `.proxies == {`, + `(keys - ["proxies", "registry-mirrors"])`, + `has("registry-mirrors")`, + `docker info --format '{{.NoProxy}}'`, + } { + if !strings.Contains(verify, required) { + t.Fatalf("Docker Sandboxes template verification omitted daemon proxy contract %q", required) + } + } +} + func TestDockerSandboxesDisabledTrustPolicyIsExplicit(t *testing.T) { root := t.TempDir() coordinator := &Coordinator{ProjectRoot: root} diff --git a/templates/docker-sandboxes/guest/configure-runner.sh b/templates/docker-sandboxes/guest/configure-runner.sh index a41db61..c65fa25 100644 --- a/templates/docker-sandboxes/guest/configure-runner.sh +++ b/templates/docker-sandboxes/guest/configure-runner.sh @@ -71,7 +71,7 @@ configuration_environment=( "PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" "LANG=C.UTF-8" ) -for environment_name in http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS NODE_USE_ENV_PROXY; do +for environment_name in SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS NODE_USE_ENV_PROXY; do if [[ -n "${!environment_name+x}" ]]; then configuration_environment+=("${environment_name}=${!environment_name}") fi diff --git a/templates/docker-sandboxes/guest/docker-daemon.json b/templates/docker-sandboxes/guest/docker-daemon.json new file mode 100644 index 0000000..a19b36a --- /dev/null +++ b/templates/docker-sandboxes/guest/docker-daemon.json @@ -0,0 +1,7 @@ +{ + "proxies": { + "http-proxy": "http://gateway.docker.internal:3128", + "https-proxy": "http://gateway.docker.internal:3128", + "no-proxy": "*" + } +} diff --git a/templates/docker-sandboxes/guest/prepare-template.sh b/templates/docker-sandboxes/guest/prepare-template.sh index cd18ff6..1235f1a 100644 --- a/templates/docker-sandboxes/guest/prepare-template.sh +++ b/templates/docker-sandboxes/guest/prepare-template.sh @@ -6,7 +6,7 @@ if [[ "$(id -u)" != "0" ]]; then exit 1 fi -for command_name in bash cut docker dockerd dpkg-query find getent grep groupadd groupmod head install nohup pgrep ps readlink seq sha256sum sort stat sudo tar tr useradd usermod wc; do +for command_name in bash cmp cut docker dockerd dpkg-query find getent grep groupadd groupmod head install jq nohup pgrep ps readlink seq sha256sum sort stat sudo tar tr useradd usermod wc; do command -v "${command_name}" >/dev/null 2>&1 || { echo "pinned source image is missing required command: ${command_name}" >&2 exit 1 @@ -63,16 +63,43 @@ getent group sudo >/dev/null 2>&1 || { usermod --append --groups docker,sudo agent # Never carry registry credentials from the pinned source image into a reusable -# runner template. Remove complete Docker client directories at the explicit -# source identities so symlinked or helper-backed configuration cannot survive. -rm -rf -- /root/.docker /home/runner/.docker /home/agent/.docker -for stale_docker_config in /root/.docker /home/runner/.docker /home/agent/.docker; do - if [[ -e "${stale_docker_config}" || -L "${stale_docker_config}" ]]; then - echo "failed to scrub source Docker client configuration at ${stale_docker_config}" >&2 +# runner template. Scrub current and legacy Docker client configuration from +# every absolute passwd home, including source identities renamed to agent. +credential_homes=(/root /home/runner /home/agent) +if ! passwd_entries="$(getent passwd)" || [[ -z "${passwd_entries}" ]]; then + echo "failed to enumerate pinned source image passwd homes" >&2 + exit 1 +fi +while IFS=: read -r _ _ _ _ _ account_home _; do + if [[ -z "${account_home}" || "${account_home}" != /* ]]; then + echo "pinned source image contains an invalid passwd home ${account_home:-}" >&2 + exit 1 + fi + normalized_home="$(readlink -m -- "${account_home}")" + if [[ "${normalized_home}" == "/" ]]; then + continue + fi + credential_homes+=("${normalized_home}") +done <<<"${passwd_entries}" + +for root_home_docker_config in /.docker /.dockercfg; do + if [[ -e "${root_home_docker_config}" || -L "${root_home_docker_config}" ]]; then + echo "pinned source image contains Docker client configuration in a root-filesystem passwd home at ${root_home_docker_config}" >&2 exit 1 fi done +for credential_home in "${credential_homes[@]}"; do + rm -rf -- "${credential_home}/.docker" + rm -f -- "${credential_home}/.dockercfg" + for stale_docker_config in "${credential_home}/.docker" "${credential_home}/.dockercfg"; do + if [[ -e "${stale_docker_config}" || -L "${stale_docker_config}" ]]; then + echo "failed to scrub source Docker client configuration at ${stale_docker_config}" >&2 + exit 1 + fi + done +done + install -d -m 0755 -o agent -g agent /home/agent install -d -m 0700 -o agent -g agent \ /home/agent/.docker \ @@ -86,8 +113,8 @@ install -d -m 0700 -o agent -g agent \ /run/user/1000 install -d -m 0755 /etc/sudoers.d /etc/apt/apt.conf.d printf '%s\n' 'agent ALL=(ALL:ALL) NOPASSWD:ALL' > /etc/sudoers.d/epar-agent -printf '%s\n' 'Defaults:agent env_keep += "http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS"' > /etc/sudoers.d/epar-proxy -chmod 0440 /etc/sudoers.d/epar-agent /etc/sudoers.d/epar-proxy +rm -f /etc/sudoers.d/epar-proxy +chmod 0440 /etc/sudoers.d/epar-agent printf '%s\n' 'APT::Periodic::Enable "0";' 'APT::Periodic::Update-Package-Lists "0";' 'APT::Periodic::Unattended-Upgrade "0";' > /etc/apt/apt.conf.d/99epar-disable-periodic rm -f /etc/systemd/system/timers.target.wants/apt-daily.timer /etc/systemd/system/timers.target.wants/apt-daily-upgrade.timer @@ -101,3 +128,17 @@ if [[ -n "$(find /var/lib/docker -mindepth 1 -print -quit)" ]]; then fi sudo -u agent -H true + +# Docker Sandboxes' forward proxy can replace registry Authorization headers +# with a host credential. Keep the sandbox-private daemon's registry traffic on +# the transparent, policy-enforced path so workflow-scoped Docker credentials +# remain authoritative. The Actions listener also starts from a clean +# environment without inherited proxy variables, so ordinary job traffic uses +# Docker Sandboxes' policy-enforced transparent path by default. +install -d -m 0755 -o root -g root /etc/docker +if [[ -e /etc/docker/daemon.json || -L /etc/docker/daemon.json ]]; then + echo "pinned source image unexpectedly supplies /etc/docker/daemon.json" >&2 + exit 1 +fi +install -m 0644 -o root -g root /opt/epar/docker-daemon.json /etc/docker/daemon.json +cmp -s /opt/epar/docker-daemon.json /etc/docker/daemon.json diff --git a/templates/docker-sandboxes/guest/run-runner.sh b/templates/docker-sandboxes/guest/run-runner.sh index 65b3059..9e6cb11 100644 --- a/templates/docker-sandboxes/guest/run-runner.sh +++ b/templates/docker-sandboxes/guest/run-runner.sh @@ -89,7 +89,7 @@ runner_environment=( "PATH=/opt/epar/hook-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" "LANG=C.UTF-8" ) -for environment_name in http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS NODE_USE_ENV_PROXY; do +for environment_name in SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE JAVA_TOOL_OPTIONS NODE_USE_ENV_PROXY; do if [[ -n "${!environment_name+x}" ]]; then runner_environment+=("${environment_name}=${!environment_name}") fi diff --git a/templates/docker-sandboxes/guest/template-entrypoint.sh b/templates/docker-sandboxes/guest/template-entrypoint.sh index dc3d4c6..0e01fcf 100644 --- a/templates/docker-sandboxes/guest/template-entrypoint.sh +++ b/templates/docker-sandboxes/guest/template-entrypoint.sh @@ -10,6 +10,7 @@ if [[ -n "${SSH_AUTH_SOCK:-}" || -n "${SSH_AUTH_SOCK_GATEWAY:-}" || -n "${SSH_AG exit 1 fi unset SSH_AUTH_SOCK SSH_AUTH_SOCK_GATEWAY SSH_AGENT_PID +unset http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY sudo -n install -d -m 0700 -o agent -g agent /run/user/1000 if [[ "${XDG_CONFIG_HOME:-}" != "/home/agent/.config" || "${XDG_CACHE_HOME:-}" != "/home/agent/.cache" || "${XDG_DATA_HOME:-}" != "/home/agent/.local/share" || "${XDG_STATE_HOME:-}" != "/home/agent/.local/state" || "${XDG_RUNTIME_DIR:-}" != "/run/user/1000" || "${DOCKER_CONFIG:-}" != "/home/agent/.docker" ]]; then echo "EPAR Docker Sandboxes template: agent configuration-path contract is not satisfied" >&2 @@ -25,6 +26,10 @@ if [[ "${EPAR_SKIP_DOCKER_READY_CHECK:-0}" != "1" ]]; then for attempt in $(seq 1 120); do daemon_count="$( (pgrep -x dockerd 2>/dev/null || true) | wc -l | tr -d '[:space:]')" if [[ "${daemon_count}" == "1" ]] && docker info >/dev/null 2>&1; then + if [[ "$(docker info --format '{{.NoProxy}}')" != "*" ]]; then + echo "EPAR Docker Sandboxes template: sandbox-private Docker daemon is not using policy-enforced transparent egress" >&2 + exit 1 + fi echo "EPAR Docker Sandboxes template: one sandbox-private Docker daemon is ready" break fi diff --git a/templates/docker-sandboxes/guest/verify-template.sh b/templates/docker-sandboxes/guest/verify-template.sh index 0d4233d..20faab3 100644 --- a/templates/docker-sandboxes/guest/verify-template.sh +++ b/templates/docker-sandboxes/guest/verify-template.sh @@ -24,13 +24,46 @@ for private_directory in /home/agent/.docker /home/agent/.config /home/agent/.ca [[ "$(stat -c '%U:%G:%a' "${private_directory}")" == "agent:agent:700" ]] done [[ ! -e /home/agent/.docker/config.json && ! -L /home/agent/.docker/config.json ]] +if ! passwd_entries="$(getent passwd)" || [[ -z "${passwd_entries}" ]]; then + echo "failed to enumerate template passwd homes" >&2 + exit 1 +fi +while IFS=: read -r _ _ _ _ _ account_home _; do + [[ -n "${account_home}" && "${account_home}" == /* ]] + normalized_home="$(readlink -m -- "${account_home}")" + if [[ "${normalized_home}" == "/" ]]; then + sudo -n test ! -e /.docker + sudo -n test ! -L /.docker + sudo -n test ! -e /.dockercfg + sudo -n test ! -L /.dockercfg + continue + fi + sudo -n test ! -e "${normalized_home}/.dockercfg" + sudo -n test ! -L "${normalized_home}/.dockercfg" + if [[ "${normalized_home}" != "/home/agent" ]]; then + sudo -n test ! -e "${normalized_home}/.docker" + sudo -n test ! -L "${normalized_home}/.docker" + fi +done <<<"${passwd_entries}" [[ ! -e /home/runner/.docker && ! -L /home/runner/.docker ]] -sudo -n test ! -e /root/.docker -sudo -n test ! -L /root/.docker +[[ ! -e /home/runner/.dockercfg && ! -L /home/runner/.dockercfg ]] +sudo -n test -f /etc/docker/daemon.json +sudo -n test ! -L /etc/docker/daemon.json +[[ "$(sudo -n stat -c '%U:%G:%a' /etc/docker/daemon.json)" == "root:root:644" ]] +sudo -n jq -e ' + .proxies == { + "http-proxy": "http://gateway.docker.internal:3128", + "https-proxy": "http://gateway.docker.internal:3128", + "no-proxy": "*" + } + and ((keys - ["proxies", "registry-mirrors"]) | length == 0) + and ((has("registry-mirrors") | not) or ((."registry-mirrors" | type) == "array" and all(."registry-mirrors"[]; type == "string"))) +' /etc/docker/daemon.json >/dev/null id -nG agent | tr ' ' '\n' | grep -Fx docker >/dev/null sudo -u agent -H sudo -n true [[ "$(pgrep -x dockerd | wc -l | tr -d '[:space:]')" == "1" ]] docker info >/dev/null +[[ "$(docker info --format '{{.NoProxy}}')" == "*" ]] [[ -x /opt/actions-runner/bin/Runner.Listener ]] [[ -x /opt/epar/check-host-trust-generation.sh ]] [[ -x /opt/epar/hook-bin/bash ]] diff --git a/templates/docker-sandboxes/helpers.sha256 b/templates/docker-sandboxes/helpers.sha256 index 74f827a..f882fa1 100644 --- a/templates/docker-sandboxes/helpers.sha256 +++ b/templates/docker-sandboxes/helpers.sha256 @@ -2,9 +2,10 @@ 3f1cee32d05ffad64004377f0276ef8aa46a2848b32bca74ac35efb3d71fd1bf ./check-runner.sh 4fe8e512539f97d00db3c2856f452f017c4de6a04832f1c1af86f1994862df59 ./collect-runner-diagnostics.sh 9d659942a7a0958c07cb0f0f069c699ec865020f0f9302bc005680a88c10c8e6 ./collect-software-inventory.sh -d88d1b579e8455c853374c93848f4c244b80a7822f86d90bb0a196f925083eb4 ./configure-runner.sh +99474651f2c675389f35e5ad9aedd32ddcd8dbc302b486f42f3b035414122952 ./configure-runner.sh +1fbc8c68c8d75f3982e23718ed3d5bd984c2afee21e26657b7a64a9f185a747f ./docker-daemon.json 32bc68fe28dbbe9eb6947a1023606c32fc9b20088efcb90b5c543d0fa3db1218 ./install-trusted-ca-certificates.sh -aef419e89e7f39ec82648282928f1236a0121d007733083f693005705772ae36 ./prepare-template.sh -8c49af7539b77e84fdec2d3d1ed03c02f86b49fd25cefe76684d324f7458770f ./run-runner.sh -f46d7c62ebc0260b1dd578017073655ba2e7a6931dd1ad3b7c53d33910be2041 ./template-entrypoint.sh -67a64b3e7f080c3ba83ebd9dab7af89d779e00719929f80c2012a44b158a73e7 ./verify-template.sh +759a46fe38c27e9a64ffbd7d0eaafaffac1292e18ddf4a9527b0d71413aa8817 ./prepare-template.sh +746836a5cdac8da3d35e7914e60d293d0c605214beb498353168bafee08b09e7 ./run-runner.sh +71f328ef12301e4d79d285eb8cd5e1ba494e3c0be32a1e5569e25a442702fb48 ./template-entrypoint.sh +56068235384e118f0b200443f21e972f9225d839bda6239c7b75b468fa4c70d4 ./verify-template.sh From 062cba65416e07fe993a0418775dca0dc4d434c9 Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 1 Aug 2026 16:02:09 +0800 Subject: [PATCH 19/22] Improve first-run wizard UX --- cmd/ephemeral-action-runner/init.go | 206 ++++++++++++++++------- cmd/ephemeral-action-runner/init_test.go | 144 ++++++++++++++-- docs/usage.md | 2 +- 3 files changed, 271 insertions(+), 81 deletions(-) diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index 81900f8..1f352c3 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -319,6 +319,7 @@ func runInitWithOptions(opts initOptions) error { hostTrustMode := config.HostTrustModeDisabled hostTrustScopes := []string{config.HostTrustScopeSystem} if providerType == "docker-container" || providerType == "docker-sandboxes" { + fmt.Fprintln(opts.Out, "Runners need this host's trusted TLS roots to access services that this machine trusts.") enabled, promptErr := promptYesNo(opts.Out, reader, "Inherit this host's trusted TLS roots into disposable runners?", true) if promptErr != nil { return promptErr @@ -399,6 +400,8 @@ type initRunnerGroupSelection struct { func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, client initRunnerGroupClient) (initRunnerGroupSelection, error) { var groups []gh.RunnerGroup var repositories map[int64][]gh.RunnerGroupRepository + showBlockedGroups := false + showGroupDetails := false for { if groups == nil { var err error @@ -409,20 +412,35 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, } fmt.Fprintln(out, "") fmt.Fprintln(out, "GitHub runner group:") - fmt.Fprintln(out, " Choose which repositories GitHub may route to these self-hosted runners.") - fmt.Fprintln(out, " Groups are ordered from the most restrictive policy to the least restrictive policy; the first item is not an automatic default.") - fmt.Fprintln(out, "") - fmt.Fprintln(out, " Repository access meanings:") - fmt.Fprintln(out, " Selected repositories: Only repositories explicitly added to the group can use its runners. This is recommended.") - fmt.Fprintln(out, " All private repositories: Every current and future private repository in the organization can use its runners.") - fmt.Fprintln(out, " All repositories: Every current and future repository allowed by the public-repository setting can use its runners.") - fmt.Fprintln(out, "") - fmt.Fprintln(out, " Recommended policy: a non-default group, Selected repositories, trusted repositories only, and public repository access disabled.") + fmt.Fprintln(out, " Choose which repositories can use these runners.") + fmt.Fprintln(out, " For better security, use a custom group for selected trusted repositories, with public access disabled.") fmt.Fprintln(out, " See docs/runner-groups.md for details.") - for i, group := range groups { - printRunnerGroupChoice(out, i+1, group, repositories[group.ID]) + if showGroupDetails { + fmt.Fprintln(out, "") + fmt.Fprintln(out, " Repository access meanings:") + fmt.Fprintln(out, " Selected repositories: Only repositories added to the group can use its runners.") + fmt.Fprintln(out, " All private repositories: All current and future private repositories can use its runners.") + fmt.Fprintln(out, " All repositories: All repositories allowed by the public-repository setting can use its runners.") + } + visibleGroups := filterRunnerGroupsForWizard(groups, repositories, showBlockedGroups) + if len(visibleGroups) == 0 { + fmt.Fprintln(out, "") + fmt.Fprintln(out, " No selectable runner groups found. Show blocked groups to review them.") + } + for i, group := range visibleGroups { + printRunnerGroupChoice(out, i+1, group, repositories[group.ID], showGroupDetails) } fmt.Fprintln(out, " R. Refresh runner groups") + if showBlockedGroups { + fmt.Fprintln(out, " B. Hide blocked runner groups") + } else { + fmt.Fprintln(out, " B. Show blocked runner groups") + } + if showGroupDetails { + fmt.Fprintln(out, " D. Hide runner group details") + } else { + fmt.Fprintln(out, " D. Show runner group details") + } fmt.Fprintln(out, " Q. Quit without writing a config") choice, err := promptRequired(out, reader, "Runner group choice") @@ -434,15 +452,21 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, groups = nil repositories = nil continue + case "b", "blocked": + showBlockedGroups = !showBlockedGroups + continue + case "d", "details": + showGroupDetails = !showGroupDetails + continue case "q", "quit": return initRunnerGroupSelection{}, fmt.Errorf("runner-group selection cancelled; no config was written") } index, parseErr := strconv.Atoi(choice) - if parseErr != nil || index < 1 || index > len(groups) { - fmt.Fprintf(out, "Choose a runner group number, R to refresh, or Q to quit.\n") + if parseErr != nil || index < 1 || index > len(visibleGroups) { + fmt.Fprintln(out, "Choose a runner group number, R to refresh, B for blocked groups, D for details, or Q to quit.") continue } - group := groups[index-1] + group := visibleGroups[index-1] selectedRepositories := repositories[group.ID] _, publicCount := repositoryPrivacyCounts(selectedRepositories) if runnerGroupVisibilityRank(group.Visibility) == 3 { @@ -477,32 +501,46 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, continue } - warnings := runnerGroupSelectionWarnings(group) - if len(warnings) > 0 { + if group.Default { fmt.Fprintln(out, "") - notRecommended := runnerGroupDoesNotMeetRecommendedPolicy(group) - if notRecommended { - fmt.Fprintln(out, "*** SECURITY WARNING: THIS RUNNER GROUP IS NOT RECOMMENDED ***") - } else { - fmt.Fprintln(out, "*** SECURITY ADVISORY: ENTERPRISE-MANAGED RUNNER GROUP ***") - } - fmt.Fprintf(out, "Runner group %q requires explicit review:\n", group.Name) - for _, warning := range warnings { - fmt.Fprintf(out, " - %s\n", warning) - } - continueLabel := "Continue after confirming the enterprise-managed policy" - if notRecommended { - fmt.Fprintln(out, "RECOMMENDED ACTION: Choose Back. Follow docs/runner-groups.md to create a dedicated non-default group with Selected repositories and public repository access disabled, then select that safer group.") - fmt.Fprintln(out, "Continuing will deliberately relax the generated policy to match this broader group. Future repositories may gain access without another EPAR configuration change.") - continueLabel = "Continue anyway and generate a relaxed policy" - } - continueSelection, err := promptContinueOrBack(out, reader, continueLabel) + fmt.Fprintln(out, "*** SECURITY REMINDER: DEFAULT RUNNER GROUP ***") + fmt.Fprintln(out, "The Default runner group is fine for trying EPAR.") + fmt.Fprintln(out, "For regular use, a custom group limited to selected trusted repositories offers better security.") + continueSelection, err := promptContinueOrBack(out, reader, "Continue with Default runner group") if err != nil { return initRunnerGroupSelection{}, err } if !continueSelection { continue } + } else { + warnings := runnerGroupSelectionWarnings(group) + if len(warnings) > 0 { + fmt.Fprintln(out, "") + notRecommended := runnerGroupDoesNotMeetRecommendedPolicy(group) + if notRecommended { + fmt.Fprintln(out, "*** SECURITY WARNING: THIS RUNNER GROUP IS NOT RECOMMENDED ***") + } else { + fmt.Fprintln(out, "*** SECURITY ADVISORY: ENTERPRISE-MANAGED RUNNER GROUP ***") + } + fmt.Fprintf(out, "Runner group %q requires explicit review:\n", group.Name) + for _, warning := range warnings { + fmt.Fprintf(out, " - %s\n", warning) + } + continueLabel := "Continue after confirming the enterprise-managed policy" + if notRecommended { + fmt.Fprintln(out, "RECOMMENDED ACTION: Choose Back. Follow docs/runner-groups.md to create a dedicated non-default group with Selected repositories and public repository access disabled, then select that safer group.") + fmt.Fprintln(out, "Continuing will deliberately relax the generated policy to match this broader group. Future repositories may gain access without another EPAR configuration change.") + continueLabel = "Continue anyway and generate a relaxed policy" + } + continueSelection, err := promptContinueOrBack(out, reader, continueLabel) + if err != nil { + return initRunnerGroupSelection{}, err + } + if !continueSelection { + continue + } + } } return initRunnerGroupSelection{ Group: group, @@ -544,15 +582,15 @@ func sortRunnerGroupsForWizard(groups []gh.RunnerGroup) []gh.RunnerGroup { ordered := append([]gh.RunnerGroup(nil), groups...) sort.SliceStable(ordered, func(i, j int) bool { left, right := ordered[i], ordered[j] + if left.Default != right.Default { + return left.Default + } if left.AllowsPublicRepositories != right.AllowsPublicRepositories { return !left.AllowsPublicRepositories } if leftRank, rightRank := runnerGroupVisibilityRank(left.Visibility), runnerGroupVisibilityRank(right.Visibility); leftRank != rightRank { return leftRank < rightRank } - if left.Default != right.Default { - return !left.Default - } if left.Inherited != right.Inherited { return !left.Inherited } @@ -561,6 +599,25 @@ func sortRunnerGroupsForWizard(groups []gh.RunnerGroup) []gh.RunnerGroup { return ordered } +func filterRunnerGroupsForWizard(groups []gh.RunnerGroup, repositories map[int64][]gh.RunnerGroupRepository, showBlocked bool) []gh.RunnerGroup { + if showBlocked { + return groups + } + visible := make([]gh.RunnerGroup, 0, len(groups)) + for _, group := range groups { + if runnerGroupBlockedByWizard(group, repositories[group.ID]) { + continue + } + visible = append(visible, group) + } + return visible +} + +func runnerGroupBlockedByWizard(group gh.RunnerGroup, repositories []gh.RunnerGroupRepository) bool { + _, publicCount := repositoryPrivacyCounts(repositories) + return runnerGroupVisibilityRank(group.Visibility) == 3 || group.AllowsPublicRepositories || publicCount > 0 +} + func runnerGroupVisibilityRank(visibility string) int { switch visibility { case config.RunnerGroupRepositoryAccessSelected: @@ -574,35 +631,41 @@ func runnerGroupVisibilityRank(visibility string) int { } } -func printRunnerGroupChoice(out io.Writer, number int, group gh.RunnerGroup, repositories []gh.RunnerGroupRepository) { +func printRunnerGroupChoice(out io.Writer, number int, group gh.RunnerGroup, repositories []gh.RunnerGroupRepository, showDetails bool) { privateCount, publicCount := repositoryPrivacyCounts(repositories) fmt.Fprintf(out, "\n %d. %s\n", number, group.Name) - switch group.Visibility { - case config.RunnerGroupRepositoryAccessSelected: - fmt.Fprintf(out, " Repository access: Selected repositories — only the %d private and %d public repositories explicitly selected in GitHub can use this group.\n", privateCount, publicCount) - case config.RunnerGroupRepositoryAccessPrivate: - fmt.Fprintln(out, " Repository access: All private repositories — every current and future private repository in the organization can use this group.") - case config.RunnerGroupRepositoryAccessAll: - fmt.Fprintln(out, " Repository access: All repositories — every current and future repository permitted by the public-repository setting can use this group.") - default: - fmt.Fprintf(out, " Repository access: Unknown GitHub value %q — do not select this group until its policy can be understood.\n", group.Visibility) - } - if group.AllowsPublicRepositories || publicCount > 0 { - fmt.Fprintln(out, " Public repositories: ALLOWED — public or fork-triggered workflows may reach self-hosted runners.") - } else { - fmt.Fprintln(out, " Public repositories: Disabled — public repositories cannot use this group.") - } - groupTypes := []string{"organization-managed", "non-default"} - if group.Default { - groupTypes = []string{"GitHub default group"} - } - if group.Inherited { - groupTypes = append(groupTypes, "inherited from the enterprise") + if showDetails { + switch group.Visibility { + case config.RunnerGroupRepositoryAccessSelected: + fmt.Fprintf(out, " Repository access: Selected repositories — only the %d private and %d public repositories explicitly selected in GitHub can use this group.\n", privateCount, publicCount) + case config.RunnerGroupRepositoryAccessPrivate: + fmt.Fprintln(out, " Repository access: All private repositories — every current and future private repository in the organization can use this group.") + case config.RunnerGroupRepositoryAccessAll: + fmt.Fprintln(out, " Repository access: All repositories — every current and future repository permitted by the public-repository setting can use this group.") + default: + fmt.Fprintf(out, " Repository access: Unknown GitHub value %q — do not select this group until its policy can be understood.\n", group.Visibility) + } + if group.AllowsPublicRepositories || publicCount > 0 { + fmt.Fprintln(out, " Public repositories: ALLOWED — public or fork-triggered workflows may reach self-hosted runners.") + } else { + fmt.Fprintln(out, " Public repositories: Disabled — public repositories cannot use this group.") + } + groupTypes := []string{"organization-managed", "non-default"} + if group.Default { + groupTypes = []string{"GitHub default group"} + } + if group.Inherited { + groupTypes = append(groupTypes, "inherited from the enterprise") + } + fmt.Fprintf(out, " Group type: %s.\n", strings.Join(groupTypes, ", ")) } - fmt.Fprintf(out, " Group type: %s.\n", strings.Join(groupTypes, ", ")) switch { + case runnerGroupVisibilityRank(group.Visibility) == 3: + fmt.Fprintln(out, " Assessment: BLOCKED BY WIZARD — repository access cannot be evaluated safely.") case group.AllowsPublicRepositories || publicCount > 0: fmt.Fprintln(out, " Assessment: BLOCKED BY WIZARD — does not satisfy the public-repository safety requirement.") + case group.Default: + fmt.Fprintln(out, " Assessment: It is fine for first-time tasting of EPAR, but generally recommend to create and use custom runner group for better security.") case runnerGroupDoesNotMeetRecommendedPolicy(group): fmt.Fprintln(out, " Assessment: NOT RECOMMENDED — requires an explicit warning and a relaxed generated policy.") case group.Inherited: @@ -868,7 +931,8 @@ func promptImageUpdatePolicy(out io.Writer, reader *bufio.Reader) (initImageUpda fmt.Fprintln(out, " 2. Daily") fmt.Fprintln(out, " 3. Every two weeks") fmt.Fprintln(out, " 4. Monthly") - fmt.Fprintln(out, " 5. Manual — check only when you run ./start image update") + fmt.Fprintln(out, " 5. Manual — check only on demand") + fmt.Fprintln(out, " Command: ./start image update") choice, hitEOF, err := promptDefault(out, reader, "Update frequency", "1") if err != nil { return initImageUpdatePolicy{}, err @@ -981,10 +1045,13 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str if addScripts { fmt.Fprintln(out, " Scripts run as root during the image build. Do not put secrets in scripts or build inputs.") for { - script, promptErr := promptRequired(out, reader, "Custom install script path") + script, promptErr := promptOptional(out, reader, "Custom install script path") if promptErr != nil { return nil, false, promptErr } + if script == "" { + break + } normalized, validationErr := validateInitCustomInstallScript(projectRoot, script) if validationErr != nil { fmt.Fprintf(out, " Invalid custom install script: %v\n", validationErr) @@ -1023,7 +1090,7 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str availableText = formatInitUintByteCount(capacity.AvailableBytes) } fmt.Fprintln(out, "") - fmt.Fprintln(out, "Runner artifact estimate (informational; configuration creation is not blocked):") + fmt.Fprintln(out, "Runner artifact estimate:") fmt.Fprintf(out, " Source: %s\n", source.Reference) fmt.Fprintf(out, " Platform: %s\n", source.Platform) if len(customScripts) == 0 { @@ -1036,12 +1103,10 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str fmt.Fprintf(out, " Estimated incremental physical peak: %s\n", formatInitUintByteCount(artifactPlan.EstimatedIncrementalPeak)) fmt.Fprintf(out, " Available physical space: %s\n", availableText) fmt.Fprintln(out, " Fixed free-space reserve: 1GiB") - fmt.Fprintf(out, " Estimate confidence: %s\n", artifactPlan.Confidence) if providerType == "docker-sandboxes" { fmt.Fprintf(out, " Automatic sandbox root limit: %s (sparse logical maximum)\n", formatInitUintByteCount(artifactPlan.LogicalRootMaximumBytes)) fmt.Fprintf(out, " Inner Docker limit: %s (independent sparse logical maximum)\n", formatInitUintByteCount(artifactPlan.LogicalDockerMaximumBytes)) } - fmt.Fprintln(out, " Expected duration: several minutes; full-latest can take substantially longer on a cold cache.") confirmed, err := promptYesNo(out, reader, "Create this configuration?", true) if err != nil { return nil, false, err @@ -1588,6 +1653,19 @@ func promptDefault(out io.Writer, reader *bufio.Reader, label string, defaultVal return value, hitEOF, nil } +func promptOptional(out io.Writer, reader *bufio.Reader, label string) (string, error) { + fmt.Fprintf(out, "%s (press Enter for none): ", label) + value, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + value = strings.TrimSpace(value) + if strings.ContainsAny(value, "\r\n") { + return "", fmt.Errorf("%s must be one line", label) + } + return value, nil +} + func promptYesNo(out io.Writer, reader *bufio.Reader, label string, defaultYes bool) (bool, error) { defaultValue := "Y" if !defaultYes { diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index 7c5f957..c0c689f 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -175,19 +175,88 @@ func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { if !strings.Contains(out.String(), "Pool name prefix (press Enter to use build-box-01-a4f9c2):") { t.Fatalf("init output did not explain default prefix acceptance:\n%s", out.String()) } + hostTrustExplanation := "Runners need this host's trusted TLS roots to access services that this machine trusts." + hostTrustPrompt := "Inherit this host's trusted TLS roots into disposable runners?" + if explanationIndex, promptIndex := strings.Index(out.String(), hostTrustExplanation), strings.Index(out.String(), hostTrustPrompt); explanationIndex < 0 || promptIndex < 0 || explanationIndex > promptIndex { + t.Fatalf("init output did not explain host trust before prompting:\n%s", out.String()) + } for _, want := range []string{"1. Docker Container", "private daemon (default)", "2. Docker Sandboxes — recommended when ready"} { if !strings.Contains(out.String(), want) { t.Fatalf("init output did not preserve explicit Docker Container default and capability-driven Docker Sandboxes labeling %q:\n%s", want, out.String()) } } - for _, want := range []string{"Repository access meanings:", "Selected repositories: Only repositories explicitly added", "Assessment: RECOMMENDED"} { + for _, want := range []string{"D. Show runner group details", "B. Show blocked runner groups", "Assessment: RECOMMENDED"} { if !strings.Contains(out.String(), want) { - t.Fatalf("init output did not explain runner-group policy term %q:\n%s", want, out.String()) + t.Fatalf("init output did not include concise runner-group choice %q:\n%s", want, out.String()) + } + } + for _, hidden := range []string{"Repository access meanings:", "Repository access:", "Public repositories:", "Group type:"} { + if strings.Contains(out.String(), hidden) { + t.Fatalf("init output included runner-group details before D was selected %q:\n%s", hidden, out.String()) + } + } +} + +func TestInitRunnerGroupDetailsAreOptIn(t *testing.T) { + client := &fakeInitRunnerGroupClient{ + groups: []gh.RunnerGroup{ + {ID: 1, Name: "restricted", Visibility: config.RunnerGroupRepositoryAccessSelected}, + {ID: 2, Name: "blocked-public", Visibility: config.RunnerGroupRepositoryAccessSelected, AllowsPublicRepositories: true}, + }, + repositories: map[int64][]gh.RunnerGroupRepository{ + 1: {{FullName: "example/private", Private: true}}, + 2: {{FullName: "example/public", Private: false}}, + }, + } + var out bytes.Buffer + selection, err := promptRunnerGroup(context.Background(), &out, bufio.NewReader(strings.NewReader("d\n1\n")), client) + if err != nil { + t.Fatal(err) + } + if selection.Group.Name != "restricted" { + t.Fatalf("selected group = %q, want restricted", selection.Group.Name) + } + for _, want := range []string{"Repository access meanings:", "Repository access: Selected repositories", "Public repositories: Disabled", "Group type: organization-managed, non-default", "D. Hide runner group details"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("runner-group details did not include %q after D was selected:\n%s", want, out.String()) + } + } + if strings.Contains(out.String(), "blocked-public") { + t.Fatalf("blocked group became visible when only details were requested:\n%s", out.String()) + } +} + +func TestInitRunnerGroupBlockedChoicesAreHiddenByDefault(t *testing.T) { + client := &fakeInitRunnerGroupClient{ + groups: []gh.RunnerGroup{ + {ID: 1, Name: "restricted", Visibility: config.RunnerGroupRepositoryAccessSelected}, + {ID: 2, Name: "blocked-public", Visibility: config.RunnerGroupRepositoryAccessSelected, AllowsPublicRepositories: true}, + {ID: 3, Name: "blocked-unknown", Visibility: "future-value"}, + }, + repositories: map[int64][]gh.RunnerGroupRepository{ + 1: {{FullName: "example/private", Private: true}}, + 2: {{FullName: "example/public", Private: false}}, + }, + } + var out bytes.Buffer + selection, err := promptRunnerGroup(context.Background(), &out, bufio.NewReader(strings.NewReader("1\n")), client) + if err != nil { + t.Fatal(err) + } + if selection.Group.Name != "restricted" { + t.Fatalf("selected group = %q, want first visible restricted group", selection.Group.Name) + } + for _, hidden := range []string{"blocked-public", "blocked-unknown"} { + if strings.Contains(out.String(), hidden) { + t.Fatalf("default runner-group list included blocked group %q:\n%s", hidden, out.String()) } } + if !strings.Contains(out.String(), "B. Show blocked runner groups") { + t.Fatalf("default runner-group list did not offer blocked groups on request:\n%s", out.String()) + } } -func TestInitAllowsWarnedDefaultGroupAndWritesMatchingPolicy(t *testing.T) { +func TestInitAllowsDefaultGroupWithReminderAndWritesMatchingPolicy(t *testing.T) { stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) stubNoWSL2(t) setInitRunnerGroupClient(t, &fakeInitRunnerGroupClient{groups: []gh.RunnerGroup{{ID: 1, Name: "Default", Visibility: config.RunnerGroupRepositoryAccessAll, Default: true}}}) @@ -212,8 +281,18 @@ func TestInitAllowsWarnedDefaultGroupAndWritesMatchingPolicy(t *testing.T) { if cfg.Runner.Group != "Default" || policy.RequireNonDefaultGroup || policy.RequiredRepositoryAccess != config.RunnerGroupRepositoryAccessAll || !policy.RequirePublicRepositoriesDisabled { t.Fatalf("unexpected default-group config: group=%q policy=%+v", cfg.Runner.Group, policy) } - if !strings.Contains(out.String(), "*** SECURITY WARNING: THIS RUNNER GROUP IS NOT RECOMMENDED ***") || !strings.Contains(out.String(), "New or unintended repositories") || !strings.Contains(out.String(), "RECOMMENDED ACTION: Choose Back") || !strings.Contains(out.String(), "docs/runner-groups.md") || !strings.Contains(out.String(), "Continue anyway and generate a relaxed policy") || strings.Count(out.String(), "requires explicit review") != 1 { - t.Fatalf("default-group warning/back option missing:\n%s", out.String()) + for _, want := range []string{"*** SECURITY REMINDER: DEFAULT RUNNER GROUP ***", "The Default runner group is fine for trying EPAR.", "For regular use, a custom group limited to selected trusted repositories offers better security.", "Continue with Default runner group"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("default-group reminder did not include %q:\n%s", want, out.String()) + } + } + for _, unwanted := range []string{"SECURITY WARNING", "NOT RECOMMENDED", "requires explicit review", "RECOMMENDED ACTION", "Continue anyway", "relaxed policy"} { + if strings.Contains(out.String(), unwanted) { + t.Fatalf("default-group reminder included alarming wording %q:\n%s", unwanted, out.String()) + } + } + if !strings.Contains(out.String(), "Assessment: It is fine for first-time tasting of EPAR, but generally recommend to create and use custom runner group for better security.") { + t.Fatalf("default-group first-use assessment missing:\n%s", out.String()) } } @@ -229,13 +308,14 @@ func TestInitCanBackFromBroadGroupAndChooseRestrictedGroup(t *testing.T) { }) dir := t.TempDir() path := filepath.Join(dir, ".local", "config.yml") + var out bytes.Buffer if err := runInitWithOptions(initOptions{ ProjectRoot: dir, ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, In: strings.NewReader("123\nexample\nkey.pem\n2\n2\n1\n\nn\n"), - Out: &bytes.Buffer{}, + Out: &out, }); err != nil { t.Fatal(err) } @@ -246,6 +326,9 @@ func TestInitCanBackFromBroadGroupAndChooseRestrictedGroup(t *testing.T) { if cfg.Runner.Group != "restricted" || cfg.Security.RunnerGroup.RequiredRepositoryAccess != config.RunnerGroupRepositoryAccessSelected { t.Fatalf("unexpected selection after back: group=%q policy=%+v", cfg.Runner.Group, cfg.Security.RunnerGroup) } + if !strings.Contains(out.String(), "*** SECURITY WARNING: THIS RUNNER GROUP IS NOT RECOMMENDED ***") || !strings.Contains(out.String(), "Continue anyway and generate a relaxed policy") { + t.Fatalf("non-default broad group no longer showed its security warning:\n%s", out.String()) + } } func TestInitRejectsPublicGroupAndAllowsAnotherSelection(t *testing.T) { @@ -270,7 +353,7 @@ func TestInitRejectsPublicGroupAndAllowsAnotherSelection(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123\nexample\nkey.pem\n2\n1\n1\n\nn\n"), + In: strings.NewReader("123\nexample\nkey.pem\nb\n2\n1\n1\n\nn\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -302,7 +385,7 @@ func TestInitBlocksUnknownRunnerGroupRepositoryAccess(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123\nexample\nkey.pem\n1\n3\n"), + In: strings.NewReader("123\nexample\nkey.pem\nb\n1\n3\n"), Out: &out, }) if err == nil || !strings.Contains(err.Error(), "selection cancelled") { @@ -316,7 +399,7 @@ func TestInitBlocksUnknownRunnerGroupRepositoryAccess(t *testing.T) { } } -func TestSortRunnerGroupsForWizardPutsRestrictiveGroupsFirst(t *testing.T) { +func TestSortRunnerGroupsForWizardPutsDefaultFirst(t *testing.T) { groups := []gh.RunnerGroup{ {ID: 1, Name: "Default", Visibility: config.RunnerGroupRepositoryAccessAll, Default: true}, {ID: 2, Name: "public-selected", Visibility: config.RunnerGroupRepositoryAccessSelected, AllowsPublicRepositories: true}, @@ -329,7 +412,7 @@ func TestSortRunnerGroupsForWizardPutsRestrictiveGroupsFirst(t *testing.T) { for i, group := range ordered { got[i] = group.Name } - want := []string{"recommended", "inherited-recommended", "all-private-repositories", "Default", "public-selected"} + want := []string{"Default", "recommended", "inherited-recommended", "all-private-repositories", "public-selected"} if !slices.Equal(got, want) { t.Fatalf("runner-group order = %#v, want %#v", got, want) } @@ -836,11 +919,16 @@ func TestInitDockerSandboxesGeneratesDesiredImageConfigAndProvisionsTemplate(t * t.Fatalf("dockerSandboxes.%s = %q, want %q", key, values.got, values.want) } } - for _, want := range []string{"Docker Sandboxes image setup:", "Runner base image:", "1. full — full-latest (default)", "2. act — act-latest", "Image catalog:", "Runner artifact estimate (informational; configuration creation is not blocked):", "Source: ghcr.io/catthehacker/ubuntu:act-latest", "Automatic sandbox root limit:"} { + for _, want := range []string{"Docker Sandboxes image setup:", "Runner base image:", "1. full — full-latest (default)", "2. act — act-latest", "Image catalog:", "Runner artifact estimate:", "Source: ghcr.io/catthehacker/ubuntu:act-latest", "Automatic sandbox root limit:"} { if !strings.Contains(out.String(), want) { t.Fatalf("init output omitted %q:\n%s", want, out.String()) } } + for _, removed := range []string{"informational; configuration creation is not blocked", "Estimate confidence:", "Expected duration:"} { + if strings.Contains(out.String(), removed) { + t.Fatalf("init output retained removed estimate text %q:\n%s", removed, out.String()) + } + } } func TestInitDockerSandboxesWritesConfigBeforeOrdinaryProvisioning(t *testing.T) { @@ -913,7 +1001,7 @@ func TestSharedDockerImageWizardCoversDockerContainerSandboxesAndWSL(t *testing. if !accepted || profile == nil || profile.Provider != providerType || profile.SourceImage != "ghcr.io/catthehacker/ubuntu:full-latest" { t.Fatalf("shared wizard profile = %+v, accepted=%t", profile, accepted) } - for _, want := range []string{"1. full — full-latest (default)", "2. act — act-latest", "3. dotnet — dotnet-latest", "4. js — js-latest", "Runner artifact estimate (informational; configuration creation is not blocked):"} { + for _, want := range []string{"1. full — full-latest (default)", "2. act — act-latest", "3. dotnet — dotnet-latest", "4. js — js-latest", "Runner artifact estimate:"} { if !strings.Contains(out.String(), want) { t.Fatalf("%s wizard output omitted %q:\n%s", providerType, want, out.String()) } @@ -958,7 +1046,7 @@ func TestImageUpdatePolicyWizardManualSkipsTimePrompt(t *testing.T) { if strings.Contains(out.String(), "Local update time") { t.Fatalf("manual policy unexpectedly prompted for a time:\n%s", out.String()) } - if !strings.Contains(out.String(), "./start image update") { + if !strings.Contains(out.String(), "5. Manual — check only on demand\n Command: ./start image update") { t.Fatalf("manual policy omitted its neutral trigger explanation:\n%s", out.String()) } } @@ -1007,6 +1095,31 @@ func TestDockerSandboxesWizardCollectsCustomTagAndInstallScript(t *testing.T) { } } +func TestDockerSandboxesWizardAllowsEmptyCustomInstallScriptChoice(t *testing.T) { + stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ + PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), + }, nil) + var out bytes.Buffer + profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, &out, bufio.NewReader(strings.NewReader("1\ny\n\n\n"))) + if err != nil { + t.Fatal(err) + } + if !accepted || profile == nil { + t.Fatalf("wizard profile = %+v, accepted=%t", profile, accepted) + } + if len(profile.CustomScripts) != 0 { + t.Fatalf("custom scripts = %#v, want none", profile.CustomScripts) + } + for _, want := range []string{"Custom install script path (press Enter for none):", "Custom install scripts: none"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("wizard output omitted %q:\n%s", want, out.String()) + } + } + if strings.Contains(out.String(), "Custom install script path is required") { + t.Fatalf("empty custom install script path was treated as required:\n%s", out.String()) + } +} + func TestDockerSandboxesWizardRepromptsAfterUnresolvableTag(t *testing.T) { stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), @@ -1282,7 +1395,7 @@ func TestDockerSandboxesProfileShowsEstimateWithoutCapacityAdmission(t *testing. if profile == nil || !accepted { t.Fatalf("informational estimate returned profile=%+v accepted=%t", profile, accepted) } - for _, want := range []string{"Runner artifact estimate (informational; configuration creation is not blocked):", "Available physical space:", "Fixed free-space reserve: 1GiB", "sparse logical maximum"} { + for _, want := range []string{"Runner artifact estimate:", "Available physical space:", "Fixed free-space reserve: 1GiB", "sparse logical maximum"} { if !strings.Contains(out.String(), want) { t.Fatalf("informational estimate output omitted %q:\n%s", want, out.String()) } @@ -1347,7 +1460,7 @@ func TestInitCapabilityReadyDockerSandboxesIsDefaultWithoutPreviewAcknowledgemen "Docker Sandboxes image setup:", "Runner base image:", "full — full-latest (default)", - "Runner artifact estimate (informational; configuration creation is not blocked):", + "Runner artifact estimate:", } { if !strings.Contains(out.String(), want) { t.Fatalf("capability-default output omitted %q:\n%s", want, out.String()) @@ -1492,7 +1605,6 @@ func TestInitDockerSandboxesUsesGuidedRootDiskDefault(t *testing.T) { "1. full — full-latest (default)", "Automatic sandbox root limit:", "Estimated download:", - "Expected duration:", } { if !strings.Contains(out.String(), want) { t.Fatalf("init output omitted %q:\n%s", want, out.String()) diff --git a/docs/usage.md b/docs/usage.md index e86fc0f..95577a6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -37,7 +37,7 @@ The wrapper uses local Go when available, otherwise it uses Docker to build and go run ./cmd/ephemeral-action-runner start ``` -When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group, shows every provider with its prerequisite status, and refuses unavailable selections. Storage does not make a provider unavailable. Docker Container, Docker Sandboxes, and WSL share one Catthehacker image/profile and custom-script flow, followed by an informational physical-growth estimate and confirmation. The wizard writes the desired configuration first; direct `init` then exits, while embedded `./start` continues through the ordinary image/template provisioning and pool startup path. When `sbx` is installed, the wizard runs `sbx daemon start --detach` before Docker Sandboxes diagnostics so a stopped daemon does not require a manual retry. +When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group. The runner-group list orders GitHub's Default group first, hides blocked groups and policy details initially, and lets you reveal either from the menu. The provider list shows every provider with its prerequisite status and refuses unavailable selections. Storage does not make a provider unavailable. Docker Container, Docker Sandboxes, and WSL share one Catthehacker image/profile and custom-script flow, followed by an informational physical-growth estimate and confirmation. The wizard writes the desired configuration first; direct `init` then exits, while embedded `./start` continues through the ordinary image/template provisioning and pool startup path. When `sbx` is installed, the wizard runs `sbx daemon start --detach` before Docker Sandboxes diagnostics so a stopped daemon does not require a manual retry. See [Docker Sandboxes](providers/docker-sandboxes.md) for source profiles, capacity, local receipts, and platform validation status. From e97778d1f8e74edcc1651b9fe4bf9f4f62cce49f Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 1 Aug 2026 16:57:47 +0800 Subject: [PATCH 20/22] fix: support Windows lock metadata and paths --- internal/filelock/filelock_windows.go | 8 ++++++-- internal/image/buildx.go | 2 +- .../image/docker_output_tag_claim_test.go | 8 ++++++++ internal/image/storage_catalog.go | 5 ++++- internal/storage/catalog/catalog.go | 8 +++++++- internal/storage/catalog/catalog_test.go | 19 +++++++++++++++++++ 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/internal/filelock/filelock_windows.go b/internal/filelock/filelock_windows.go index e9db7b8..d55c165 100644 --- a/internal/filelock/filelock_windows.go +++ b/internal/filelock/filelock_windows.go @@ -12,6 +12,7 @@ import ( const ( lockfileFailImmediately = 0x00000001 lockfileExclusiveLock = 0x00000002 + lockfileOffsetHigh = 1 ) var ( @@ -22,7 +23,10 @@ var ( ) func lockFile(file *os.File) error { - var overlapped syscall.Overlapped + // Keep the lock range outside the metadata payload. Windows enforces byte + // range locks for ordinary reads, so locking byte zero would prevent another + // process from reading owner diagnostics while the lock is held. + overlapped := syscall.Overlapped{OffsetHigh: lockfileOffsetHigh} result, _, callErr := procLockFileEx.Call(file.Fd(), lockfileExclusiveLock|lockfileFailImmediately, 0, 1, 0, uintptr(unsafe.Pointer(&overlapped))) if result != 0 { return nil @@ -34,7 +38,7 @@ func lockFile(file *os.File) error { } func unlockFile(file *os.File) error { - var overlapped syscall.Overlapped + overlapped := syscall.Overlapped{OffsetHigh: lockfileOffsetHigh} result, _, callErr := procUnlockFileEx.Call(file.Fd(), 0, 1, 0, uintptr(unsafe.Pointer(&overlapped))) if result != 0 { return nil diff --git a/internal/image/buildx.go b/internal/image/buildx.go index 28526cf..614df44 100644 --- a/internal/image/buildx.go +++ b/internal/image/buildx.go @@ -278,7 +278,7 @@ func (m *Coordinator) ensureBuildxBuilder(ctx context.Context, registryReference BuildKitImageID: buildKitImageID, } storageDirectory := filepath.Join(scope.projectRoot, ".local", "storage") - if err := validateRegularParent(storageDirectory, m.ProjectRoot); err != nil { + if err := validateRegularParent(storageDirectory, scope.projectRoot); err != nil { return "", fmt.Errorf("validate EPAR storage directory: %w", err) } if err := validateRegularParent(filepath.Dir(scope.lockPath), storageDirectory); err != nil { diff --git a/internal/image/docker_output_tag_claim_test.go b/internal/image/docker_output_tag_claim_test.go index 5118d79..9113695 100644 --- a/internal/image/docker_output_tag_claim_test.go +++ b/internal/image/docker_output_tag_claim_test.go @@ -138,6 +138,14 @@ func TestDockerOutputTagClaimRejectsOtherActiveArtifactManifest(t *testing.T) { } } +func TestDockerOutputTagConflictFallsBackToCanonicalPath(t *testing.T) { + value := &storagecatalog.Catalog{Configs: []storagecatalog.Config{{ID: "legacy-config", Path: "canonical-config.yml"}}} + err := dockerOutputTagConflict(value, "legacy-config", "docker.io/example/runner:current", "manifest-one", "manifest-two") + if !strings.Contains(err.Error(), "canonical-config.yml") { + t.Fatalf("legacy catalog conflict omitted canonical configuration path: %v", err) + } +} + func TestDockerOutputTagClaimExpiresAfterPublisherCrash(t *testing.T) { t.Setenv("EPAR_STATE_HOME", filepath.Join(t.TempDir(), "host-state")) project := t.TempDir() diff --git a/internal/image/storage_catalog.go b/internal/image/storage_catalog.go index 5fc57df..9166f76 100644 --- a/internal/image/storage_catalog.go +++ b/internal/image/storage_catalog.go @@ -319,7 +319,10 @@ func dockerOutputTagConflict(value *storagecatalog.Catalog, configID, tag, exist path := configID for _, configRecord := range value.Configs { if configRecord.ID == configID { - path = configRecord.Path + path = configRecord.DisplayPath + if path == "" { + path = configRecord.Path + } break } } diff --git a/internal/storage/catalog/catalog.go b/internal/storage/catalog/catalog.go index 1cab1e3..3595827 100644 --- a/internal/storage/catalog/catalog.go +++ b/internal/storage/catalog/catalog.go @@ -71,6 +71,7 @@ type Config struct { ID string `json:"id"` InstallationID string `json:"installationId"` Path string `json:"path"` + DisplayPath string `json:"displayPath,omitempty"` ProjectRoot string `json:"projectRoot"` BuildCacheLimitBytes uint64 `json:"buildCacheLimitBytes,omitempty"` ControllerLeaseUntil *time.Time `json:"controllerLeaseUntil,omitempty"` @@ -305,8 +306,13 @@ func RegisterConfig(value *Catalog, projectRoot, configPath string, now time.Tim if err != nil { return Config{}, err } + displayPath, err := filepath.Abs(configPath) + if err != nil { + return Config{}, err + } + displayPath = filepath.Clean(displayPath) installationSum := sha256.Sum256([]byte(value.InstallationID + "\x00" + root)) - record := Config{ID: id, InstallationID: hex.EncodeToString(installationSum[:12]), Path: path, ProjectRoot: root, LastSeenAt: now.UTC()} + record := Config{ID: id, InstallationID: hex.EncodeToString(installationSum[:12]), Path: path, DisplayPath: displayPath, ProjectRoot: root, LastSeenAt: now.UTC()} for index := range value.Configs { if value.Configs[index].ID == id { if value.Configs[index].InstallationID != "" { diff --git a/internal/storage/catalog/catalog_test.go b/internal/storage/catalog/catalog_test.go index 41c083c..a999967 100644 --- a/internal/storage/catalog/catalog_test.go +++ b/internal/storage/catalog/catalog_test.go @@ -108,6 +108,25 @@ func TestDifferentProjectRootsHaveDifferentInstallationIdentities(t *testing.T) } } +func TestRegisterConfigPreservesActionablePathSpelling(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "Config.yml") + if err := os.WriteFile(configPath, []byte("provider: test\n"), 0o600); err != nil { + t.Fatal(err) + } + record, err := RegisterConfig(&Catalog{InstallationID: "host-catalog"}, root, configPath, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + want, err := filepath.Abs(configPath) + if err != nil { + t.Fatal(err) + } + if record.DisplayPath != filepath.Clean(want) { + t.Fatalf("display path = %q, want %q", record.DisplayPath, filepath.Clean(want)) + } +} + func TestBackendLocksAreSeparatedAndSerializeTheSameBackend(t *testing.T) { store, err := Open(t.TempDir()) if err != nil { From dd9140507154c4b65f2d96ebe4f6684c778c103a Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 1 Aug 2026 17:22:59 +0800 Subject: [PATCH 21/22] fix: repair host trust verification --- internal/pool/controller_lock_test.go | 4 ++-- scripts/docker-sandboxes/validate-assets.ps1 | 19 ++++++++++++------- templates/docker-sandboxes/.dockerignore | 1 + 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/pool/controller_lock_test.go b/internal/pool/controller_lock_test.go index 8b991ff..818776e 100644 --- a/internal/pool/controller_lock_test.go +++ b/internal/pool/controller_lock_test.go @@ -295,9 +295,9 @@ func setPoolControllerLockStateHome(t *testing.T) string { return root } -func testPoolControllerManager(t *testing.T, projectRoot, configName, providerType, prefix string) Manager { +func testPoolControllerManager(t *testing.T, projectRoot, configName, providerType, prefix string) *Manager { t.Helper() - manager := Manager{ + manager := &Manager{ ProjectRoot: projectRoot, ConfigPath: configName, Config: config.Config{ diff --git a/scripts/docker-sandboxes/validate-assets.ps1 b/scripts/docker-sandboxes/validate-assets.ps1 index 181fd02..f6088fa 100644 --- a/scripts/docker-sandboxes/validate-assets.ps1 +++ b/scripts/docker-sandboxes/validate-assets.ps1 @@ -155,19 +155,24 @@ $launcherHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $launcherPath).Hash Assert-Equal 'hook launcher source' $launcherHash $lock.hookLauncher.sha256 $hashManifestPath = Join-Path $templateDirectory 'helpers.sha256' $manifestEntries = Get-Content -LiteralPath $hashManifestPath -$guestFiles = @(Get-ChildItem -LiteralPath (Join-Path $templateDirectory 'guest') -Filter '*.sh' -File | Sort-Object Name) -Assert-Equal 'helper manifest entry count' $manifestEntries.Count $guestFiles.Count +$guestDirectory = Join-Path $templateDirectory 'guest' +$guestAssetFiles = @(Get-ChildItem -LiteralPath $guestDirectory -File | Sort-Object Name) +$guestScripts = @($guestAssetFiles | Where-Object Extension -EQ '.sh') +Assert-Equal 'helper manifest entry count' $manifestEntries.Count $guestAssetFiles.Count +$manifestFileNames = @() foreach ($line in $manifestEntries) { - if ($line -notmatch '^([0-9a-f]{64}) \./([a-z0-9.-]+\.sh)$') { + if ($line -notmatch '^([0-9a-f]{64}) \./((?:[a-z0-9.-]+\.sh)|docker-daemon\.json)$') { throw "Invalid helper hash entry: $line" } - $helperPath = Join-Path (Join-Path $templateDirectory 'guest') $Matches[2] + $manifestFileNames += $Matches[2] + $helperPath = Join-Path $guestDirectory $Matches[2] if (-not (Test-Path -LiteralPath $helperPath -PathType Leaf)) { throw "Helper hash references missing file: $helperPath" } $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $helperPath).Hash.ToLowerInvariant() Assert-Equal "helper $($Matches[2])" $actualHash $Matches[1] } +Assert-Equal 'unique helper manifest entry count' @($manifestFileNames | Sort-Object -Unique).Count $guestAssetFiles.Count Write-Host '[3/6] Checking Dockerfile and entrypoint invariants.' $dockerfilePath = Join-Path $templateDirectory 'Dockerfile' @@ -194,12 +199,12 @@ foreach ($required in @( if ($dockerfile -match '(?im)apt-get\s+update|(?im)\blatest\b|(?im)COPY\s+.*var/lib/docker|(?im)--privileged|(?im)--secret') { throw 'Dockerfile contains an unpinned, privileged, secret, or /var/lib/docker preload pattern' } -foreach ($requiredContextEntry in @('!Dockerfile', '!helpers.sha256', '!guest/*.sh', '!hook-launcher/*.go', '!custom-install/run.sh', '!profiles/*.compatibility.json')) { +foreach ($requiredContextEntry in @('!Dockerfile', '!helpers.sha256', '!guest/*.sh', '!guest/docker-daemon.json', '!hook-launcher/*.go', '!custom-install/run.sh', '!profiles/*.compatibility.json')) { if (-not ($dockerignore -split "`r?`n").Contains($requiredContextEntry)) { throw ".dockerignore is missing deterministic context entry: $requiredContextEntry" } } -$guestText = ($guestFiles | ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName }) -join "`n" +$guestText = ($guestScripts | ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName }) -join "`n" if ($guestText -match '(?im)apt-get\s+update|(?im)(^|[;&|]\s*)dockerd(?:\s|$)|(?im)-----BEGIN .*PRIVATE KEY-----|(?im)AKIA[0-9A-Z]{16}') { throw 'Guest helpers contain a boot-time package update, dockerd start, or credential pattern' } @@ -258,7 +263,7 @@ else { if ($null -eq $bashPath) { throw 'bash is required to syntax-check guest helpers' } -foreach ($guestFile in $guestFiles) { +foreach ($guestFile in $guestScripts) { & $bashPath -n $guestFile.FullName if ($LASTEXITCODE -ne 0) { throw "bash -n failed for $($guestFile.FullName)" diff --git a/templates/docker-sandboxes/.dockerignore b/templates/docker-sandboxes/.dockerignore index 7fda218..b0c8854 100644 --- a/templates/docker-sandboxes/.dockerignore +++ b/templates/docker-sandboxes/.dockerignore @@ -3,6 +3,7 @@ !helpers.sha256 !guest/ !guest/*.sh +!guest/docker-daemon.json !hook-launcher/ !hook-launcher/*.go !custom-install/ From ac5ded25926d2ec681bbbcec7121a98757ad290f Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 1 Aug 2026 23:50:41 +0800 Subject: [PATCH 22/22] Refactor first-run wizard and start contract --- .github/workflows/host-trust-verification.yml | 5 + cmd/ephemeral-action-runner/init.go | 332 ++++---- cmd/ephemeral-action-runner/init_test.go | 181 ++++- cmd/ephemeral-action-runner/init_wizard.go | 713 ++++++++++++++++++ cmd/ephemeral-action-runner/start_test.go | 19 +- docs/advanced/docker-registry-mirrors.md | 2 +- docs/advanced/docker-sandboxes-template.md | 4 +- docs/advanced/no-go-install.md | 2 +- docs/advanced/windows-startup.md | 24 +- docs/development/adding-provider.md | 4 +- docs/development/design.md | 4 +- docs/development/principles.md | 8 +- docs/image-build.md | 8 +- docs/logging.md | 6 +- docs/operations.md | 20 +- docs/providers/docker-container.md | 4 +- docs/providers/docker-sandboxes.md | 6 +- docs/providers/tart.md | 2 +- docs/providers/wsl.md | 2 +- docs/runner-groups.md | 2 +- docs/storage.md | 12 +- docs/troubleshooting.md | 12 +- docs/usage.md | 22 +- internal/provider/factory.go | 122 +++ internal/provider/factory_test.go | 137 ++++ .../provider/registry/contributions_test.go | 7 +- internal/provider/registry/registry.go | 16 +- scripts/test/start-command-forwarding.ps1 | 63 ++ scripts/test/start-command-forwarding.sh | 50 +- 29 files changed, 1520 insertions(+), 269 deletions(-) create mode 100644 cmd/ephemeral-action-runner/init_wizard.go create mode 100644 internal/provider/factory_test.go create mode 100644 scripts/test/start-command-forwarding.ps1 diff --git a/.github/workflows/host-trust-verification.yml b/.github/workflows/host-trust-verification.yml index cfb1641..26b4eeb 100644 --- a/.github/workflows/host-trust-verification.yml +++ b/.github/workflows/host-trust-verification.yml @@ -234,6 +234,11 @@ jobs: if: runner.os == 'Windows' run: scripts/test/windows-native-controller-contract.ps1 -ProjectRoot $env:GITHUB_WORKSPACE + - name: Verify Windows start command forwarding + shell: pwsh + if: runner.os == 'Windows' + run: scripts/test/start-command-forwarding.ps1 -ProjectRoot $env:GITHUB_WORKSPACE + - name: Verify Docker Sandboxes plan-only contracts shell: pwsh if: runner.os == 'Windows' diff --git a/cmd/ephemeral-action-runner/init.go b/cmd/ephemeral-action-runner/init.go index 1f352c3..68b4a00 100644 --- a/cmd/ephemeral-action-runner/init.go +++ b/cmd/ephemeral-action-runner/init.go @@ -27,6 +27,7 @@ import ( "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" imageartifact "github.com/solutionforest/ephemeral-action-runner/internal/image" "github.com/solutionforest/ephemeral-action-runner/internal/logging" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes" sandboxcapacity "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/capacity" sandboxpolicy "github.com/solutionforest/ephemeral-action-runner/internal/provider/dockersandboxes/policy" @@ -139,7 +140,7 @@ type initDockerSandboxesCapacityResult struct { CapacityStatus storage.CapacityStatus } -type initDockerSandboxesProfile struct { +type initDockerImageProfile struct { Provider string HostPlatform sandboxpromotion.Platform GuestPlatform string @@ -150,6 +151,10 @@ type initDockerSandboxesProfile struct { DockerDisk string } +// Retain the established helper name while generated-config renderers and +// provider tests migrate to the provider-neutral Docker image profile name. +type initDockerSandboxesProfile = initDockerImageProfile + type initImageUpdatePolicy struct { Frequency string Time string @@ -268,7 +273,7 @@ func runInitWithOptions(opts initOptions) error { } fmt.Fprintln(opts.Out, "EPAR first-run setup") fmt.Fprintln(opts.Out, "") - fmt.Fprintln(opts.Out, "This creates .local/config.yml for an EPAR runner.") + 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.") fmt.Fprintln(opts.Out, "See README.md and docs/github-app.md for the GitHub App steps.") fmt.Fprintln(opts.Out, "") @@ -296,78 +301,22 @@ func runInitWithOptions(opts initOptions) error { APIBaseURL: "https://api.github.com", WebBaseURL: "https://github.com", } - runnerGroup, err := promptRunnerGroup(opts.Context, opts.Out, reader, newInitRunnerGroupClient(githubConfig)) - if err != nil { - return err - } - providerType, _, selectedProfile, err := promptInitProvider(opts.Context, opts.ProjectRoot, opts.Out, reader, opts.SkipDockerCheck) - if err != nil { - return err - } defaultPrefix, err := generatedPoolNamePrefix() if err != nil { return err } - fmt.Fprintln(opts.Out, "") - fmt.Fprintln(opts.Out, "Pool name prefix must be unique for this machine/config within the GitHub organization.") - fmt.Fprintln(opts.Out, "EPAR cleanup deletes GitHub runner records matching this prefix.") - poolNamePrefix, err := promptPoolNamePrefix(opts.Out, reader, defaultPrefix) + outcome, err := runInitConfigurationWizard(opts, reader, githubConfig, appID, organization, privateKeyPath, defaultPrefix) if err != nil { return err } - - hostTrustMode := config.HostTrustModeDisabled - hostTrustScopes := []string{config.HostTrustScopeSystem} - if providerType == "docker-container" || providerType == "docker-sandboxes" { - fmt.Fprintln(opts.Out, "Runners need this host's trusted TLS roots to access services that this machine trusts.") - enabled, promptErr := promptYesNo(opts.Out, reader, "Inherit this host's trusted TLS roots into disposable runners?", true) - if promptErr != nil { - return promptErr - } - if enabled { - hostTrustMode = config.HostTrustModeOverlay - hostTrustScopes = hostTrustScopesForOS(initHostTrustOS) - deferred := os.Getenv("EPAR_HOST_TRUST_INIT_DEFERRED") == "1" - if !opts.SkipHostTrustCheck && !deferred { - preflightCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - _, collectErr := initResolveHostTrust(preflightCtx, hosttrust.Options{ - Mode: hostTrustMode, - Scopes: hostTrustScopes, - ControllerHostOS: initHostTrustOS, - }) - cancel() - if collectErr != nil { - return fmt.Errorf("collect host trusted TLS roots before writing config: %w", collectErr) - } - } - } + if !outcome.Create { + fmt.Fprintln(opts.Out, "Setup cancelled. No config was written.") + return nil } - updatePolicy, err := promptImageUpdatePolicy(opts.Out, reader) + content, err := renderInitWizardConfig(outcome.Draft) if err != nil { return err } - - profile := selectedProfile - if providerType != "tart" && profile == nil { - return fmt.Errorf("%s image selection did not produce a provisioning profile", providerType) - } - var content string - switch providerType { - case "docker-container": - content = defaultDockerContainerConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile, updatePolicy) - case "wsl": - content = defaultWSLConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup, *profile, updatePolicy) - case "tart": - content = defaultTartConfig(appID, organization, privateKeyPath, poolNamePrefix, runnerGroup, updatePolicy) - case "docker-sandboxes": - guestPlatform, runnerArchitectureLabel, platformErr := dockerSandboxesPlatform(profile.HostPlatform) - if platformErr != nil { - return platformErr - } - content = defaultDockerSandboxesConfig(appID, organization, privateKeyPath, poolNamePrefix, hostTrustMode, hostTrustScopes, runnerGroup, *profile, updatePolicy, guestPlatform, runnerArchitectureLabel) - default: - return fmt.Errorf("unsupported provider.type %q", providerType) - } if err := os.MkdirAll(filepath.Dir(opts.ConfigPath), 0755); err != nil { return err } @@ -380,15 +329,19 @@ func runInitWithOptions(opts initOptions) error { fmt.Fprintln(opts.Out, "Initialization succeeded. Startup will now provision the selected runner artifact and apply storage admission before side effects.") return nil } + startCommand := "./start" + if initGOOS == "windows" { + startCommand = `.\start.ps1` + } fmt.Fprintf(opts.Out, ` Next: - %s start + %s Manual/advanced: %s image build --replace %s pool verify --instances 2 --register-only --cleanup %s pool up --instances 2 -`, binaryName, binaryName, binaryName, binaryName) +`, startCommand, startCommand, startCommand, startCommand) return nil } @@ -398,6 +351,17 @@ type initRunnerGroupSelection struct { } func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, client initRunnerGroupClient) (initRunnerGroupSelection, error) { + result, err := promptRunnerGroupWizard(ctx, out, reader, client, false) + if err != nil { + return initRunnerGroupSelection{}, err + } + if result.Action == initWizardQuit { + return initRunnerGroupSelection{}, fmt.Errorf("runner-group selection cancelled; no config was written") + } + return result.Value, nil +} + +func promptRunnerGroupWizard(ctx context.Context, out io.Writer, reader *bufio.Reader, client initRunnerGroupClient, allowBack bool) (initWizardResult[initRunnerGroupSelection], error) { var groups []gh.RunnerGroup var repositories map[int64][]gh.RunnerGroupRepository showBlockedGroups := false @@ -407,7 +371,7 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, var err error groups, repositories, err = loadInitRunnerGroups(ctx, client) if err != nil { - return initRunnerGroupSelection{}, fmt.Errorf("load GitHub runner groups: %w", err) + return initWizardResult[initRunnerGroupSelection]{}, fmt.Errorf("load GitHub runner groups: %w", err) } } fmt.Fprintln(out, "") @@ -441,13 +405,20 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, } else { fmt.Fprintln(out, " D. Show runner group details") } + if allowBack { + fmt.Fprintln(out, " 0. Back") + } fmt.Fprintln(out, " Q. Quit without writing a config") choice, err := promptRequired(out, reader, "Runner group choice") if err != nil { - return initRunnerGroupSelection{}, err + return initWizardResult[initRunnerGroupSelection]{}, err } switch strings.ToLower(choice) { + case "0", "back": + if allowBack { + return initWizardResult[initRunnerGroupSelection]{Action: initWizardBack}, nil + } case "r", "refresh": groups = nil repositories = nil @@ -459,11 +430,11 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, showGroupDetails = !showGroupDetails continue case "q", "quit": - return initRunnerGroupSelection{}, fmt.Errorf("runner-group selection cancelled; no config was written") + return initWizardResult[initRunnerGroupSelection]{Action: initWizardQuit}, nil } index, parseErr := strconv.Atoi(choice) if parseErr != nil || index < 1 || index > len(visibleGroups) { - fmt.Fprintln(out, "Choose a runner group number, R to refresh, B for blocked groups, D for details, or Q to quit.") + fmt.Fprintln(out, "Choose a runner group number, R to refresh, B for blocked groups, D for details, 0 to go back when shown, or Q to quit.") continue } group := visibleGroups[index-1] @@ -476,7 +447,7 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, fmt.Fprintln(out, "RECOMMENDED ACTION: Do not select this group. Review its policy in GitHub, update EPAR if support is available, and choose Refresh; otherwise choose another documented group.") refresh, err := promptBackRefreshQuit(out, reader) if err != nil { - return initRunnerGroupSelection{}, err + return initWizardResult[initRunnerGroupSelection]{}, err } if refresh { groups = nil @@ -492,7 +463,7 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, fmt.Fprintln(out, "If you intentionally operate a separately reviewed public-project deployment, finish initialization with a secure group first and document any manual policy override afterward.") refresh, err := promptBackRefreshQuit(out, reader) if err != nil { - return initRunnerGroupSelection{}, err + return initWizardResult[initRunnerGroupSelection]{}, err } if refresh { groups = nil @@ -508,7 +479,7 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, fmt.Fprintln(out, "For regular use, a custom group limited to selected trusted repositories offers better security.") continueSelection, err := promptContinueOrBack(out, reader, "Continue with Default runner group") if err != nil { - return initRunnerGroupSelection{}, err + return initWizardResult[initRunnerGroupSelection]{}, err } if !continueSelection { continue @@ -535,14 +506,14 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, } continueSelection, err := promptContinueOrBack(out, reader, continueLabel) if err != nil { - return initRunnerGroupSelection{}, err + return initWizardResult[initRunnerGroupSelection]{}, err } if !continueSelection { continue } } } - return initRunnerGroupSelection{ + return initWizardResult[initRunnerGroupSelection]{Action: initWizardNext, Value: initRunnerGroupSelection{ Group: group, Policy: config.RunnerGroupSecurityConfig{ Enforcement: config.RunnerGroupEnforcementEnforce, @@ -551,7 +522,7 @@ func promptRunnerGroup(ctx context.Context, out io.Writer, reader *bufio.Reader, RequiredRepositoryAccess: group.Visibility, RequirePublicRepositoriesDisabled: true, }, - }, nil + }}, nil } } @@ -709,7 +680,7 @@ func runnerGroupDoesNotMeetRecommendedPolicy(group gh.RunnerGroup) bool { func promptContinueOrBack(out io.Writer, reader *bufio.Reader, continueLabel string) (bool, error) { fmt.Fprintf(out, " 1. %s\n", continueLabel) - fmt.Fprintln(out, " 2. Back to group selection") + fmt.Fprintln(out, " 0. Back to group selection") for { choice, err := promptRequired(out, reader, "Choice") if err != nil { @@ -718,32 +689,32 @@ func promptContinueOrBack(out io.Writer, reader *bufio.Reader, continueLabel str switch strings.ToLower(choice) { case "1", "continue": return true, nil - case "2", "back": + case "0", "2", "back": return false, nil default: - fmt.Fprintln(out, "Choose 1 to continue or 2 to go back.") + fmt.Fprintln(out, "Choose 1 to continue or 0 to go back.") } } } func promptBackRefreshQuit(out io.Writer, reader *bufio.Reader) (bool, error) { - fmt.Fprintln(out, " 1. Back to group selection") - fmt.Fprintln(out, " 2. Refresh runner groups") - fmt.Fprintln(out, " 3. Quit without writing a config") + fmt.Fprintln(out, " 0. Back to group selection") + fmt.Fprintln(out, " R. Refresh runner groups") + fmt.Fprintln(out, " Q. Quit without writing a config") for { choice, err := promptRequired(out, reader, "Choice") if err != nil { return false, err } switch strings.ToLower(choice) { - case "1", "back": + case "0", "1", "back": return false, nil - case "2", "refresh": + case "2", "r", "refresh": return true, nil - case "3", "quit": + case "3", "q", "quit": return false, fmt.Errorf("runner-group selection cancelled; no config was written") default: - fmt.Fprintln(out, "Choose 1 to go back, 2 to refresh, or 3 to quit.") + fmt.Fprintln(out, "Choose 0 to go back, R to refresh, or Q to quit.") } } } @@ -845,10 +816,14 @@ func promptInitProvider(ctx context.Context, projectRoot string, out io.Writer, fmt.Fprintln(out, "Refreshing provider prerequisites...") continue } - if providerType == "tart" { - return providerType, sandboxpromotion.Record{}, nil, nil + descriptor, found := providerregistry.DescriptorFor(providerType) + if !found { + return "", sandboxpromotion.Record{}, nil, fmt.Errorf("provider %q has no registry contribution", providerType) } - if providerType == "docker-container" || providerType == "docker-sandboxes" || providerType == "wsl" { + switch descriptor.WizardOnboarding { + case provider.WizardOnboardingNone: + return providerType, sandboxpromotion.Record{}, nil, nil + case provider.WizardOnboardingCatthehackerDocker: profile, accepted, profileErr := promptDockerImageProfile(ctx, projectRoot, providerType, hostPlatform, out, reader) if profileErr != nil { return "", sandboxpromotion.Record{}, nil, profileErr @@ -857,12 +832,21 @@ func promptInitProvider(ctx context.Context, projectRoot string, out io.Writer, return "", sandboxpromotion.Record{}, nil, fmt.Errorf("%s image setup did not complete; no config was written", providerType) } return providerType, sandboxpromotion.Record{}, profile, nil + default: + return "", sandboxpromotion.Record{}, nil, fmt.Errorf("provider %q has unsupported image onboarding strategy %q", providerType, descriptor.WizardOnboarding) } - return "", sandboxpromotion.Record{}, nil, fmt.Errorf("provider %q has no registered image onboarding flow", providerType) } } func promptInitProviderChoice(ctx context.Context, projectRoot string, hostPlatform sandboxpromotion.Platform, record sandboxpromotion.Record, promoted bool, out io.Writer, reader *bufio.Reader, skipDockerCheck bool) (string, bool, bool, error) { + result, promotionPassed, err := promptInitProviderChoiceWizard(ctx, projectRoot, hostPlatform, record, promoted, out, reader, skipDockerCheck, false, "") + if err != nil { + return "", false, false, err + } + return result.Value, promotionPassed, result.Action == initWizardRefresh, nil +} + +func promptInitProviderChoiceWizard(ctx context.Context, projectRoot string, hostPlatform sandboxpromotion.Platform, record sandboxpromotion.Record, promoted bool, out io.Writer, reader *bufio.Reader, skipDockerCheck, allowBack bool, preferredProvider string) (initWizardResult[string], bool, error) { prerequisites := detectInitProviderPrerequisites(ctx, hostPlatform, skipDockerCheck) operationalDefault := !promoted && prerequisites.DockerSandboxesAvailable promotionPassed := false @@ -911,11 +895,11 @@ func promptInitProviderChoice(ctx context.Context, projectRoot string, hostPlatf } else if promoted || !prerequisites.DockerAvailable { defaultProvider = "" } - providerType, refresh, err := promptProviderOptions(out, reader, prerequisites, promoted, promotionPassed, operationalDefault, defaultProvider) + result, err := promptProviderOptionsWizard(out, reader, prerequisites, promoted, promotionPassed, operationalDefault, defaultProvider, allowBack, preferredProvider) if err != nil { - return "", false, false, err + return initWizardResult[string]{}, false, err } - return providerType, promotionPassed, refresh, nil + return result, promotionPassed, nil } func promptDockerSandboxesProfile(ctx context.Context, projectRoot string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (*initDockerSandboxesProfile, bool, error) { @@ -972,18 +956,18 @@ func promptImageUpdatePolicy(out io.Writer, reader *bufio.Reader) (initImageUpda } } -func promptDockerImageProfile(ctx context.Context, projectRoot, providerType string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (*initDockerSandboxesProfile, bool, error) { +func promptDockerImageProfileWizard(ctx context.Context, projectRoot, providerType string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (initWizardResult[*initDockerSandboxesProfile], *initArtifactEstimate, error) { guestPlatform, err := initDockerGuestPlatform(providerType, hostPlatform) if err != nil { - return nil, false, fmt.Errorf("%s image setup is unavailable: %w", providerType, err) + return initWizardResult[*initDockerSandboxesProfile]{}, nil, fmt.Errorf("%s image setup is unavailable: %w", providerType, err) } descriptor, found := providerregistry.DescriptorFor(providerType) if !found || !descriptor.GuidedArtifacts || len(descriptor.WizardImageProfiles) == 0 { - return nil, false, fmt.Errorf("%s has no registered guided image onboarding contribution", providerType) + return initWizardResult[*initDockerSandboxesProfile]{}, nil, fmt.Errorf("%s has no registered guided image onboarding contribution", providerType) } fmt.Fprintln(out, "") fmt.Fprintf(out, "%s image setup:\n", descriptor.DisplayName) - fmt.Fprintln(out, " Choose the desired Catthehacker Ubuntu image. EPAR will create the configuration now and provision or update the reusable runner artifact during startup.") + fmt.Fprintln(out, " Choose the Catthehacker Ubuntu image for this runner. EPAR will provision or update the reusable runner artifact during startup.") fmt.Fprintln(out, "") fmt.Fprintln(out, "Runner base image:") for index, profile := range descriptor.WizardImageProfiles { @@ -996,12 +980,20 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str customChoice := strconv.Itoa(len(descriptor.WizardImageProfiles) + 1) fmt.Fprintf(out, " %s. Another catthehacker/ubuntu tag, such as go-24.04\n", customChoice) fmt.Fprintln(out, " Image catalog: https://github.com/catthehacker/docker_images#images-available") + fmt.Fprintln(out, " 0. Back") var source imageartifact.ResolvedDockerSource for { - choice, hitEOF, promptErr := promptDefault(out, reader, "Runner base image", "1") + choiceResult, hitEOF, promptErr := promptWizardDefault(out, reader, "Runner base image", "1") if promptErr != nil { - return nil, false, promptErr + return initWizardResult[*initDockerSandboxesProfile]{}, nil, promptErr + } + if choiceResult.Action == initWizardBack { + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardBack}, nil, nil + } + choice := choiceResult.Value + if choice == "0" { + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardBack}, nil, nil } normalizedChoice := strings.ToLower(choice) input := "" @@ -1012,15 +1004,20 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str } } if normalizedChoice == customChoice { - input, promptErr = promptRequired(out, reader, "catthehacker/ubuntu tag") + var requiredResult initWizardResult[string] + requiredResult, promptErr = promptWizardRequired(out, reader, "catthehacker/ubuntu tag") if promptErr != nil { - return nil, false, promptErr + return initWizardResult[*initDockerSandboxesProfile]{}, nil, promptErr + } + if requiredResult.Action == initWizardBack { + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardBack}, nil, nil } + input = requiredResult.Value } if input == "" { - fmt.Fprintf(out, " Choose a built-in image from 1 to %d, or %s for another catthehacker/ubuntu tag.\n", len(descriptor.WizardImageProfiles), customChoice) + fmt.Fprintf(out, " Choose a built-in image from 1 to %d, %s for another catthehacker/ubuntu tag, or 0 to go back.\n", len(descriptor.WizardImageProfiles), customChoice) if hitEOF { - return nil, false, fmt.Errorf("invalid runner base image %q", choice) + return initWizardResult[*initDockerSandboxesProfile]{}, nil, fmt.Errorf("invalid runner base image %q", choice) } continue } @@ -1033,22 +1030,29 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str fmt.Fprintf(out, " That image cannot be used for %s: %v\n", guestPlatform, err) fmt.Fprintln(out, " Choose an existing ghcr.io/catthehacker/ubuntu tag that publishes this platform.") if hitEOF { - return nil, false, fmt.Errorf("resolve runner source image: %w", err) + return initWizardResult[*initDockerSandboxesProfile]{}, nil, fmt.Errorf("resolve runner source image: %w", err) } } var customScripts []string - addScripts, err := promptYesNo(out, reader, "Run custom install scripts while building the runner artifact?", false) + addScriptsResult, err := promptWizardYesNo(out, reader, "Run custom install scripts while building the runner artifact?", false) if err != nil { - return nil, false, err + return initWizardResult[*initDockerSandboxesProfile]{}, nil, err + } + if addScriptsResult.Action == initWizardBack { + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardBack}, nil, nil } - if addScripts { + if addScriptsResult.Value { fmt.Fprintln(out, " Scripts run as root during the image build. Do not put secrets in scripts or build inputs.") for { - script, promptErr := promptOptional(out, reader, "Custom install script path") + scriptResult, promptErr := promptWizardOptional(out, reader, "Custom install script path") if promptErr != nil { - return nil, false, promptErr + return initWizardResult[*initDockerSandboxesProfile]{}, nil, promptErr } + if scriptResult.Action == initWizardBack { + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardBack}, nil, nil + } + script := scriptResult.Value if script == "" { break } @@ -1058,11 +1062,14 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str continue } customScripts = append(customScripts, normalized) - another, promptErr := promptYesNo(out, reader, "Add another custom install script?", false) + anotherResult, promptErr := promptWizardYesNo(out, reader, "Add another custom install script?", false) if promptErr != nil { - return nil, false, promptErr + return initWizardResult[*initDockerSandboxesProfile]{}, nil, promptErr + } + if anotherResult.Action == initWizardBack { + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardBack}, nil, nil } - if !another { + if !anotherResult.Value { break } } @@ -1072,49 +1079,37 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str if providerType == "docker-sandboxes" { policyFingerprint, err = initDockerSandboxesPolicyFingerprint(ctx) if err != nil { - return nil, false, err + return initWizardResult[*initDockerSandboxesProfile]{}, nil, err } } sourceEstimate, err := imageartifact.EstimateSourceSize(source.CompressedLayerBytes, 0) if err != nil { - return nil, false, err + return initWizardResult[*initDockerSandboxesProfile]{}, nil, err } const dockerDisk = config.DockerSandboxesDefaultDockerDisk dockerDiskBytes, _ := config.ParseByteSize(dockerDisk) artifactPlan, err := imageartifact.PlanArtifactStorage(providerType, sourceEstimate, false, uint64(dockerDiskBytes)) if err != nil { - return nil, false, err + return initWizardResult[*initDockerSandboxesProfile]{}, nil, err } var availableText = "unknown" if capacity, probeErr := storage.ProbeFilesystemCapacity(projectRoot, time.Now()); probeErr == nil && capacity.Known { availableText = formatInitUintByteCount(capacity.AvailableBytes) } - fmt.Fprintln(out, "") - fmt.Fprintln(out, "Runner artifact estimate:") - fmt.Fprintf(out, " Source: %s\n", source.Reference) - fmt.Fprintf(out, " Platform: %s\n", source.Platform) - if len(customScripts) == 0 { - fmt.Fprintln(out, " Custom install scripts: none") - } else { - fmt.Fprintf(out, " Custom install scripts: %s\n", strings.Join(customScripts, ", ")) + estimate := &initArtifactEstimate{ + Source: source.Reference, + Platform: source.Platform, + CustomScripts: append([]string(nil), customScripts...), + DownloadBytes: source.CompressedLayerBytes, + ExpandedBytes: sourceEstimate.ExpandedBytes, + IncrementalPeakBytes: artifactPlan.EstimatedIncrementalPeak, + AvailablePhysicalSpace: availableText, } - fmt.Fprintf(out, " Estimated download: %s compressed layers\n", formatInitUintByteCount(source.CompressedLayerBytes)) - fmt.Fprintf(out, " Estimated expanded source: %s\n", formatInitUintByteCount(sourceEstimate.ExpandedBytes)) - fmt.Fprintf(out, " Estimated incremental physical peak: %s\n", formatInitUintByteCount(artifactPlan.EstimatedIncrementalPeak)) - fmt.Fprintf(out, " Available physical space: %s\n", availableText) - fmt.Fprintln(out, " Fixed free-space reserve: 1GiB") if providerType == "docker-sandboxes" { - fmt.Fprintf(out, " Automatic sandbox root limit: %s (sparse logical maximum)\n", formatInitUintByteCount(artifactPlan.LogicalRootMaximumBytes)) - fmt.Fprintf(out, " Inner Docker limit: %s (independent sparse logical maximum)\n", formatInitUintByteCount(artifactPlan.LogicalDockerMaximumBytes)) - } - confirmed, err := promptYesNo(out, reader, "Create this configuration?", true) - if err != nil { - return nil, false, err + estimate.LogicalRootMaximumBytes = artifactPlan.LogicalRootMaximumBytes + estimate.LogicalDockerMaximumBytes = artifactPlan.LogicalDockerMaximumBytes } - if !confirmed { - return nil, false, nil - } - return &initDockerSandboxesProfile{ + profile := &initDockerSandboxesProfile{ Provider: providerType, HostPlatform: hostPlatform, GuestPlatform: guestPlatform, @@ -1123,7 +1118,20 @@ func promptDockerImageProfile(ctx context.Context, projectRoot, providerType str PolicyFingerprint: policyFingerprint, RootDisk: config.DockerSandboxesAutomaticRootDisk, DockerDisk: dockerDisk, - }, true, nil + } + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardNext, Value: profile}, estimate, nil +} + +func promptDockerImageProfile(ctx context.Context, projectRoot, providerType string, hostPlatform sandboxpromotion.Platform, out io.Writer, reader *bufio.Reader) (*initDockerSandboxesProfile, bool, error) { + result, estimate, err := promptDockerImageProfileWizard(ctx, projectRoot, providerType, hostPlatform, out, reader) + if err != nil { + return nil, false, err + } + if result.Action == initWizardBack { + return nil, false, nil + } + renderInitArtifactEstimate(out, estimate) + return result.Value, true, nil } func initDockerGuestPlatform(providerType string, hostPlatform sandboxpromotion.Platform) (string, error) { @@ -1467,6 +1475,14 @@ func detectInitProviderPrerequisites(ctx context.Context, hostPlatform sandboxpr } func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites initProviderPrerequisites, promoted, promotionPassed, operationalDefault bool, defaultProvider string) (string, bool, error) { + result, err := promptProviderOptionsWizard(out, reader, prerequisites, promoted, promotionPassed, operationalDefault, defaultProvider, false, "") + if err != nil { + return "", false, err + } + return result.Value, result.Action == initWizardRefresh, nil +} + +func promptProviderOptionsWizard(out io.Writer, reader *bufio.Reader, prerequisites initProviderPrerequisites, promoted, promotionPassed, operationalDefault bool, defaultProvider string, allowBack bool, preferredProvider string) (initWizardResult[string], error) { options := make([]initProviderOption, 0, len(providerregistry.Descriptors())) for _, descriptor := range providerregistry.Descriptors() { option := initProviderOption{ @@ -1475,11 +1491,11 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in Label: descriptor.WizardLabel, Aliases: append([]string(nil), descriptor.WizardAliases...), } - switch descriptor.Type { - case "docker-container": + switch descriptor.WizardPrerequisite { + case provider.WizardPrerequisiteDocker: option.Available = prerequisites.DockerAvailable option.Status = prerequisites.DockerStatus - case "docker-sandboxes": + case provider.WizardPrerequisiteDockerSandboxes: option.Available = prerequisites.DockerSandboxesAvailable && (!promoted || promotionPassed) option.Status = prerequisites.DockerSandboxesStatus if operationalDefault { @@ -1487,19 +1503,22 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in } else if promoted { option.Label = "Docker Sandboxes (independently certified for this exact platform)" } - case "wsl": + case provider.WizardPrerequisiteWSL2: option.Available = prerequisites.WSLAvailable option.Status = prerequisites.WSLStatus - case "tart": + case provider.WizardPrerequisiteTart: option.Available = prerequisites.TartAvailable option.Status = prerequisites.TartStatus default: - return "", false, fmt.Errorf("registered provider %q has no prerequisite contribution", descriptor.Type) + return initWizardResult[string]{}, fmt.Errorf("registered provider %q has no prerequisite contribution", descriptor.Type) } options = append(options, option) } if err := validateWizardProviderOptions(options); err != nil { - return "", false, err + return initWizardResult[string]{}, err + } + if preferredProvider != "" { + defaultProvider = preferredProvider } defaultNumber := prioritizeDefaultProviderOption(options, defaultProvider) @@ -1518,6 +1537,9 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in fmt.Fprintf(out, " Prerequisites: %s\n", option.Status) } fmt.Fprintln(out, " R. Refresh provider prerequisites") + if allowBack { + fmt.Fprintln(out, " 0. Back") + } for { var value string var hitEOF bool @@ -1526,7 +1548,7 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in fmt.Fprint(out, "Runner provider: ") value, err = reader.ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { - return "", false, err + return initWizardResult[string]{}, err } hitEOF = errors.Is(err, io.EOF) if hitEOF { @@ -1537,11 +1559,14 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in value, hitEOF, err = promptDefault(out, reader, "Runner provider", defaultNumber) } if err != nil { - return "", false, err + return initWizardResult[string]{}, err } normalized := strings.ToLower(value) if normalized == "r" || normalized == "refresh" { - return "", true, nil + return initWizardResult[string]{Action: initWizardRefresh}, nil + } + if allowBack && (normalized == "0" || normalized == "back") { + return initWizardResult[string]{Action: initWizardBack}, nil } var selected *initProviderOption for index := range options { @@ -1561,18 +1586,18 @@ func promptProviderOptions(out io.Writer, reader *bufio.Reader, prerequisites in } } if selected != nil && selected.Available { - return selected.Type, false, nil + return initWizardResult[string]{Action: initWizardNext, Value: selected.Type}, nil } if selected != nil { fmt.Fprintf(out, "%s is unavailable: %s\n", selected.Label, selected.Status) } else { - fmt.Fprintln(out, "Choose an available provider number or name shown above, or R to refresh.") + fmt.Fprintln(out, "Choose an available provider number or name shown above, R to refresh, or 0 to go back when shown.") } if hitEOF { if selected != nil { - return "", false, fmt.Errorf("runner provider %q is unavailable: %s", value, selected.Status) + return initWizardResult[string]{}, fmt.Errorf("runner provider %q is unavailable: %s", value, selected.Status) } - return "", false, fmt.Errorf("invalid runner provider %q", value) + return initWizardResult[string]{}, fmt.Errorf("invalid runner provider %q", value) } } } @@ -1614,6 +1639,9 @@ func validateWizardProviderOptions(options []initProviderOption) error { if option.Number != descriptor.WizardNumber || option.Label == "" || len(option.Aliases) == 0 { return fmt.Errorf("wizard provider %q does not use its complete registry contribution", option.Type) } + if err := provider.ValidateWizardContributions(descriptor); err != nil { + return fmt.Errorf("wizard provider %q has incomplete contributions: %w", option.Type, err) + } if _, duplicate := registered[option.Type]; duplicate { return fmt.Errorf("wizard provider %q is duplicated", option.Type) } diff --git a/cmd/ephemeral-action-runner/init_test.go b/cmd/ephemeral-action-runner/init_test.go index c0c689f..1c7c9c4 100644 --- a/cmd/ephemeral-action-runner/init_test.go +++ b/cmd/ephemeral-action-runner/init_test.go @@ -172,7 +172,7 @@ func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { if !strings.Contains(out.String(), "start") || !strings.Contains(out.String(), "pool up --instances 2") { t.Fatalf("init output did not include next steps:\n%s", out.String()) } - if !strings.Contains(out.String(), "Pool name prefix (press Enter to use build-box-01-a4f9c2):") { + if !strings.Contains(out.String(), "Pool name prefix (press Enter to use build-box-01-a4f9c2; /back to return):") { t.Fatalf("init output did not explain default prefix acceptance:\n%s", out.String()) } hostTrustExplanation := "Runners need this host's trusted TLS roots to access services that this machine trusts." @@ -197,6 +197,160 @@ func TestInitCreatesDefaultDockerContainerConfig(t *testing.T) { } } +func TestInitWizardCanBackAcrossSectionsAndPreservesCompletedAnswers(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + input := strings.Join([]string{ + "123456", "solutionforest", ".local/github-app.pem", "1", + "", "2", "n", "custom-prefix", "y", "2", "06:30", + "0", "0", "/back", "/back", "0", "0", + "1", "", "1", "", "", "", "", "", + }, "\n") + "\n" + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ProjectRoot: dir, ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, In: strings.NewReader(input), Out: &out}); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Image.SourceImage != "ghcr.io/catthehacker/ubuntu:act-latest" || cfg.Pool.NamePrefix != "custom-prefix" || cfg.Image.UpdateFrequency != config.ImageUpdateFrequencyDaily || cfg.Image.UpdateTime != "06:30" { + t.Fatalf("answers were not preserved after repeated Back: image=%q prefix=%q updates=%s@%s", cfg.Image.SourceImage, cfg.Pool.NamePrefix, cfg.Image.UpdateFrequency, cfg.Image.UpdateTime) + } + for _, want := range []string{"0. Back", "/back to return", "Current runner artifact setup:", "Configuration review:"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("wizard transcript omitted %q:\n%s", want, out.String()) + } + } + if got := strings.Count(out.String(), "Configuration review:"); got != 2 { + t.Fatalf("review count = %d, want 2 after returning from the first review", got) + } +} + +func TestInitWizardQuitAtReviewWritesNoConfig(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + input := strings.Join([]string{"123456", "solutionforest", ".local/github-app.pem", "1", "", "", "", "", "", "", "", "q"}, "\n") + "\n" + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ProjectRoot: dir, ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, In: strings.NewReader(input), Out: &out}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("config exists after review quit: %v", err) + } + if !strings.Contains(out.String(), "Q. Quit without writing a config") || !strings.Contains(out.String(), "Setup cancelled. No config was written.") { + t.Fatalf("review quit was not explained:\n%s", out.String()) + } +} + +func TestInitWizardReviewCanEditPoolDirectly(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + input := strings.Join([]string{ + "123456", "solutionforest", ".local/github-app.pem", "1", + "", "", "", "", "", "", "", + "5", "edited-prefix", "", + }, "\n") + "\n" + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ProjectRoot: dir, ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, In: strings.NewReader(input), Out: &out}); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Pool.NamePrefix != "edited-prefix" { + t.Fatalf("pool.namePrefix = %q, want direct review edit", cfg.Pool.NamePrefix) + } + if got := strings.Count(out.String(), "Configuration review:"); got != 2 { + t.Fatalf("review count = %d, want initial and edited reviews", got) + } +} + +func TestInitWizardBackAfterDirectEditUsesNaturalHistory(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + input := strings.Join([]string{ + "123456", "solutionforest", ".local/github-app.pem", "1", + "", "", "", "", "", "", "", + "5", "edited-prefix", + "0", "0", "/back", "", "", "", "", "q", + }, "\n") + "\n" + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ProjectRoot: dir, ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, In: strings.NewReader(input), Out: &out}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("config exists after review quit: %v", err) + } + transcript := out.String() + firstReview := strings.Index(transcript, "Configuration review:") + secondRelative := strings.Index(transcript[firstReview+1:], "Configuration review:") + if firstReview < 0 || secondRelative < 0 { + t.Fatalf("expected two reviews after direct edit:\n%s", transcript) + } + secondReview := firstReview + 1 + secondRelative + afterSecondReview := transcript[secondReview:] + updates := strings.Index(afterSecondReview, "Automatic image and Actions runner updates:") + pool := strings.Index(afterSecondReview, "Pool name prefix must be unique") + if updates < 0 || pool < 0 || updates > pool { + t.Fatalf("Back after direct edit did not resume the natural history at updates:\n%s", afterSecondReview) + } +} + +func TestInitWizardProviderEditInvalidatesProviderSpecificDraft(t *testing.T) { + stubInitHostAndRandom(t, "Build Box 01", []byte{0xa4, 0xf9, 0xc2}) + stubNoWSL2(t) + stubTartAvailable(t) + dir := t.TempDir() + path := filepath.Join(dir, ".local", "config.yml") + input := strings.Join([]string{ + "123456", "solutionforest", ".local/github-app.pem", "1", + "", "2", "n", "custom-prefix", "y", "", "", + "3", "4", "", + }, "\n") + "\n" + var out bytes.Buffer + if err := runInitWithOptions(initOptions{ProjectRoot: dir, ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, In: strings.NewReader(input), Out: &out}); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Provider.Type != "tart" || cfg.Pool.NamePrefix != "custom-prefix" { + t.Fatalf("provider edit produced provider=%q prefix=%q, want Tart with preserved pool", cfg.Provider.Type, cfg.Pool.NamePrefix) + } + reviews := strings.Split(out.String(), "Configuration review:") + if len(reviews) != 3 { + t.Fatalf("review count = %d, want 2", len(reviews)-1) + } + if !strings.Contains(reviews[1], "Runner artifact estimate:") || strings.Contains(reviews[2], "Runner artifact estimate:") { + t.Fatalf("provider-specific estimate was not invalidated after switching to Tart:\n%s", out.String()) + } +} + +func TestInitWizardFreeTextZeroIsNotBack(t *testing.T) { + var out bytes.Buffer + result, err := promptPoolNamePrefixWizard(&out, bufio.NewReader(strings.NewReader("0\nvalid-prefix\n")), "default-prefix") + if err != nil { + t.Fatal(err) + } + if result.Action != initWizardNext || result.Value != "valid-prefix" { + t.Fatalf("pool result = %+v, want valid-prefix after rejecting literal zero", result) + } + if !strings.Contains(out.String(), "Pool name prefix is invalid") { + t.Fatalf("literal zero was treated as navigation instead of text validation:\n%s", out.String()) + } +} + func TestInitRunnerGroupDetailsAreOptIn(t *testing.T) { client := &fakeInitRunnerGroupClient{ groups: []gh.RunnerGroup{ @@ -558,7 +712,7 @@ func TestInitCanDisableHostTrustOverlay(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n\n\nn\n\n\n\n"), Out: &bytes.Buffer{}, }); err != nil { t.Fatal(err) @@ -586,7 +740,7 @@ func TestInitDoesNotWriteEnabledConfigWhenHostTrustPreflightFails(t *testing.T) ProjectRoot: dir, ConfigPath: path, SkipDockerCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n\n\n\n"), Out: &bytes.Buffer{}, }) if err == nil || !strings.Contains(err.Error(), "collector unavailable") { @@ -608,7 +762,7 @@ func TestInitAcceptsCustomPoolNamePrefix(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n\ncustom-prefix\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n\ncustom-prefix\n\n\n\n\n"), Out: &bytes.Buffer{}, }); err != nil { t.Fatal(err) @@ -634,7 +788,7 @@ func TestInitRepromptsInvalidPoolNamePrefix(t *testing.T) { ConfigPath: path, SkipDockerCheck: true, SkipHostTrustCheck: true, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\nn\n\n-bad\nfixed-prefix\n\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n\n\n\n-bad\nfixed-prefix\n\n\n\n\n"), Out: &out, }); err != nil { t.Fatal(err) @@ -851,7 +1005,7 @@ func TestInitWSL2ChoiceDefaultsToDockerContainerAndRepromptsInvalidValues(t *tes if !strings.Contains(string(configBytes), "type: docker-container") { t.Fatalf("config did not use the default Docker Container provider:\n%s", configBytes) } - if !strings.Contains(out.String(), "Choose an available provider number or name shown above, or R to refresh.") { + if !strings.Contains(out.String(), "Choose an available provider number or name shown above, R to refresh, or 0 to go back when shown.") { t.Fatalf("init output did not explain invalid provider input:\n%s", out.String()) } if strings.Contains(out.String(), "Start runners now?") { @@ -1110,7 +1264,7 @@ func TestDockerSandboxesWizardAllowsEmptyCustomInstallScriptChoice(t *testing.T) if len(profile.CustomScripts) != 0 { t.Fatalf("custom scripts = %#v, want none", profile.CustomScripts) } - for _, want := range []string{"Custom install script path (press Enter for none):", "Custom install scripts: none"} { + for _, want := range []string{"Custom install script path (press Enter for none; /back to return):", "Custom install scripts: none"} { if !strings.Contains(out.String(), want) { t.Fatalf("wizard output omitted %q:\n%s", want, out.String()) } @@ -1144,16 +1298,20 @@ func TestDockerSandboxesWizardRepromptsAfterUnresolvableTag(t *testing.T) { } } -func TestDockerSandboxesWizardConfirmationRefusalReturnsNoProfile(t *testing.T) { +func TestDockerSandboxesWizardBackReturnsNoProfile(t *testing.T) { stubInitDockerSandboxesSetup(t, sandboxpromotion.WindowsAMD64, initDockerSandboxesDiscovery{ PolicyFingerprint: "sha256:" + strings.Repeat("b", 64), }, nil) - profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, io.Discard, bufio.NewReader(strings.NewReader("1\nn\nn\n"))) + var out bytes.Buffer + profile, accepted, err := promptDockerSandboxesProfile(context.Background(), t.TempDir(), sandboxpromotion.WindowsAMD64, &out, bufio.NewReader(strings.NewReader("/back\n"))) if err != nil { t.Fatal(err) } if profile != nil || accepted { - t.Fatalf("confirmation refusal returned profile=%+v accepted=%t", profile, accepted) + t.Fatalf("back returned profile=%+v accepted=%t", profile, accepted) + } + if strings.Contains(out.String(), "Create this configuration?") { + t.Fatalf("provider setup retained an early create confirmation:\n%s", out.String()) } } @@ -2046,6 +2204,9 @@ func TestInitOffersTartConfigWhenAvailable(t *testing.T) { if !strings.Contains(out.String(), "2. Docker Sandboxes — recommended when ready") || !strings.Contains(out.String(), "3. WSL2") || !strings.Contains(out.String(), "4. Tart (experimental)") || !strings.Contains(out.String(), "Docker CLI or daemon check failed: Docker is unavailable on this Mac") { t.Fatalf("init output did not offer Tart:\n%s", out.String()) } + if !strings.Contains(out.String(), "Configuration review:") || !strings.Contains(out.String(), "Runner image: ghcr.io/cirruslabs/ubuntu:latest") || !strings.Contains(out.String(), "Reusable artifact: epar-ubuntu-24-arm64") || strings.Contains(out.String(), "Runner artifact estimate:") || strings.Contains(out.String(), "Create this configuration?") { + t.Fatalf("Tart review was missing or contained Docker-specific setup output:\n%s", out.String()) + } } func TestTartAvailabilityRequiresNativeMacOSAndSuccessfulVersion(t *testing.T) { diff --git a/cmd/ephemeral-action-runner/init_wizard.go b/cmd/ephemeral-action-runner/init_wizard.go new file mode 100644 index 0000000..ece8696 --- /dev/null +++ b/cmd/ephemeral-action-runner/init_wizard.go @@ -0,0 +1,713 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "github.com/solutionforest/ephemeral-action-runner/internal/config" + "github.com/solutionforest/ephemeral-action-runner/internal/hosttrust" + "github.com/solutionforest/ephemeral-action-runner/internal/provider" + providerregistry "github.com/solutionforest/ephemeral-action-runner/internal/provider/registry" +) + +type initWizardStep uint8 + +const ( + initWizardRunnerGroup initWizardStep = iota + initWizardProvider + initWizardProviderSetup + initWizardPool + initWizardHostTrust + initWizardUpdates + initWizardReview +) + +type initWizardAction uint8 + +const ( + initWizardNext initWizardAction = iota + initWizardBack + initWizardRefresh + initWizardQuit + initWizardEdit +) + +type initWizardResult[T any] struct { + Action initWizardAction + Value T +} + +type initWizardHistory []initWizardStep + +func (history *initWizardHistory) push(step initWizardStep) { + *history = append(*history, step) +} + +func (history *initWizardHistory) pop() (initWizardStep, bool) { + if len(*history) == 0 { + return 0, false + } + last := len(*history) - 1 + step := (*history)[last] + *history = (*history)[:last] + return step, true +} + +func (history *initWizardHistory) reset(step initWizardStep) { + *history = append((*history)[:0], step) +} + +type initArtifactEstimate struct { + Source string + Platform string + CustomScripts []string + DownloadBytes uint64 + ExpandedBytes uint64 + IncrementalPeakBytes uint64 + AvailablePhysicalSpace string + LogicalRootMaximumBytes uint64 + LogicalDockerMaximumBytes uint64 +} + +type initWizardDraft struct { + AppID int64 + Organization string + PrivateKeyPath string + RunnerGroup initRunnerGroupSelection + ProviderType string + Profile *initDockerSandboxesProfile + Estimate *initArtifactEstimate + PoolNamePrefix string + HostTrustMode string + HostTrustScopes []string + UpdatePolicy initImageUpdatePolicy + PoolChosen bool + HostTrustChosen bool + UpdatesChosen bool +} + +type initWizardOutcome struct { + Draft initWizardDraft + Create bool +} + +func runInitConfigurationWizard(opts initOptions, reader *bufio.Reader, githubConfig config.GitHubConfig, appID int64, organization, privateKeyPath, defaultPrefix string) (initWizardOutcome, error) { + draft := initWizardDraft{ + AppID: appID, + Organization: organization, + PrivateKeyPath: privateKeyPath, + PoolNamePrefix: defaultPrefix, + HostTrustMode: config.HostTrustModeDisabled, + HostTrustScopes: []string{config.HostTrustScopeSystem}, + UpdatePolicy: initImageUpdatePolicy{Frequency: config.ImageUpdateFrequencyWeekly, Time: config.DefaultImageUpdateTime}, + } + client := newInitRunnerGroupClient(githubConfig) + step := initWizardRunnerGroup + history := make(initWizardHistory, 0, 8) + editHistory := make(initWizardHistory, 0, 3) + editing := false + + for { + var action initWizardAction + switch step { + case initWizardRunnerGroup: + result, err := promptRunnerGroupWizard(opts.Context, opts.Out, reader, client, len(history) > 0) + if err != nil { + return initWizardOutcome{}, err + } + action = result.Action + if action == initWizardNext { + draft.RunnerGroup = result.Value + } + case initWizardProvider: + result, err := promptInitProviderWizard(opts.Context, opts.ProjectRoot, opts.Out, reader, opts.SkipDockerCheck, true, draft.ProviderType) + if err != nil { + return initWizardOutcome{}, err + } + action = result.Action + if action == initWizardNext && result.Value != draft.ProviderType { + oldUsesHostTrust := initProviderUsesHostTrust(draft.ProviderType) + draft.ProviderType = result.Value + draft.Profile = nil + draft.Estimate = nil + newUsesHostTrust := initProviderUsesHostTrust(draft.ProviderType) + if !newUsesHostTrust || !oldUsesHostTrust { + draft.HostTrustMode = config.HostTrustModeDisabled + draft.HostTrustScopes = []string{config.HostTrustScopeSystem} + draft.HostTrustChosen = false + } + } + case initWizardProviderSetup: + result, estimate, err := promptProviderSetupWizard(opts.Context, opts.ProjectRoot, draft.ProviderType, draft.Profile, draft.Estimate, opts.Out, reader) + if err != nil { + return initWizardOutcome{}, err + } + action = result.Action + if action == initWizardNext { + draft.Profile = result.Value + draft.Estimate = estimate + } + case initWizardPool: + fmt.Fprintln(opts.Out, "") + fmt.Fprintln(opts.Out, "Pool name prefix must be unique for this machine/config within the GitHub organization.") + fmt.Fprintln(opts.Out, "EPAR cleanup deletes GitHub runner records matching this prefix.") + result, err := promptPoolNamePrefixWizard(opts.Out, reader, draft.PoolNamePrefix) + if err != nil { + return initWizardOutcome{}, err + } + action = result.Action + if action == initWizardNext { + draft.PoolNamePrefix = result.Value + draft.PoolChosen = true + } + case initWizardHostTrust: + defaultEnabled := true + if draft.HostTrustChosen { + defaultEnabled = draft.HostTrustMode == config.HostTrustModeOverlay + } + result, err := promptHostTrustWizard(opts, reader, defaultEnabled) + if err != nil { + return initWizardOutcome{}, err + } + action = result.Action + if action == initWizardNext { + draft.HostTrustMode = config.HostTrustModeDisabled + draft.HostTrustScopes = []string{config.HostTrustScopeSystem} + if result.Value { + draft.HostTrustMode = config.HostTrustModeOverlay + draft.HostTrustScopes = hostTrustScopesForOS(initHostTrustOS) + } + draft.HostTrustChosen = true + } + case initWizardUpdates: + result, err := promptImageUpdatePolicyWizard(opts.Out, reader, draft.UpdatePolicy) + if err != nil { + return initWizardOutcome{}, err + } + action = result.Action + if action == initWizardNext { + draft.UpdatePolicy = result.Value + draft.UpdatesChosen = true + } + case initWizardReview: + result, err := promptInitReview(opts.Out, reader, draft) + if err != nil { + return initWizardOutcome{}, err + } + switch result.Action { + case initWizardNext: + return initWizardOutcome{Draft: draft, Create: true}, nil + case initWizardQuit: + return initWizardOutcome{Draft: draft}, nil + case initWizardBack: + action = initWizardBack + default: + editHistory.reset(initWizardReview) + step = result.Value + editing = true + continue + } + } + + switch action { + case initWizardQuit: + return initWizardOutcome{Draft: draft}, nil + case initWizardBack: + if editing { + if len(editHistory) == 0 { + step = initWizardReview + editing = false + continue + } + step, _ = editHistory.pop() + if step == initWizardReview { + editing = false + } + continue + } + if len(history) == 0 { + continue + } + step, _ = history.pop() + if step == initWizardReview { + editing = false + } + continue + case initWizardRefresh: + continue + } + + next := nextInitWizardStep(step, draft, editing) + if editing { + if next == initWizardReview { + step = initWizardReview + editHistory = editHistory[:0] + editing = false + continue + } + editHistory.push(step) + step = next + continue + } + history.push(step) + step = next + if step == initWizardReview { + editing = false + } + } +} + +func nextInitWizardStep(current initWizardStep, draft initWizardDraft, editing bool) initWizardStep { + if editing { + switch current { + case initWizardProvider: + if initProviderNeedsSetup(draft.ProviderType) { + return initWizardProviderSetup + } + if initProviderUsesHostTrust(draft.ProviderType) && !draft.HostTrustChosen { + return initWizardHostTrust + } + return initWizardReview + case initWizardProviderSetup: + if initProviderUsesHostTrust(draft.ProviderType) && !draft.HostTrustChosen { + return initWizardHostTrust + } + return initWizardReview + default: + return initWizardReview + } + } + switch current { + case initWizardRunnerGroup: + return initWizardProvider + case initWizardProvider: + if initProviderNeedsSetup(draft.ProviderType) { + return initWizardProviderSetup + } + return initWizardPool + case initWizardProviderSetup: + return initWizardPool + case initWizardPool: + if initProviderUsesHostTrust(draft.ProviderType) { + return initWizardHostTrust + } + return initWizardUpdates + case initWizardHostTrust: + return initWizardUpdates + default: + return initWizardReview + } +} + +func initProviderNeedsSetup(providerType string) bool { + descriptor, found := providerregistry.DescriptorFor(providerType) + return found && descriptor.WizardOnboarding != "none" +} + +func initProviderUsesHostTrust(providerType string) bool { + descriptor, found := providerregistry.DescriptorFor(providerType) + return found && descriptor.WizardHostTrust == provider.WizardHostTrustOverlay +} + +func promptWizardDefault(out io.Writer, reader *bufio.Reader, label, defaultValue string) (initWizardResult[string], bool, error) { + fmt.Fprintf(out, "%s (press Enter to use %s; /back to return): ", label, defaultValue) + value, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return initWizardResult[string]{}, false, err + } + hitEOF := errors.Is(err, io.EOF) + value = strings.TrimSpace(value) + if strings.EqualFold(value, "/back") { + return initWizardResult[string]{Action: initWizardBack}, hitEOF, nil + } + if value == "" { + value = defaultValue + } + return initWizardResult[string]{Action: initWizardNext, Value: value}, hitEOF, nil +} + +func promptWizardOptional(out io.Writer, reader *bufio.Reader, label string) (initWizardResult[string], error) { + fmt.Fprintf(out, "%s (press Enter for none; /back to return): ", label) + value, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return initWizardResult[string]{}, err + } + value = strings.TrimSpace(value) + if strings.EqualFold(value, "/back") { + return initWizardResult[string]{Action: initWizardBack}, nil + } + return initWizardResult[string]{Action: initWizardNext, Value: value}, nil +} + +func promptWizardRequired(out io.Writer, reader *bufio.Reader, label string) (initWizardResult[string], error) { + for { + fmt.Fprintf(out, "%s (/back to return): ", label) + value, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return initWizardResult[string]{}, err + } + value = strings.TrimSpace(value) + if strings.EqualFold(value, "/back") { + return initWizardResult[string]{Action: initWizardBack}, nil + } + if value != "" { + return initWizardResult[string]{Action: initWizardNext, Value: value}, nil + } + if errors.Is(err, io.EOF) { + return initWizardResult[string]{}, fmt.Errorf("%s is required", label) + } + fmt.Fprintf(out, "%s is required.\n", label) + } +} + +func promptWizardYesNo(out io.Writer, reader *bufio.Reader, label string, defaultYes bool) (initWizardResult[bool], error) { + defaultValue := "Y" + if !defaultYes { + defaultValue = "N" + } + for { + result, hitEOF, err := promptWizardDefault(out, reader, label+" [Y/n]", defaultValue) + if err != nil || result.Action == initWizardBack { + return initWizardResult[bool]{Action: result.Action}, err + } + switch strings.ToLower(result.Value) { + case "y", "yes": + return initWizardResult[bool]{Action: initWizardNext, Value: true}, nil + case "n", "no": + return initWizardResult[bool]{Action: initWizardNext, Value: false}, nil + default: + fmt.Fprintln(out, "Please answer yes or no, or enter /back to return.") + if hitEOF { + return initWizardResult[bool]{}, fmt.Errorf("invalid yes/no response %q", result.Value) + } + } + } +} + +func promptPoolNamePrefixWizard(out io.Writer, reader *bufio.Reader, defaultValue string) (initWizardResult[string], error) { + for { + result, hitEOF, err := promptWizardDefault(out, reader, "Pool name prefix", defaultValue) + if err != nil || result.Action == initWizardBack { + return result, err + } + if validationErr := config.ValidatePrefix(result.Value); validationErr != nil { + fmt.Fprintf(out, "Pool name prefix is invalid: %v\n", validationErr) + if hitEOF { + return initWizardResult[string]{}, validationErr + } + continue + } + return result, nil + } +} + +func promptHostTrustWizard(opts initOptions, reader *bufio.Reader, defaultEnabled bool) (initWizardResult[bool], error) { + fmt.Fprintln(opts.Out, "Runners need this host's trusted TLS roots to access services that this machine trusts.") + result, err := promptWizardYesNo(opts.Out, reader, "Inherit this host's trusted TLS roots into disposable runners?", defaultEnabled) + if err != nil || result.Action != initWizardNext || !result.Value { + return result, err + } + deferred := os.Getenv("EPAR_HOST_TRUST_INIT_DEFERRED") == "1" + if !opts.SkipHostTrustCheck && !deferred { + preflightCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + _, collectErr := initResolveHostTrust(preflightCtx, hosttrust.Options{Mode: config.HostTrustModeOverlay, Scopes: hostTrustScopesForOS(initHostTrustOS), ControllerHostOS: initHostTrustOS}) + cancel() + if collectErr != nil { + return initWizardResult[bool]{}, fmt.Errorf("collect host trusted TLS roots before writing config: %w", collectErr) + } + } + return result, nil +} + +func renderInitArtifactEstimate(out io.Writer, estimate *initArtifactEstimate) { + if estimate == nil { + return + } + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Runner artifact estimate:") + fmt.Fprintf(out, " Source: %s\n", estimate.Source) + fmt.Fprintf(out, " Platform: %s\n", estimate.Platform) + if len(estimate.CustomScripts) == 0 { + fmt.Fprintln(out, " Custom install scripts: none") + } else { + fmt.Fprintf(out, " Custom install scripts: %s\n", strings.Join(estimate.CustomScripts, ", ")) + } + fmt.Fprintf(out, " Estimated download: %s compressed layers\n", formatInitUintByteCount(estimate.DownloadBytes)) + fmt.Fprintf(out, " Estimated expanded source: %s\n", formatInitUintByteCount(estimate.ExpandedBytes)) + fmt.Fprintf(out, " Estimated incremental physical peak: %s\n", formatInitUintByteCount(estimate.IncrementalPeakBytes)) + fmt.Fprintf(out, " Available physical space: %s\n", estimate.AvailablePhysicalSpace) + fmt.Fprintln(out, " Fixed free-space reserve: 1GiB") + if estimate.LogicalRootMaximumBytes > 0 { + fmt.Fprintf(out, " Automatic sandbox root limit: %s (sparse logical maximum)\n", formatInitUintByteCount(estimate.LogicalRootMaximumBytes)) + fmt.Fprintf(out, " Inner Docker limit: %s (independent sparse logical maximum)\n", formatInitUintByteCount(estimate.LogicalDockerMaximumBytes)) + } +} + +func promptProviderSetupWizard(ctx context.Context, projectRoot, providerType string, existing *initDockerSandboxesProfile, existingEstimate *initArtifactEstimate, out io.Writer, reader *bufio.Reader) (initWizardResult[*initDockerSandboxesProfile], *initArtifactEstimate, error) { + descriptor, found := providerregistry.DescriptorFor(providerType) + if !found { + return initWizardResult[*initDockerSandboxesProfile]{}, nil, fmt.Errorf("unsupported provider.type %q", providerType) + } + switch descriptor.WizardOnboarding { + case provider.WizardOnboardingNone: + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardNext}, nil, nil + case provider.WizardOnboardingCatthehackerDocker: + if existing != nil && existing.Provider == providerType { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Current runner artifact setup:") + fmt.Fprintf(out, " Image: %s\n", existing.SourceImage) + if len(existing.CustomScripts) == 0 { + fmt.Fprintln(out, " Custom install scripts: none") + } else { + fmt.Fprintf(out, " Custom install scripts: %s\n", strings.Join(existing.CustomScripts, ", ")) + } + fmt.Fprintln(out, " 1. Continue with this setup (default)") + fmt.Fprintln(out, " 2. Change runner artifact setup") + fmt.Fprintln(out, " 0. Back") + for { + choice, hitEOF, err := promptDefault(out, reader, "Runner artifact setup", "1") + if err != nil { + return initWizardResult[*initDockerSandboxesProfile]{}, nil, err + } + switch strings.ToLower(strings.TrimSpace(choice)) { + case "1", "continue": + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardNext, Value: existing}, existingEstimate, nil + case "2", "change": + return promptDockerImageProfileWizard(ctx, projectRoot, providerType, initSandboxPromotionPlatform(), out, reader) + case "0", "back": + return initWizardResult[*initDockerSandboxesProfile]{Action: initWizardBack}, nil, nil + default: + fmt.Fprintln(out, "Choose 1 to continue, 2 to change, or 0 to go back.") + if hitEOF { + return initWizardResult[*initDockerSandboxesProfile]{}, nil, fmt.Errorf("invalid runner artifact setup choice %q", choice) + } + } + } + } + return promptDockerImageProfileWizard(ctx, projectRoot, providerType, initSandboxPromotionPlatform(), out, reader) + default: + return initWizardResult[*initDockerSandboxesProfile]{}, nil, fmt.Errorf("provider %q has unsupported onboarding strategy %q", providerType, descriptor.WizardOnboarding) + } +} + +func promptInitProviderWizard(ctx context.Context, projectRoot string, out io.Writer, reader *bufio.Reader, skipDockerCheck, allowBack bool, preferredProvider string) (initWizardResult[string], error) { + hostPlatform := initSandboxPromotionPlatform() + record, promoted := initSandboxPromotionLookup(hostPlatform) + for { + result, _, err := promptInitProviderChoiceWizard(ctx, projectRoot, hostPlatform, record, promoted, out, reader, skipDockerCheck, allowBack, preferredProvider) + if err != nil { + return initWizardResult[string]{}, err + } + if result.Action == initWizardRefresh { + fmt.Fprintln(out, "Refreshing provider prerequisites...") + continue + } + return result, nil + } +} + +func promptImageUpdatePolicyWizard(out io.Writer, reader *bufio.Reader, current initImageUpdatePolicy) (initWizardResult[initImageUpdatePolicy], error) { + defaultChoice := "1" + switch current.Frequency { + case config.ImageUpdateFrequencyDaily: + defaultChoice = "2" + case config.ImageUpdateFrequencyBiweekly: + defaultChoice = "3" + case config.ImageUpdateFrequencyMonthly: + defaultChoice = "4" + case config.ImageUpdateFrequencyManual: + defaultChoice = "5" + } + for { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Automatic image and Actions runner updates:") + fmt.Fprintln(out, " 1. Weekly") + fmt.Fprintln(out, " 2. Daily") + fmt.Fprintln(out, " 3. Every two weeks") + fmt.Fprintln(out, " 4. Monthly") + fmt.Fprintln(out, " 5. Manual — check only on demand") + fmt.Fprintln(out, " Command: ./start image update") + fmt.Fprintln(out, " 0. Back") + result, hitEOF, err := promptWizardDefault(out, reader, "Update frequency", defaultChoice) + if err != nil { + return initWizardResult[initImageUpdatePolicy]{}, err + } + if result.Action == initWizardBack || result.Value == "0" { + return initWizardResult[initImageUpdatePolicy]{Action: initWizardBack}, nil + } + frequency := "" + switch strings.ToLower(result.Value) { + case "1", "weekly": + frequency = config.ImageUpdateFrequencyWeekly + case "2", "daily": + frequency = config.ImageUpdateFrequencyDaily + case "3", "biweekly", "every two weeks": + frequency = config.ImageUpdateFrequencyBiweekly + case "4", "monthly": + frequency = config.ImageUpdateFrequencyMonthly + case "5", "manual": + return initWizardResult[initImageUpdatePolicy]{Action: initWizardNext, Value: initImageUpdatePolicy{Frequency: config.ImageUpdateFrequencyManual, Time: config.DefaultImageUpdateTime}}, nil + default: + fmt.Fprintln(out, " Choose 1–5, 0 to go back, or enter daily, weekly, biweekly, monthly, or manual.") + if hitEOF { + return initWizardResult[initImageUpdatePolicy]{}, fmt.Errorf("invalid image update frequency %q", result.Value) + } + continue + } + updateTimeDefault := current.Time + if updateTimeDefault == "" { + updateTimeDefault = config.DefaultImageUpdateTime + } + for { + timeResult, _, promptErr := promptWizardDefault(out, reader, "Local update time (24-hour HH:MM)", updateTimeDefault) + if promptErr != nil { + return initWizardResult[initImageUpdatePolicy]{}, promptErr + } + if timeResult.Action == initWizardBack { + return initWizardResult[initImageUpdatePolicy]{Action: initWizardBack}, nil + } + policy := initImageUpdatePolicy{Frequency: frequency, Time: timeResult.Value} + image := config.Default().Image + image.UpdateFrequency = policy.Frequency + image.UpdateTime = policy.Time + if validationErr := config.ValidateImageUpdatePolicy(image); validationErr != nil { + fmt.Fprintf(out, " %v\n", validationErr) + continue + } + return initWizardResult[initImageUpdatePolicy]{Action: initWizardNext, Value: policy}, nil + } + } +} + +type initReviewOption struct { + Label string + Step initWizardStep +} + +func promptInitReview(out io.Writer, reader *bufio.Reader, draft initWizardDraft) (initWizardResult[initWizardStep], error) { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Configuration review:") + fmt.Fprintf(out, " GitHub App: %d for %s\n", draft.AppID, draft.Organization) + fmt.Fprintf(out, " Private key path: %s\n", draft.PrivateKeyPath) + fmt.Fprintf(out, " Runner group: %s\n", draft.RunnerGroup.Group.Name) + fmt.Fprintf(out, " Provider: %s\n", providerDisplayName(draft.ProviderType)) + descriptor, found := providerregistry.DescriptorFor(draft.ProviderType) + if !found || !descriptor.WizardReview.Valid() { + return initWizardResult[initWizardStep]{}, fmt.Errorf("provider %q has no valid wizard review contribution", draft.ProviderType) + } + switch descriptor.WizardReview { + case provider.WizardReviewDockerImage: + if draft.Profile == nil { + return initWizardResult[initWizardStep]{}, fmt.Errorf("provider %q review requires an image profile", draft.ProviderType) + } + fmt.Fprintf(out, " Runner image: %s\n", draft.Profile.SourceImage) + if len(draft.Profile.CustomScripts) == 0 { + fmt.Fprintln(out, " Custom install scripts: none") + } else { + fmt.Fprintf(out, " Custom install scripts: %s\n", strings.Join(draft.Profile.CustomScripts, ", ")) + } + case provider.WizardReviewNativeImage: + fmt.Fprintf(out, " Runner image: %s\n", descriptor.WizardReviewSource) + fmt.Fprintf(out, " Reusable artifact: %s\n", descriptor.WizardReviewOutput) + default: + return initWizardResult[initWizardStep]{}, fmt.Errorf("provider %q has unknown wizard review contribution %q", draft.ProviderType, descriptor.WizardReview) + } + fmt.Fprintf(out, " Pool name prefix: %s\n", draft.PoolNamePrefix) + if initProviderUsesHostTrust(draft.ProviderType) { + if draft.HostTrustMode == config.HostTrustModeOverlay { + fmt.Fprintln(out, " Host trusted TLS roots: inherited") + } else { + fmt.Fprintln(out, " Host trusted TLS roots: not inherited") + } + } else { + fmt.Fprintln(out, " Host trusted TLS roots: not applicable") + } + fmt.Fprintf(out, " Updates: %s", draft.UpdatePolicy.Frequency) + if draft.UpdatePolicy.Frequency != config.ImageUpdateFrequencyManual { + fmt.Fprintf(out, " at %s local time", draft.UpdatePolicy.Time) + } + fmt.Fprintln(out) + renderInitArtifactEstimate(out, draft.Estimate) + + options := []initReviewOption{ + {Label: "Looks good, proceed to create configuration"}, + {Label: "Change runner group", Step: initWizardRunnerGroup}, + {Label: "Change provider", Step: initWizardProvider}, + } + if initProviderNeedsSetup(draft.ProviderType) { + options = append(options, initReviewOption{Label: "Change runner image or install scripts", Step: initWizardProviderSetup}) + } + options = append(options, initReviewOption{Label: "Change pool name prefix", Step: initWizardPool}) + if initProviderUsesHostTrust(draft.ProviderType) { + options = append(options, initReviewOption{Label: "Change host trust", Step: initWizardHostTrust}) + } + options = append(options, initReviewOption{Label: "Change update frequency", Step: initWizardUpdates}) + + fmt.Fprintln(out, "") + for index, option := range options { + defaultLabel := "" + if index == 0 { + defaultLabel = " (default)" + } + fmt.Fprintf(out, " %d. %s%s\n", index+1, option.Label, defaultLabel) + } + fmt.Fprintln(out, " 0. Back") + fmt.Fprintln(out, " Q. Quit without writing a config") + for { + value, hitEOF, err := promptDefault(out, reader, "Review choice", "1") + if err != nil { + return initWizardResult[initWizardStep]{}, err + } + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "0", "back": + return initWizardResult[initWizardStep]{Action: initWizardBack}, nil + case "q", "quit": + return initWizardResult[initWizardStep]{Action: initWizardQuit}, nil + } + index, parseErr := strconv.Atoi(normalized) + if parseErr == nil && index >= 1 && index <= len(options) { + if index == 1 { + return initWizardResult[initWizardStep]{Action: initWizardNext}, nil + } + return initWizardResult[initWizardStep]{Action: initWizardEdit, Value: options[index-1].Step}, nil + } + fmt.Fprintln(out, "Choose a review action, 0 to go back, or Q to quit.") + if hitEOF { + return initWizardResult[initWizardStep]{}, fmt.Errorf("invalid review choice %q", value) + } + } +} + +func renderInitWizardConfig(draft initWizardDraft) (string, error) { + descriptor, found := providerregistry.DescriptorFor(draft.ProviderType) + if !found { + return "", fmt.Errorf("unsupported provider.type %q", draft.ProviderType) + } + if descriptor.WizardOnboarding == provider.WizardOnboardingCatthehackerDocker && draft.Profile == nil { + return "", fmt.Errorf("%s image selection did not produce a provisioning profile", draft.ProviderType) + } + switch draft.ProviderType { + case "docker-container": + return defaultDockerContainerConfig(draft.AppID, draft.Organization, draft.PrivateKeyPath, draft.PoolNamePrefix, draft.HostTrustMode, draft.HostTrustScopes, draft.RunnerGroup, *draft.Profile, draft.UpdatePolicy), nil + case "wsl": + return defaultWSLConfig(draft.AppID, draft.Organization, draft.PrivateKeyPath, draft.PoolNamePrefix, draft.RunnerGroup, *draft.Profile, draft.UpdatePolicy), nil + case "tart": + return defaultTartConfig(draft.AppID, draft.Organization, draft.PrivateKeyPath, draft.PoolNamePrefix, draft.RunnerGroup, draft.UpdatePolicy), nil + case "docker-sandboxes": + guestPlatform, runnerArchitectureLabel, err := dockerSandboxesPlatform(draft.Profile.HostPlatform) + if err != nil { + return "", err + } + return defaultDockerSandboxesConfig(draft.AppID, draft.Organization, draft.PrivateKeyPath, draft.PoolNamePrefix, draft.HostTrustMode, draft.HostTrustScopes, draft.RunnerGroup, *draft.Profile, draft.UpdatePolicy, guestPlatform, runnerArchitectureLabel), nil + default: + return "", fmt.Errorf("unsupported provider.type %q", draft.ProviderType) + } +} diff --git a/cmd/ephemeral-action-runner/start_test.go b/cmd/ephemeral-action-runner/start_test.go index 58463c8..eb79889 100644 --- a/cmd/ephemeral-action-runner/start_test.go +++ b/cmd/ephemeral-action-runner/start_test.go @@ -119,6 +119,19 @@ func TestMatchingStartCommandPreservesWrapperEntryPoint(t *testing.T) { } } +func assertSingleFinalInitReview(t *testing.T, output string) { + t.Helper() + if got := strings.Count(output, "Configuration review:"); got != 1 { + t.Fatalf("configuration review count = %d, want exactly one:\n%s", got, output) + } + if !strings.Contains(output, "1. Looks good, proceed to create configuration (default)") { + t.Fatalf("configuration review omitted the forward action:\n%s", output) + } + if strings.Contains(output, "Create this configuration?") { + t.Fatalf("provider setup retained an early creation decision:\n%s", output) + } +} + func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { dir := t.TempDir() stubNoWSL2(t) @@ -162,6 +175,7 @@ func TestStartInteractiveMissingConfigRunsInitAndContinues(t *testing.T) { t.Fatalf("output missing %q:\n%s", want, out.String()) } } + assertSingleFinalInitReview(t, out.String()) if fake.ensureCalls != 1 || fake.runCalls != 1 { t.Fatalf("ensure/run calls = %d/%d, want 1/1", fake.ensureCalls, fake.runCalls) } @@ -189,7 +203,7 @@ func TestStartInteractiveMissingConfigCanExitToReview(t *testing.T) { err := runStartWithOptions(startOptions{ Context: context.Background(), ProjectRoot: dir, - In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\n\n\nn\n\n\nn\n"), + In: strings.NewReader("123456\nsolutionforest\n.local/github-app.pem\n1\n1\n\nn\n\n\n\n\n\nn\n"), Out: &out, ManagerFactory: func(string, string, bool, bool) (starterManager, error) { t.Fatal("manager factory should not run after choosing to review the new config") @@ -253,6 +267,7 @@ func TestStartInteractiveMissingConfigCanSelectWSL2(t *testing.T) { if !strings.Contains(out.String(), "Continuing with") { t.Fatalf("output missing continuation message:\n%s", out.String()) } + assertSingleFinalInitReview(t, out.String()) if fake.ensureCalls != 1 || fake.runCalls != 1 { t.Fatalf("ensure/run calls = %d/%d, want 1/1", fake.ensureCalls, fake.runCalls) } @@ -317,6 +332,7 @@ func TestStartInteractiveMissingConfigCanSelectDockerSandboxes(t *testing.T) { if !strings.Contains(out.String(), "Docker Sandboxes — recommended") || !strings.Contains(out.String(), "Continuing with") { t.Fatalf("output did not include capability-ready Docker Sandboxes selection and start continuation:\n%s", out.String()) } + assertSingleFinalInitReview(t, out.String()) if fake.ensureCalls != 1 || fake.runCalls != 1 { t.Fatalf("ensure/run calls = %d/%d, want 1/1", fake.ensureCalls, fake.runCalls) } @@ -364,6 +380,7 @@ func TestStartInteractiveMissingConfigCanSelectTartWithoutDocker(t *testing.T) { if !strings.Contains(out.String(), "Continuing with") { t.Fatalf("output missing continuation message:\n%s", out.String()) } + assertSingleFinalInitReview(t, out.String()) if fake.ensureCalls != 1 || fake.runCalls != 1 { t.Fatalf("ensure/run calls = %d/%d, want 1/1", fake.ensureCalls, fake.runCalls) } diff --git a/docs/advanced/docker-registry-mirrors.md b/docs/advanced/docker-registry-mirrors.md index 14cc0f2..a6e6ba7 100644 --- a/docs/advanced/docker-registry-mirrors.md +++ b/docs/advanced/docker-registry-mirrors.md @@ -165,7 +165,7 @@ EPAR intentionally does not rewrite Docker image names inside workflows. That av Start a runner instance with mirrors configured: ```bash -./bin/ephemeral-action-runner pool verify --instances 1 --cleanup +./start pool verify --instances 1 --cleanup ``` For Docker Container, inspect the inner daemon: diff --git a/docs/advanced/docker-sandboxes-template.md b/docs/advanced/docker-sandboxes-template.md index 2bb508a..9cb7cdc 100644 --- a/docs/advanced/docker-sandboxes-template.md +++ b/docs/advanced/docker-sandboxes-template.md @@ -26,12 +26,12 @@ The compatibility scripts under `scripts/docker-sandboxes` delegate to this comm ## Configure And Prewarm -Run `./start` with no configuration. The wizard offers `full-latest`, `act-latest`, `dotnet-latest`, `js-latest`, or another `catthehacker/ubuntu` tag; verifies the tag and native platform; validates optional custom install scripts; displays source, platform, size estimates, reserve, and duration; then saves the desired configuration after one confirmation. Normal startup performs the build and import, and a provisioning failure leaves the configuration available for a retry. +Run `./start` with no configuration. The wizard offers `full-latest`, `act-latest`, `dotnet-latest`, `js-latest`, or another `catthehacker/ubuntu` tag; verifies the tag and native platform; validates optional custom install scripts; displays source, platform, size estimates, and reserve in the final review; then saves the desired configuration after one confirmation. Normal startup performs the build and import, and a provisioning failure leaves the configuration available for a retry. After configuration, prewarm the selected template outside the job path: ```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-native-controller.ps1 pool verify --config .local/docker-sandboxes.yml --project-root . --instances 1 --cleanup +.\start.ps1 pool verify --config .local\docker-sandboxes.yml --project-root . --instances 1 --cleanup ``` Do not add `--register-only`. This creates, verifies, and exactly removes one unregistered sandbox without requesting a GitHub registration token. The first create can still be slow; later creates reuse the host-level template cache. diff --git a/docs/advanced/no-go-install.md b/docs/advanced/no-go-install.md index a8f79d6..0a841f6 100644 --- a/docs/advanced/no-go-install.md +++ b/docs/advanced/no-go-install.md @@ -28,7 +28,7 @@ On Windows the helper reads local-machine and current-user root stores and exclu Do not replace the official wrapper with a bare `docker run` for Docker Sandboxes. EPAR rejects the legacy controller-in-Docker path for that provider. -You can run the Docker wrapper directly instead of through `./start`: +For wrapper-development diagnostics, you can run the Docker helper directly. Normal manual and automatic operation should continue to use `./start`, `start.ps1`, or `start.cmd`: ```bash scripts/run-with-docker.sh version diff --git a/docs/advanced/windows-startup.md b/docs/advanced/windows-startup.md index acb3705..e87acfa 100644 --- a/docs/advanced/windows-startup.md +++ b/docs/advanced/windows-startup.md @@ -6,11 +6,7 @@ Use the Startup folder for a personal machine where a visible foreground window Run EPAR manually once first so `.local\config.yml` exists. The first run can take a while because `start` may build or refresh the configured image before starting runners. -For startup automation, build a local binary from the source folder once: - -```powershell -go build -o .\bin\ephemeral-action-runner.exe .\cmd\ephemeral-action-runner -``` +Startup automation must use the same wrapper as a manual launch so first-run handling, local-Go and no-Go selection, argument forwarding, trust setup, and controller updates stay consistent. The examples below run `start.cmd` through `cmd.exe`; a PowerShell-based task may instead run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File D:\path\to\ephemeral-action-runner\start.ps1 --config .local\config.yml`. ## Startup Folder Shortcut @@ -20,10 +16,11 @@ Open the current user's Startup folder: Start-Process shell:startup ``` -Create a shortcut to the local binary: +Create a shortcut to the wrapper through `cmd.exe`: ```text -Target: D:\path\to\ephemeral-action-runner\bin\ephemeral-action-runner.exe +Target: C:\Windows\System32\cmd.exe +Arguments: /d /s /c ""D:\path\to\ephemeral-action-runner\start.cmd" --config .local\config.yml" Start in: D:\path\to\ephemeral-action-runner ``` @@ -35,9 +32,9 @@ You can also create the shortcut from PowerShell: $root = "D:\path\to\ephemeral-action-runner" $startup = [Environment]::GetFolderPath("Startup") $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut("$startup\EPAR.lnk") -$shortcut.TargetPath = Join-Path $root "bin\ephemeral-action-runner.exe" +$shortcut.TargetPath = $env:ComSpec $shortcut.WorkingDirectory = $root -$shortcut.Arguments = "start --config .local\config.yml" +$shortcut.Arguments = '/d /s /c ""{0}" --config .local\config.yml"' -f (Join-Path $root "start.cmd") $shortcut.Save() ``` @@ -49,8 +46,8 @@ Create a user logon task: 2. Choose **Create Task**. 3. On **Triggers**, add **At log on**. Add a short delay if Docker needs time to start. 4. On **Actions**, choose **Start a program**. -5. Set **Program/script** to `D:\path\to\ephemeral-action-runner\bin\ephemeral-action-runner.exe`. -6. Set **Add arguments** to `start --config .local\config.yml`. +5. Set **Program/script** to `C:\Windows\System32\cmd.exe`. +6. Set **Add arguments** to `/d /s /c ""D:\path\to\ephemeral-action-runner\start.cmd" --config .local\config.yml"`. 7. Set **Start in** to `D:\path\to\ephemeral-action-runner`. If the host runtime is tied to the user session, keep the task as a user logon task. A boot-time system task may start too early or without the expected Docker context. @@ -59,9 +56,10 @@ PowerShell equivalent: ```powershell $root = "D:\path\to\ephemeral-action-runner" +$arguments = '/d /s /c ""{0}" --config .local\config.yml"' -f (Join-Path $root "start.cmd") $action = New-ScheduledTaskAction ` - -Execute (Join-Path $root "bin\ephemeral-action-runner.exe") ` - -Argument "start --config .local\config.yml" ` + -Execute $env:ComSpec ` + -Argument $arguments ` -WorkingDirectory $root $trigger = New-ScheduledTaskTrigger -AtLogOn $trigger.Delay = "PT1M" diff --git a/docs/development/adding-provider.md b/docs/development/adding-provider.md index 51c13c1..6ac2ca5 100644 --- a/docs/development/adding-provider.md +++ b/docs/development/adding-provider.md @@ -6,11 +6,13 @@ Put provider commands and host integration in `internal/provider/`. Sh A provider is complete only when it: -- Registers its constructor, configuration rules, wizard contribution, reusable-artifact capabilities, and platform status in the provider registry. +- Registers its constructor, configuration rules, prerequisite strategy, onboarding strategy, host-trust applicability, review contribution, reusable-artifact capabilities, and platform status in the provider registry. - Implements every required lifecycle, exact inventory, artifact-requirement, storage-surface, ownership receipt, and crash-recovery contract without silent fallback. - Uses the wizard’s complete machine-derived prefix and the shared `pool.RunnerName` format. - Preserves strict `pool.instances`, durable exact identities, quarantine on uncertainty, resumable cleanup, diagnostics, and replacement. - Reuses Catthehacker defaults when it consumes Docker runner images, unless an intentional exception is documented and tested. - Adds provider contract tests, configuration and wizard tests, race tests, wrapper checks, and relevant live-platform evidence. +Prefer an existing onboarding strategy when the provider has the same domain behavior. Docker-image consumers normally reuse the Catthehacker strategy, including image profiles, custom scripts, estimates, and update policy. Add a new typed strategy only when the provider requires different inputs or artifact behavior; the common wizard continues to own prompts, section history, `0` and `/back` navigation, review, and final creation. A provider still needs an explicit generated-configuration renderer until configuration serialization is consolidated, and its output must be compared with the shared manager path and an established provider. + Provider cleanup must target exact identities and record enough immutable evidence for common startup housekeeping to distinguish current, superseded, incomplete, and unknown resources. Never replace an unavailable exact operation with a prefix deletion, wildcard, broad prune, reset, or deletion of an unknown/shared resource. diff --git a/docs/development/design.md b/docs/development/design.md index 49d8896..6e86bc0 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -4,7 +4,7 @@ EPAR has one provider-neutral control flow: ```mermaid flowchart LR - CLI["CLI and ./start wizard"] --> Pool["Common pool lifecycle"] + CLI["Universal start wrapper and CLI"] --> Pool["Common pool lifecycle"] Pool --> Provider["Provider contracts"] Pool --> GitHub["GitHub runner API"] Pool --> Storage["Capacity and retention"] @@ -21,6 +21,8 @@ flowchart LR Provider code must not implement a second pool lifecycle. A capability that every provider needs belongs in a common contract; genuinely optional behavior uses an explicit capability interface. +The first-run wizard follows the same rule. Its section state, Back history, review, and rendering are provider-neutral. Provider descriptors declare prerequisite, onboarding, host-trust, and review contributions; shared strategies implement reusable flows such as Catthehacker image selection. A new provider may reuse an existing strategy, while a genuinely new capability adds one typed strategy instead of inserting provider-name branches throughout the wizard. + ## Instance Lifecycle For every provider, the common controller: diff --git a/docs/development/principles.md b/docs/development/principles.md index b1f7d7f..21a269a 100644 --- a/docs/development/principles.md +++ b/docs/development/principles.md @@ -2,9 +2,15 @@ EPAR extensions preserve the existing user flow and controller design. +## Universal Start Contract + +The `start` wrapper family is EPAR's universal operator entry point for first-run configuration, normal controller operation, manual launch, and machine autorun. Use `./start` on macOS, Linux, WSL, and Git Bash, `start.ps1` from native PowerShell, and `start.cmd` from native Command Prompt or Windows startup automation. Operator documentation and startup automation must invoke one of these wrappers rather than a locally built controller binary. + +With no arguments, a wrapper invokes the controller's `start` command. When the first argument is a global flag, the wrapper inserts `start` before forwarding the complete argument array. When the first argument is an explicit command, the wrapper forwards that command and every argument exactly. Local-Go execution and the no-Go cached native-controller path must have the same command, argument, configuration, trust, reconciliation, and remediation behavior. Direct `go run` and `scripts/run-with-docker.*` invocations are development and diagnostic interfaces, not alternative operator entry points. + ## First Run -`./start` is the Quick Start and general source entry point. With no command, or with start flags only, it runs the `start` command and opens the missing-configuration wizard when needed; with an explicit command, it must forward that command and all arguments exactly as the binary and `go run ./cmd/ephemeral-action-runner` do. The local-Go and no-Go native-controller paths must behave the same, including native-host operational build trust, startup reconciliation of exactly owned stale work, and user-facing remediation commands that use the entry point the user actually invoked. A no-Go bootstrap TLS failure must preserve the native build transcript and report the requested host and presented certificate metadata without disabling verification or retrying insecurely. A selectable provider must appear in the wizard with its tooling and daemon prerequisite status; storage estimates never make provider selection unavailable or prevent configuration creation. Docker Container, Docker Sandboxes, and WSL use the same Catthehacker image, custom-script, and update-policy onboarding flow. The wizard writes the desired configuration first, then an embedded `./start` continues through the ordinary artifact provisioning and storage-admission path. Local artifact inputs always apply immediately; provider-neutral scheduling may defer only remote mutable source and Actions runner observations. Reject an unavailable platform or invalid image clearly and never silently switch a configured provider or artifact. Builder operational trust and optional runner trust are separate contracts: system roots always support the owned builder, while `image.hostTrustMode` controls only runner inheritance. +The wrapper opens the missing-configuration wizard when needed. A no-Go bootstrap TLS failure must preserve the native build transcript and report the requested host and presented certificate metadata without disabling verification or retrying insecurely. A selectable provider must appear in the wizard with its tooling and daemon prerequisite status; storage estimates never make provider selection unavailable or prevent configuration creation. Docker Container, Docker Sandboxes, and WSL use the same Catthehacker image, custom-script, and update-policy onboarding strategy. The wizard keeps later answers in a navigable draft, shows provider-specific estimates only when applicable, and writes only after one provider-neutral final review. An embedded `./start` then continues through the ordinary artifact provisioning and storage-admission path. Local artifact inputs always apply immediately; provider-neutral scheduling may defer only remote mutable source and Actions runner observations. Reject an unavailable platform or invalid image clearly and never silently switch a configured provider or artifact. Builder operational trust and optional runner trust are separate contracts: system roots always support the owned builder, while `image.hostTrustMode` controls only runner inheritance. ## Runner Names diff --git a/docs/image-build.md b/docs/image-build.md index e520bc2..b3d1e9e 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -41,8 +41,8 @@ Scripts run as root in listed order after the Actions runner is installed and be The built-in `install-web-e2e.sh` adds browser/E2E tooling. It needs EPAR's pinned `actions/runner-images` checkout: ```bash -ephemeral-action-runner image update-upstream -ephemeral-action-runner image build --replace +./start image update-upstream +./start image build --replace ``` The default Catthehacker sources and runner-only Tart builds do not require that checkout. Use the exact configuration and provider guide to decide whether a selected script needs it. @@ -84,8 +84,8 @@ Docker Sandboxes uses `image.sourceImage`, `image.sourcePlatform`, and `image.cu ## Verify A Customized Artifact ```bash -ephemeral-action-runner pool verify --instances 1 --cleanup -ephemeral-action-runner pool verify --instances 1 --register-only --cleanup +./start pool verify --instances 1 --cleanup +./start pool verify --instances 1 --register-only --cleanup ``` The first command checks an unregistered disposable instance. The second also checks GitHub registration. Provider-specific runtime checks run when their feature markers are present; for example, Docker-enabled images validate Docker, Compose, Buildx, and a real container. diff --git a/docs/logging.md b/docs/logging.md index 81dd13d..20722ef 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,6 +1,6 @@ # Logging -EPAR writes logs under `work/logs` by default. Use `ephemeral-action-runner logs path` to print the resolved directory. +EPAR writes logs under `work/logs` by default. Use `./start logs path` to print the resolved directory. ```text work/logs/ @@ -58,8 +58,8 @@ EPAR rotates active manager and transcript files at `logging.maxFileSizeMiB`, re Inspect or preview recognized log maintenance with: ```bash -ephemeral-action-runner logs list -ephemeral-action-runner logs prune --dry-run +./start logs list +./start logs prune --dry-run ``` Remove `--dry-run` only after reviewing the exact retention plan. diff --git a/docs/operations.md b/docs/operations.md index 9c9e5af..153c4bf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -38,9 +38,9 @@ For transient GitHub or network failures during replacement, including `429` and ## Inspect status and logs ```bash -ephemeral-action-runner status -ephemeral-action-runner logs path -ephemeral-action-runner logs list +./start status +./start logs path +./start logs list ``` Add `--no-github` to `status` when you need a local-only view. By default, host logs live under `work/logs`; manager events are console-first and instance/build transcripts are file artifacts. A failed launch or readiness check appends bounded guest diagnostics to the relevant instance log. See [Logging](logging.md) for locations, formats, retention, and shipping. @@ -50,8 +50,8 @@ When multiple configs run concurrently, give each one a distinct `logging.direct ## Clean up safely ```bash -ephemeral-action-runner cleanup -ephemeral-action-runner pool down +./start cleanup +./start pool down ``` `pool down` is an alias for `cleanup`. Cleanup is intentionally bounded: Docker Sandboxes uses the durable ledger of exact owned identities, while legacy providers use the configured `pool.namePrefix` boundary. Unknown, shared, or identity-drifted resources are report-only rather than broad deletion targets. Do not reuse a prefix across machines or independent supervisors in the same GitHub organization. @@ -61,11 +61,11 @@ Use `cleanup --no-github` only when you intentionally want to leave GitHub runne ## Maintain storage and retention ```bash -ephemeral-action-runner storage status -ephemeral-action-runner storage prune -ephemeral-action-runner storage prune --execute -ephemeral-action-runner storage prune --legacy -ephemeral-action-runner logs prune --dry-run +./start storage status +./start storage prune +./start storage prune --execute +./start storage prune --legacy +./start logs prune --dry-run ``` Normal `./start` reconciles interrupted exact-owned work and retires unreferenced superseded artifacts. `storage prune` is a preview until `--execute` is supplied. `storage prune --legacy` reports prefix-era resources and requires its displayed plan hash for execution. Log pruning is separate. EPAR does not run broad Docker prune, Docker Sandboxes reset, WSL reset, Docker Desktop reset, or VHDX compaction. Read [Storage](storage.md) before reclaiming capacity. diff --git a/docs/providers/docker-container.md b/docs/providers/docker-container.md index 9d30ec4..f4200cd 100644 --- a/docs/providers/docker-container.md +++ b/docs/providers/docker-container.md @@ -56,8 +56,8 @@ The outer container has no host Docker socket mount and does not publish host po ## Verification ```bash -ephemeral-action-runner pool verify --instances 1 --cleanup -ephemeral-action-runner pool verify --instances 1 --register-only --cleanup +./start pool verify --instances 1 --cleanup +./start pool verify --instances 1 --register-only --cleanup ``` For an ARM64 host that must run amd64 Docker images, verify execution inside a live EPAR runner rather than relying on image pull success: diff --git a/docs/providers/docker-sandboxes.md b/docs/providers/docker-sandboxes.md index ddc8282..e686377 100644 --- a/docs/providers/docker-sandboxes.md +++ b/docs/providers/docker-sandboxes.md @@ -84,12 +84,12 @@ dockerSandboxes: ## Normal Workflow -1. Run `./start` with no config and select Docker Sandboxes when its tooling and diagnostics pass. Choose a Catthehacker profile or tag and optional custom install scripts; review the non-blocking physical-growth estimate, sparse logical limits, reserve, confidence, and expected duration. +1. Run `./start` with no config and select Docker Sandboxes when its tooling and diagnostics pass. Choose a Catthehacker profile or tag and optional custom install scripts; review the non-blocking physical-growth estimate, sparse logical limits, and reserve. 2. The wizard writes the desired configuration. Embedded `./start` then enters the ordinary provisioning path, performs authoritative storage admission, builds and imports the template, and activates it only after exact readback. On macOS or Linux, review the narrowly scoped helper prompts described in [Private Filesystem and VM Helper Approval](#private-filesystem-and-vm-helper-approval) if the host presents them. 3. Prewarm the selected template without GitHub registration: ```powershell - powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-native-controller.ps1 pool verify --config .local/docker-sandboxes.yml --project-root . --instances 1 --cleanup + .\start.ps1 pool verify --config .local\docker-sandboxes.yml --project-root . --instances 1 --cleanup ``` 4. Start the pool with `./start`. EPAR reuses the verified imported template without a registry check until the configured update schedule is due; local input changes and missing templates still rebuild immediately. @@ -139,7 +139,7 @@ The opt-in `TestLiveRunnerTemplateIsolation` proof also exercises authenticated Use the prewarm command above for an unregistered lifecycle check. To include GitHub registration, run: ```bash -ephemeral-action-runner pool verify --config .local/docker-sandboxes.yml --instances 1 --register-only --cleanup +./start pool verify --config .local/docker-sandboxes.yml --instances 1 --register-only --cleanup ``` The shared pool treats provisioning, ready, draining, quarantined, and cleanup-pending instances as capacity-consuming states. Cleanup uses durable exact sandbox, GitHub runner, and staging-directory identities; it never uses an `sbx` reset or broad prefix deletion. diff --git a/docs/providers/tart.md b/docs/providers/tart.md index b200f19..4a90599 100644 --- a/docs/providers/tart.md +++ b/docs/providers/tart.md @@ -53,7 +53,7 @@ Tart clones the reusable image, starts the VM headless, uses the guest agent for ## Verification ```bash -ephemeral-action-runner pool verify --instances 1 --cleanup +./start pool verify --instances 1 --cleanup ``` For the optional Rosetta experiment, use `configs/tart.web-e2e.example.yml` or set a distinct `provider.rosettaTag`. Verify a real container execution before routing amd64 workflows: diff --git a/docs/providers/wsl.md b/docs/providers/wsl.md index d067c85..572edd6 100644 --- a/docs/providers/wsl.md +++ b/docs/providers/wsl.md @@ -54,7 +54,7 @@ EPAR imports each runner from `provider.sourceImage`, enables systemd in the reu ## Verification ```powershell -ephemeral-action-runner pool verify --instances 1 --cleanup +.\start.ps1 pool verify --instances 1 --cleanup ``` For a lean rootfs source, export Ubuntu 24.04 once before building: diff --git a/docs/runner-groups.md b/docs/runner-groups.md index 83cc36a..2e9d114 100644 --- a/docs/runner-groups.md +++ b/docs/runner-groups.md @@ -8,7 +8,7 @@ GitHub decides which repositories can route jobs to a self-hosted runner through 2. Create a dedicated group for EPAR runners. 3. Choose **Selected repositories** and add only repositories whose workflows are trusted to run on the EPAR host. 4. Keep public repository access disabled. -5. Run `./start` or `ephemeral-action-runner init` and select that group when the wizard lists the organization’s live runner groups. +5. Run `./start` and select that group when the wizard lists the organization’s live runner groups. See GitHub’s [runner-group access documentation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access) for the organization and enterprise controls. diff --git a/docs/storage.md b/docs/storage.md index 987963f..c61f75c 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -15,12 +15,12 @@ storage: `storage status` reports capacity, exact ownership, references, live blockers, cleanup-pending work, and reclaimable estimates. `storage prune` is always a preview unless `--execute` is supplied. ```text -ephemeral-action-runner storage status -ephemeral-action-runner storage status --json -ephemeral-action-runner storage prune -ephemeral-action-runner storage prune --execute -ephemeral-action-runner storage prune --legacy -ephemeral-action-runner storage prune --legacy --execute --plan +./start storage status +./start storage status --json +./start storage prune +./start storage prune --execute +./start storage prune --legacy +./start storage prune --legacy --execute --plan ``` With `automaticHousekeeping: conservative`, EPAR reconciles interrupted work at startup and after successful artifact activation. It immediately retires an unreferenced, superseded resource only when its catalog receipt and live readback prove that EPAR created or introduced it. The grace period applies to abandoned or incomplete temporary work, not to a successfully replaced generation. A resource referenced by another configuration, lease, container, sandbox, distribution, or builder remains protected. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 17b3dc0..0cf3481 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -31,7 +31,7 @@ Long Buildx operations show a bounded console summary with downloaded bytes, com ```bash ./start --help -go run ./cmd/ephemeral-action-runner version +./start version docker version docker info docker system df @@ -202,7 +202,7 @@ Set `EPAR_DISABLE_DOCKER_SANDBOXES=1` before starting EPAR when Docker Sandboxes After reviewing retained Docker Sandboxes diagnostics, acknowledge that review only for the exact configured pool: ```bash -ephemeral-action-runner cleanup --acknowledge-failed-diagnostics +./start cleanup --acknowledge-failed-diagnostics ``` ## Docker image build runs out of space @@ -278,11 +278,11 @@ Use `[system]` on Linux. Overlay mode collects the current host roots, validates Use the normal host entry point so EPAR can inspect the real Windows certificate stores or macOS Keychain: ```powershell -./start -go run ./cmd/ephemeral-action-runner image build --replace +.\start.ps1 +.\start.ps1 image build --replace ``` -On no-Go Windows, use `scripts\run-with-docker.ps1 image build --replace`; on macOS/Linux, use `scripts/run-with-docker.sh image build --replace`. The wrapper uses a native-host trust feed while compiling the native controller; the resulting native controller reads host trust directly for `start`, `image build`, `pool up`, and `pool verify`, even when runner overlay is disabled. The legacy containerized controller still requires the separate native-host feed bridge. A bare Linux toolchain container is not a replacement for either path. +On no-Go Windows, use `start.cmd image build --replace` or `.\start.ps1 image build --replace`; on macOS/Linux, use `./start image build --replace`. The wrapper uses a native-host trust feed while compiling the native controller; the resulting native controller reads host trust directly for `start`, `image build`, `pool up`, and `pool verify`, even when runner overlay is disabled. Direct `scripts/run-with-docker.*` calls are wrapper-development diagnostics. The legacy containerized controller still requires the separate native-host feed bridge. A bare Linux toolchain container is not a replacement for either path. ## Windows Docker Desktop WSL2 disk is smaller than expected @@ -356,7 +356,7 @@ Then inspect runner-group policy and the first registration error. A strict poli For a confirmed stale EPAR resource, run the configured cleanup command: ```bash -go run ./cmd/ephemeral-action-runner cleanup +./start cleanup ``` Cleanup is bounded by the configured pool and durable exact lifecycle identities; it does not authorize a broad prefix deletion, wildcard, Docker prune, or removal of unknown/shared resources. Keep `pool.namePrefix` unique per controller and organization. diff --git a/docs/usage.md b/docs/usage.md index 95577a6..edee87d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -31,13 +31,13 @@ On native Windows PowerShell, run: .\start.ps1 ``` -The wrapper uses local Go when available, otherwise it uses Docker to build and cache a native controller under `.local/bin`. See [Running EPAR Without Installing Go](advanced/no-go-install.md) for the fallback details. The equivalent direct source command is: +The wrapper uses local Go when available, otherwise it uses Docker to build and cache a native controller under `.local/bin`. See [Running EPAR Without Installing Go](advanced/no-go-install.md) for the fallback details. Direct `go run` commands are development and diagnostic equivalents rather than the normal operator interface: ```bash go run ./cmd/ephemeral-action-runner start ``` -When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group. The runner-group list orders GitHub's Default group first, hides blocked groups and policy details initially, and lets you reveal either from the menu. The provider list shows every provider with its prerequisite status and refuses unavailable selections. Storage does not make a provider unavailable. Docker Container, Docker Sandboxes, and WSL share one Catthehacker image/profile and custom-script flow, followed by an informational physical-growth estimate and confirmation. The wizard writes the desired configuration first; direct `init` then exits, while embedded `./start` continues through the ordinary image/template provisioning and pool startup path. When `sbx` is installed, the wizard runs `sbx daemon start --detach` before Docker Sandboxes diagnostics so a stopped daemon does not require a manual retry. +When `.local/config.yml` is absent and the terminal is interactive, `./start` launches the same first-run wizard as `init`. It asks for the GitHub App and an explicit runner group. The runner-group list orders GitHub's Default group first, hides blocked groups and policy details initially, and lets you reveal either from the menu. The provider list shows every provider with its prerequisite status and refuses unavailable selections. Storage does not make a provider unavailable. Docker Container, Docker Sandboxes, and WSL share one Catthehacker image/profile and custom-script flow. Later option lists use `0` to go back, text prompts use `/back`, and a final provider-neutral review shows the applicable artifact estimate before one creation decision. Running `./start init` exits after writing, while an embedded first run continues through the ordinary image/template provisioning and pool startup path. When `sbx` is installed, the wizard runs `sbx daemon start --detach` before Docker Sandboxes diagnostics so a stopped daemon does not require a manual retry. See [Docker Sandboxes](providers/docker-sandboxes.md) for source profiles, capacity, local receipts, and platform validation status. @@ -46,7 +46,7 @@ See [Docker Sandboxes](providers/docker-sandboxes.md) for source profiles, capac Create configuration without starting runners: ```bash -go run ./cmd/ephemeral-action-runner init +./start init ``` Pass a config path and an instance count through the wrapper: @@ -55,13 +55,13 @@ Pass a config path and an instance count through the wrapper: ./start --config .local/ci.yml --instances 2 ``` -Equivalent direct command: +Development/diagnostic equivalent: ```bash go run ./cmd/ephemeral-action-runner start --config .local/ci.yml --instances 2 ``` -On Windows PowerShell, use backslash paths when that is clearer: +On Windows PowerShell, use backslash paths when that is clearer. The second command is the development/diagnostic equivalent: ```powershell .\start.ps1 --config .local\ci.yml --instances 2 @@ -95,13 +95,13 @@ Manual policy means this command triggers remote checks. `./start image build` r Verify one disposable runner without GitHub registration: ```bash -go run ./cmd/ephemeral-action-runner pool verify --instances 1 --cleanup +./start pool verify --instances 1 --cleanup ``` Verify registration and online/idle state: ```bash -go run ./cmd/ephemeral-action-runner pool verify --instances 2 --register-only --cleanup +./start pool verify --instances 2 --register-only --cleanup ``` `--cleanup` removes verification resources after the check. Docker Sandboxes uses its exact ownership records; legacy providers use the configured pool-name boundary. Use [Operations](operations.md) for the distinction and recovery guidance. @@ -111,9 +111,9 @@ go run ./cmd/ephemeral-action-runner pool verify --instances 2 --register-only - `start` is the normal command because it checks the reusable image or template first. `pool up` is for a pool you have deliberately prepared: ```bash -go run ./cmd/ephemeral-action-runner pool up --instances 2 -go run ./cmd/ephemeral-action-runner status -go run ./cmd/ephemeral-action-runner cleanup +./start pool up --instances 2 +./start status +./start cleanup ``` Use `status --no-github` or `cleanup --no-github` when you intentionally need to skip GitHub runner status or deletion. `pool down` is an alias for cleanup. @@ -121,7 +121,7 @@ Use `status --no-github` or `cleanup --no-github` when you intentionally need to For a command-construction preview on compatible providers, add `--dry-run`: ```bash -go run ./cmd/ephemeral-action-runner pool verify --dry-run --instances 1 +./start pool verify --dry-run --instances 1 ``` Docker Sandboxes intentionally does not support dry-run instance creation because EPAR must read back the exact active template-cache identity. Use its admission and template checks instead. diff --git a/internal/provider/factory.go b/internal/provider/factory.go index 96780b5..39c6cd7 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -20,6 +20,12 @@ type Descriptor struct { ImageMode string GuidedArtifacts bool WizardImageProfiles []WizardImageProfile + WizardPrerequisite WizardPrerequisiteKind + WizardOnboarding WizardOnboardingKind + WizardHostTrust WizardHostTrustKind + WizardReview WizardReviewKind + WizardReviewSource string + WizardReviewOutput string } type WizardImageProfile struct { @@ -33,6 +39,122 @@ const ( ImageModeTemplate = "sandbox-template" ) +type WizardPrerequisiteKind string + +const ( + WizardPrerequisiteDocker WizardPrerequisiteKind = "docker" + WizardPrerequisiteDockerSandboxes WizardPrerequisiteKind = "docker-sandboxes" + WizardPrerequisiteWSL2 WizardPrerequisiteKind = "wsl2" + WizardPrerequisiteTart WizardPrerequisiteKind = "tart" +) + +func (kind WizardPrerequisiteKind) Valid() bool { + switch kind { + case WizardPrerequisiteDocker, WizardPrerequisiteDockerSandboxes, WizardPrerequisiteWSL2, WizardPrerequisiteTart: + return true + default: + return false + } +} + +type WizardOnboardingKind string + +const ( + WizardOnboardingNone WizardOnboardingKind = "none" + WizardOnboardingCatthehackerDocker WizardOnboardingKind = "catthehacker-docker" +) + +func (kind WizardOnboardingKind) Valid() bool { + switch kind { + case WizardOnboardingNone, WizardOnboardingCatthehackerDocker: + return true + default: + return false + } +} + +type WizardHostTrustKind string + +const ( + WizardHostTrustNone WizardHostTrustKind = "none" + WizardHostTrustOverlay WizardHostTrustKind = "overlay" +) + +func (kind WizardHostTrustKind) Valid() bool { + switch kind { + case WizardHostTrustNone, WizardHostTrustOverlay: + return true + default: + return false + } +} + +type WizardReviewKind string + +const ( + WizardReviewDockerImage WizardReviewKind = "docker-image" + WizardReviewNativeImage WizardReviewKind = "native-image" +) + +func (kind WizardReviewKind) Valid() bool { + switch kind { + case WizardReviewDockerImage, WizardReviewNativeImage: + return true + default: + return false + } +} + +// ValidateWizardContributions rejects incomplete or incompatible onboarding +// metadata before a provider can enter the first-run wizard. +func ValidateWizardContributions(descriptor Descriptor) error { + if !descriptor.WizardSupported { + return nil + } + if descriptor.WizardNumber == "" || descriptor.WizardLabel == "" || len(descriptor.WizardAliases) == 0 { + return fmt.Errorf("wizard number, label, and aliases are required") + } + if !descriptor.WizardPrerequisite.Valid() { + return fmt.Errorf("unknown prerequisite strategy %q", descriptor.WizardPrerequisite) + } + if !descriptor.WizardOnboarding.Valid() { + return fmt.Errorf("unknown onboarding strategy %q", descriptor.WizardOnboarding) + } + if !descriptor.WizardHostTrust.Valid() { + return fmt.Errorf("unknown host-trust contribution %q", descriptor.WizardHostTrust) + } + if !descriptor.WizardReview.Valid() { + return fmt.Errorf("unknown review contribution %q", descriptor.WizardReview) + } + + switch descriptor.WizardOnboarding { + case WizardOnboardingCatthehackerDocker: + if descriptor.WizardReview != WizardReviewDockerImage { + return fmt.Errorf("Catthehacker onboarding requires the Docker-image review contribution") + } + if descriptor.ImageMode != ImageModeDocker && descriptor.ImageMode != ImageModeTemplate { + return fmt.Errorf("Catthehacker onboarding requires Docker or template image mode, got %q", descriptor.ImageMode) + } + if !descriptor.GuidedArtifacts || len(descriptor.WizardImageProfiles) == 0 { + return fmt.Errorf("Catthehacker onboarding requires guided artifact profiles") + } + case WizardOnboardingNone: + if descriptor.WizardReview != WizardReviewNativeImage { + return fmt.Errorf("providers without onboarding require the native-image review contribution") + } + if descriptor.ImageMode != ImageModeNative { + return fmt.Errorf("providers without onboarding require native image mode, got %q", descriptor.ImageMode) + } + if descriptor.GuidedArtifacts || len(descriptor.WizardImageProfiles) != 0 { + return fmt.Errorf("providers without onboarding cannot declare guided artifact profiles") + } + if descriptor.WizardReviewSource == "" || descriptor.WizardReviewOutput == "" { + return fmt.Errorf("native-image review requires source and output metadata") + } + } + return nil +} + func UnsupportedTypeError(providerType string) error { return fmt.Errorf("unsupported provider.type %q", providerType) } diff --git a/internal/provider/factory_test.go b/internal/provider/factory_test.go new file mode 100644 index 0000000..f5f821b --- /dev/null +++ b/internal/provider/factory_test.go @@ -0,0 +1,137 @@ +package provider + +import ( + "strings" + "testing" +) + +func TestWizardContributionKindsRejectMissingAndUnknownValues(t *testing.T) { + for name, valid := range map[string]bool{ + "missing prerequisite": WizardPrerequisiteKind("").Valid(), + "unknown prerequisite": WizardPrerequisiteKind("future").Valid(), + "missing onboarding": WizardOnboardingKind("").Valid(), + "unknown onboarding": WizardOnboardingKind("future").Valid(), + "missing host trust": WizardHostTrustKind("").Valid(), + "unknown host trust": WizardHostTrustKind("future").Valid(), + "missing review": WizardReviewKind("").Valid(), + "unknown review": WizardReviewKind("future").Valid(), + } { + if valid { + t.Errorf("%s contribution was accepted", name) + } + } +} + +func TestWizardContributionKindsAcceptRegisteredStrategies(t *testing.T) { + for name, valid := range map[string]bool{ + "Docker prerequisite": WizardPrerequisiteDocker.Valid(), + "Docker Sandboxes prerequisite": WizardPrerequisiteDockerSandboxes.Valid(), + "WSL2 prerequisite": WizardPrerequisiteWSL2.Valid(), + "Tart prerequisite": WizardPrerequisiteTart.Valid(), + "no onboarding": WizardOnboardingNone.Valid(), + "Catthehacker onboarding": WizardOnboardingCatthehackerDocker.Valid(), + "no host trust": WizardHostTrustNone.Valid(), + "overlay host trust": WizardHostTrustOverlay.Valid(), + "Docker image review": WizardReviewDockerImage.Valid(), + "native image review": WizardReviewNativeImage.Valid(), + } { + if !valid { + t.Errorf("%s contribution was rejected", name) + } + } +} + +func TestValidateWizardContributionsRejectsIncompatibleStrategies(t *testing.T) { + base := Descriptor{ + WizardSupported: true, + WizardNumber: "1", + WizardLabel: "Example", + WizardAliases: []string{"example"}, + ImageMode: ImageModeDocker, + GuidedArtifacts: true, + WizardImageProfiles: []WizardImageProfile{{Name: "full", Tag: "full-latest"}}, + WizardPrerequisite: WizardPrerequisiteDocker, + WizardOnboarding: WizardOnboardingCatthehackerDocker, + WizardHostTrust: WizardHostTrustOverlay, + WizardReview: WizardReviewDockerImage, + } + tests := map[string]Descriptor{ + "onboarding without compatible review": func() Descriptor { + value := base + value.WizardReview = WizardReviewNativeImage + return value + }(), + "review without compatible onboarding": func() Descriptor { + value := base + value.WizardOnboarding = WizardOnboardingNone + return value + }(), + "Docker onboarding without profiles": func() Descriptor { + value := base + value.WizardImageProfiles = nil + return value + }(), + } + for name, descriptor := range tests { + t.Run(name, func(t *testing.T) { + if err := ValidateWizardContributions(descriptor); err == nil { + t.Fatal("incompatible wizard contributions were accepted") + } + }) + } +} + +func TestValidateWizardContributionsAcceptsNativeProviderWithoutOnboarding(t *testing.T) { + descriptor := Descriptor{ + WizardSupported: true, + WizardNumber: "4", + WizardLabel: "Native", + WizardAliases: []string{"native"}, + ImageMode: ImageModeNative, + WizardPrerequisite: WizardPrerequisiteTart, + WizardOnboarding: WizardOnboardingNone, + WizardHostTrust: WizardHostTrustNone, + WizardReview: WizardReviewNativeImage, + WizardReviewSource: "example/source", + WizardReviewOutput: "example-output", + } + if err := ValidateWizardContributions(descriptor); err != nil { + t.Fatalf("compatible native provider was rejected: %v", err) + } +} + +func TestValidateWizardContributionsExplainsMissingAndUnknownContributions(t *testing.T) { + base := Descriptor{ + WizardSupported: true, + WizardNumber: "1", + WizardLabel: "Example", + WizardAliases: []string{"example"}, + ImageMode: ImageModeDocker, + GuidedArtifacts: true, + WizardImageProfiles: []WizardImageProfile{{Name: "full", Tag: "full-latest"}}, + WizardPrerequisite: WizardPrerequisiteDocker, + WizardOnboarding: WizardOnboardingCatthehackerDocker, + WizardHostTrust: WizardHostTrustOverlay, + WizardReview: WizardReviewDockerImage, + } + tests := []struct { + name string + want string + edit func(*Descriptor) + }{ + {name: "missing prerequisite", want: "prerequisite", edit: func(value *Descriptor) { value.WizardPrerequisite = "" }}, + {name: "unknown onboarding", want: "onboarding", edit: func(value *Descriptor) { value.WizardOnboarding = "future" }}, + {name: "missing host trust", want: "host-trust", edit: func(value *Descriptor) { value.WizardHostTrust = "" }}, + {name: "unknown review", want: "review", edit: func(value *Descriptor) { value.WizardReview = "future" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + descriptor := base + test.edit(&descriptor) + err := ValidateWizardContributions(descriptor) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validation error = %v, want a clear %s error", err, test.want) + } + }) + } +} diff --git a/internal/provider/registry/contributions_test.go b/internal/provider/registry/contributions_test.go index d44af06..8fe3674 100644 --- a/internal/provider/registry/contributions_test.go +++ b/internal/provider/registry/contributions_test.go @@ -22,6 +22,9 @@ func TestEveryProviderRegistersRequiredContributions(t *testing.T) { if descriptor.WizardNumber == "" || descriptor.WizardLabel == "" || len(descriptor.WizardAliases) == 0 { t.Errorf("%s has an incomplete ./start wizard contribution", descriptor.Type) } + if err := provider.ValidateWizardContributions(descriptor); err != nil { + t.Errorf("%s has incomplete wizard contributions: %v", descriptor.Type, err) + } if !descriptor.ConfigurationDecoder || !descriptor.ConfigurationDefaults || !descriptor.ConfigurationValidator { t.Errorf("%s has an incomplete configuration contribution", descriptor.Type) } @@ -36,9 +39,9 @@ func TestEveryProviderRegistersRequiredContributions(t *testing.T) { default: t.Errorf("%s has unsupported image mode %q", descriptor.Type, descriptor.ImageMode) } - if descriptor.ImageMode == provider.ImageModeTemplate { + if descriptor.WizardOnboarding == provider.WizardOnboardingCatthehackerDocker { if !descriptor.GuidedArtifacts || len(descriptor.WizardImageProfiles) == 0 { - t.Errorf("%s has no guided template provisioning contribution", descriptor.Type) + t.Errorf("%s has no guided Docker-image provisioning contribution", descriptor.Type) } if descriptor.WizardImageProfiles[0].Name != "full" || descriptor.WizardImageProfiles[0].Tag != "full-latest" { t.Errorf("%s does not register its default image profile first", descriptor.Type) diff --git a/internal/provider/registry/registry.go b/internal/provider/registry/registry.go index 3efa6b3..f6816bb 100644 --- a/internal/provider/registry/registry.go +++ b/internal/provider/registry/registry.go @@ -34,7 +34,7 @@ type entry struct { var entries = []entry{ { - descriptor: provider.Descriptor{Type: "docker-container", DisplayName: "Docker Container", WizardSupported: true, WizardNumber: "1", WizardLabel: "Docker Container — private daemon", WizardAliases: []string{"docker", "docker-container"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker, GuidedArtifacts: true, WizardImageProfiles: catthehackerProfiles()}, + descriptor: provider.Descriptor{Type: "docker-container", DisplayName: "Docker Container", WizardSupported: true, WizardNumber: "1", WizardLabel: "Docker Container — private daemon", WizardAliases: []string{"docker", "docker-container"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker, GuidedArtifacts: true, WizardImageProfiles: catthehackerProfiles(), WizardPrerequisite: provider.WizardPrerequisiteDocker, WizardOnboarding: provider.WizardOnboardingCatthehackerDocker, WizardHostTrust: provider.WizardHostTrustOverlay, WizardReview: provider.WizardReviewDockerImage}, factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { hostGateway := config.DockerConfigNeedsHostGateway(cfg.Docker) environment := map[string]string{ @@ -61,6 +61,10 @@ var entries = []entry{ ImageMode: provider.ImageModeTemplate, GuidedArtifacts: true, WizardImageProfiles: catthehackerProfiles(), + WizardPrerequisite: provider.WizardPrerequisiteDockerSandboxes, + WizardOnboarding: provider.WizardOnboardingCatthehackerDocker, + WizardHostTrust: provider.WizardHostTrustOverlay, + WizardReview: provider.WizardReviewDockerImage, }, factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { sandboxes := dockersandboxes.NewWithDryRun("", dryRun) @@ -68,14 +72,14 @@ var entries = []entry{ }, }, { - descriptor: provider.Descriptor{Type: "wsl", DisplayName: "WSL2", WizardSupported: true, WizardNumber: "3", WizardLabel: "WSL2", WizardAliases: []string{"wsl", "wsl2"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker, GuidedArtifacts: true, WizardImageProfiles: catthehackerProfiles()}, + descriptor: provider.Descriptor{Type: "wsl", DisplayName: "WSL2", WizardSupported: true, WizardNumber: "3", WizardLabel: "WSL2", WizardAliases: []string{"wsl", "wsl2"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeDocker, GuidedArtifacts: true, WizardImageProfiles: catthehackerProfiles(), WizardPrerequisite: provider.WizardPrerequisiteWSL2, WizardOnboarding: provider.WizardOnboardingCatthehackerDocker, WizardHostTrust: provider.WizardHostTrustNone, WizardReview: provider.WizardReviewDockerImage}, factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { installRoot := config.ProjectPath(projectRoot, cfg.Provider.InstallRoot) return adaptLegacy(wsl.New("", installRoot, projectRoot, dryRun), providerStorage(cfg, projectRoot), dryRun) }, }, { - descriptor: provider.Descriptor{Type: "tart", DisplayName: "Tart (experimental)", WizardSupported: true, WizardNumber: "4", WizardLabel: "Tart (experimental)", WizardAliases: []string{"tart"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeNative}, + descriptor: provider.Descriptor{Type: "tart", DisplayName: "Tart (experimental)", WizardSupported: true, WizardNumber: "4", WizardLabel: "Tart (experimental)", WizardAliases: []string{"tart"}, ConfigurationDecoder: true, ConfigurationDefaults: true, ConfigurationValidator: true, LifecycleSupported: true, StorageSupported: true, ImageMode: provider.ImageModeNative, WizardPrerequisite: provider.WizardPrerequisiteTart, WizardOnboarding: provider.WizardOnboardingNone, WizardHostTrust: provider.WizardHostTrustNone, WizardReview: provider.WizardReviewNativeImage, WizardReviewSource: "ghcr.io/cirruslabs/ubuntu:latest", WizardReviewOutput: "epar-ubuntu-24-arm64"}, factory: func(cfg config.Config, projectRoot string, dryRun bool) Runtime { return adaptLegacy(tart.New("", dryRun), providerStorage(cfg, projectRoot), dryRun) }, @@ -135,11 +139,11 @@ func New(cfg config.Config, projectRoot string, dryRun bool) (Runtime, error) { return Runtime{}, provider.UnsupportedTypeError(cfg.Provider.Type) } descriptor := registered.descriptor - if !descriptor.WizardSupported || descriptor.WizardNumber == "" || descriptor.WizardLabel == "" || len(descriptor.WizardAliases) == 0 || !descriptor.ConfigurationDecoder || !descriptor.ConfigurationDefaults || !descriptor.ConfigurationValidator || !descriptor.LifecycleSupported || !descriptor.StorageSupported || descriptor.ImageMode == "" { + if !descriptor.ConfigurationDecoder || !descriptor.ConfigurationDefaults || !descriptor.ConfigurationValidator || !descriptor.LifecycleSupported || !descriptor.StorageSupported || descriptor.ImageMode == "" { return Runtime{}, fmt.Errorf("provider %q has an incomplete registry entry", cfg.Provider.Type) } - if (descriptor.ImageMode == provider.ImageModeTemplate || descriptor.Type == "docker-container" || descriptor.Type == "wsl") && (!descriptor.GuidedArtifacts || len(descriptor.WizardImageProfiles) == 0) { - return Runtime{}, fmt.Errorf("Docker-image-capable provider %q has no guided artifact onboarding contribution", cfg.Provider.Type) + if err := provider.ValidateWizardContributions(descriptor); err != nil { + return Runtime{}, fmt.Errorf("provider %q has incomplete wizard contributions: %w", cfg.Provider.Type, err) } runtime := registered.factory(cfg, projectRoot, dryRun) if runtime.Lifecycle == nil || runtime.Storage == nil { diff --git a/scripts/test/start-command-forwarding.ps1 b/scripts/test/start-command-forwarding.ps1 new file mode 100644 index 0000000..e5e998a --- /dev/null +++ b/scripts/test/start-command-forwarding.ps1 @@ -0,0 +1,63 @@ +param( + [Parameter(Mandatory = $true)] + [string] $ProjectRoot +) + +$ErrorActionPreference = 'Stop' +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("epar-start-forwarding-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $testRoot | Out-Null + +try { + $argumentLog = Join-Path $testRoot 'arguments.txt' + $fakeGo = Join-Path $testRoot 'go.ps1' + @' +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $Forwarded +) +if ($Forwarded.Count -eq 1 -and $Forwarded[0] -eq 'version') { + exit 0 +} +[System.IO.File]::WriteAllLines($env:EPAR_START_FORWARD_LOG, $Forwarded) +'@ | Set-Content -LiteralPath $fakeGo -Encoding utf8NoBOM + + $env:EPAR_GO_BIN = $fakeGo + $env:EPAR_USE_DOCKER_RUN = '0' + $env:EPAR_START_FORWARD_LOG = $argumentLog + + function Assert-ForwardedArguments { + param([string[]] $Expected) + $actual = [string[]] (Get-Content -LiteralPath $argumentLog) + if ($actual.Count -ne $Expected.Count) { + throw "forwarded argument count=$($actual.Count), want $($Expected.Count): actual=[$($actual -join ', ')]" + } + for ($index = 0; $index -lt $Expected.Count; $index++) { + if ($actual[$index] -cne $Expected[$index]) { + throw "forwarded argument $index='$($actual[$index])', want '$($Expected[$index])'" + } + } + } + + $startPowerShell = Join-Path $ProjectRoot 'start.ps1' + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startPowerShell + if ($LASTEXITCODE -ne 0) { throw "default start.ps1 forwarding exited $LASTEXITCODE" } + Assert-ForwardedArguments @('run', './cmd/ephemeral-action-runner', 'start') + + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startPowerShell --config '.local\config with spaces.yml' --label 'value "with quotes"' + if ($LASTEXITCODE -ne 0) { throw "start.ps1 forwarding exited $LASTEXITCODE" } + Assert-ForwardedArguments @('run', './cmd/ephemeral-action-runner', 'start', '--config', '.local\config with spaces.yml', '--label', 'value "with quotes"') + + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startPowerShell storage status --config '.local\config with spaces.yml' + if ($LASTEXITCODE -ne 0) { throw "start.ps1 explicit-command forwarding exited $LASTEXITCODE" } + Assert-ForwardedArguments @('run', './cmd/ephemeral-action-runner', 'storage', 'status', '--config', '.local\config with spaces.yml') + + $startCommand = Join-Path $ProjectRoot 'start.cmd' + $commandLine = '""{0}" status --config "{1}""' -f $startCommand, '.local\config with spaces.yml' + & $env:ComSpec /d /s /c $commandLine + if ($LASTEXITCODE -ne 0) { throw "start.cmd forwarding exited $LASTEXITCODE" } + Assert-ForwardedArguments @('run', './cmd/ephemeral-action-runner', 'status', '--config', '.local\config with spaces.yml') + + Write-Output 'Windows start.ps1 and start.cmd command-forwarding smoke passed' +} finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/test/start-command-forwarding.sh b/scripts/test/start-command-forwarding.sh index e620d0b..0e5cad8 100644 --- a/scripts/test/start-command-forwarding.sh +++ b/scripts/test/start-command-forwarding.sh @@ -30,44 +30,34 @@ export EPAR_START_FORWARD_LOG="$test_root/arguments" export EPAR_CONFIG="$test_root/config.yml" printf 'image:\n hostTrustMode: disabled\n' >"$EPAR_CONFIG" +assert_forwarded() { + local description="$1" + shift + local expected_file="$test_root/expected-arguments" + printf '%s\n' "$@" >"$expected_file" + if ! cmp -s "$expected_file" "$EPAR_START_FORWARD_LOG"; then + echo "$description forwarding mismatch:" >&2 + diff -u "$expected_file" "$EPAR_START_FORWARD_LOG" >&2 || true + exit 1 + fi +} + "$repo_root/start" -actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" -expected="run ./cmd/ephemeral-action-runner start " -if [[ "$actual" != "$expected" ]]; then - echo "default start forwarding mismatch: got '$actual', want '$expected'" >&2 - exit 1 -fi +assert_forwarded "default start" run ./cmd/ephemeral-action-runner start "$repo_root/start" --config .local/custom-config.yml --instances 2 -actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" -expected="run ./cmd/ephemeral-action-runner start --config .local/custom-config.yml --instances 2 " -if [[ "$actual" != "$expected" ]]; then - echo "start flag forwarding mismatch: got '$actual', want '$expected'" >&2 - exit 1 -fi +assert_forwarded "start flag" run ./cmd/ephemeral-action-runner start --config .local/custom-config.yml --instances 2 + +"$repo_root/start" --config ".local/config with spaces.yml" --label 'value "with quotes"' +assert_forwarded "quoted start" run ./cmd/ephemeral-action-runner start --config ".local/config with spaces.yml" --label 'value "with quotes"' "$repo_root/start" storage prune --provider docker-sandboxes -actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" -expected="run ./cmd/ephemeral-action-runner storage prune --provider docker-sandboxes " -if [[ "$actual" != "$expected" ]]; then - echo "explicit command forwarding mismatch: got '$actual', want '$expected'" >&2 - exit 1 -fi +assert_forwarded "explicit command" run ./cmd/ephemeral-action-runner storage prune --provider docker-sandboxes "$repo_root/start" image update --config .local/custom-config.yml -actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" -expected="run ./cmd/ephemeral-action-runner image update --config .local/custom-config.yml " -if [[ "$actual" != "$expected" ]]; then - echo "image update forwarding mismatch: got '$actual', want '$expected'" >&2 - exit 1 -fi +assert_forwarded "image update" run ./cmd/ephemeral-action-runner image update --config .local/custom-config.yml "$repo_root/start" version -actual="$(tr '\n' ' ' <"$EPAR_START_FORWARD_LOG")" -expected="run ./cmd/ephemeral-action-runner version " -if [[ "$actual" != "$expected" ]]; then - echo "version forwarding mismatch: got '$actual', want '$expected'" >&2 - exit 1 -fi +assert_forwarded "version" run ./cmd/ephemeral-action-runner version printf 'start command-forwarding smoke passed\n'