diff --git a/README.md b/README.md index d06eef6c..fe49b469 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ spec: ``` It derives the application container, Traefik routing and TLS, release layout -under `/var/lib/ob/shop`, and retention policy. `ob canonical` prints every +under `/var/lib/onebox/app`, and retention policy. `ob canonical` prints every derived value with its source: `# default`, `# shorthand`, or `# override`. ### 3. Plan, approve, deploy diff --git a/api/application/v1alpha1/application.schema.json b/api/application/v1alpha1/application.schema.json index 14270a02..8581545d 100644 --- a/api/application/v1alpha1/application.schema.json +++ b/api/application/v1alpha1/application.schema.json @@ -26,23 +26,17 @@ "type": "object" }, "name": { - "description": "The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin \"ob-\" or be a name the host layout reserves. Stable application name used in generated runtime identities.", + "description": "The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin \"onebox-\" or be a name the host layout reserves. Stable application name used in generated runtime identities.", "examples": [ "shop" ], "not": { "anyOf": [ { - "pattern": "^ob-" + "pattern": "^onebox-" }, { - "const": "ob" - }, - { - "const": "onebox-proxy" - }, - { - "const": "_host" + "const": "onebox" } ] }, @@ -221,7 +215,7 @@ "type": "object" }, "basePath": { - "default": "/var/lib/ob", + "default": "/var/lib/onebox", "description": "Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter.", "examples": [ "/srv/ob" @@ -926,7 +920,7 @@ "type": "boolean" }, "network": { - "default": "ob-ingress", + "default": "onebox-ingress", "description": "External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved.", "type": "string" } @@ -1281,6 +1275,24 @@ "description": "Also accepts the version to run." }, "description": "Supporting services managed outside application releases, such as databases and caches.", + "propertyNames": { + "not": { + "anyOf": [ + { + "const": "proxy" + }, + { + "const": "discovery" + }, + { + "const": "ingress" + }, + { + "const": "services" + } + ] + } + }, "type": "object" }, "workloads": { @@ -2107,6 +2119,7 @@ "examples": [ 2 ], + "maximum": 100, "minimum": 1, "type": "integer" }, diff --git a/cmd/ob/commands.go b/cmd/ob/commands.go index c4bc24dd..8bd8b16a 100644 --- a/cmd/ob/commands.go +++ b/cmd/ob/commands.go @@ -670,7 +670,7 @@ func connect(cmd *cobra.Command, g *globalFlags, cfg *app.Resolved, p *ctypes.Pr // Without this the engine derives every path from the project default // rather than the selected environment, so `ob status --env staging` // against an environment with its own base_path reported on - // /var/lib/ob/ while staging lives in /srv/staging/. It also + // /var/lib/onebox/app while staging lives in /srv/staging/app. It also // leaves Environment empty on the host-ownership check, which now // compares it. Environment: g.Env, diff --git a/cmd/ob/main.go b/cmd/ob/main.go index d1bea48d..c65b766f 100644 --- a/cmd/ob/main.go +++ b/cmd/ob/main.go @@ -3,6 +3,8 @@ package main import ( "context" "errors" + "fmt" + "io" "os" "os/signal" "syscall" @@ -105,6 +107,7 @@ func main() { ui.RestoreCursor(os.Stderr) stopSignals() }() + applyTestHostStateOverride(os.Stderr) if err := newRootCmd().ExecuteContext(ctx); err != nil { // the one line every failure ends on — red where the terminal allows ui.New(os.Stderr, false).Failf("ob: %v", err) @@ -121,3 +124,22 @@ func main() { os.Exit(2) } } + +// applyTestHostStateOverride honours the test-only host state override, and +// says so whenever it is in the environment. With it, one host can hold more +// than one owner record, so it must never be in effect silently — least of +// all by leaking into an operator's shell or a deploy job from a test job. +func applyTestHostStateOverride(w io.Writer) (restore func()) { + restore = func() {} + value, set := os.LookupEnv(app.TestHostStateDirEnv) + if !set { + return restore + } + undo, err := app.SetTestHostStateDir(value) + if err != nil { + fmt.Fprintf(w, "warning: %s is ignored: %v\n", app.TestHostStateDirEnv, err) + return restore + } + fmt.Fprintf(w, "warning: %s=%s moves host state for a test suite; unset it on real hosts\n", app.TestHostStateDirEnv, value) + return undo +} diff --git a/cmd/ob/main_test.go b/cmd/ob/main_test.go index 14cd4cbe..6960bd1e 100644 --- a/cmd/ob/main_test.go +++ b/cmd/ob/main_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/labstack/onebox/internal/app" ) const mainTestProject = `apiVersion: onebox.run/v1alpha1 @@ -134,3 +136,21 @@ func TestExplicitProjectPathDoesNotFallback(t *testing.T) { t.Fatalf("explicit missing ob.yml must not fall back to ob.yaml: %v", err) } } + +func TestTestHostStateOverrideIsNeverSilent(t *testing.T) { + var out bytes.Buffer + t.Setenv(app.TestHostStateDirEnv, "/tmp/fixture-host") + t.Cleanup(applyTestHostStateOverride(&out)) + if !strings.Contains(out.String(), "unset it on real hosts") { + t.Fatalf("override warning = %q", out.String()) + } + if got := (app.Names{}).HostDir(); got != "/tmp/fixture-host" { + t.Fatalf("override not applied: %s", got) + } + out.Reset() + t.Setenv(app.TestHostStateDirEnv, "relative") + applyTestHostStateOverride(&out) + if !strings.Contains(out.String(), "is ignored") { + t.Fatalf("relative override warning = %q", out.String()) + } +} diff --git a/cmd/ob/ops_contract_test.go b/cmd/ob/ops_contract_test.go index ffe1e564..5ae95689 100644 --- a/cmd/ob/ops_contract_test.go +++ b/cmd/ob/ops_contract_test.go @@ -190,10 +190,10 @@ spec: } fake := &transport.Fake{HostName: "example.invalid", Dynamic: func(command string) (transport.Result, bool) { switch { - case strings.HasPrefix(command, ": ob-epoch-probe;"): + case strings.HasPrefix(command, ": onebox-epoch-probe;"): return transport.Result{ExitCode: app.ProbeAbsent}, true case strings.Contains(command, "/_host/owner"): - return transport.Result{Stdout: "shop\n"}, true + return transport.Result{Stdout: "shop production\n"}, true case strings.Contains(command, " logs "): return transport.Result{Stdout: "log-secret\n", Stderr: "log-warning\n"}, true case strings.Contains(command, "docker ps"): diff --git a/cmd/ob/output.go b/cmd/ob/output.go index bb9f88f0..f063f7c9 100644 --- a/cmd/ob/output.go +++ b/cmd/ob/output.go @@ -667,7 +667,7 @@ func stageArtifact(final, suffix string, write func(string) error) (stagedArtifa if err := os.MkdirAll(filepath.Dir(final), 0o755); err != nil { return stagedArtifact{}, err } - reserved, err := os.CreateTemp(filepath.Dir(final), filepath.Base(final)+".ob-tmp"+suffix+"-") + reserved, err := os.CreateTemp(filepath.Dir(final), filepath.Base(final)+".onebox-tmp"+suffix+"-") if err != nil { return stagedArtifact{}, err } @@ -682,7 +682,7 @@ func stageArtifact(final, suffix string, write func(string) error) (stagedArtifa // the other kill window, between the two renames inside commit(), and // there it is the caller's only remaining copy: deleting it would destroy // the data this machinery exists to protect. - backup := final + ".ob-bak" + suffix + backup := final + ".onebox-bak" + suffix orphan := false switch { case fileExists(final): @@ -802,7 +802,7 @@ func commitArtifactSet(artifacts ...stagedArtifact) error { // the STAGED name durable — the renames above are further directory // changes with nothing behind them. Without this, a command can report // success and a power loss can leave the destination absent while the - // .ob-tmp name survives. + // .onebox-tmp name survives. // // A sync failure rolls the set back like any other: returning it with the // renames standing would report failure with a fresh, complete, approvable diff --git a/cmd/ob/preview_test.go b/cmd/ob/preview_test.go index 832758e6..545c91d6 100644 --- a/cmd/ob/preview_test.go +++ b/cmd/ob/preview_test.go @@ -33,7 +33,7 @@ func TestPreviewRendersAndRedacts(t *testing.T) { if err != nil { t.Fatalf("preview failed: %v\n%s", err, out) } - for _, want := range []string{"# digest ", "name: demo", "nginx:1.27", "ob.app: demo"} { + for _, want := range []string{"# digest ", "name: demo", "nginx:1.27", "onebox.app: demo"} { if !strings.Contains(out, want) { t.Errorf("missing %q\n%s", want, out) } diff --git a/cmd/ob/staged_artifact_test.go b/cmd/ob/staged_artifact_test.go index 5289bfc3..b89cd382 100644 --- a/cmd/ob/staged_artifact_test.go +++ b/cmd/ob/staged_artifact_test.go @@ -242,12 +242,12 @@ func TestSameArtifactPathSeesThroughEquivalentSpellings(t *testing.T) { // it replaced — the opposite of the guarantee. func TestDiscardKeepsTheBackupWhenTheRestoreFailed(t *testing.T) { dir := t.TempDir() - backup := filepath.Join(dir, "plan.json.ob-bak.plan") + backup := filepath.Join(dir, "plan.json.onebox-bak.plan") if err := os.WriteFile(backup, []byte("previous plan"), 0o600); err != nil { t.Fatal(err) } artifact := stagedArtifact{ - staged: filepath.Join(dir, "plan.json.ob-tmp.plan"), + staged: filepath.Join(dir, "plan.json.onebox-tmp.plan"), // A destination whose directory does not exist, so restore's rename // cannot succeed. final: filepath.Join(dir, "gone", "plan.json"), @@ -271,12 +271,12 @@ func TestDiscardKeepsTheBackupWhenTheRestoreFailed(t *testing.T) { func TestDiscardRemovesTheBackupWhenTheRestoreSucceeded(t *testing.T) { dir := t.TempDir() final := filepath.Join(dir, "plan.json") - backup := filepath.Join(dir, "plan.json.ob-bak.plan") + backup := filepath.Join(dir, "plan.json.onebox-bak.plan") if err := os.WriteFile(backup, []byte("previous plan"), 0o600); err != nil { t.Fatal(err) } artifact := stagedArtifact{ - staged: filepath.Join(dir, "plan.json.ob-tmp.plan"), + staged: filepath.Join(dir, "plan.json.onebox-tmp.plan"), final: final, backup: backup, replaced: true, @@ -322,7 +322,7 @@ func TestStageArtifactClearsARedundantBackup(t *testing.T) { if err := os.WriteFile(plan, []byte("current plan"), 0o600); err != nil { t.Fatal(err) } - stale := plan + ".ob-bak.plan" + stale := plan + ".onebox-bak.plan" if err := os.WriteFile(stale, []byte("previous run's plan"), 0o600); err != nil { t.Fatal(err) } @@ -340,7 +340,7 @@ func TestStageArtifactClearsARedundantBackup(t *testing.T) { func TestStageArtifactKeepsABackupThatIsTheOnlyCopy(t *testing.T) { dir := t.TempDir() plan := filepath.Join(dir, "plan.json") - orphan := plan + ".ob-bak.plan" + orphan := plan + ".onebox-bak.plan" if err := os.WriteFile(orphan, []byte("the only copy"), 0o600); err != nil { t.Fatal(err) } @@ -363,7 +363,7 @@ func TestAFailedRunLeavesAnOrphanBackupIntact(t *testing.T) { dir := t.TempDir() plan := filepath.Join(dir, "plan.json") report := filepath.Join(dir, "report.json") - orphan := plan + ".ob-bak.plan" + orphan := plan + ".onebox-bak.plan" if err := os.WriteFile(orphan, []byte("the only copy"), 0o600); err != nil { t.Fatal(err) } @@ -399,7 +399,7 @@ func TestAFailedRunLeavesAnOrphanBackupIntact(t *testing.T) { func TestASuccessfulRunClearsAnOrphanBackup(t *testing.T) { dir := t.TempDir() plan := filepath.Join(dir, "plan.json") - orphan := plan + ".ob-bak.plan" + orphan := plan + ".onebox-bak.plan" if err := os.WriteFile(orphan, []byte("previous copy"), 0o600); err != nil { t.Fatal(err) } diff --git a/cmd/onebox-discovery/main.go b/cmd/onebox-discovery/main.go index d32eb77d..3a00c035 100644 --- a/cmd/onebox-discovery/main.go +++ b/cmd/onebox-discovery/main.go @@ -17,7 +17,7 @@ func main() { var socket, output, network, application string flag.StringVar(&socket, "socket", "/var/run/docker.sock", "Docker Engine Unix socket") flag.StringVar(&output, "output", "/dynamic/onebox.yml", "atomic Traefik dynamic configuration output") - flag.StringVar(&network, "network", "ob-ingress", "Docker network carrying routed backends") + flag.StringVar(&network, "network", "onebox-ingress", "Docker network carrying routed backends") flag.StringVar(&application, "app", "", "Onebox Compose project to observe") flag.Parse() if application == "" { diff --git a/docs/product.md b/docs/product.md index bcae0456..442dfc48 100644 --- a/docs/product.md +++ b/docs/product.md @@ -41,10 +41,11 @@ CVE response, and an upgrade path on a host Onebox otherwise does not manage. An operator who wants a pinned installer run inside the lock, fence and journal boundary declares it as a bootstrap hook. -Owned application containers have one visible grammar: -`--`, with a one-based replica ordinal that is never -omitted. The managed host proxy is `onebox-proxy`. These names are generated -identity, not user configuration. +Workload containers are `--`, with a one-based replica +ordinal that is never omitted. Containers Onebox runs from its own images are +`onebox-` with no ordinal, because none of them has replicas: the host +proxy `onebox-proxy`, and managed services such as `onebox-postgres`. These names +are generated identity, not user configuration. The broader managed-operations goal is direction, not an inventory. Owned today: host bootstrap, the host prerequisite check, the proxy and its TLS, the host diff --git a/e2e/apps/one-app-one-host.sh b/e2e/apps/one-app-one-host.sh index 60cab264..fcbbe658 100755 --- a/e2e/apps/one-app-one-host.sh +++ b/e2e/apps/one-app-one-host.sh @@ -3,7 +3,7 @@ set -uo pipefail export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" APP="$1"; PORT="$2"; PATHQ="$3"; WL="${4:-}" -NAME="ob-e2e-$APP" +NAME="onebox-e2e-$APP" S=${ONEBOX_E2E_SCRATCH:-/tmp} REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -94,7 +94,7 @@ fi # Verify from the host, not inside the container: depending on whichever of # curl or wget an image happens to ship is not a property of the deploy. -ip=$(ssh -o BatchMode=yes "root@$IP" "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' \$(docker ps -q --filter label=ob.app=$APP --filter label=ob.workload=$WL | head -1) 2>/dev/null | awk '{print \$1}'" 2>/dev/null | tr -d '\r') +ip=$(ssh -o BatchMode=yes "root@$IP" "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' \$(docker ps -q --filter label=onebox.app=$APP --filter label=onebox.workload=$WL | head -1) 2>/dev/null | awk '{print \$1}'" 2>/dev/null | tr -d '\r') code="" for attempt in 1 2 3 4 5 6 7 8 9 10; do code=$(ssh -o BatchMode=yes "root@$IP" "curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://$ip:$PORT$PATHQ" 2>/dev/null | tr -d '\r') @@ -102,9 +102,9 @@ for attempt in 1 2 3 4 5 6 7 8 9 10; do sleep 6 done -running=$(ssh -o BatchMode=yes "root@$IP" "docker ps -q --filter label=ob.app=$APP | wc -l" 2>/dev/null | tr -d ' ') -vols=$(ssh -o BatchMode=yes "root@$IP" "docker volume ls --format '{{.Name}}' | grep -c '^ob_' || true" 2>/dev/null | tr -d ' ') -cur=$(ssh -o BatchMode=yes "root@$IP" "readlink /var/lib/ob/$APP/current" 2>/dev/null) +running=$(ssh -o BatchMode=yes "root@$IP" "docker ps -q --filter label=onebox.app=$APP | wc -l" 2>/dev/null | tr -d ' ') +vols=$(ssh -o BatchMode=yes "root@$IP" "docker volume ls --format '{{.Name}}' | grep -c '^onebox_' || true" 2>/dev/null | tr -d ' ') +cur=$(ssh -o BatchMode=yes "root@$IP" "readlink /var/lib/onebox/app/current" 2>/dev/null) healthy=$(echo "$out" | grep '^healthy' | sed 's/^healthy *//') echo " ${elapsed}s http=$code containers=$running volumes=$vols healthy=[${healthy:-none declared}]" diff --git a/e2e/destroy_test.go b/e2e/destroy_test.go index b1842936..3379639f 100644 --- a/e2e/destroy_test.go +++ b/e2e/destroy_test.go @@ -51,6 +51,10 @@ spec: if err := os.MkdirAll(releaseDir, 0o700); err != nil { t.Fatal(err) } + // What bootstrap writes; destroy deletes nothing without it. + if err := os.WriteFile(resolved.NamesFor("production").AppMarker(), []byte(application+"\n"), 0o600); err != nil { + t.Fatal(err) + } composePath := filepath.Join(releaseDir, "compose.yaml") envPath := filepath.Join(releaseDir, "legacy.env") composeBody := fmt.Sprintf(`services: @@ -68,7 +72,7 @@ volumes: for path, body := range map[string]string{ composePath: composeBody, envPath: "LEGACY_SECRET=recorded-value\n", - filepath.Join(releaseDir, "ob.snapshot.yml"): snapshotBody, + filepath.Join(releaseDir, "onebox.snapshot.yml"): snapshotBody, } { if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 07a9a675..c6066f2a 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -29,7 +29,7 @@ func TestApplicationFixturesLoad(t *testing.T) { for _, path := range []string{ "testdata/app/ob.yml", "testdata/worker/ob.yml", - "testdata/worker/ob-broken.yml", + "testdata/worker/broken.yml", } { t.Run(path, func(t *testing.T) { // Loading validates: there is no separate step that can be @@ -42,14 +42,7 @@ func TestApplicationFixturesLoad(t *testing.T) { } func TestZeroDowntimeDeploy(t *testing.T) { - if os.Getenv("ONEBOX_E2E") != "1" { - t.Skip("set ONEBOX_E2E=1 (requires local docker)") - } - // Opting in is a promise that Docker is here. Skipping past a broken daemon - // once ONEBOX_E2E=1 is set turns a gate into a green tick for work nobody did. - if err := exec.CommandContext(t.Context(), "docker", "info").Run(); err != nil { - t.Fatalf("ONEBOX_E2E=1 was set but docker is not usable: %v", err) - } + gate(t) dir, err := filepath.Abs("testdata/app") if err != nil { t.Fatal(err) diff --git a/e2e/network_ownership_test.go b/e2e/network_ownership_test.go index 109b479c..efd6eb74 100644 --- a/e2e/network_ownership_test.go +++ b/e2e/network_ownership_test.go @@ -60,7 +60,7 @@ spec: if err := e.EnsureApplicationNetwork(ctx); err != nil { t.Fatalf("create owned application network: %v", err) } - owner, err := exec.CommandContext(ctx, "docker", "network", "inspect", "-f", `{{index .Labels "ob.app"}}`, network).Output() + owner, err := exec.CommandContext(ctx, "docker", "network", "inspect", "-f", `{{index .Labels "onebox.app"}}`, network).Output() if err != nil || strings.TrimSpace(string(owner)) != application { t.Fatalf("new network owner = %q, %v", owner, err) } @@ -87,11 +87,11 @@ spec: }) up := append(append([]string{}, legacyArgs...), "up", "-d") if out, err := exec.CommandContext(ctx, "docker", up...).CombinedOutput(); err != nil { - t.Fatalf("start legacy proxy: %v\n%s", err, out) + t.Fatalf("start the Compose proxy: %v\n%s", err, out) } if err := e.EnsureApplicationNetwork(ctx); err != nil { - t.Fatalf("migrate legacy Compose network: %v", err) + t.Fatalf("adopt the application.s Compose network: %v", err) } runtimePath := filepath.Join(dir, "runtime.yaml") diff --git a/e2e/ops_test.go b/e2e/ops_test.go index fc4c62f4..a8d48277 100644 --- a/e2e/ops_test.go +++ b/e2e/ops_test.go @@ -35,6 +35,13 @@ func gate(t *testing.T) { if err := exec.CommandContext(t.Context(), "docker", "info").Run(); err != nil { t.Fatalf("ONEBOX_E2E=1 was set but docker is not usable: %v", err) } + // Host state is fixed under /var/lib/onebox, which this suite neither can + // nor should write; each test gets its own, as each gets its own basePath. + restore, err := app.SetTestHostStateDir(filepath.Join(t.TempDir(), "host")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(restore) } // buildDeploy loads config+compose fresh (env-sensitive) and returns an @@ -43,7 +50,7 @@ func gate(t *testing.T) { // so it has to be the project as this test actually configured it — the project // file plus the base path the fixture overrides in Go. Staging a placeholder // meant recovery refused every interrupted release as unparseable, and staging -// the file alone left it pointing at the default /var/lib/ob. +// the file alone left it pointing at the default /var/lib/onebox. func releaseSnapshot(t *testing.T, dir, cfgFile, base string) []byte { t.Helper() body, err := os.ReadFile(filepath.Join(dir, cfgFile)) @@ -228,7 +235,7 @@ func TestBrokenWorkerHaltsDeployOldKeepsServing(t *testing.T) { } waitBody(t, "http://localhost:18081/", "v1\n", 30*time.Second) - e2, id2, staging2 := buildDeploy(t, dir, "ob-broken.yml", "v2", base) + e2, id2, staging2 := buildDeploy(t, dir, "broken.yml", "v2", base) err := e2.Deploy(context.Background(), id2, staging2) if err == nil || !strings.Contains(err.Error(), "worker") { t.Fatalf("broken worker must halt the release: %v", err) diff --git a/e2e/server_execution_test.go b/e2e/server_execution_test.go index 587c8e29..cf5f6893 100644 --- a/e2e/server_execution_test.go +++ b/e2e/server_execution_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "github.com/labstack/onebox/internal/app" ) // Exercise the public CLI through SSH, generated systemd units, real Docker, @@ -18,8 +20,11 @@ func TestServerDurableExecutions(t *testing.T) { s.requireDocker(t) name := fmt.Sprintf("durable%d", time.Now().UnixNano()) base := "/tmp/onebox-" + name - root := base + "/" + name - unit := "ob-" + name + "-refresh" + root := base + "/app" + // This fixture shares the server with the suite's own application, so it + // needs host state of its own; one host holds one owner record otherwise. + t.Setenv(app.TestHostStateDirEnv, base+"/_host") + unit := "onebox-job-refresh" dir := t.TempDir() s.run(t, "mkdir -p "+base+"/data") t.Cleanup(func() { @@ -70,9 +75,6 @@ spec: t.Fatal(err) } s.deploy(t, dir) - // A legacy compose-run container can survive a crash without durable labels. - // The first durable activation must reclaim this stopped, owned container. - s.run(t, "docker compose -p "+name+" --project-directory "+root+"/current -f "+root+"/current/compose.yaml run --no-deps --name "+name+"-refresh-1 refresh true") if out, err := s.obInput(t, dir, s.obHome(t), "y\n", "job", "run", "refresh", "--input", "SOURCE=custom"); err == nil { t.Fatalf("index should fail before allow marker: %s", out) } diff --git a/e2e/server_harness_test.go b/e2e/server_harness_test.go index 6c42def6..931593d1 100644 --- a/e2e/server_harness_test.go +++ b/e2e/server_harness_test.go @@ -149,7 +149,7 @@ var ( func obBinary(t *testing.T) string { t.Helper() obOnce.Do(func() { - dir, err := os.MkdirTemp("", "ob-e2e-bin") + dir, err := os.MkdirTemp("", "onebox-e2e-bin") if err != nil { obErr = err return @@ -304,7 +304,22 @@ func (s *server) obInput(t *testing.T, dir, home, stdin string, args ...string) ) out, err := cmd.CombinedOutput() t.Logf("ob %s\n%s", strings.Join(args, " "), out) - return string(out), err + return withoutHostStateWarning(string(out)), err +} + +// withoutHostStateWarning drops the warning ob prints while a fixture's +// test-only host state override is set, so callers parsing output see only +// what the command itself wrote. +func withoutHostStateWarning(out string) string { + prefix := "warning: " + app.TestHostStateDirEnv + "=" + lines := strings.SplitAfter(out, "\n") + kept := lines[:0] + for _, line := range lines { + if !strings.HasPrefix(line, prefix) { + kept = append(kept, line) + } + } + return strings.Join(kept, "") } func (s *server) mustOb(t *testing.T, dir string, args ...string) string { diff --git a/e2e/server_probe_test.go b/e2e/server_probe_test.go index 511b5a06..480e85d9 100644 --- a/e2e/server_probe_test.go +++ b/e2e/server_probe_test.go @@ -39,7 +39,7 @@ func TestServerProbes(t *testing.T) { t.Error("the service is archiving into a repository it cannot reach") } timers := strings.TrimSpace(s.run(t, - "systemctl list-units --type=timer --all --no-pager | grep -c ob-backup || true")) + "systemctl list-units --type=timer --all --no-pager | grep -c onebox-backup- || true")) if timers != "0" { t.Errorf("a failed enablement left %s backup timer(s) installed", timers) } @@ -83,7 +83,7 @@ func TestServerProbes(t *testing.T) { // Names.BackupCredentialFile: /backup/secrets/-.env credentials := strings.TrimSpace(s.run(t, - "ls /var/lib/ob/observer/backup/secrets 2>/dev/null | wc -l")) + "ls /var/lib/onebox/app/backup/secrets 2>/dev/null | wc -l")) if credentials == "0" { t.Error("re-enabling removed the credential file it had just installed") } @@ -202,12 +202,12 @@ func TestServerProbes(t *testing.T) { s.teardown(t, dir) for _, probe := range []struct{ name, command, want string }{ - {"release tree", "ls -d /var/lib/ob/observer 2>/dev/null | wc -l", "0"}, - {"backup timers", "systemctl list-units --type=timer --all --no-pager | grep -c ob-backup || true", "0"}, - {"unit files", "ls /etc/systemd/system/ob-backup-* 2>/dev/null | wc -l", "0"}, - {"containers", "docker ps -aq --filter label=ob.app=observer | wc -l", "0"}, - {"networks", "docker network ls --filter label=ob.app=observer -q | wc -l", "0"}, - {"volumes", "docker volume ls -q | grep -c observer || true", "0"}, + {"release tree", "ls -d /var/lib/onebox/app 2>/dev/null | wc -l", "0"}, + {"backup timers", "systemctl list-units --type=timer --all --no-pager | grep -c onebox-backup- || true", "0"}, + {"unit files", "ls /etc/systemd/system/onebox-backup-* 2>/dev/null | wc -l", "0"}, + {"containers", "docker ps -aq --filter label=onebox.app=observer | wc -l", "0"}, + {"networks", "docker network ls --filter label=onebox.app=observer -q | wc -l", "0"}, + {"volumes", "docker volume ls -q --filter label=onebox.app=observer | wc -l", "0"}, } { if got := strings.TrimSpace(s.run(t, probe.command)); got != probe.want { t.Errorf("destroy left %s behind: %s (want %s)", probe.name, got, probe.want) diff --git a/e2e/server_test.go b/e2e/server_test.go index f7db594c..b96c7192 100644 --- a/e2e/server_test.go +++ b/e2e/server_test.go @@ -187,40 +187,39 @@ func TestServerLifecycle(t *testing.T) { t.Run("scheduled jobs are bounded and failures reach status", func(t *testing.T) { s.run(t, "systemd-analyze verify "+ - "/etc/systemd/system/ob-observer-chore.service "+ - "/etc/systemd/system/ob-observer-chore.timer "+ - "/etc/systemd/system/ob-observer-timeout--chore.service "+ - "/etc/systemd/system/ob-observer-timeout--chore.timer") - - // Model a host last touched by v2026.8.5: the timer exists, but its - // service invokes Compose directly and has no bounded runner or notifier. - // Upgrading the local package is intentionally side-effect free; the - // scoped apply command must bridge that installed generation without an - // unrelated release deploy. - legacyService := `[Unit] + "/etc/systemd/system/onebox-job-chore.service "+ + "/etc/systemd/system/onebox-job-chore.timer "+ + "/etc/systemd/system/onebox-job-timeout-chore.service "+ + "/etc/systemd/system/onebox-job-timeout-chore.timer") + + // Model a unit edited by hand: the timer exists, but its service invokes + // Compose directly and has no bounded runner or notifier. The scoped apply + // command must restore the generated unit without an unrelated release + // deploy. + staleService := `[Unit] Description=Onebox scheduled job chore for observer After=docker.service Requires=docker.service [Service] Type=oneshot -ExecStart=/usr/bin/docker compose -p observer -f /var/lib/ob/observer/current/compose.yaml run --rm --no-deps chore +ExecStart=/usr/bin/docker compose -p observer -f /var/lib/onebox/app/current/compose.yaml run --rm --no-deps chore ` - encodedLegacy := base64.StdEncoding.EncodeToString([]byte(legacyService)) + encodedStale := base64.StdEncoding.EncodeToString([]byte(staleService)) s.run(t, strings.Join([]string{ - "printf '%s' '" + encodedLegacy + "' | base64 -d > /etc/systemd/system/ob-observer-chore.service", - "rm -f /etc/systemd/system/ob-observer-chore.run /etc/systemd/system/ob-observer-chore.notify", + "printf '%s' '" + encodedStale + "' | base64 -d > /etc/systemd/system/onebox-job-chore.service", + "rm -f /etc/systemd/system/onebox-job-chore.run /etc/systemd/system/onebox-job-chore.notify", "systemctl daemon-reload", }, "\n")) - before := s.run(t, "systemctl cat ob-observer-chore.service") + before := s.run(t, "systemctl cat onebox-job-chore.service") if strings.Contains(before, "TimeoutStartSec=") || strings.Contains(before, "ExecStopPost=") { - t.Fatalf("legacy fixture already has the current unit contract:\n%s", before) + t.Fatalf("stale fixture already has the current unit contract:\n%s", before) } s.mustOb(t, dir, "schedule", "apply") after := s.run(t, strings.Join([]string{ - "test -s /etc/systemd/system/ob-observer-chore.run", - "test -s /etc/systemd/system/ob-observer-chore.notify", - "systemctl cat ob-observer-chore.service", + "test -s /etc/systemd/system/onebox-job-chore.run", + "test -s /etc/systemd/system/onebox-job-chore.notify", + "systemctl cat onebox-job-chore.service", }, "\n")) for _, want := range []string{"ExecStart=/bin/sh", "ExecStopPost=/bin/sh", "TimeoutStartSec="} { if !strings.Contains(after, want) { @@ -230,9 +229,9 @@ ExecStart=/usr/bin/docker compose -p observer -f /var/lib/ob/observer/current/co // A normal host-fired run proves the generated runner, current-release // lookup, Docker invocation and app-wide schedule lock compose on systemd. - s.run(t, "systemctl start ob-observer-chore.service") + s.run(t, "systemctl start onebox-job-chore.service") if result := strings.TrimSpace(s.run(t, - "systemctl show ob-observer-chore.service --property=Result --value")); result != "success" { + "systemctl show onebox-job-chore.service --property=Result --value")); result != "success" { t.Fatalf("normal scheduled run result = %q, want success", result) } @@ -247,7 +246,7 @@ ExecStart=/usr/bin/docker compose -p observer -f /var/lib/ob/observer/current/co // One failure, one sleep, one success: the record counts both attempts. s.run(t, "rm -rf /tmp/onebox-e2e-retry && mkdir -p /tmp/onebox-e2e-retry") - s.run(t, "systemctl start ob-observer-retry--chore.service") + s.run(t, "systemctl start onebox-job-retry-chore.service") retry := s.mustOb(t, dir, "job", "history", "retry-chore", "--output", "json") for _, want := range []string{`"outcome": "success"`, `"attempts": 2`} { if !strings.Contains(retry, want) { @@ -297,21 +296,21 @@ HTTPServer(("127.0.0.1", 18080), Handler).handle_request() s.run(t, strings.Join([]string{ "set -e", "command -v python3 >/dev/null", - "systemctl stop ob-e2e-schedule-receiver.service >/dev/null 2>&1 || true", - "systemctl reset-failed ob-e2e-schedule-receiver.service >/dev/null 2>&1 || true", + "systemctl stop onebox-e2e-schedule-receiver.service >/dev/null 2>&1 || true", + "systemctl reset-failed onebox-e2e-schedule-receiver.service >/dev/null 2>&1 || true", "rm -f /tmp/onebox-schedule-notify", "printf '%s' '" + encoded + "' | base64 -d > /tmp/onebox-schedule-receiver.py", - "systemd-run --quiet --collect --unit=ob-e2e-schedule-receiver /usr/bin/python3 /tmp/onebox-schedule-receiver.py", + "systemd-run --quiet --collect --unit=onebox-e2e-schedule-receiver /usr/bin/python3 /tmp/onebox-schedule-receiver.py", "for i in $(seq 1 50); do ss -ltn | grep -q '127.0.0.1:18080' && break; sleep .1; done", "ss -ltn | grep -q '127.0.0.1:18080'", }, "\n")) // systemctl returns non-zero because TimeoutStartSec terminates the job. - if err := s.try(t, "systemctl start ob-observer-timeout--chore.service"); err == nil { + if err := s.try(t, "systemctl start onebox-job-timeout-chore.service"); err == nil { t.Fatal("wedged scheduled job was not terminated by its timeout") } if result := strings.TrimSpace(s.run(t, - "systemctl show ob-observer-timeout--chore.service --property=Result --value")); result != "timeout" { + "systemctl show onebox-job-timeout-chore.service --property=Result --value")); result != "timeout" { t.Fatalf("timed-out scheduled run result = %q, want timeout", result) } // The runner was killed mid-run; ExecStopPost still wrote the record. @@ -486,7 +485,7 @@ HTTPServer(("127.0.0.1", 18080), Handler).handle_request() // A new host owns a different generated credential while the physical // generation still carries the source role hash. Rotating only the target // file reproduces that boundary without requiring a second test server. - s.run(t, "printf 'POSTGRES_PASSWORD=%s\\n' 0123456789abcdef0123456789abcdef0123456789abcdef > /var/lib/ob/observer/services/postgres.secret.env") + s.run(t, "printf 'POSTGRES_PASSWORD=%s\\n' 0123456789abcdef0123456789abcdef0123456789abcdef > /var/lib/onebox/app/services/postgres.secret.env") s.mustOb(t, dir, "backup", "restore", "postgres", "--generation", generation, "--confirm", "postgres") if note := s.psql(t, "select note from survivors limit 1"); note != "written before" { t.Fatalf("the recovered cluster does not hold the row: %q", note) @@ -505,7 +504,7 @@ HTTPServer(("127.0.0.1", 18080), Handler).handle_request() if mode := s.psql(t, "show archive_mode"); mode == "on" { t.Error("archiving is still on after disable") } - units := s.run(t, "systemctl list-units --type=timer --all --no-pager | grep -c ob-backup || true") + units := s.run(t, "systemctl list-units --type=timer --all --no-pager | grep -c onebox-backup || true") if strings.TrimSpace(units) != "0" { t.Errorf("backup timers survived disable: %s", units) } @@ -518,7 +517,7 @@ HTTPServer(("127.0.0.1", 18080), Handler).handle_request() t.Fatalf("destroy failed: %v\n%s", err, out) } if left := strings.TrimSpace(s.run(t, - `docker ps -aq --filter label=ob.app=observer | wc -l`)); left != "0" { + `docker ps -aq --filter label=onebox.app=observer | wc -l`)); left != "0" { t.Errorf("%s containers survived destroy", left) } // The object store is not ob's and must be untouched by a teardown of diff --git a/e2e/testdata/worker/ob-broken.yml b/e2e/testdata/worker/broken.yml similarity index 100% rename from e2e/testdata/worker/ob-broken.yml rename to e2e/testdata/worker/broken.yml diff --git a/internal/app/backup_names_test.go b/internal/app/backup_names_test.go index 4029c1ac..31e130bc 100644 --- a/internal/app/backup_names_test.go +++ b/internal/app/backup_names_test.go @@ -14,10 +14,10 @@ func TestProtectedServiceReservesRestoreRuntimeNames(t *testing.T) { } all := spec.All("production") for _, reserved := range []string{ - "ob_example_database_restore", - "example-database-restore-1", - "ob_example_database_restore-net", - "ob_example_database_restore-stage", + "onebox_database_restore", + "onebox-database-restore", + "onebox_database_restore-net", + "onebox_database_restore-stage", } { if !contains(all, reserved) { t.Errorf("protected runtime name %q is not reserved: %#v", reserved, all) @@ -26,7 +26,7 @@ func TestProtectedServiceReservesRestoreRuntimeNames(t *testing.T) { } func TestProtectedForeignCollisionFailsClosedWithoutAdoption(t *testing.T) { - reserved := []string{"ob_example_database_restore-stage"} + reserved := []string{"onebox_database_restore-stage"} checks := collisionChecks("example", reserved, map[string]string{reserved[0]: "other-app"}) if len(checks) != 1 || checks[0].OK || !strings.Contains(checks[0].Detail, "owned by application other-app") || !strings.Contains(checks[0].Remedy, "will not adopt") { t.Fatalf("foreign collision checks = %#v", checks) diff --git a/internal/app/backup_walg.go b/internal/app/backup_walg.go index f10e365d..411ff0a6 100644 --- a/internal/app/backup_walg.go +++ b/internal/app/backup_walg.go @@ -39,7 +39,7 @@ const WalgExecutable = "/usr/local/bin/wal-g" // while a backup target names its own entries, so something has to bridge the // two — and doing it here keeps the project's vocabulary out of wal-g's and // wal-g's out of the operator's encrypted file. -const WalgBinary = WalgMountPath + "/ob-wal-g" +const WalgBinary = WalgMountPath + "/onebox-wal-g" // WalgTrustStore is the host trust store as the container sees it. The staged // copy lands beside the binary, inside the directory already mounted read-only @@ -151,8 +151,7 @@ func WalgEnvironment(target BackupTarget, repository, database, service string) // PgSuperuser is the role the postgres driver creates. It must agree with the // driver's `user` field, and the contract test holds the two together. wal-g // connects as it rather than as the operating-system user, which is `postgres` -// — a role the driver never creates, because Onebox owns the identity and makes -// it the application's so two projects on one host cannot silently share one. +// — a role the driver never creates, because Onebox owns the identity. const PgSuperuser = "onebox" // WalgCredentialEntries are the entry names the target-side credential file diff --git a/internal/app/backup_walg_test.go b/internal/app/backup_walg_test.go index d12f9f2d..31c23fd7 100644 --- a/internal/app/backup_walg_test.go +++ b/internal/app/backup_walg_test.go @@ -56,7 +56,7 @@ func TestRecordedProjectionWinsOverEditedIntent(t *testing.T) { } edited := &Resolved{ Spec: &Spec{ - Name: "shop", BasePath: "/var/lib/ob", + Name: "shop", BasePath: "/var/lib/onebox", Services: map[string]Service{"db": {Driver: "postgres", Version: 18, Backup: &BackupPolicy{ Target: "moved", RecoveryKind: "pitr", MaxDataLoss: "15m", }}}, diff --git a/internal/app/compose.go b/internal/app/compose.go index 936e74fa..9405e473 100644 --- a/internal/app/compose.go +++ b/internal/app/compose.go @@ -22,7 +22,7 @@ import ( // overlayKeys is the closed set. Anything outside it is copied untouched. type overlay struct { Network string // ingress network to append, empty when the proxy is off - Labels map[string]any // ob.* identity and traefik.* routing + Labels map[string]any // onebox.* identity and traefik.* routing HasRoute bool // routes were declared, so traefik.* is ours // EnvFiles are the resolved entries and connection files, projected onto // the referenced service. A workload adopted from a Compose file has a role @@ -201,9 +201,9 @@ func refuseConflicts(ref string, svc map[string]any, ov overlay) error { } } for _, k := range sortedKeys(labelMap(svc["labels"])) { - if strings.HasPrefix(k, "ob.") { - return errf("compose_ob_label", ref, "", - "referenced service in %q declares %q; the ob. namespace is Onebox's", ref, k) + if strings.HasPrefix(k, "onebox.") { + return errf("compose_onebox_label", ref, "", + "referenced service in %q declares %q; the onebox. namespace is Onebox's", ref, k) } if ov.HasRoute && strings.HasPrefix(k, "traefik.") { return errf("compose_traefik_label", ref, "", diff --git a/internal/app/compose_test.go b/internal/app/compose_test.go index 151d7cf8..5ffc57cc 100644 --- a/internal/app/compose_test.go +++ b/internal/app/compose_test.go @@ -26,7 +26,7 @@ func mergeFixtureDeps(t *testing.T, service string, ov overlay) definitions { // workload the declaration cannot express keeps every setting it declared. func TestMergePreservesWhatTheUserWrote(t *testing.T) { got, err := mergeFixture(t, "postgres", overlay{ - Labels: map[string]any{"ob.app": "ledger", "ob.workload": "db", "ob.release": "r1"}, + Labels: map[string]any{"onebox.app": "ledger", "onebox.workload": "db", "onebox.release": "r1"}, }) if err != nil { t.Fatal(err) @@ -44,20 +44,20 @@ func TestMergePreservesWhatTheUserWrote(t *testing.T) { t.Error("the authored environment must survive") } labels := labelMap(got["labels"]) - if labels["ob.app"] != "ledger" || labels["ob.release"] != "r1" { + if labels["onebox.app"] != "ledger" || labels["onebox.release"] != "r1" { t.Errorf("identity labels missing: %v", labels) } } // TestMergeAppendsIngressPreservingOrder: existing networks are kept, in order. func TestMergeAppendsIngressPreservingOrder(t *testing.T) { - got, err := mergeFixture(t, "redis", overlay{Network: "ob-ingress"}) + got, err := mergeFixture(t, "redis", overlay{Network: "onebox-ingress"}) if err != nil { t.Fatal(err) } nets := networkNames(got["networks"]) - if len(nets) != 2 || nets[0] != "default" || nets[1] != "ob-ingress" { - t.Fatalf("networks = %v, want [default ob-ingress]", nets) + if len(nets) != 2 || nets[0] != "default" || nets[1] != "onebox-ingress" { + t.Fatalf("networks = %v, want [default onebox-ingress]", nets) } } @@ -70,10 +70,10 @@ func TestMergeRefusesConflicts(t *testing.T) { code string }{ {"named", overlay{}, "compose_container_name"}, - {"hostnet", overlay{Network: "ob-ingress"}, "compose_network_mode"}, + {"hostnet", overlay{Network: "onebox-ingress"}, "compose_network_mode"}, {"labelled", overlay{HasRoute: true}, "compose_traefik_label"}, - {"owned", overlay{}, "compose_ob_label"}, - {"attached", overlay{Network: "ob-ingress"}, "compose_ingress_attached"}, + {"owned", overlay{}, "compose_onebox_label"}, + {"attached", overlay{Network: "onebox-ingress"}, "compose_ingress_attached"}, } for _, c := range cases { t.Run(c.code, func(t *testing.T) { @@ -160,7 +160,7 @@ spec: t.Fatal(err) } out := string(r.Bytes) - for _, want := range []string{webPin, databasePin, "pg_isready", "ob.workload: db"} { + for _, want := range []string{webPin, databasePin, "pg_isready", "onebox.workload: db"} { if !strings.Contains(out, want) { t.Errorf("missing %q in rendered runtime\n%s", want, out) } diff --git a/internal/app/constraints.go b/internal/app/constraints.go index 3cfcf52f..3760d893 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -201,18 +201,42 @@ var ( // reservedAppNames are the identities the host layout already uses. An // application taking one of them would derive names that collide with the -// proxy's or the host namespace's, and the collision would appear as a -// container that vanishes rather than as an error. -var reservedAppNames = []string{"ob", "onebox-proxy", "_host"} +// host's, and the collision would appear as a container that vanishes rather +// than as an error. +// +// The onebox-* namespace is refused by prefix as well, in checkAppName: it +// names what Onebox runs on the host — the proxy, its ingress network, managed +// services — and an application called onebox or onebox- would +// derive its own names inside it. +var reservedAppNames = []string{"onebox"} + +// reservedServiceNames are names a service would share with something else +// Onebox runs. A managed service's container is onebox-, so proxy, +// discovery and ingress would derive the host proxy's containers or its ingress +// network; its Compose project is onebox_, so services would derive +// the service network. +var reservedServiceNames = []string{"proxy", "discovery", "ingress", ServiceNetworkName} + +// checkServiceName refuses a service name whose container would be the host +// proxy's. +func checkServiceName(name string) error { + for _, reserved := range reservedServiceNames { + if name == reserved { + return errf("project_invalid", "services."+name, "", + "%q is reserved: it would derive onebox-%s or onebox_%s, which Onebox already uses", name, name, name) + } + } + return nil +} // checkAppName is the identifier grammar plus the reservations. func checkAppName(name string) error { if err := gIdent.check("app", name); err != nil { return err } - if strings.HasPrefix(name, "ob-") { + if strings.HasPrefix(name, Namespace+"-") { return errf("project_invalid", "app", "", - "%q begins with \"ob-\", which names host-scoped resources Onebox owns", name) + "%q begins with %q, which names host-scoped resources Onebox owns", name, Namespace+"-") } for _, reserved := range reservedAppNames { if name == reserved { @@ -270,6 +294,12 @@ func checkEnum(path, value string, allowed []string) error { "%q is not one of %s", value, strings.Join(quoteAll(allowed), ", ")) } +// MaxReplicas bounds a workload's replicas. Onebox runs every replica on one +// host, and every derived name and rollout step is per replica, so an +// unbounded count — a typo with an extra zero — would make loading a project +// build millions of names. +const MaxReplicas = 100 + func checkPositive(path string, value int) error { if value <= 0 { return errf("project_invalid", path, "", "must be a positive whole number, got %d", value) diff --git a/internal/app/eject.go b/internal/app/eject.go index 957218dd..2eee6a75 100644 --- a/internal/app/eject.go +++ b/internal/app/eject.go @@ -72,7 +72,7 @@ func (r *Resolved) Eject(dest, releaseID string, images Images, overwrite bool) // Write and rename before touching the project. An interruption then leaves // the project still pointing at the generator rather than at a file that // may not exist. - tmp := target + ".ob-tmp" + tmp := target + ".onebox-tmp" if err := os.WriteFile(tmp, stripped, 0o600); err != nil { return nil, errf("eject_failed", dest, "", "cannot write %q: %v", dest, err) } @@ -168,7 +168,7 @@ func dropLabels(svc *yaml.Node) { var kept []*yaml.Node for i := 0; i+1 < len(labels.Content); i += 2 { k := labels.Content[i].Value - if strings.HasPrefix(k, "ob.") || strings.HasPrefix(k, "traefik.") { + if strings.HasPrefix(k, "onebox.") || strings.HasPrefix(k, "traefik.") { continue } kept = append(kept, labels.Content[i], labels.Content[i+1]) @@ -187,7 +187,7 @@ func dropIngress(svc *yaml.Node) { } var kept []*yaml.Node for _, n := range nets.Content { - if n.Value == IngressNetwork || strings.HasPrefix(n.Value, "ob-") { + if n.Value == IngressNetwork || strings.HasPrefix(n.Value, Namespace+"-") { continue } kept = append(kept, n) @@ -265,7 +265,7 @@ func repointProject(path, dest string, names []string) error { return errf("eject_failed", path, "", "%v", err) } - tmp := path + ".ob-tmp" + tmp := path + ".onebox-tmp" if err := os.WriteFile(tmp, []byte(sb.String()), 0o600); err != nil { return errf("eject_failed", path, "", "%v", err) } diff --git a/internal/app/eject_test.go b/internal/app/eject_test.go index 37186297..2cda4c84 100644 --- a/internal/app/eject_test.go +++ b/internal/app/eject_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "gopkg.in/yaml.v3" ) const ejectProject = `apiVersion: onebox.run/v1alpha1 @@ -57,7 +59,7 @@ func TestEjectedRuntimeIsOrdinaryCompose(t *testing.T) { t.Fatal(err) } out := string(body) - for _, forbidden := range []string{"ob.app", "ob.release", "ob.workload", "traefik.", "ob-ingress"} { + for _, forbidden := range []string{"onebox.app", "onebox.release", "onebox.workload", "traefik.", "onebox-ingress"} { if strings.Contains(out, forbidden) { t.Errorf("ejected runtime still carries %q\n%s", forbidden, out) } @@ -302,7 +304,7 @@ spec: t.Fatalf("the workload was not handed over: %+v", res.Workloads) } // And no temporary file survives to be mistaken for the runtime. - if _, err := os.Stat(filepath.Join(dir, "compose.yaml.ob-tmp")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(dir, "compose.yaml.onebox-tmp")); !os.IsNotExist(err) { t.Error("a temporary runtime was left behind") } // The project now references the file that is actually on disk. @@ -314,3 +316,21 @@ spec: t.Fatalf("project was not repointed at the placed file: %q", ref) } } + +// Only Onebox's own namespace is stripped. An author's network that happens to +// begin "ob-" is theirs, and dropping it would cut the ejected service off. +func TestDropIngressKeepsTheAuthorsNetworks(t *testing.T) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte("networks: [default, onebox-ingress, ob-backend]\n"), &doc); err != nil { + t.Fatal(err) + } + svc := doc.Content[0] + dropIngress(svc) + out, err := yaml.Marshal(svc) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "onebox-ingress") || !strings.Contains(string(out), "ob-backend") { + t.Fatalf("networks after ejection:\n%s", out) + } +} diff --git a/internal/app/errors.go b/internal/app/errors.go index 5e2d17c4..8d674114 100644 --- a/internal/app/errors.go +++ b/internal/app/errors.go @@ -77,7 +77,7 @@ var errorCodes = map[string]string{ "compose_extends": "a referenced service uses extends, which hides what runs", "compose_container_name": "a referenced service fixes its container name, which Onebox owns", "compose_network_mode": "a referenced service sets network_mode, which conflicts with the network it needs", - "compose_ob_label": "a referenced service carries a label in a namespace Onebox generates into", + "compose_onebox_label": "a referenced service carries a label in a namespace Onebox generates into", "compose_traefik_label": "a referenced service carries routing labels while also declaring a route", "compose_ingress_attached": "a referenced service already attaches the ingress network", diff --git a/internal/app/generate.go b/internal/app/generate.go index 663bdcea..f7f56790 100644 --- a/internal/app/generate.go +++ b/internal/app/generate.go @@ -34,11 +34,11 @@ type Rendered struct { // UnresolvedImage is what an unresolved build stands in as. It is not a real // reference and no registry serves it, so a runtime carrying it fails at the // pull rather than starting something unintended. -const UnresolvedImage = "ob-unresolved-image:no-release" +const UnresolvedImage = "onebox-unresolved-image:no-release" // WorkloadRevisionLabel identifies the complete rendered service contract // independently of the application release that happened to create it. -const WorkloadRevisionLabel = "ob.workload-revision" +const WorkloadRevisionLabel = "onebox.workload-revision" // Images supplies the exact image reference a release workload will run. It is // required for build-sourced workloads and overrides authored tags after a plan @@ -95,7 +95,7 @@ func (r *Resolved) render(env, releaseID string, images Images) (*Rendered, erro // Ownership must be on the volume itself. Preflight reads // labels to tell a previous release from a stranger's resource, // and an unlabelled volume we created looks like a collision. - "labels": map[string]any{"ob.app": p.Name}, + "labels": map[string]any{"onebox.app": p.Name}, } } // Definitions a referenced service depends on: a segmented network, an @@ -197,7 +197,7 @@ func stampWorkloadRevisionWithSecretOutputs(service map[string]any, secretOutput labels, _ := service["labels"].(map[string]any) canonicalLabels := make(map[string]any, len(labels)) for key, value := range labels { - if key != "ob.release" && key != WorkloadRevisionLabel && key != "ob.secret-generation" { + if key != "onebox.release" && key != WorkloadRevisionLabel && key != "onebox.secret-generation" { canonicalLabels[key] = value } } @@ -227,9 +227,9 @@ func workloadRevisionHex(value []byte) string { func (p *Spec) overlayFor(n Names, name string, w Workload, releaseID string) overlay { ov := overlay{ Labels: map[string]any{ - "ob.app": p.Name, - "ob.workload": name, - "ob.release": releaseID, + "onebox.app": p.Name, + "onebox.workload": name, + "onebox.release": releaseID, }, HasRoute: len(w.NormalisedRoutes()) > 0, Health: healthcheck(w.Health), @@ -331,14 +331,14 @@ func (p *Spec) renderWorkload(n Names, name string, w Workload, releaseID string for k, v := range w.Labels { labels[k] = v } - labels["ob.app"] = p.Name - labels["ob.workload"] = name - labels["ob.release"] = releaseID + labels["onebox.app"] = p.Name + labels["onebox.workload"] = name + labels["onebox.release"] = releaseID // Onebox runs the replicas itself under derived slot names, so the count is // not a Compose concern — but it must still be part of the bound content. // Without it a scale change renders an identical runtime and the plan digest // never notices. - labels["ob.replicas"] = fmt.Sprint(w.Replicas) + labels["onebox.replicas"] = fmt.Sprint(w.Replicas) for k, v := range p.routeLabels(n, name, w) { labels[k] = v } @@ -617,7 +617,7 @@ func (p *Spec) routesAnywhere() bool { // before anything sends a signal. Without the guard the container reports // healthy until the moment it dies, and the requests in flight at that moment // are lost. -const DrainFile = "/tmp/ob-drain" +const DrainFile = "/tmp/onebox-drain" // drainGuarded prefixes a shell-form check with the drain test. func drainGuarded(check string) string { @@ -919,7 +919,7 @@ func (p *Spec) ExternalConnectionProjections(workloadName string, w Workload) [] entries[destination] = external.Connection.Entries[part] } out = append(out, ExternalConnectionProjection{ - Path: ".ob-external-" + need.Name + "_" + workloadName + ".env", + Path: ".onebox-external-" + need.Name + "_" + workloadName + ".env", Source: external.Connection.Source, Entries: entries, }) diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index 0face0c1..f4f687e2 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -173,16 +173,16 @@ func TestRenderedRuntime(t *testing.T) { for _, want := range []string{ "name: ledger", "image: ghcr.io/acme/ledger:1.4.0", - "ob.app: ledger", - "ob.workload: web", - "ob.release: 20260802-120000-abc1234", + "onebox.app: ledger", + "onebox.workload: web", + "onebox.release: 20260802-120000-abc1234", "traefik.enable:", "Host(`ledger.example.com`)", - "traefik.http.services.ledger_web.loadbalancer.server.port:", + "traefik.http.services.onebox_web.loadbalancer.server.port:", "condition: service_healthy", "stop_grace_period: 30s", "mem_limit: 1GB", - "ob_ledger_web_uploads", + "onebox_web_uploads", "/data/postgres:/var/lib/postgresql/data", "pg_isready -U ledger", } { @@ -288,7 +288,7 @@ func TestNoProxyAddsNothing(t *testing.T) { if strings.Contains(out, "traefik") { t.Error("no proxy must not add routing labels") } - if strings.Contains(out, "ob-ingress") { + if strings.Contains(out, "onebox-ingress") { t.Error("no proxy must not attach an ingress network") } } @@ -485,7 +485,7 @@ spec: t.Errorf("an explicit false must not be dropped\n%s", out) } // Onebox's own labels still land alongside the user's. - if !strings.Contains(out, "ob.app: ledger") { + if !strings.Contains(out, "onebox.app: ledger") { t.Error("identity labels must survive user labels") } } @@ -493,7 +493,7 @@ spec: // TestUserLabelsCannotClaimOneboxNamespaces: the two namespaces Onebox // generates into are reserved, so a user label can never silently win. func TestUserLabelsCannotClaimOneboxNamespaces(t *testing.T) { - for _, bad := range []string{"ob.app", "traefik.enable"} { + for _, bad := range []string{"onebox.app", "traefik.enable"} { y := `apiVersion: onebox.run/v1alpha1 kind: Application metadata: {name: ledger} @@ -524,7 +524,7 @@ func TestReplicaCountIsBound(t *testing.T) { // name, would never see a collision that exists. Found by deploying. func TestVolumeNamesArePinned(t *testing.T) { out := string(render(t, appFixture)) - if !strings.Contains(out, "name: ob_ledger_web_uploads") { + if !strings.Contains(out, "name: onebox_web_uploads") { t.Errorf("the derived volume name must be pinned\n%s", out) } } diff --git a/internal/app/host_owner_probe.go b/internal/app/host_owner_probe.go index 6878c26b..f8cd2bf5 100644 --- a/internal/app/host_owner_probe.go +++ b/internal/app/host_owner_probe.go @@ -91,22 +91,14 @@ func HostOwnerProbe(recordPath string) string { // HostOwnerRecord is the parsed content of the host owner record: the // application that claimed the host, and the environment it claimed it for. // -// The record is a single line, "" or " ". -// The first form predates the environment field; it still identifies the owner, -// so it parses rather than failing, and Environment is empty. +// The record is a single line, " ". type HostOwnerRecord struct { Application string Environment string } -// Legacy reports a record written before the environment was recorded. -func (r HostOwnerRecord) Legacy() bool { return r.Environment == "" } - // String renders the record as it is written to the host. func (r HostOwnerRecord) String() string { - if r.Legacy() { - return r.Application - } return r.Application + " " + r.Environment } @@ -124,18 +116,8 @@ func (r HostOwnerRecord) String() string { // environment. Anything else is not a record this tool wrote. func ParseHostOwnerRecord(record string) (HostOwnerRecord, bool) { fields := strings.Fields(record) - switch len(fields) { - case 1: - if !gIdent.pattern.MatchString(fields[0]) { - return HostOwnerRecord{}, false - } - return HostOwnerRecord{Application: fields[0]}, true - case 2: - if !gIdent.pattern.MatchString(fields[0]) || !gIdent.pattern.MatchString(fields[1]) { - return HostOwnerRecord{}, false - } - return HostOwnerRecord{Application: fields[0], Environment: fields[1]}, true - default: + if len(fields) != 2 || !gIdent.pattern.MatchString(fields[0]) || !gIdent.pattern.MatchString(fields[1]) { return HostOwnerRecord{}, false } + return HostOwnerRecord{Application: fields[0], Environment: fields[1]}, true } diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index 5da8c081..bad6b24d 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -404,6 +404,7 @@ var schemaConstraints = []struct { {[]string{"api_version"}, map[string]any{"const": APIVersion}}, {[]string{"app"}, appNameConstraint()}, {[]string{"base_path"}, pattern(gAbsPath)}, + {[]string{"services"}, serviceNamesConstraint()}, {[]string{"services", "*", "features", "extensions"}, map[string]any{ "propertyNames": map[string]any{"pattern": gExtension.pattern.String()}, }}, @@ -414,7 +415,7 @@ var schemaConstraints = []struct { {[]string{"environments", "*", "policy", "migrations", "backup_max_age"}, pattern(gDur)}, {[]string{"workloads", "*", "role"}, enum(eRole)}, - {[]string{"workloads", "*", "replicas"}, map[string]any{"minimum": 1}}, + {[]string{"workloads", "*", "replicas"}, map[string]any{"minimum": 1, "maximum": MaxReplicas}}, {[]string{"workloads", "*", "strategy"}, enum(eStrategy)}, {[]string{"workloads", "*", "deployment_phase"}, enum(eJobDeploymentPhase)}, {[]string{"workloads", "*", "operator_run"}, enum(eJobOperatorRun)}, @@ -725,17 +726,26 @@ func anyRequired(fields []any) []any { // reservations, which a schema can hold as well as the loader can. func appNameConstraint() map[string]any { forbidden := make([]any, 0, len(reservedAppNames)+1) - forbidden = append(forbidden, map[string]any{"pattern": "^ob-"}) + forbidden = append(forbidden, map[string]any{"pattern": "^onebox-"}) for _, name := range reservedAppNames { forbidden = append(forbidden, map[string]any{"const": name}) } out := pattern(gIdent) out["not"] = map[string]any{"anyOf": forbidden} out["description"] = "The application's name. Expects " + gIdent.means + - ", and may not begin \"ob-\" or be a name the host layout reserves." + ", and may not begin \"onebox-\" or be a name the host layout reserves." return out } +// serviceNamesConstraint holds the service names the host proxy reserves. +func serviceNamesConstraint() map[string]any { + forbidden := make([]any, 0, len(reservedServiceNames)) + for _, name := range reservedServiceNames { + forbidden = append(forbidden, map[string]any{"const": name}) + } + return map[string]any{"propertyNames": map[string]any{"not": map[string]any{"anyOf": forbidden}}} +} + func bindSourceConstraint() map[string]any { out := pattern(gBindSource) out["not"] = map[string]any{"pattern": `(^|/)\.\.(/|$)`} diff --git a/internal/app/jsonschema_test.go b/internal/app/jsonschema_test.go index 7a8d1318..903aea90 100644 --- a/internal/app/jsonschema_test.go +++ b/internal/app/jsonschema_test.go @@ -293,7 +293,7 @@ func TestPublishedSchemaDocumentsImportantDefaultsAndExamples(t *testing.T) { key string expected any }{ - {[]string{"spec", "basePath"}, "default", "/var/lib/ob"}, + {[]string{"spec", "basePath"}, "default", "/var/lib/onebox"}, {[]string{"spec", "deployment", "retainReleases"}, "default", float64(5)}, {[]string{"spec", "environments", "*", "policy", "requireApproval"}, "default", true}, {[]string{"spec", "workloads", "*", "replicas"}, "default", float64(1)}, diff --git a/internal/app/load.go b/internal/app/load.go index 8065e4b3..4e241119 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -551,6 +551,9 @@ func crossFieldRules(p *Spec) error { } for _, name := range sortedKeys(p.Services) { + if err := checkServiceName(name); err != nil { + return err + } if _, clash := p.Workloads[name]; clash { return errf("identifier_collision", "services."+name, "", "%q names both a workload and a service; their derived volume names would collide", name) @@ -664,36 +667,15 @@ func canonicalRouteHost(host string) string { } // checkDerivedNames refuses an over-long generated name rather than truncating. +// It measures the names themselves — every one All derives, with the escaped +// hyphens, replica ordinals and restore suffixes that make them longer than the +// identifiers they come from. func checkDerivedNames(p *Spec) error { - check := func(kind, name string) error { - if len(name) <= maxDerivedName { - return nil - } - return errf("derived_name_too_long", name, "", - "derived %s name %q is %d characters, over the %d-character limit; shorten the identifiers", - kind, name, len(name), maxDerivedName) - } - for _, w := range sortedKeys(p.Workloads) { - if err := check("container", p.Name+"_"+w); err != nil { - return err - } - for _, v := range p.Workloads[w].Volumes { - if v.IsBind() { - continue - } - if err := check("volume", "ob_"+p.Name+"_"+w+"_"+v.Name); err != nil { - return err - } - } - } - for _, s := range sortedKeys(p.Services) { - if err := check("service project", "ob_"+p.Name+"_"+s); err != nil { - return err - } - for _, v := range p.Services[s].Volumes { - if err := check("volume", "ob_"+p.Name+"_"+s+"_"+v); err != nil { - return err - } + for _, name := range p.All("") { + if len(name) > maxDerivedName { + return errf("derived_name_too_long", name, "", + "derived name %q is %d characters, over the %d-character limit; shorten the identifiers", + name, len(name), maxDerivedName) } } return nil diff --git a/internal/app/load_test.go b/internal/app/load_test.go index 3c18c02f..4c7bb054 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -28,7 +28,7 @@ func TestAPIVersionV1IsRequired(t *testing.T) { } func TestRoutedProjectRefusesDefaultAsProxyNetwork(t *testing.T) { - for _, network := range []string{"default", "ledger_default", "ob_ledger"} { + for _, network := range []string{"default", "ledger_default", "onebox_services"} { t.Run(network, func(t *testing.T) { _, err := loadFixtureBytes([]byte(min+"proxy: {network: "+network+"}\n"), "ob.yml") if err == nil || !strings.Contains(err.Error(), "proxy.network") || !strings.Contains(err.Error(), "reserved") { @@ -191,10 +191,10 @@ spec: a: image: nginx `, true}, - {"app starting ob-", `apiVersion: onebox.run/v1alpha1 + {"app starting onebox-", `apiVersion: onebox.run/v1alpha1 kind: Application metadata: - name: ob-app + name: onebox-app spec: environments: {p: {server: h}} workloads: @@ -476,11 +476,11 @@ func TestDefaultsMaterialise(t *testing.T) { if err != nil { t.Fatal(err) } - if p.BasePath != "/var/lib/ob" { - t.Errorf("base_path = %q, want /var/lib/ob", p.BasePath) + if p.BasePath != "/var/lib/onebox" { + t.Errorf("base_path = %q, want /var/lib/onebox", p.BasePath) } - if p.Proxy.Network != "ob-ingress" { - t.Errorf("proxy.network = %q, want ob-ingress", p.Proxy.Network) + if p.Proxy.Network != "onebox-ingress" { + t.Errorf("proxy.network = %q, want onebox-ingress", p.Proxy.Network) } if p.Deployment.RetainReleases != 5 { t.Errorf("retain_releases = %d, want 5", p.Deployment.RetainReleases) @@ -573,6 +573,35 @@ func TestOverLongNameRefused(t *testing.T) { } } +// Escaped hyphens and the rollout suffix make a container name longer than its +// identifiers. The limit applies to the name Docker is given, not the inputs. +func TestOverLongEscapedContainerNameRefused(t *testing.T) { + application := "a-b-c-d-e-f-g-h-i-j-k-l-m-n-o" + workload := "w-x-y-z-a-b-c" + if len(application)+1+len(workload) > maxDerivedName { + t.Fatal("fixture no longer isolates the escaped-name case") + } + y := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata:\n name: " + application + + "\nspec:\n environments: {p: {server: h}}\n workloads: {" + workload + ": {image: nginx}}\n" + _, err := loadFixtureBytes([]byte(y), "ob.yml") + var e *Error + if !asError(err, &e) || e.Code != "derived_name_too_long" { + t.Fatalf("got %v, want derived_name_too_long for %s", err, (Names{App: application}).TransientContainer(workload)) + } +} + +// Every replica has a name and a rollout step on one host, so the count is +// bounded before anything derives from it: a typo must not build billions of +// names while the project loads. +func TestReplicasAreBounded(t *testing.T) { + y := "apiVersion: onebox.run/v1alpha1\nkind: Application\nmetadata:\n name: shop\n" + + "spec:\n environments: {p: {server: h}}\n workloads: {web: {image: nginx, replicas: 2000000000}}\n" + _, err := loadFixtureBytes([]byte(y), "ob.yml") + if err == nil || !strings.Contains(err.Error(), "replicas") { + t.Fatalf("an unbounded replica count loaded: %v", err) + } +} + // TestConversionDrafts loads every draft recorded for tasks 1.1-1.3. These are // real projects: five here and eight open-source. func TestConversionDrafts(t *testing.T) { @@ -847,7 +876,7 @@ spec: {"image reference with a command", base + "workloads: {w: {role: application, image: \"x:1; rm -rf /\"}}\n"}, {"base path with a quote", - base + "workloads: {w: {role: application, image: x:1}}\nbase_path: \"/var/lib/ob'; rm -rf /; '\"\n"}, + base + "workloads: {w: {role: application, image: x:1}}\nbase_path: \"/var/lib/onebox'; rm -rf /; '\"\n"}, {"env file path with a newline", base + "workloads: {w: {role: application, image: x:1, env_files: [\"a.env\\nb\"]}}\n"}, {"health path with a quote", diff --git a/internal/app/names.go b/internal/app/names.go index 078494f4..07d22d6d 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -9,26 +9,67 @@ import ( // Derived names are contract. Once a volume exists its name can never change // without moving data, so every pattern here is fixed and pinned by a golden -// test. Runtime containers use the human-facing -- -// grammar; persistent and provider-internal names use the injective join below. +// test. Workload containers use the human-facing -- +// grammar. Containers Onebox runs from its own images — managed services, their +// restore drills, and the host proxy — use onebox- with no ordinal, +// because none of them has replicas. Persistent and provider-internal names use +// the injective join below under onebox_. // // Persistent and provider-internal identifiers are joined with underscores. -// Hyphens would be ambiguous there: `ob--` maps both (a-b, c) and -// (a, b-c) to `ob-a-b-c`. Underscore is excluded from the identifier grammar and -// accepted in project and volume names, which makes that derivation injective. +// Hyphens would be ambiguous there: `onebox--` maps both +// (a-b, c) and (a, b-c) to `onebox-a-b-c`. Underscore is excluded from the +// identifier grammar and accepted in project and volume names, which makes that +// derivation injective. // Runtime segments escape an authored hyphen as `--`, leaving a single hyphen as // an unambiguous separator while ordinary names retain the simple form. const ( // ProxyProject and IngressNetwork are host-scoped. ProxyProject = "onebox-proxy" - IngressNetwork = "ob-ingress" + IngressNetwork = "onebox-ingress" - // HostNamespace holds state shared by everything on the box. + // Namespace begins every name Onebox derives outside the author's own + // containers: onebox- for what it runs from its own images, and + // onebox__... for data and plumbing. Neither carries the + // application: a host has one, the onebox.app label records which, and the host + // is released only after these resources are removed. + Namespace = "onebox" + + // ServiceNetworkName is the last segment of the network that joins workloads + // to managed services. + ServiceNetworkName = "services" + + // HostNamespace is the last segment of HostStateDir, the state shared by + // everything on the box. HostNamespace = "_host" + // AppNamespace holds the application's state. It is fixed rather than the + // application's name: a host has one application, and the host owner record + // says which. + AppNamespace = "app" + + // AppMarkerFile is the ownership marker inside AppDir. + AppMarkerFile = ".onebox-app" + // DefaultBasePath follows the platform convention for variable state owned // by a program that installs nothing of its own. - DefaultBasePath = "/var/lib/ob" + DefaultBasePath = "/var/lib/onebox" + + // HostStateDir holds the host owner record, the host lock, the host journal + // and the proxy. It is fixed, not under basePath. basePath says where one + // application's state lives; a host path that moved with it gave every + // basePath its own owner record, so a second application with another + // basePath could claim the same host — and every name Onebox derives + // without the application relies on one application per host. + HostStateDir = DefaultBasePath + "/" + HostNamespace + + // TestHostStateDirEnv is the environment variable the ob command reads to + // call SetTestHostStateDir, for test suites that drive the binary and + // cannot write to /var/lib, or that keep a fixture's host state inside the + // fixture's own directory so its cleanup removes it. Fixture applications + // must still not run on one machine at the same time: units and managed + // containers carry no application. It is not a supported setting — with it, + // one host can hold several owner records — and ob warns when it is set. + TestHostStateDirEnv = "ONEBOX_TEST_HOST_STATE_DIR" ) // Names derives every generated name for one project and environment. @@ -52,9 +93,20 @@ func (p *Spec) NamesFor(env string) Names { // ComposeProject is the application's Compose project. It is the application // identifier alone, which cannot collide with any derived name because -// identifiers contain no underscore and may not begin `ob-`. +// identifiers contain no underscore and may not be or begin `onebox`. func (n Names) ComposeProject() string { return n.App } +// ComposeCreatedApplicationNetwork reports whether a network with no +// onebox.app label is still this application's: Compose created it for the +// application's own project, as it does when the project's Compose file runs a +// proxy beside the workloads before Onebox creates the network. Docker cannot +// label it afterwards. The engine and preflight both decide with this, so +// preflight predicts the engine exactly; a project label on any other name +// proves nothing. +func (n Names) ComposeCreatedApplicationNetwork(network, composeProject string) bool { + return network == n.ApplicationNetwork() && composeProject == n.ComposeProject() +} + // ApplicationNetwork is the stable default network shared by every workload // in the application Compose project. It is created outside Compose so a // release teardown cannot remove a network that still has an unmanaged proxy @@ -64,13 +116,15 @@ func (n Names) ApplicationNetwork() string { return join(n.App, "default") } // ServiceProject is a supporting service's own Compose project, kept separate // from the application's so a release or rollback cannot remove it. func (n Names) ServiceProject(service string) string { - return join("ob", n.App, service) + return join(Namespace, service) } -// ServiceContainer is a service's stable singleton slot. The explicit ordinal -// keeps every application-owned runtime name in one predictable grammar. +// ServiceContainer is a managed service's container. The name says who runs it, +// not who owns it: the onebox.app label carries ownership, and a host has one +// application, so the application in the name would tell an operator nothing. +// There is no ordinal because a managed service is always a singleton. func (n Names) ServiceContainer(service string) string { - return containerName(n.App, service, 1) + return runtimeName(Namespace, service) } // ServiceNetwork joins the application to its services. It is one network per @@ -78,10 +132,10 @@ func (n Names) ServiceContainer(service string) string { // declared name, and a network per service would mean every workload joining // several to say the same thing. // -// It cannot collide with ServiceProject — that always carries a third segment — -// and it is created once, outside any release, because the services attached to -// it outlive every release. -func (n Names) ServiceNetwork() string { return join("ob", n.App) } +// It would collide with the project of a service called "services", so that +// name is reserved. It is created once, outside any release, because the +// services attached to it outlive every release. +func (n Names) ServiceNetwork() string { return join(Namespace, ServiceNetworkName) } // ServiceDir holds what services need and releases must not touch: their // generated Compose documents and their credentials. @@ -113,7 +167,7 @@ func (n Names) BackupAdapterDir(service string) string { // optional trust store and holds no secret: it names credential entries and // reads their values from the environment. func (n Names) BackupWrapperFile(service string) string { - return path.Join(n.BackupAdapterDir(service), "ob-wal-g") + return path.Join(n.BackupAdapterDir(service), "onebox-wal-g") } // BackupTrustStoreFile is an optional host certificate authority bundle. It is @@ -140,18 +194,6 @@ func (n Names) BackupCredentialFile(service, target string) string { return path.Join(n.BackupSecretDir(), runtimeName(service, target)+".env") } -// BackupCredentialFiles returns the current credential path followed by the -// pre-2026.8.6 spelling when the two differ. The legacy path is removal and -// migration input only; new runtime documents always use the first path. -func (n Names) BackupCredentialFiles(service, target string) []string { - current := n.BackupCredentialFile(service, target) - legacy := path.Join(n.BackupSecretDir(), service+"-"+target+".env") - if current == legacy { - return []string{current} - } - return []string{current, legacy} -} - // BackupLifecycleStateFile is the durable target-side source used before // rendering a managed service. It is separate from active-volume selection: // one binds lifecycle/image policy, the other binds the physical data volume. @@ -186,32 +228,34 @@ func (n Names) ServiceAliasFile(service, workload string) string { // workload and service identifiers are unique across both blocks, which the // loader enforces; without that rule these would collide. func (n Names) WorkloadVolume(workload, volume string) string { - return join("ob", n.App, workload, volume) + return join(Namespace, workload, volume) } func (n Names) ServiceVolume(service, volume string) string { - return join("ob", n.App, service, volume) + return join(Namespace, service, volume) } func (n Names) BackupRestoreProject(service string) string { - return join("ob", n.App, service, "restore") + return join(Namespace, service, "restore") } +// BackupRestoreContainer is the transient restore-drill container beside a +// managed service, named like the service it restores. func (n Names) BackupRestoreContainer(service string) string { - return runtimeName(n.App, service, "restore", "1") + return runtimeName(Namespace, service, "restore") } func (n Names) BackupRestoreNetwork(service string) string { - return join("ob", n.App, service, "restore-net") + return join(Namespace, service, "restore-net") } func (n Names) BackupRestoreVolume(service string) string { - return join("ob", n.App, service, "restore-stage") + return join(Namespace, service, "restore-stage") } // ScheduledJobUnit is the systemd unit name without its suffix. func (n Names) ScheduledJobUnit(job string) string { - return "ob-" + runtimeName(n.App, job) + return JobUnitPrefix + job } // ScheduleRunLock serializes host-fired jobs with every operation holding the @@ -244,55 +288,28 @@ func (n Names) ScheduledJobPause(job string) string { return path.Join(n.AppDir(), "schedule", job+".paused") } -// ScheduledJobUnitPrefixes returns the current namespace followed by the -// pre-2026.8.6 spelling when the application name contains a hyphen. -func (n Names) ScheduledJobUnitPrefixes() []string { - return distinctNames("ob-"+runtimeName(n.App)+"-", "ob-"+n.App+"-") +// BackupUnit is the systemd unit name without its suffix, so the .service and +// .timer that pair together cannot be spelled differently. It carries neither +// the application nor the environment: the host owner record names both. +func (n Names) BackupUnit(service, operation string) string { + return BackupUnitPrefix + runtimeName(service, operation) } -// BackupTimerForEnvironment names a backup timer. -// -// The "ob-backup-" prefix keeps it out of the namespace SyncSchedules owns. -// That is not cosmetic: the job scheduler treats every unit named "ob--*" -// as its own and removes the ones no longer declared, so backup timers named -// that way were deleted by the next deploy — every scheduled backup silently -// stopped, and the only trace was a line in the deploy output saying the -// schedule was "no longer declared". -func (n Names) BackupTimerForEnvironment(environment, service, operation string) string { - return n.BackupUnitForEnvironment(environment, service, operation) + ".timer" -} - -// BackupUnitForEnvironment is the systemd unit name without its suffix, so -// the .service and .timer that pair together cannot be spelled differently. -func (n Names) BackupUnitForEnvironment(environment, service, operation string) string { - return BackupUnitPrefix + runtimeName(n.App, environment, service, operation) -} - -// BackupUnitPrefixesForEnvironment returns the current injective namespace and -// the pre-2026.8.6 namespace when they differ. Reconciliation needs both so an -// upgrade removes old timers instead of leaving duplicate schedules behind. -func (n Names) BackupUnitPrefixesForEnvironment(environment string) []string { - return distinctNames( - BackupUnitPrefix+runtimeName(n.App, environment)+"-", - BackupUnitPrefix+n.App+"-"+environment+"-", - ) -} - -// BackupUnitPrefixes returns every application-wide backup namespace that -// teardown owns, including the legacy spelling used before segment escaping. -func (n Names) BackupUnitPrefixes() []string { - return distinctNames( - BackupUnitPrefix+runtimeName(n.App)+"-", - BackupUnitPrefix+n.App+"-", - ) -} - -// BackupUnitPrefix is the systemd namespace backup owns outright. -const BackupUnitPrefix = "ob-backup-" - -// Container is a workload's stable runtime slot. Container names are -// host-global, so every one carries the application, component, and a -// one-based replica ordinal — including singleton workloads. +// JobUnitPrefix and BackupUnitPrefix are the systemd namespaces Onebox owns +// outright. They are disjoint, and that is not cosmetic: the job scheduler +// removes every unit in its namespace that the project no longer declares, and +// backup timers once named inside it were deleted by the next deploy — every +// scheduled backup silently stopped. +const ( + JobUnitPrefix = Namespace + "-job-" + BackupUnitPrefix = Namespace + "-backup-" +) + +// Container is a workload's stable runtime slot. It carries the application, +// component, and a one-based replica ordinal — including singleton workloads, +// because replicas can change and a rollout moves containers between slots. The +// application keeps the author's containers distinct from the onebox-* ones +// Onebox runs, and from anything else the operator runs on the host. func (n Names) Container(workload string, replica int) string { return containerName(n.App, workload, replica) } @@ -313,11 +330,11 @@ func (n Names) TransientContainer(workload string) string { // harmless while the two live in different namespaces, and a trap the moment // anyone reads one list and assumes the other. func (n Names) Router(workload string, route int) string { - return join(n.App, workload, fmt.Sprintf("r%d", route)) + return join(Namespace, workload, fmt.Sprintf("r%d", route)) } func (n Names) ProxyService(workload string) string { - return join(n.App, workload) + return join(Namespace, workload) } // ProxyServiceFor is the Traefik backend for one route. @@ -332,17 +349,48 @@ func (n Names) ProxyServiceFor(workload string, route int) string { if route == 0 { return n.ProxyService(workload) } - return join(n.App, workload, fmt.Sprintf("r%d", route)) + return join(Namespace, workload, fmt.Sprintf("r%d", route)) } // AppDir, ReleasesDir, ReleaseDir, CurrentLink and HostDir are the remote layout. -func (n Names) AppDir() string { return path.Join(n.BasePath, n.App) } +func (n Names) AppDir() string { return path.Join(n.BasePath, AppNamespace) } func (n Names) ReleasesDir() string { return path.Join(n.AppDir(), "releases") } + +// AppMarker records which application's state AppDir holds. AppDir is a fixed, +// generic name under an operator-chosen basePath, so a directory of that name +// may predate Onebox: bootstrap refuses to adopt one without the marker, and +// destroy removes nothing without it. +func (n Names) AppMarker() string { return path.Join(n.AppDir(), AppMarkerFile) } func (n Names) ReleaseDir(id string) string { return path.Join(n.ReleasesDir(), id) } func (n Names) CurrentLink() string { return path.Join(n.AppDir(), "current") } -func (n Names) HostDir() string { return path.Join(n.BasePath, HostNamespace) } +func (n Names) HostDir() string { + if testHostStateDir != "" { + return testHostStateDir + } + return HostStateDir +} + +// testHostStateDir is set only by SetTestHostStateDir. Nothing in the product +// reads the environment for it: the ob command does, loudly, and test suites +// set it directly. +var testHostStateDir string + +// SetTestHostStateDir relocates host state for a test suite, and returns the +// function that restores it. A relative dir is refused, not ignored. +func SetTestHostStateDir(dir string) (restore func(), err error) { + if !path.IsAbs(dir) { + return nil, fmt.Errorf("test host state directory %q is not an absolute path", dir) + } + previous := testHostStateDir + testHostStateDir = path.Clean(dir) + return func() { testHostStateDir = previous }, nil +} + +// HostJournalDir holds the journal of operations on the host itself, such as +// applying the proxy. +func (n Names) HostJournalDir() string { return path.Join(n.HostDir(), "journal") } // HostOwnerPath is where the host owner record lives. Preflight and the engine // both probe it, and a preflight that reads a different path than the mutation @@ -473,18 +521,6 @@ func join(parts ...string) string { return out } -func distinctNames(names ...string) []string { - out := make([]string, 0, len(names)) - seen := map[string]bool{} - for _, name := range names { - if !seen[name] { - out = append(out, name) - seen[name] = true - } - } - return out -} - func containerName(app, component string, replica int) string { if replica < 1 { panic("container replica ordinal must be positive") diff --git a/internal/app/names_test.go b/internal/app/names_test.go index 839ed178..08492038 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -47,7 +47,6 @@ func TestDerivedNamesGolden(t *testing.T) { "ledger", "ledger-migrate-1", "ledger-migrate-new", - "ledger-postgres-1", "ledger-web-1", "ledger-web-2", "ledger-web-3", @@ -55,11 +54,12 @@ func TestDerivedNamesGolden(t *testing.T) { "ledger-worker-1", "ledger-worker-new", "ledger_default", - "ob_ledger", - "ob_ledger_postgres", - "ob_ledger_postgres_data", - "ob_ledger_postgres_wal", - "ob_ledger_web_uploads", + "onebox-postgres", + "onebox_postgres", + "onebox_postgres_data", + "onebox_postgres_wal", + "onebox_services", + "onebox_web_uploads", } got := p.All("production") if len(got) != len(want) { @@ -74,21 +74,20 @@ func TestDerivedNamesGolden(t *testing.T) { // TestDerivationIsInjective is the property the naming contract rests on. The // obvious hyphen-joined pattern fails it: (a-b, c) and (a, b-c) both derive -// ob-a-b-c, and two resources would share one volume. +// onebox-a-b-c, and two resources would share one volume. The application is +// not part of these names — a host has one — so only the components vary. func TestDerivationIsInjective(t *testing.T) { idents := []string{"a", "b", "a-b", "b-c", "c", "web", "web-1", "x-y-z"} seen := map[string]string{} - for _, app := range idents { - n := Names{App: app, BasePath: DefaultBasePath} - for _, svc := range idents { - for _, vol := range idents { - name := n.ServiceVolume(svc, vol) - key := app + "|" + svc + "|" + vol - if prev, dup := seen[name]; dup { - t.Fatalf("collision: %q derived from both %s and %s", name, prev, key) - } - seen[name] = key + n := Names{App: "shop", BasePath: DefaultBasePath} + for _, svc := range idents { + for _, vol := range idents { + name := n.ServiceVolume(svc, vol) + key := svc + "|" + vol + if prev, dup := seen[name]; dup { + t.Fatalf("collision: %q derived from both %s and %s", name, prev, key) } + seen[name] = key } } } @@ -98,63 +97,49 @@ func TestBackupNamesEscapeHyphenatedSegments(t *testing.T) { credentialNames := map[string]string{} jobNames := map[string]string{} unitNames := map[string]string{} - for _, application := range idents { - n := Names{App: application, BasePath: DefaultBasePath} - for _, service := range idents { - job := n.ScheduledJobUnit(service) - jobSource := application + "|" + service - if previous, exists := jobNames[job]; exists { - t.Fatalf("scheduled job collision: %q derives from both %s and %s", job, previous, jobSource) - } - jobNames[job] = jobSource - for _, target := range idents { - credential := n.BackupCredentialFile(service, target) - credentialSource := application + "|" + service + "|" + target - // Credential paths are application-scoped, so only pairs within - // the same application must be globally unique. - credentialKey := application + "|" + credential - if previous, exists := credentialNames[credentialKey]; exists { - t.Fatalf("credential collision: %q derives from both %s and %s", credential, previous, credentialSource) - } - credentialNames[credentialKey] = credentialSource + n := Names{App: "shop", BasePath: DefaultBasePath} + for _, service := range idents { + job := n.ScheduledJobUnit(service) + if previous, exists := jobNames[job]; exists { + t.Fatalf("scheduled job collision: %q derives from both %s and %s", job, previous, service) + } + jobNames[job] = service + for _, target := range idents { + credential := n.BackupCredentialFile(service, target) + source := service + "|" + target + if previous, exists := credentialNames[credential]; exists { + t.Fatalf("credential collision: %q derives from both %s and %s", credential, previous, source) } + credentialNames[credential] = source } - for _, environment := range idents { - for _, service := range idents { - for _, target := range idents { - unit := n.BackupUnitForEnvironment(environment, service, target) - unitSource := application + "|" + environment + "|" + service + "|" + target - if previous, exists := unitNames[unit]; exists { - t.Fatalf("backup unit collision: %q derives from both %s and %s", unit, previous, unitSource) - } - unitNames[unit] = unitSource - } + } + for _, service := range idents { + for _, operation := range idents { + unit := n.BackupUnit(service, operation) + source := service + "|" + operation + if previous, exists := unitNames[unit]; exists { + t.Fatalf("backup unit collision: %q derives from both %s and %s", unit, previous, source) } + unitNames[unit] = source } } - n := Names{App: "help-desk", BasePath: DefaultBasePath} + n = Names{App: "help-desk", BasePath: DefaultBasePath} if got := n.BackupCredentialFile("data-base", "off-site"); !strings.HasSuffix(got, "/data--base-off--site.env") { t.Fatalf("escaped credential path = %q", got) } - if got := n.BackupUnitForEnvironment("pre-prod", "data-base", "back-up"); got != "ob-backup-help--desk-pre--prod-data--base-back--up" { + if got := n.BackupUnit("data-base", "back-up"); got != "onebox-backup-data--base-back--up" { t.Fatalf("escaped backup unit = %q", got) } - if got := n.BackupCredentialFiles("data-base", "off-site"); len(got) != 2 || !strings.HasSuffix(got[1], "/data-base-off-site.env") { - t.Fatalf("credential migration paths = %#v", got) - } - if got := n.BackupUnitPrefixesForEnvironment("pre-prod"); len(got) != 2 || got[1] != "ob-backup-help-desk-pre-prod-" { - t.Fatalf("unit reconciliation prefixes = %#v", got) - } - if got := n.ScheduledJobUnit("data-base"); got != "ob-help--desk-data--base" { - t.Fatalf("escaped scheduled job unit = %q", got) + if got := n.ScheduledJobUnit("data-base"); got != "onebox-job-data-base" { + t.Fatalf("scheduled job unit = %q", got) } } // TestHyphenJoinWouldCollide records why underscore was chosen, so the reason // survives someone deciding hyphens look tidier. func TestHyphenJoinWouldCollide(t *testing.T) { - hyphen := func(app, svc string) string { return "ob-" + app + "-" + svc } + hyphen := func(app, svc string) string { return "onebox-" + app + "-" + svc } if hyphen("a-b", "c") != hyphen("a", "b-c") { t.Skip("hyphen joining no longer ambiguous; the underscore rule may be revisited") } @@ -170,13 +155,13 @@ func TestBasePathPerEnvironment(t *testing.T) { if err != nil { t.Fatal(err) } - if got := p.NamesFor("production").ReleaseDir("r1"); got != "/var/lib/ob/ledger/releases/r1" { + if got := p.NamesFor("production").ReleaseDir("r1"); got != "/var/lib/onebox/app/releases/r1" { t.Errorf("production release dir = %q", got) } - if got := p.NamesFor("staging").ReleaseDir("r1"); got != "/mnt/data/ob/ledger/releases/r1" { + if got := p.NamesFor("staging").ReleaseDir("r1"); got != "/mnt/data/ob/app/releases/r1" { t.Errorf("staging release dir = %q", got) } - if got := p.NamesFor("production").HostDir(); got != "/var/lib/ob/_host" { + if got := p.NamesFor("production").HostDir(); got != "/var/lib/onebox/_host" { t.Errorf("host dir = %q", got) } } @@ -211,7 +196,7 @@ func TestRuntimeContainerDerivationIsInjective(t *testing.T) { seen := map[string]string{} add := func(name, source string) { t.Helper() - if previous, exists := seen[name]; exists { + if previous, exists := seen[name]; exists && previous != source { t.Fatalf("runtime name %q derives from both %s and %s", name, previous, source) } seen[name] = source @@ -223,11 +208,27 @@ func TestRuntimeContainerDerivationIsInjective(t *testing.T) { add(n.Container(component, replica), fmt.Sprintf("container %s/%s/%d", application, component, replica)) } add(n.TransientContainer(component), "transient "+application+"/"+component) - add(n.BackupRestoreContainer(component), "restore "+application+"/"+component) + // Managed containers do not carry the application: a host has one, + // so only the component has to be distinct. + add(n.ServiceContainer(component), "service "+component) + add(n.BackupRestoreContainer(component), "restore "+component) } } } +func TestManagedContainersAreOneboxSingletons(t *testing.T) { + n := Names{App: "shop"} + if got := n.ServiceContainer("postgres"); got != "onebox-postgres" { + t.Errorf("service container = %q, want onebox-postgres", got) + } + if got := n.BackupRestoreContainer("postgres"); got != "onebox-postgres-restore" { + t.Errorf("restore container = %q, want onebox-postgres-restore", got) + } + if got := n.ServiceContainer("pg-main"); got != "onebox-pg--main" { + t.Errorf("hyphenated service container = %q, want onebox-pg--main", got) + } +} + // TestNormalisedRoutesReturnsDeclaredRoutes keeps generation behind one route // accessor even though the public contract now has only the explicit list form. func TestNormalisedRoutesReturnsDeclaredRoutes(t *testing.T) { @@ -253,8 +254,8 @@ func TestRouterDoesNotLookLikeAReplica(t *testing.T) { if n.Router("web", 2) == n.Container("web", 2) { t.Fatalf("router and replica derive the same name: %q", n.Router("web", 2)) } - if got := n.Router("web", 0); got != "ledger_web_r0" { - t.Errorf("router = %q, want ledger_web_r0", got) + if got := n.Router("web", 0); got != "onebox_web_r0" { + t.Errorf("router = %q, want onebox_web_r0", got) } } @@ -284,8 +285,70 @@ func TestNoDerivedNameCollidesWithHostScoped(t *testing.T) { if name == ProxyProject || name == IngressNetwork { t.Fatalf("derived name %q collides with a host-scoped name", name) } - if strings.HasPrefix(name, "ob-") { - t.Fatalf("derived name %q entered the reserved hyphenated namespace", name) + } +} + +// The onebox-* container namespace is Onebox's. An application called onebox +// would derive workload containers inside it, and a service called proxy or +// discovery would derive the host proxy's own container names. +func TestOneboxContainerNamespaceIsReserved(t *testing.T) { + for label, body := range map[string]string{ + "application onebox": `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: onebox +spec: + environments: {production: {server: root@203.0.113.10}} + workloads: + web: {image: nginx} +`, + "service proxy": `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + workloads: + web: {image: nginx} + services: + proxy: {driver: redis, version: 7} +`, + "service ingress": `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + workloads: + web: {image: nginx} + services: + ingress: {driver: redis, version: 7} +`, + "service services": `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + workloads: + web: {image: nginx} + services: + services: {driver: redis, version: 7} +`, + "service discovery": `apiVersion: onebox.run/v1alpha1 +kind: Application +metadata: + name: shop +spec: + environments: {production: {server: root@203.0.113.10}} + workloads: + web: {image: nginx} + services: + discovery: {driver: postgres, version: 18} +`, + } { + if _, err := loadFixtureBytes([]byte(body), "ob.yml"); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Errorf("%s: loaded, or refused for another reason: %v", label, err) } } } diff --git a/internal/app/namespace_test.go b/internal/app/namespace_test.go new file mode 100644 index 00000000..5f906405 --- /dev/null +++ b/internal/app/namespace_test.go @@ -0,0 +1,120 @@ +package app + +import ( + "go/scanner" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// cliNames are the only names that keep the command's own prefix: the project +// file, and the artifacts an operator saves, passes between commands, and reads +// on their own machine. Everything Onebox names on a host, inside a container, +// or for itself is in the onebox namespace — see reference/naming. +var cliNames = map[string]bool{ + "ob": true, + "ob.yml": true, + "ob.yaml": true, + "ob.yml.tmpl": true, + "ob.exe": true, + "ob-docgen": true, + "ob-plan.json": true, + "ob-approval.json": true, + "ob-job-plan.json": true, + "ob-job-approval.json": true, + "ob-detached-job-plan.json": true, + "ob-backup-report.json": true, +} + +// obName also matches a bare "ob-", "ob_" or "ob." literal, because names +// were built by joining such a prefix onto an identifier. +var obName = regexp.MustCompile(`(?:^|[^A-Za-z0-9_])(\.?ob[-_.](?:[A-Za-z0-9][A-Za-z0-9_.-]*)?)`) + +// TestNoNewNamesOutsideTheOneboxNamespace fails when a string literal +// introduces an ob-, ob_ or ob. name that is not one of cliNames. It reads the +// product, and the end-to-end suites, whose names must match what the product +// installs or their probes pass vacuously. Comments are not checked; names are. +func TestNoNewNamesOutsideTheOneboxNamespace(t *testing.T) { + root := filepath.Join("..", "..") + for _, dir := range []string{"internal", "cmd", "e2e"} { + err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "testdata" { + return filepath.SkipDir + } + return nil + } + inE2E := strings.HasPrefix(filepath.ToSlash(path), filepath.ToSlash(filepath.Join(root, "e2e"))+"/") + if !inE2E && (strings.HasSuffix(path, "_test.go") || strings.HasSuffix(path, "_test.py")) { + return nil + } + var literals []string + switch filepath.Ext(path) { + case ".go": + literals = goStringLiterals(t, path) + case ".py", ".sh": + literals = uncommentedLines(t, path) + default: + return nil + } + for _, literal := range literals { + for _, match := range obName.FindAllStringSubmatch(literal, -1) { + name := match[1] + if len(name) > len("ob.") { + name = strings.TrimRight(name, ".") + } + if !cliNames[name] { + t.Errorf("%s: %q is outside the onebox namespace; only the CLI's own files keep the ob prefix", path, name) + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + } +} + +func goStringLiterals(t *testing.T, path string) []string { + t.Helper() + src, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var s scanner.Scanner + fset := token.NewFileSet() + s.Init(fset.AddFile(path, fset.Base(), len(src)), src, nil, 0) + var out []string + for { + _, tok, lit := s.Scan() + if tok == token.EOF { + return out + } + if tok == token.STRING || tok == token.CHAR { + out = append(out, lit) + } + } +} + +func uncommentedLines(t *testing.T, path string) []string { + t.Helper() + src, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var out []string + for _, line := range strings.Split(string(src), "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") { + out = append(out, line) + } + } + return out +} diff --git a/internal/app/naming_scope_test.go b/internal/app/naming_scope_test.go index f1bb58a1..fef9bfb3 100644 --- a/internal/app/naming_scope_test.go +++ b/internal/app/naming_scope_test.go @@ -5,15 +5,18 @@ import ( "testing" ) -// 8.5 — every name Onebox derives carries the application. -// -// Container, volume and network names are host-global in the container -// runtime. A workload-scoped name such as `web` or `data` can collide with -// something Onebox does not own, and the collision surfaces as a container -// that vanishes or a volume shared between two applications — not as an error. +// 8.5 — every name derived from the author's workloads carries the +// application; everything Onebox derives for itself is in the onebox namespace. // +// Container names are host-global in the container runtime. A workload-scoped +// name such as `web-1` can collide with something the operator runs by hand, +// and the collision surfaces as a container that vanishes — not as an error. // The transient rollout name is included deliberately: it exists for seconds // during a handover, which is exactly when nobody is looking at it. +// +// Onebox's own containers, volumes, projects, networks, proxy routes and state +// directory do not carry the application. A host has one application, the onebox.app label records which, and +// the host is released only after those resources are removed. func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { n := Names{App: "shop", BasePath: DefaultBasePath} @@ -21,38 +24,46 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { "container": n.Container("web", 1), "replica container": n.Container("web", 2), "transient rollout": n.TransientContainer("web"), - "workload volume": n.WorkloadVolume("web", "uploads"), - "service container": n.ServiceContainer("postgres"), - "service project": n.ServiceProject("postgres"), - "service volume": n.ServiceVolume("postgres", "data"), - "service network": n.ServiceNetwork(), "application network": n.ApplicationNetwork(), "compose project": n.ComposeProject(), - "proxy service": n.ProxyService("web"), - "proxy service r1": n.ProxyServiceFor("web", 1), - "router": n.Router("web", 0), - "application dir": n.AppDir(), - "release dir": n.ReleaseDir("R1"), } { if !strings.Contains(got, "shop") { t.Errorf("%s = %q, which does not carry the application", label, got) } } - - // And two applications never derive the same name for the same thing. - other := Names{App: "ledger", BasePath: DefaultBasePath} - for label, pair := range map[string][2]string{ - "container": {n.Container("web", 1), other.Container("web", 1)}, - "transient": {n.TransientContainer("web"), other.TransientContainer("web")}, - "workload volume": {n.WorkloadVolume("web", "data"), other.WorkloadVolume("web", "data")}, - "service volume": {n.ServiceVolume("postgres", "data"), other.ServiceVolume("postgres", "data")}, - "service network": {n.ServiceNetwork(), other.ServiceNetwork()}, - "application network": {n.ApplicationNetwork(), other.ApplicationNetwork()}, - "router": {n.Router("web", 0), other.Router("web", 0)}, - "application dir": {n.AppDir(), other.AppDir()}, + for label, got := range map[string]string{ + "service container": n.ServiceContainer("postgres"), + "restore container": n.BackupRestoreContainer("postgres"), + } { + if !strings.HasPrefix(got, Namespace+"-") { + t.Errorf("%s = %q, which is not in the onebox-* namespace", label, got) + } + } + for label, got := range map[string]string{ + "workload volume": n.WorkloadVolume("web", "uploads"), + "service project": n.ServiceProject("postgres"), + "service volume": n.ServiceVolume("postgres", "data"), + "service network": n.ServiceNetwork(), + "restore project": n.BackupRestoreProject("postgres"), + "restore network": n.BackupRestoreNetwork("postgres"), + "restore volume": n.BackupRestoreVolume("postgres"), + "proxy service": n.ProxyService("web"), + "proxy service r1": n.ProxyServiceFor("web", 1), + "router": n.Router("web", 0), + } { + if !strings.HasPrefix(got, Namespace+"_") { + t.Errorf("%s = %q, which is not in the onebox_ namespace", label, got) + } + if strings.Contains(got, "shop") { + t.Errorf("%s = %q, which carries the application", label, got) + } + } + for label, got := range map[string]string{ + "application dir": n.AppDir(), + "release dir": n.ReleaseDir("R1"), } { - if pair[0] == pair[1] { - t.Errorf("%s: two applications derive the same name %q", label, pair[0]) + if !strings.HasPrefix(got, "/var/lib/onebox/app") { + t.Errorf("%s = %q, which is not under /var/lib/onebox/app", label, got) } } } @@ -101,27 +112,27 @@ spec: runtime := string(rendered.Bytes) for _, want := range []string{ // One backend per route, each carrying its own port. - "traefik.http.services.shop_web.loadbalancer.server.port: \"3000\"", - "traefik.http.services.shop_web_r1.loadbalancer.server.port: \"3001\"", - "traefik.http.services.shop_web_r2.loadbalancer.server.port: \"9000\"", - "traefik.tcp.services.shop_web_r3.loadbalancer.server.port: \"5432\"", + "traefik.http.services.onebox_web.loadbalancer.server.port: \"3000\"", + "traefik.http.services.onebox_web_r1.loadbalancer.server.port: \"3001\"", + "traefik.http.services.onebox_web_r2.loadbalancer.server.port: \"9000\"", + "traefik.tcp.services.onebox_web_r3.loadbalancer.server.port: \"5432\"", // Each router names the backend it means. - "traefik.http.routers.shop_web_r0.service: shop_web", - "traefik.http.routers.shop_web_r1.service: shop_web_r1", + "traefik.http.routers.onebox_web_r0.service: onebox_web", + "traefik.http.routers.onebox_web_r1.service: onebox_web_r1", // Middleware order is authored behavior, not a set to sort. - "traefik.http.routers.shop_web_r0.middlewares: compress@file,secure-headers@file", + "traefik.http.routers.onebox_web_r0.middlewares: compress@file,secure-headers@file", // The non-HTTP route is a TCP router matching on SNI, forwarded intact. - "traefik.tcp.routers.shop_web_r3.rule: HostSNI(`db.example.com`)", - "traefik.tcp.routers.shop_web_r3.middlewares: office-only@file", - "traefik.tcp.routers.shop_web_r3.tls.passthrough: \"true\"", + "traefik.tcp.routers.onebox_web_r3.rule: HostSNI(`db.example.com`)", + "traefik.tcp.routers.onebox_web_r3.middlewares: office-only@file", + "traefik.tcp.routers.onebox_web_r3.tls.passthrough: \"true\"", // And the scheme reaches the backend that needs it. - "traefik.http.services.shop_web_r2.loadbalancer.server.scheme: h2c", + "traefik.http.services.onebox_web_r2.loadbalancer.server.scheme: h2c", } { if !strings.Contains(runtime, want) { t.Errorf("the generated runtime is missing:\n %s", want) } } - if strings.Contains(runtime, "traefik.http.routers.shop_web_r1.middlewares") { + if strings.Contains(runtime, "traefik.http.routers.onebox_web_r1.middlewares") { t.Fatal("middleware from route zero leaked onto route one") } } @@ -148,7 +159,7 @@ spec: if err != nil { t.Fatal(err) } - if want := "traefik.http.routers.shop_web_r0.middlewares: prefix@file,auth@file,prefix@file"; !strings.Contains(string(rendered.Bytes), want) { + if want := "traefik.http.routers.onebox_web_r0.middlewares: prefix@file,auth@file,prefix@file"; !strings.Contains(string(rendered.Bytes), want) { t.Fatalf("middleware chain lost its authored order or repetition:\n%s", rendered.Bytes) } } diff --git a/internal/app/preflight.go b/internal/app/preflight.go index 3d1afd66..2b662bee 100644 --- a/internal/app/preflight.go +++ b/internal/app/preflight.go @@ -9,7 +9,6 @@ import ( "sort" "strings" - "github.com/labstack/onebox/internal/shellquote" "github.com/labstack/onebox/internal/transport" "github.com/compose-spec/compose-go/v2/dotenv" @@ -100,12 +99,13 @@ func (r *Resolved) Preflight(ctx context.Context, run Runner) (*Report, error) { // 3. The base path. Checked without creating anything: preflight that // mutates is not preflight. report.Checks = append(report.Checks, basePathCheck(ctx, run, n.BasePath)) + report.Checks = append(report.Checks, hostStateCheck(ctx, run, n.HostDir())) report.Checks = append(report.Checks, hostOwnerCheck(ctx, run, n.HostOwnerPath(), p.Name, r.Env)) // 4. Name collisions. One listing per resource kind rather than one command // per name — a project with twenty derived names should not cost twenty // round trips. - owned, err := ownedNames(ctx, run, p, r.Env) + owned, err := ownedNames(ctx, run, p.Name) if err != nil { return nil, err } @@ -203,11 +203,8 @@ func hostOwnerCheck(ctx context.Context, run Runner, path, application, environm if owner.Application != application { return Check{Name: "host owner", Detail: fmt.Sprintf("host is owned by application %s", owner.Application), Remedy: "choose an unowned host; Onebox supports one application owner per host"} } - if owner.Legacy() { - return Check{Name: "host owner", OK: true, Detail: application + " (claimed before environments were recorded; ob bootstrap will complete it)"} - } if owner.Environment != environment { - // Every runtime name is application-scoped, so a second environment on + // No derived name carries the environment, so a second environment on // this host would reuse the first one's containers and volumes rather // than collide with them. Nothing downstream can see the difference. return Check{ @@ -220,6 +217,18 @@ func hostOwnerCheck(ctx context.Context, run Runner, path, application, environm } func basePathCheck(ctx context.Context, run Runner, base string) Check { + return writablePathCheck(ctx, run, "base path", base, "set base_path to a directory this account owns") +} + +// hostStateCheck tests the fixed host state directory the same way. It does +// not follow basePath, so a basePath this account owns says nothing about it. +func hostStateCheck(ctx context.Context, run Runner, dir string) Check { + return writablePathCheck(ctx, run, "host state", dir, "deploy as an account that can write it") +} + +// writablePathCheck reports whether this account can create or write path. +// setting names what the operator changes instead of granting access. +func writablePathCheck(ctx context.Context, run Runner, label, base, setting string) Check { // Walk up to the nearest ancestor we can see and test that it is usable. // // -e follows symlinks, so the walk has to stop at a link it cannot @@ -238,36 +247,36 @@ func basePathCheck(ctx context.Context, run Runner, base string) Check { base, ProbeNotRegular, ProbeStatePathNotDirectory, ProbeUndetermined) res, err := run.Run(ctx, cmd) if err != nil { - return Check{Name: "base path", Detail: "could not read the base path", Remedy: "verify target access, then retry"} + return Check{Name: label, Detail: "could not read the " + label, Remedy: "verify target access, then retry"} } where := strings.TrimSpace(res.Stdout) switch res.ExitCode { case 0: - return Check{Name: "base path", OK: true, Detail: base} + return Check{Name: label, OK: true, Detail: base} case ProbeNotRegular: return Check{ - Name: "base path", + Name: label, Detail: fmt.Sprintf("%s is a symlink whose target does not exist", where), - Remedy: fmt.Sprintf("repair or remove %s; ob will not create a base path through a broken link", where), + Remedy: fmt.Sprintf("repair or remove %s; ob will not create a directory through a broken link", where), } case ProbeStatePathNotDirectory: return Check{ - Name: "base path", + Name: label, Detail: fmt.Sprintf("%s is not a directory", where), - Remedy: fmt.Sprintf("remove %s, or set base_path somewhere ob can create a directory", where), + Remedy: fmt.Sprintf("remove %s, or %s", where, setting), } case ProbeUndetermined: return Check{ - Name: "base path", + Name: label, Detail: fmt.Sprintf("%s cannot be searched, so its contents could not be checked", where), Remedy: fmt.Sprintf("grant this account access to %s, then retry", where), } } if res.ExitCode == 1 { return Check{ - Name: "base path", + Name: label, Detail: fmt.Sprintf("%s is not writable by this account", where), - Remedy: fmt.Sprintf("grant write access to %s, or set base_path to a directory this account owns", where), + Remedy: fmt.Sprintf("grant write access to %s, or %s", where, setting), } } // The probe emits 0, 1, 4, 5 and 6 and nothing else, so any other status @@ -276,8 +285,8 @@ func basePathCheck(ctx context.Context, run Runner, base string) Check { // a cause preflight never observed, with an empty path where the offending // directory should be. return Check{ - Name: "base path", - Detail: fmt.Sprintf("the base path could not be checked (exit %d)", res.ExitCode), + Name: label, + Detail: fmt.Sprintf("the %s could not be checked (exit %d)", label, res.ExitCode), Remedy: "verify target access and that a POSIX shell is available, then retry", } } @@ -285,26 +294,15 @@ func basePathCheck(ctx context.Context, run Runner, base string) Check { // ownedNames lists the container, volume and network names already on the host, // with whichever application owns each. A name held by this application is the // normal case — a previous release — and only a foreign holder is a collision. -func ownedNames(ctx context.Context, run Runner, project *Spec, environment string) (map[string]string, error) { +func ownedNames(ctx context.Context, run Runner, application string) (map[string]string, error) { owned := map[string]string{} - application := project.Name - n := project.NamesFor(environment) - legacyServiceState := false - if len(project.Services) > 0 { - res, err := run.Run(ctx, "test -d "+shellquote.Quote(n.ServiceDir())) - if err != nil { - return nil, errf("server_unreachable", "", "", "cannot inspect legacy service-network ownership: %v", err) - } - legacyServiceState = res.ExitCode == 0 - } - + n := Names{App: application} for _, q := range []struct { - cmd, kind string - composeProject bool + cmd, kind string }{ - {`docker ps -a --format '{{.Names}}\t{{.Label "ob.app"}}'`, "container", false}, - {`docker volume ls --format '{{.Name}}\t{{.Label "ob.app"}}'`, "volume", false}, - {`docker network ls --format '{{.Name}}\t{{.Label "ob.app"}}\t{{.Label "com.docker.compose.project"}}'`, "network", true}, + {`docker ps -a --format '{{.Names}}\t{{.Label "onebox.app"}}'`, "container"}, + {`docker volume ls --format '{{.Name}}\t{{.Label "onebox.app"}}'`, "volume"}, + {`docker network ls --format '{{.Name}}\t{{.Label "onebox.app"}}\t{{.Label "com.docker.compose.project"}}'`, "network"}, } { res, err := run.Run(ctx, q.cmd) if err != nil { @@ -328,17 +326,8 @@ func ownedNames(ctx context.Context, run Runner, project *Spec, environment stri if len(fields) > 1 { owner = strings.TrimSpace(fields[1]) } - // Before Onebox labelled networks, Compose still labelled the - // application default with its project. That is sufficient migration - // evidence for this exact application, but not for a hand-created - // network with only the derived name. - if owner == "" && q.composeProject && name == n.ApplicationNetwork() && len(fields) > 2 && strings.TrimSpace(fields[2]) == application { - owner = application - } - // Durable service state proves only an observed legacy service - // network. Applying it after all resource kinds are merged would also - // bless an unlabelled container or volume with the same name. - if owner == "" && q.kind == "network" && name == n.ServiceNetwork() && legacyServiceState { + // The same rule the engine applies, so preflight predicts it. + if owner == "" && q.kind == "network" && len(fields) > 2 && n.ComposeCreatedApplicationNetwork(name, strings.TrimSpace(fields[2])) { owner = application } // Docker permits the same name in different resource kinds. Every diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index f173f242..f36fdcdd 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -43,16 +43,16 @@ func healthyRunner() *fakeRunner { return &fakeRunner{answers: map[string]transport.Result{ "docker version": {Stdout: "27.1.1\n"}, "docker buildx imagetools inspect --help": {Stdout: "Usage: docker buildx imagetools inspect [OPTIONS] NAME\n --format string\n"}, - "/_host/owner": {Stdout: "ledger\n"}, + "/_host/owner": {Stdout: "ledger production\n"}, "docker ps": {Stdout: ""}, "docker volume": {Stdout: ""}, - "docker network": {Stdout: "ob-ingress\t\n"}, + "docker network": {Stdout: "onebox-ingress\t\n"}, }} } func TestPreflightRefusesForeignHostOwner(t *testing.T) { run := healthyRunner() - run.answers["/_host/owner"] = transport.Result{Stdout: "another-app\n"} + run.answers["/_host/owner"] = transport.Result{Stdout: "another-app production\n"} report := preflight(t, run, preflightProject) if report.OK() || !strings.Contains(report.Failures()[0].Detail, "another-app") { t.Fatalf("foreign host owner was not reported: %+v", report.Failures()) @@ -218,12 +218,12 @@ func TestPreviousReleaseIsNotACollision(t *testing.T) { // create must never be adopted silently. func TestForeignHolderIsACollision(t *testing.T) { run := healthyRunner() - run.answers["docker volume"] = transport.Result{Stdout: "ob_ledger_web_uploads\t\n"} + run.answers["docker volume"] = transport.Result{Stdout: "onebox_web_uploads\t\n"} rep := preflight(t, run, preflightProject) var found bool for _, c := range rep.Failures() { - if c.Name == "name collisions" && strings.Contains(c.Detail, "ob_ledger_web_uploads") { + if c.Name == "name collisions" && strings.Contains(c.Detail, "onebox_web_uploads") { found = true } } @@ -242,22 +242,10 @@ func TestForeignApplicationNetworkIsACollision(t *testing.T) { } } -func TestLegacyComposeApplicationNetworkBelongsToTheApp(t *testing.T) { - run := healthyRunner() - run.answers["docker network ls"] = transport.Result{Stdout: "ledger_default\t\tledger\n"} - - report := preflight(t, run, preflightProject) - for _, failure := range report.Failures() { - if failure.Name == "name collisions" { - t.Fatalf("the app's legacy Compose network was reported as foreign: %s", failure.Detail) - } - } -} - func TestOwnedNetworkDoesNotMaskForeignHolderOfTheSameName(t *testing.T) { run := healthyRunner() run.answers["docker volume ls"] = transport.Result{Stdout: "ledger_default\t\n"} - run.answers["docker network ls"] = transport.Result{Stdout: "ledger_default\tledger\tledger\n"} + run.answers["docker network ls"] = transport.Result{Stdout: "ledger_default\tledger\n"} report := preflight(t, run, preflightProject) if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ledger_default") { @@ -265,50 +253,33 @@ func TestOwnedNetworkDoesNotMaskForeignHolderOfTheSameName(t *testing.T) { } } -func TestLegacyServiceNetworkRequiresOneboxState(t *testing.T) { +func TestComposeProjectOwnsOnlyTheApplicationNetwork(t *testing.T) { + run := healthyRunner() + run.answers["docker network ls"] = transport.Result{Stdout: "ledger_default\t\tledger\n"} + report := preflight(t, run, preflightProject) + for _, failure := range report.Failures() { + if failure.Name == "name collisions" { + t.Fatalf("the application's Compose network was reported as foreign: %s", failure.Detail) + } + } + project := preflightProject + "services: {postgres: {version: 17}}\n" + run = healthyRunner() + run.answers["docker network ls"] = transport.Result{Stdout: "onebox_services\t\tledger\n"} + report = preflight(t, run, project) + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "onebox_services") { + t.Fatalf("a project label proved ownership of the service network: %+v", report.Failures()) + } +} - t.Run("fresh host refuses an unlabelled network", func(t *testing.T) { - run := healthyRunner() - run.answers["docker network ls"] = transport.Result{Stdout: "ob_ledger\t\t\n"} - run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{ExitCode: 1} - report := preflight(t, run, project) - if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ob_ledger") { - t.Fatalf("an unproved service network must be foreign: %+v", report.Failures()) - } - }) - - t.Run("compose project is not service ownership evidence", func(t *testing.T) { - run := healthyRunner() - run.answers["docker network ls"] = transport.Result{Stdout: "ob_ledger\t\tledger\n"} - run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{ExitCode: 1} - report := preflight(t, run, project) - if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ob_ledger") { - t.Fatalf("a Compose label incorrectly proved service-network ownership: %+v", report.Failures()) - } - }) - - t.Run("existing service state proves the legacy network", func(t *testing.T) { - run := healthyRunner() - run.answers["docker network ls"] = transport.Result{Stdout: "ob_ledger\t\t\n"} - run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{} - report := preflight(t, run, project) - for _, failure := range report.Failures() { - if failure.Name == "name collisions" { - t.Fatalf("the app's legacy service network was reported as foreign: %s", failure.Detail) - } - } - }) - - t.Run("service state does not bless another resource kind", func(t *testing.T) { - run := healthyRunner() - run.answers["docker volume ls"] = transport.Result{Stdout: "ob_ledger\t\n"} - run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{} - report := preflight(t, run, project) - if report.OK() || !strings.Contains(report.Failures()[0].Detail, "ob_ledger") { - t.Fatalf("legacy service state masked a foreign volume: %+v", report.Failures()) - } - }) +func TestUnlabelledServiceNetworkIsForeign(t *testing.T) { + project := preflightProject + "services: {postgres: {version: 17}}\n" + run := healthyRunner() + run.answers["docker network ls"] = transport.Result{Stdout: "onebox_services\t\n"} + report := preflight(t, run, project) + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "onebox_services") { + t.Fatalf("an unlabelled service network must be foreign: %+v", report.Failures()) + } } // TestRuntimeFailureShortCircuits: without a container runtime every other @@ -567,7 +538,7 @@ func TestHostOwnerRecordParsesTheSameForPreflightAndEngine(t *testing.T) { record string ok bool }{ - {"sample", true}, + {"sample", false}, {"sample production", true}, {" sample production ", true}, {"sample production extra", false}, @@ -606,3 +577,23 @@ func TestEnvironmentNamesMustSurviveTheOwnerRecord(t *testing.T) { } } } + +// Host state does not follow basePath, so a basePath this account owns proves +// nothing about it: preflight must test the host state directory itself. +func TestPreflightChecksTheFixedHostStateDirectory(t *testing.T) { + run := healthyRunner() + run.answers[`p="/var/lib/onebox/_host"`] = transport.Result{ExitCode: 1, Stdout: "/var/lib\n"} + report := preflight(t, run, preflightProject) + var found bool + for _, failure := range report.Failures() { + if failure.Name == "host state" && strings.Contains(failure.Remedy, "/var/lib") { + found = true + } + if failure.Name == "base path" { + t.Fatalf("the base path was reported for the host state directory: %+v", failure) + } + } + if !found { + t.Fatalf("an unwritable host state directory was not reported: %+v", report.Failures()) + } +} diff --git a/internal/app/secret_generation.go b/internal/app/secret_generation.go index df269c80..7830d124 100644 --- a/internal/app/secret_generation.go +++ b/internal/app/secret_generation.go @@ -9,7 +9,7 @@ import ( "gopkg.in/yaml.v3" ) -const SecretGenerationDirectory = ".ob-secret-generations" +const SecretGenerationDirectory = ".onebox-secret-generations" var opaqueSecretGeneration = regexp.MustCompile(`^sg-[0-9a-f]{24}$`) @@ -159,12 +159,12 @@ func secretEnvPathMatches(value, output string) bool { func setGenerationLabel(service map[string]any, generation string) error { raw, exists := service["labels"] if !exists { - service["labels"] = map[string]any{"ob.secret-generation": generation} + service["labels"] = map[string]any{"onebox.secret-generation": generation} return nil } switch labels := raw.(type) { case map[string]any: - labels["ob.secret-generation"] = generation + labels["onebox.secret-generation"] = generation case []any: out := make([]any, 0, len(labels)+1) for _, item := range labels { @@ -172,11 +172,11 @@ func setGenerationLabel(service map[string]any, generation string) error { if !ok { return fmt.Errorf("list entry is malformed") } - if !strings.HasPrefix(value, "ob.secret-generation=") { + if !strings.HasPrefix(value, "onebox.secret-generation=") { out = append(out, value) } } - service["labels"] = append(out, "ob.secret-generation="+generation) + service["labels"] = append(out, "onebox.secret-generation="+generation) default: return fmt.Errorf("mapping or list required") } @@ -189,13 +189,13 @@ func generationLabel(raw any) (string, error) { } switch labels := raw.(type) { case map[string]any: - value, exists := labels["ob.secret-generation"] + value, exists := labels["onebox.secret-generation"] if !exists { return "", nil } generation, ok := value.(string) if !ok { - return "", fmt.Errorf("ob.secret-generation must be a string") + return "", fmt.Errorf("onebox.secret-generation must be a string") } return generation, nil case []any: @@ -204,7 +204,7 @@ func generationLabel(raw any) (string, error) { if !ok { return "", fmt.Errorf("list entry is malformed") } - if generation, found := strings.CutPrefix(value, "ob.secret-generation="); found { + if generation, found := strings.CutPrefix(value, "onebox.secret-generation="); found { return generation, nil } } diff --git a/internal/app/secret_generation_test.go b/internal/app/secret_generation_test.go index a135eb4d..a0bc6c75 100644 --- a/internal/app/secret_generation_test.go +++ b/internal/app/secret_generation_test.go @@ -29,15 +29,15 @@ func TestSecretGenerationValidatorIsStrict(t *testing.T) { func TestApplySecretGenerationChangesOnlyAffectedSecretBindings(t *testing.T) { input := []byte(`services: web: - env_file: [plain.env, .ob-decrypted-sops-api.env, .ob-service-postgres.env] - labels: {ob.app: shop} + env_file: [plain.env, .onebox-decrypted-sops-api.env, .onebox-service-postgres.env] + labels: {onebox.app: shop} worker: - env_file: [.ob-decrypted-sops-worker.env] - labels: {ob.app: shop} + env_file: [.onebox-decrypted-sops-worker.env] + labels: {onebox.app: shop} `) graph := []SecretDeclaration{ - {OutputPath: ".ob-decrypted-sops-api.env", AffectedWorkloads: []string{"web"}}, - {OutputPath: ".ob-decrypted-sops-worker.env", AffectedWorkloads: []string{"worker"}}, + {OutputPath: ".onebox-decrypted-sops-api.env", AffectedWorkloads: []string{"web"}}, + {OutputPath: ".onebox-decrypted-sops-worker.env", AffectedWorkloads: []string{"worker"}}, } generation := "sg-111111111111111111111111" output, err := ApplySecretGeneration(input, graph, generation) @@ -45,12 +45,12 @@ func TestApplySecretGenerationChangesOnlyAffectedSecretBindings(t *testing.T) { t.Fatal(err) } text := string(output) - for _, secret := range []string{".ob-decrypted-sops-api.env", ".ob-decrypted-sops-worker.env"} { + for _, secret := range []string{".onebox-decrypted-sops-api.env", ".onebox-decrypted-sops-worker.env"} { if !strings.Contains(text, SecretGenerationPath(generation, secret)) { t.Fatalf("runtime does not select generation path for %s:\n%s", secret, text) } } - for _, unchanged := range []string{"plain.env", ".ob-service-postgres.env"} { + for _, unchanged := range []string{"plain.env", ".onebox-service-postgres.env"} { if !strings.Contains(text, unchanged) { t.Fatalf("non-secret binding %s changed:\n%s", unchanged, text) } @@ -64,12 +64,12 @@ func TestApplySecretGenerationChangesOnlyAffectedSecretBindings(t *testing.T) { func TestSecretGenerationFromComposeRefusesPartialOrMixedState(t *testing.T) { for name, runtime := range map[string]string{ "partial": `services: - web: {labels: {ob.secret-generation: sg-111111111111111111111111}} - worker: {labels: {ob.app: shop}} + web: {labels: {onebox.secret-generation: sg-111111111111111111111111}} + worker: {labels: {onebox.app: shop}} `, "mixed": `services: - web: {labels: {ob.secret-generation: sg-111111111111111111111111}} - worker: {labels: {ob.secret-generation: sg-222222222222222222222222}} + web: {labels: {onebox.secret-generation: sg-111111111111111111111111}} + worker: {labels: {onebox.secret-generation: sg-222222222222222222222222}} `, } { t.Run(name, func(t *testing.T) { @@ -85,16 +85,16 @@ func TestApplySecretGenerationReplacesListFormLabel(t *testing.T) { const newGeneration = "sg-222222222222222222222222" input := []byte(`services: web: - env_file: [.ob-decrypted-sops-api.env] - labels: [ob.app=shop, ob.secret-generation=` + oldGeneration + `] + env_file: [.onebox-decrypted-sops-api.env] + labels: [onebox.app=shop, onebox.secret-generation=` + oldGeneration + `] `) - graph := []SecretDeclaration{{OutputPath: ".ob-decrypted-sops-api.env", AffectedWorkloads: []string{"web"}}} + graph := []SecretDeclaration{{OutputPath: ".onebox-decrypted-sops-api.env", AffectedWorkloads: []string{"web"}}} output, err := ApplySecretGeneration(input, graph, newGeneration) if err != nil { t.Fatal(err) } text := string(output) - if strings.Contains(text, oldGeneration) || strings.Count(text, "ob.secret-generation=") != 1 { + if strings.Contains(text, oldGeneration) || strings.Count(text, "onebox.secret-generation=") != 1 { t.Fatalf("list-form generation label was not replaced exactly once:\n%s", text) } selected, err := SecretGenerationFromCompose(output, []string{"web"}) diff --git a/internal/app/secrets_graph_test.go b/internal/app/secrets_graph_test.go index ccead5b7..5ea71db2 100644 --- a/internal/app/secrets_graph_test.go +++ b/internal/app/secrets_graph_test.go @@ -35,8 +35,8 @@ spec: `) graph := resolved.SecretDeclarationGraph() want := []SecretDeclaration{ - {ID: "secret_bb87e5bf4bf6", SourceFile: "shared.enc.env", Provider: "sops", OutputPath: ".ob-decrypted-sops-shared.enc.env", Scope: "runtime-default", Order: 0, AffectedWorkloads: []string{"web", "worker"}}, - {ID: "secret_dbdbbfa277bf", SourceFile: "later.enc.env", Provider: "sops", OutputPath: ".ob-decrypted-sops-later.enc.env", Scope: "runtime-default", Order: 1, AffectedWorkloads: []string{"web", "worker"}}, + {ID: "secret_4cb584324f13", SourceFile: "shared.enc.env", Provider: "sops", OutputPath: ".onebox-decrypted-sops-shared.enc.env", Scope: "runtime-default", Order: 0, AffectedWorkloads: []string{"web", "worker"}}, + {ID: "secret_d3ed670385a9", SourceFile: "later.enc.env", Provider: "sops", OutputPath: ".onebox-decrypted-sops-later.enc.env", Scope: "runtime-default", Order: 1, AffectedWorkloads: []string{"web", "worker"}}, } if !reflect.DeepEqual(graph, want) { t.Fatalf("graph = %#v, want %#v", graph, want) @@ -55,7 +55,7 @@ spec: `) first := resolved.SecretDeclarationGraph() second := resolved.SecretDeclarationGraph() - if len(first) != 1 || first[0].ID != "secret_ee5df50c58b5" || !reflect.DeepEqual(first, second) { + if len(first) != 1 || first[0].ID != "secret_b495147e924f" || !reflect.DeepEqual(first, second) { t.Fatalf("unstable declaration IDs: first=%+v second=%+v", first, second) } } @@ -76,8 +76,8 @@ spec: worker: {role: Worker, image: nginx} ` want := []SecretDeclaration{ - {ID: "secret_a87617a41c5a", SourceFile: "first.enc.env", Provider: "sops", OutputPath: ".ob-decrypted-sops-first.enc.env", Scope: "runtime-default", Order: 0, AffectedWorkloads: []string{"web", "worker"}}, - {ID: "secret_e21b0c3c5c76", SourceFile: "second.enc.env", Provider: "sops", OutputPath: ".ob-decrypted-sops-second.enc.env", Scope: "runtime-default", Order: 1, AffectedWorkloads: []string{"web", "worker"}}, + {ID: "secret_5f8f352141fa", SourceFile: "first.enc.env", Provider: "sops", OutputPath: ".onebox-decrypted-sops-first.enc.env", Scope: "runtime-default", Order: 0, AffectedWorkloads: []string{"web", "worker"}}, + {ID: "secret_9ac32abb2f52", SourceFile: "second.enc.env", Provider: "sops", OutputPath: ".onebox-decrypted-sops-second.enc.env", Scope: "runtime-default", Order: 1, AffectedWorkloads: []string{"web", "worker"}}, } if got := secretGraphProject(t, base).SecretDeclarationGraph(); !reflect.DeepEqual(got, want) { t.Fatalf("base graph = %#v, want literal %#v", got, want) @@ -158,8 +158,8 @@ spec: probe: {} `) want := []SecretDeclaration{{ - ID: "secret_84b31ed35a16", SourceFile: "secrets/database.env", Provider: "sops", - OutputPath: ".ob-external-database_web.env", Scope: "external:web", Order: 0, + ID: "secret_d625b2713093", SourceFile: "secrets/database.env", Provider: "sops", + OutputPath: ".onebox-external-database_web.env", Scope: "external:web", Order: 0, AffectedWorkloads: []string{"web"}, ProjectionEntries: []SecretProjectionEntry{ {Destination: "A_DATABASE_URL", Source: "DATABASE_URL"}, diff --git a/internal/app/service_image_state_test.go b/internal/app/service_image_state_test.go index b0eac1c2..e244f6a2 100644 --- a/internal/app/service_image_state_test.go +++ b/internal/app/service_image_state_test.go @@ -13,7 +13,7 @@ func serviceImageTestResolved(withPolicy bool) *Resolved { } return &Resolved{ Spec: &Spec{ - Name: "example", BasePath: "/var/lib/ob", + Name: "example", BasePath: "/var/lib/onebox", Services: map[string]Service{"database": service}, BackupTargets: map[string]BackupTarget{"offsite": backupTestTarget()}, }, diff --git a/internal/app/services.go b/internal/app/services.go index fdcdf223..a9743925 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -58,9 +58,8 @@ type driver struct { secretEnv []string // scheme builds the client URL: scheme://user:password@host:port/database. scheme string - // user is the identity the service is created with, and database is the - // application name, so two projects on one host cannot end up sharing a - // database by accident. + // user is the identity the service is created with; the database is named + // after the application, an identity Onebox derives rather than asks for. user string // urlQuery is appended to the connection string. Some drivers need a // parameter to be usable at all: a Mongo root user created through @@ -368,9 +367,9 @@ func (p *Spec) renderService(n Names, name string, s Service, selectedImage stri "restart": "unless-stopped", "container_name": n.ServiceContainer(name), "labels": map[string]any{ - "ob.app": p.Name, - "ob.service": name, - "ob.driver": key, + "onebox.app": p.Name, + "onebox.service": name, + "onebox.driver": key, }, "networks": []string{n.ServiceNetwork()}, // The credential file is written on the target and never travels with @@ -464,7 +463,7 @@ func (p *Spec) renderService(n Names, name string, s Service, selectedImage stri svc["volumes"] = mounts volumes[full] = map[string]any{ "name": full, - "labels": map[string]any{"ob.app": p.Name, "ob.service": name}, + "labels": map[string]any{"onebox.app": p.Name, "onebox.service": name}, } } if s.Resources != nil { @@ -497,8 +496,8 @@ func (p *Spec) renderService(n Names, name string, s Service, selectedImage stri } // identityEnv is the user and database the service is created with, under the -// variable names each driver expects. Both are the application name, so two -// projects on one host cannot silently share a database. +// variable names each driver expects. The user is the driver's fixed role and +// the database is named after the application. func identityEnv(key string, d driver, app string) map[string]any { switch key { case "postgres": @@ -847,7 +846,7 @@ func writeEnvFile(path string, names map[string]string, parts map[string]string) // on Debian and Ubuntu. The temp file shares the target's directory so the // rename cannot cross a filesystem. func atomicEnvFile(path string, body func(target string) string) string { - quoted, temp := shellQuote(path), shellQuote(path+".ob-tmp") + quoted, temp := shellQuote(path), shellQuote(path+".onebox-tmp") var b strings.Builder fmt.Fprintf(&b, "if ! printf '' > %[1]s; then echo 'cannot write '%[1]s >&2; exit 1; fi\n", temp) // Every append is checked too. The rename is only reached when the temp file diff --git a/internal/app/services_test.go b/internal/app/services_test.go index fb3bfe15..f5d384e1 100644 --- a/internal/app/services_test.go +++ b/internal/app/services_test.go @@ -43,10 +43,10 @@ func renderStore(t *testing.T, body string) string { // mean a rollback could remove the database's volume. func TestServiceIsItsOwnProject(t *testing.T) { doc := renderStore(t, "services: {store: {driver: postgres, version: 17}}\n") - if !strings.Contains(doc, "name: ob_shop_store") { + if !strings.Contains(doc, "name: onebox_store") { t.Fatalf("service is not in its own project:\n%s", doc) } - if !strings.Contains(doc, "ob_shop_store_data:/var/lib/postgresql/data") { + if !strings.Contains(doc, "onebox_store_data:/var/lib/postgresql/data") { t.Fatalf("no durable volume at the driver's data path:\n%s", doc) } if !strings.Contains(doc, "external: true") { @@ -60,7 +60,7 @@ func TestServiceDocumentCarriesNoCredential(t *testing.T) { if strings.Contains(doc, "POSTGRES_PASSWORD:") { t.Fatalf("a credential reached the generated runtime:\n%s", doc) } - if !strings.Contains(doc, "/var/lib/ob/shop/services/store.secret.env") { + if !strings.Contains(doc, "/var/lib/onebox/app/services/store.secret.env") { t.Fatalf("no reference to the target-side credential:\n%s", doc) } } @@ -76,10 +76,10 @@ func TestNeedingAServiceJoinsItAndReadsItsURL(t *testing.T) { t.Fatal(err) } body := string(out.Bytes) - if !strings.Contains(body, "ob_shop") { + if !strings.Contains(body, "onebox_services") { t.Fatalf("workload did not join the service network:\n%s", body) } - if !strings.Contains(body, "/var/lib/ob/shop/services/store.client.env") { + if !strings.Contains(body, "/var/lib/onebox/app/services/store.client.env") { t.Fatalf("workload cannot learn how to reach the service:\n%s", body) } // depends_on cannot cross Compose projects; emitting it would make the diff --git a/internal/app/testdata/compose.yaml b/internal/app/testdata/compose.yaml index 504a30dd..fa9d1b03 100644 --- a/internal/app/testdata/compose.yaml +++ b/internal/app/testdata/compose.yaml @@ -42,11 +42,11 @@ services: owned: image: nginx labels: - ob.app: someone-else + onebox.app: someone-else attached: image: nginx - networks: [default, ob-ingress] + networks: [default, onebox-ingress] segmented: image: nginx diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index f4cc610a..d6c95be0 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -7,7 +7,7 @@ { "case": "conformance/a plugin log driver", "loads": true, - "digest": "cec4ac9813f5363b2a55a480f5db33cc53c04c91ad11af39c9100be5fd0010f1" + "digest": "ba08f725a75ef6f26e3b0e2b5032d479dd54be284babd8b08c21a4e6814c2365" }, { "case": "conformance/absolute compose ref", @@ -22,15 +22,15 @@ { "case": "conformance/absolute writable bind mount", "loads": true, - "digest": "49df20c8d9726213a80cb8f865a8f25083a6003d1c9a15aff869098b74673455" + "digest": "963edc703c904f47a04f88a78ca109468367b232ab6e4ff656cb07cc270fc300" }, { "case": "conformance/an absolute bind mount is external", "loads": true, - "digest": "0c9e00afa123a09626e894b0de83007cf142155735146d9cd0e0c814785ed526" + "digest": "d8dda544924031d00c57aeb7dfaae6b02bec441e7389d50deaec73a3bf801a26" }, { - "case": "conformance/app starting ob-", + "case": "conformance/app starting onebox-", "loads": false, "code": "project_invalid" }, @@ -92,7 +92,7 @@ { "case": "conformance/base_path absolute", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/bind source containing a compose separator", @@ -107,7 +107,7 @@ { "case": "conformance/daemon role", "loads": true, - "digest": "10803382e10cb8685ebf5006c9f47cd74e6544cdab7b1a1674e79f0ba302bf0f" + "digest": "6c35d6ada2608260eec6a61921f0abb12e0ef48aeb03c400d1315314e4b195a2" }, { "case": "conformance/declared durability still refuses replicas", @@ -117,12 +117,12 @@ { "case": "conformance/duration in days", "loads": true, - "digest": "087192685a9b863e38bc6b96e8436075b279b2d7222ad66b7ff1a9d782a127d2" + "digest": "dac5c9ed4decb409eb9fb2ed8c90191ffd524d92a17dfc3ce442600f9b78d60d" }, { "case": "conformance/encrypted env file entry", "loads": true, - "digest": "d87891ad760c2aca38053753731a3bf03ddab6f987d4cd9e87110af4715708d3" + "digest": "c300b69e70991a95bc2625fe246ca4a41557913ec412009253dcc7365b03b3c3" }, { "case": "conformance/env file entry without a file", @@ -132,17 +132,17 @@ { "case": "conformance/environment-scoped env files", "loads": true, - "digest": "56cbcd764cb9b8ee0cb78468a1cacc7b54a5afa917ccb1b222194727ddb3434c" + "digest": "68c89d3fbb35e9876736c4d05ca6d384af1c2fd110e60fbf00285ce7e538ede8" }, { "case": "conformance/explicit operator job remains a runtime service", "loads": true, - "digest": "13d1b0f2e12b3bee654c9611be6461f90c52efdcbc8294597e53f6378bb70c28" + "digest": "0b39c8152c3d3542a65d61c8455f58b63aece4ed100c66dff9e851afa4c631c7" }, { "case": "conformance/explicit workloads block", "loads": true, - "digest": "6f81377bb89168720a2e43bb638100f9e70d9b4f6392559ee65a6c8089637ba2" + "digest": "9526cdd08e1b6b018b4fce194741030d5cc09618317df9d24072e4fdcee99356" }, { "case": "conformance/external lifecycle field", @@ -152,12 +152,12 @@ { "case": "conformance/external service connection", "loads": true, - "digest": "704b83e1340e576ad8acda97f924dbdcb10de59395a7c22bc05a83bdece753a9" + "digest": "9b15e15fef71409b167288343b8e318b300a7f595ee4393cb83e4cd0cf52ea19" }, { "case": "conformance/hook naming a declared job", "loads": true, - "digest": "4af7b3c8d6b1fc8122040f4ec485f748900153b6cb41f2e145413c23b6d20db2" + "digest": "ec59f3b4014aade8bb1eb3bf58e086504f16555e8eebe3894a1d6beb9c6a4485" }, { "case": "conformance/hook naming an unlisted seam", @@ -172,7 +172,7 @@ { "case": "conformance/hook with local", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/host proxy name", @@ -192,7 +192,7 @@ { "case": "conformance/image reference with registry port", "loads": true, - "digest": "1d27b063513ee1c15067cb23e31898cb271aba6250df66dd6f1c115369c2f80e" + "digest": "6f8443574dda0e2d8ae2f91ea46e46c67853c72401610578c8bc8fdfe0e31472" }, { "case": "conformance/image reference with uppercase repository", @@ -207,12 +207,12 @@ { "case": "conformance/inferred durability does not refuse replicas", "loads": true, - "digest": "0c179c61a883e6dd0a59885d60ca765709015aec445fc4be770a6e021e2d7201" + "digest": "448ed517fa84e18a05c9cb777a168a8c503d5d65ec8d69fed2825a2b3a00bf47" }, { "case": "conformance/job data_effect unknown", "loads": true, - "digest": "13d1b0f2e12b3bee654c9611be6461f90c52efdcbc8294597e53f6378bb70c28" + "digest": "0b39c8152c3d3542a65d61c8455f58b63aece4ed100c66dff9e851afa4c631c7" }, { "case": "conformance/job requires data_effect", @@ -222,7 +222,7 @@ { "case": "conformance/job with data_effect", "loads": true, - "digest": "13d1b0f2e12b3bee654c9611be6461f90c52efdcbc8294597e53f6378bb70c28" + "digest": "0b39c8152c3d3542a65d61c8455f58b63aece4ed100c66dff9e851afa4c631c7" }, { "case": "conformance/log driver with a space", @@ -242,12 +242,12 @@ { "case": "conformance/migration_policy expand-only", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/minimum project", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/missing api_version", @@ -282,17 +282,17 @@ { "case": "conformance/notification with no events", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/one-char identifier", "loads": true, - "digest": "087192685a9b863e38bc6b96e8436075b279b2d7222ad66b7ff1a9d782a127d2" + "digest": "dac5c9ed4decb409eb9fb2ed8c90191ffd524d92a17dfc3ce442600f9b78d60d" }, { "case": "conformance/operator proxy owns route middleware", "loads": true, - "digest": "bfb6fed92b7b1fee8a9170f5d5880e5eb70d36854803c8675f17fcb17dc002ba" + "digest": "93ba8c0a7344004feaa711ef682e2fe31c226768397069613a0a723e58177782" }, { "case": "conformance/persistence block with no mode still refuses replicas", @@ -302,7 +302,7 @@ { "case": "conformance/persistence external", "loads": true, - "digest": "8bc10aaa4453e53980a552c2a78fd78dc7ebf42259a6567b27920fe784c3d579" + "digest": "1c8cf8cafe45c46cc614eba4f81fc6ed7adca446b097004aff81e02158b430f5" }, { "case": "conformance/port out of range", @@ -312,7 +312,7 @@ { "case": "conformance/provider-qualified route middlewares", "loads": true, - "digest": "68e64d82ad9afb845f41e9bfe57a920c0452649a74644877f6f69949714edbc4" + "digest": "995ffdd8bfc296f2187388c2664856775d4b37beaa21c2351e0d88908411210e" }, { "case": "conformance/proxy kind none with a route", @@ -322,17 +322,17 @@ { "case": "conformance/proxy kind none without a route", "loads": true, - "digest": "6f81377bb89168720a2e43bb638100f9e70d9b4f6392559ee65a6c8089637ba2" + "digest": "9526cdd08e1b6b018b4fce194741030d5cc09618317df9d24072e4fdcee99356" }, { "case": "conformance/published udp port", "loads": true, - "digest": "029238424aa0c9fd448a4c2b930ded33d9ce30b53b3ab45a087f47f8ff77de8c" + "digest": "5decea845fe4a831db17eb6fe96ea5e0a767b6e438a0315f9b260d9f314da508" }, { "case": "conformance/recreate workload with published host port", "loads": true, - "digest": "c8766522ff86082d00c7d2e43233e15696c4bf2567a0df2a3ccc8fede134fbfd" + "digest": "ffaae765a295e0440afa21cf7e1b2bfc9b915a2c3997b93850daf77d65f5b316" }, { "case": "conformance/relative bind escaping the release", @@ -347,12 +347,12 @@ { "case": "conformance/relative env_file", "loads": true, - "digest": "9d40880f62c05790ff682afc8c185f0ebc454e77522e9e734ef8f256461dea91" + "digest": "cc5dff1d0b16cd83e693041b4d4f1ac0c9fffa34073e36d23c81755fab16418d" }, { "case": "conformance/relative read-only bind mount", "loads": true, - "digest": "a2527f885d806b157fa60c13f03e245749143e08e512d512f14970da6f8f2a95" + "digest": "937f5226f44cc90e738d5fd68de1201d3a227a3be9709011fac9515b5a5e3481" }, { "case": "conformance/relative writable bind mount", @@ -362,7 +362,7 @@ { "case": "conformance/repeated route middleware remains ordered", "loads": true, - "digest": "2422cebd0a6c76e245e576cfefa0bb2596966f7e47dda8c3fa42013633339b3e" + "digest": "5fb1a46d08a46a8d9b481a560c06893857d6451cd50ed96d4b53d571772ee245" }, { "case": "conformance/rolling workload with published host port", @@ -372,12 +372,12 @@ { "case": "conformance/routes list", "loads": true, - "digest": "c44552dd7728f71b6232b172d17b60dff503ae9e576cc039db083dd76365a488" + "digest": "324585a45f34f693d6ef1660d91bb04866f3d29170abf3515df0cde234ff4e38" }, { "case": "conformance/scheduled job", "loads": true, - "digest": "13d1b0f2e12b3bee654c9611be6461f90c52efdcbc8294597e53f6378bb70c28" + "digest": "0b39c8152c3d3542a65d61c8455f58b63aece4ed100c66dff9e851afa4c631c7" }, { "case": "conformance/scheduled job invalid timeout", @@ -387,22 +387,22 @@ { "case": "conformance/scheduled job run policy", "loads": true, - "digest": "13d1b0f2e12b3bee654c9611be6461f90c52efdcbc8294597e53f6378bb70c28" + "digest": "0b39c8152c3d3542a65d61c8455f58b63aece4ed100c66dff9e851afa4c631c7" }, { "case": "conformance/service backup policy", "loads": true, - "digest": "77d034b99d52d2ca044aae16988d35c67e0b24a504d092a5050887a0063abe06 postgres=c1475eb63145a73b" + "digest": "73135190e9f9fae42347e107f496211e4d2f75bfe5ff934ead0096f4f3571a19 postgres=ee9972ab51c56731" }, { "case": "conformance/service scalar", "loads": true, - "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 postgres=16f6016ba043ba3b" + "digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a postgres=f566c9221cb196ab" }, { "case": "conformance/settings key that is a real driver flag", "loads": true, - "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 redis=f5e4171f39cd0dcb" + "digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a redis=fd038e9c5b5c3b09" }, { "case": "conformance/settings key with a shell metacharacter", @@ -477,7 +477,7 @@ { "case": "conformance/unmanaged proxy keeps its routes", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "11d9655e57e8814a2fad47821e7e691d7d401f01d2595d4610fe260c1a2a443c" }, { "case": "conformance/unqualified route middleware", @@ -492,12 +492,12 @@ { "case": "conformance/url check with contains and advisory", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/volume scalar with a path", "loads": true, - "digest": "70ab58ff576ee933006bae05e3634e08a4bbb45bde2422bc62d2a5ad41ad12e6" + "digest": "615d6b5e05cc3cda30185684fe9773c6eb24abe8be3ab8733e98056279b6275f" }, { "case": "conformance/volume scalar without a path", @@ -507,7 +507,7 @@ { "case": "conformance/volumes without persistence still load", "loads": true, - "digest": "70ab58ff576ee933006bae05e3634e08a4bbb45bde2422bc62d2a5ad41ad12e6" + "digest": "615d6b5e05cc3cda30185684fe9773c6eb24abe8be3ab8733e98056279b6275f" }, { "case": "conformance/worker with schedule", @@ -532,17 +532,17 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "64ea7ff80a65ba1594bc9565675e7ff194b3acdb44540ebb2e84a46d2e4c4bf1 postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" + "digest": "e82f2e9eac87e3494cf0aa86b80f91a60f363f0af7aac28bb45e1ec6ed2e40fb postgres=d88fd3d620b30ea1 redis=53bad39e8f359a02" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "baf976b3c71b90c78e1bcab801a9765b6111abae7df5600a15233a9811c99a0e postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" + "digest": "10748708444a383dc2e587286c13f06a4f3ea218835e2eeab1af83ed312ec741 postgres=d88fd3d620b30ea1 redis=53bad39e8f359a02" }, { "case": "corpus/ext-authentik.yml", "loads": true, - "digest": "e5aebd1b4f98af7beaa603ae44d981486150335461306d7cabf173476bdf27ee" + "digest": "d7fdef6fcc8d19296aa43c09e11a3925f1e31ba035c8e90bd6cb9095d3a59cac" }, { "case": "corpus/ext-frigate.yml", @@ -552,7 +552,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "96f5d0556ae66e0c5052343fb6d763a16686510181a1406266864c5eed49be80 postgres=e70cc45c347098f9" + "digest": "f6028df90e48b43f68c3375115ef402255ebd0aadf0ddde9eb9e2d538598cb65 postgres=446e25618a3d2e4e" }, { "case": "corpus/ext-immich-sourced.yml", @@ -567,32 +567,32 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "1b9c415f24ea342e79a3c14b5ad6bb485f29df88b7478371e99ee7f59006d86a postgres=809549d286e2dbdc redis=86933b446609e6d8" + "digest": "3831bd9a52d01deada4936e218fc8ba33c96a0b1d939e6bf503f3fbda8ba0895 postgres=c9e06c2c21513656 redis=83f7ec43501bb1f4" }, { "case": "corpus/ext-paperless.yml", "loads": true, - "digest": "8ec597bfc4bc7d71d879e5f7dc12d94009c326fdba1720b65d7c5ea726e7a183" + "digest": "b0ef183132b8159b180a29eb5bdb28de719dc97e7e5c151bc7896b96cbdb04ad" }, { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "9919231c9f5b2702f56fda1f56bae20a1d3912039e8013144af7ce204b986f2f events=1e798590e3a5dda4 postgres=b1fac70440c33545" + "digest": "4574810017062b8d857ad2c43597b484fe88e6b4198f75ec74c2d705cbcf17d1 events=79a3b4327069a1dc postgres=128c7ea3ed2b4f69" }, { "case": "corpus/ext-umami.yml", "loads": true, - "digest": "4088b4fc05094ac274c76205c774fb6b99f03c00abc97f7e2574eadf4ca7c6f6 postgres=36c6c38ba304b445" + "digest": "fd193a8c2f51cbe206e2e038ccd277c3e6785c84912b5967a2afd58c1320ac13 postgres=a0f143f02b9f5845" }, { "case": "corpus/ghost.yml", "loads": true, - "digest": "ba2aaf586bcb73298f6175fb36813d8720a3f67d58c7bc023bf35a564b1e19d3 mysql=0f13a6374095d11b" + "digest": "7ab89c09054e821edfb0edd5c014b488ca06785d052f4178a688d4008961e1ad mysql=6dc2ab74dac73160" }, { "case": "corpus/gitea.yml", "loads": true, - "digest": "c6347add0af261bc2f87d29691f6de5b14401d63c7034ed5ba8c0798913d7d52" + "digest": "0bf67b194d6c01daec00313f1e6f2cd9be3613a5dffd8ad0097a728360d7a269" }, { "case": "corpus/goal.yml", @@ -602,7 +602,7 @@ { "case": "corpus/immich.yml", "loads": true, - "digest": "d8e311adf9eb1ae4fc7e1362e53de2ba2e6ccefa2deefd0a8b1a976c7b5bfa06" + "digest": "923534f84d1c3d4c4d0fd59c6c20b9d2d197acfc045046531b7235cfc73e0d3e" }, { "case": "corpus/monk.yml", @@ -612,17 +612,17 @@ { "case": "corpus/n8n.yml", "loads": true, - "digest": "45034dd978133e0aab7db59c3ee86a2157e306a608a8633feeb204b4958c5fef" + "digest": "ecc2235d37c777399acbda26f8c149515e3960fbfbdd286873459fc5c8b0164d" }, { "case": "corpus/paperless.yml", "loads": true, - "digest": "80991cdd434564e0612397e3f64427088ce51d87f30e2502cdc9a496773ba81c" + "digest": "c5eea938217dd6488e684078199dc338bc4915d8be3de3e0f728e6ced3b2b01d" }, { "case": "corpus/penpot.yml", "loads": true, - "digest": "4d7d95dd74e291c481366cf606f5b2537fa5bf97cf126bea50df9fa927d26da3 postgres=fc584b1b50db23a6 redis=fcccb6a023ae5734" + "digest": "1cd500aae4deac5cf744dec3f1222b13aec493457de4b6069a3449126ad70210 postgres=fce3c20f4ad2e0e4 redis=e6f4fb5264fe75ae" }, { "case": "corpus/pursue.yml", @@ -637,21 +637,21 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "0b1a575f884bc3ae2aa87bde1df62dc2a00068ba17c56f2288fa7412eb918a96 mongodb=eaca06e5d1b88e4b" + "digest": "4a091cd29c4a572eb57fcc9cd68edb76fbd56eb88ce16aab29efb30cb16af69f mongodb=09562fa5123be0a1" }, { "case": "corpus/umami.yml", "loads": true, - "digest": "ea01ea117cbbae96ad9e6f3bc9dff6bf40839cdb21dcc540aca078e2d79d503b" + "digest": "93e16cdd46259289535d57fa3fe762f65717a8ab054daa365e14f1ca8f5b815d" }, { "case": "corpus/uptime-kuma.yml", "loads": true, - "digest": "e3f215d2cbbd19d05f6c4dd7b07f5b2e7cb28a79fc1877ec2ca25b06ff2feef4" + "digest": "0814a4130438e336934f9265f6d4f482bac99ba4198df1c7ccc0f2668655003e" }, { "case": "corpus/vaultwarden.yml", "loads": true, - "digest": "d09dabcf3ed38023913407762fa2d2c6a25db15625ae90386ee23cd9628ab838" + "digest": "5779468825f6562666a9d32ad1e7e1390e35de0ffaef97e8a377ccf81047f99b" } ] diff --git a/internal/app/testdata/corpus/pursue.yml b/internal/app/testdata/corpus/pursue.yml index 3057628d..8a21c5b6 100644 --- a/internal/app/testdata/corpus/pursue.yml +++ b/internal/app/testdata/corpus/pursue.yml @@ -42,6 +42,6 @@ spec: kind: TraefikDocker image: "traefik:v3.7@sha256:1cb3845d7a05e1473c9086351426597e911db49db382b6e4769f9b0744962ac8" config: traefik - network: ob-ingress + network: onebox-ingress registries: ghcr: {server: ghcr.io, username: vishr, passwordEnv: GHCR_TOKEN} diff --git a/internal/app/testdata/corpus/recast.yml b/internal/app/testdata/corpus/recast.yml index bb5fae1e..84db7bba 100644 --- a/internal/app/testdata/corpus/recast.yml +++ b/internal/app/testdata/corpus/recast.yml @@ -50,6 +50,6 @@ spec: kind: TraefikDocker image: "traefik:v3.7@sha256:1cb3845d7a05e1473c9086351426597e911db49db382b6e4769f9b0744962ac8" config: traefik - network: ob-ingress + network: onebox-ingress registries: ghcr: {server: ghcr.io, username: vishr, passwordEnv: GHCR_TOKEN} diff --git a/internal/app/types.go b/internal/app/types.go index 3b1e4ade..fbec2531 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -34,7 +34,7 @@ type Spec struct { // then writes `.App.App`. The authored key is still `app:`. Name string `json:"app" description:"Stable application name used in generated container, volume, network, and host paths." example:"shop"` Annotations map[string]string `json:"-"` - BasePath string `json:"base_path" description:"Absolute host directory beneath which Onebox stores application state and releases." default:"/var/lib/ob" example:"/srv/ob"` + BasePath string `json:"base_path" description:"Absolute host directory beneath which Onebox stores application state and releases." default:"/var/lib/onebox" example:"/srv/ob"` Environments map[string]Environment `json:"environments" description:"Named environments, each naming the server it deploys to and the policy applied to it."` Workloads map[string]Workload `json:"workloads,omitempty" description:"Application containers, workers, daemons, and jobs managed as releases."` Services map[string]Service `json:"services,omitempty" description:"Supporting services managed outside application releases, such as databases and caches."` @@ -551,7 +551,7 @@ type Proxy struct { Kind string `json:"kind" description:"Proxy implementation, or none to disable routing." default:"traefik-docker"` Image string `json:"image,omitempty" description:"Container image used for the managed proxy."` Config string `json:"config,omitempty" description:"Repository-relative proxy configuration directory. Dynamic YAML or TOML files extend Onebox's managed configuration. A managed DNS challenge may use a directory containing only .env for provider credentials. Including traefik.yml or traefik.yaml instead takes ownership of the static configuration, which must use the watched file-provider directory /etc/traefik/dynamic, must not enable the Docker provider, must define certificatesResolvers.letsencrypt for exact terminating routes, and must define the DNS-01 certificatesResolvers.onebox-wildcard for wildcard terminating routes. Dynamic files may not reuse Onebox-generated router or service names or redefine the managed onebox-compress middleware."` - Network string `json:"network" description:"External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved." default:"ob-ingress"` + Network string `json:"network" description:"External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved." default:"onebox-ingress"` Entrypoints map[string]ProxyEntrypoint `json:"entrypoints,omitempty" description:"Additional named TCP listeners published by the managed proxy. Onebox adds them to its generated static configuration; a proxy.config containing custom traefik.yml or traefik.yaml must define matching Traefik entrypoints."` DNSChallenge *ProxyDNSChallenge `json:"dns_challenge,omitempty" description:"Managed ACME DNS-01 challenge used to issue wildcard certificates. Provider credentials belong in proxy.config/.env; Onebox continues to own the static proxy configuration."` } @@ -603,5 +603,5 @@ func (e EnvFile) StagedPath() string { // the separator alone collides, and the generated document would then list // one name twice and quietly keep whichever entry came last. escaped := strings.ReplaceAll(strings.ReplaceAll(e.File, "-", "--"), "/", "-") - return ".ob-decrypted-" + e.Provider + "-" + escaped + return ".onebox-decrypted-" + e.Provider + "-" + escaped } diff --git a/internal/app/validate.go b/internal/app/validate.go index ee185353..f3e09592 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -255,6 +255,10 @@ func validateWorkload(w Workload, path string) error { if err := checkEnum(path+".role", w.Role, eRole); err != nil { return err } + if w.Replicas > MaxReplicas { + return errf("project_invalid", path+".replicas", "", + "%d replicas is more than the %d one host runs; Onebox deploys to a single host", w.Replicas, MaxReplicas) + } if err := checkPositive(path+".replicas", w.Replicas); err != nil { return err } @@ -463,7 +467,7 @@ func validateWorkload(w Workload, path string) error { return err } for key := range w.Labels { - if strings.HasPrefix(key, "ob.") || strings.HasPrefix(key, "traefik.") { + if strings.HasPrefix(key, "onebox.") || strings.HasPrefix(key, "traefik.") { return errf("project_invalid", path+".labels", "", "%q is in a namespace Onebox generates into; choose another key", key) } diff --git a/internal/app/workload_contract.go b/internal/app/workload_contract.go index fb153142..fe2f39da 100644 --- a/internal/app/workload_contract.go +++ b/internal/app/workload_contract.go @@ -17,15 +17,15 @@ const ( // WorkloadStartupRevisionLabel binds startup-only, non-secret inputs that are // not represented by the rendered Compose service itself. The value is a // one-way aggregate; source values never enter the runtime or plan. - WorkloadStartupRevisionLabel = "ob.startup-revision" + WorkloadStartupRevisionLabel = "onebox.startup-revision" // WorkloadSecretRevisionLabel is an opaque per-workload secret identity. It - // deliberately differs from ob.secret-generation: a generation is the + // deliberately differs from onebox.secret-generation: a generation is the // transaction-wide storage slot, while this identity changes only for the // workloads whose effective secret inputs changed. - WorkloadSecretRevisionLabel = "ob.secret-revision" + WorkloadSecretRevisionLabel = "onebox.secret-revision" // WorkloadSecretInputRevisionLabel identifies the encrypted source material // and value-free projection declaration that produced a workload's secrets. - WorkloadSecretInputRevisionLabel = "ob.secret-input-revision" + WorkloadSecretInputRevisionLabel = "onebox.secret-input-revision" ) var workloadStartupRevision = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) diff --git a/internal/app/workload_contract_test.go b/internal/app/workload_contract_test.go index 9bac2457..ff672126 100644 --- a/internal/app/workload_contract_test.go +++ b/internal/app/workload_contract_test.go @@ -15,8 +15,8 @@ func TestWorkloadRevisionIgnoresSecretStorageGeneration(t *testing.T) { compose := []byte(`services: worker: image: example/worker@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - env_file: [.ob-secret-generations/` + first + `/app.env] - labels: {ob.app: sample, ob.release: R1, ob.secret-generation: ` + first + `} + env_file: [.onebox-secret-generations/` + first + `/app.env] + labels: {onebox.app: sample, onebox.release: R1, onebox.secret-generation: ` + first + `} `) contract := map[string]WorkloadContract{"worker": { SecretRevision: first, diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index eda755ec..9af130b5 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -17,21 +17,21 @@ func routedContainer(id string, created time.Time, health, ip string, labels map } return Container{ ID: id, Created: created, Running: true, Health: health, - Labels: base, Networks: map[string]string{"ob-ingress": ip}, + Labels: base, Networks: map[string]string{"onebox-ingress": ip}, } } func TestBuildPreservesHealthAwareHTTPRouting(t *testing.T) { old := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) labels := map[string]string{ - "traefik.http.routers.shop_web_r0.rule": "Host(`shop.example.com`)", - "traefik.http.routers.shop_web_r0.entrypoints": "websecure", - "traefik.http.routers.shop_web_r0.middlewares": "compress@file,secure@file", - "traefik.http.routers.shop_web_r0.tls": "true", - "traefik.http.routers.shop_web_r0.tls.certresolver": "letsencrypt", - "traefik.http.routers.shop_web_r0.service": "shop_web", - "traefik.http.services.shop_web.loadbalancer.server.port": "3000", - "traefik.http.services.shop_web.loadbalancer.server.scheme": "h2c", + "traefik.http.routers.onebox_web_r0.rule": "Host(`shop.example.com`)", + "traefik.http.routers.onebox_web_r0.entrypoints": "websecure", + "traefik.http.routers.onebox_web_r0.middlewares": "compress@file,secure@file", + "traefik.http.routers.onebox_web_r0.tls": "true", + "traefik.http.routers.onebox_web_r0.tls.certresolver": "letsencrypt", + "traefik.http.routers.onebox_web_r0.service": "onebox_web", + "traefik.http.services.onebox_web.loadbalancer.server.port": "3000", + "traefik.http.services.onebox_web.loadbalancer.server.scheme": "h2c", } containers := []Container{ routedContainer("healthy", old, "healthy", "172.20.0.2", labels), @@ -39,18 +39,18 @@ func TestBuildPreservesHealthAwareHTTPRouting(t *testing.T) { routedContainer("starting", old, "starting", "172.20.0.4", labels), routedContainer("unhealthy", old, "unhealthy", "172.20.0.5", labels), } - document, err := Build(containers, "ob-ingress") + document, err := Build(containers, "onebox-ingress") if err != nil { t.Fatal(err) } - router := document.HTTP.Routers["shop_web_r0"] - if router.Rule != "Host(`shop.example.com`)" || router.Service != "shop_web" { + router := document.HTTP.Routers["onebox_web_r0"] + if router.Rule != "Host(`shop.example.com`)" || router.Service != "onebox_web" { t.Fatalf("router = %+v", router) } if strings.Join(router.Middlewares, ",") != "compress@file,secure@file" || router.TLS.CertResolver != "letsencrypt" { t.Fatalf("router middleware/tls = %+v", router) } - servers := document.HTTP.Services["shop_web"].LoadBalancer.Servers + servers := document.HTTP.Services["onebox_web"].LoadBalancer.Servers if len(servers) != 2 || servers[0].URL != "h2c://172.20.0.2:3000" || servers[1].URL != "h2c://172.20.0.3:3000" { t.Fatalf("eligible servers = %+v", servers) } @@ -66,7 +66,7 @@ func TestBuildPreservesWildcardRuleAndCertificateDomain(t *testing.T) { "traefik.http.routers.preview_web_r0.service": "preview_web", "traefik.http.services.preview_web.loadbalancer.server.port": "8080", } - document, err := Build([]Container{routedContainer("healthy", time.Now(), "healthy", "172.20.0.2", labels)}, "ob-ingress") + document, err := Build([]Container{routedContainer("healthy", time.Now(), "healthy", "172.20.0.2", labels)}, "onebox-ingress") if err != nil { t.Fatal(err) } @@ -92,7 +92,7 @@ func TestBuildUsesNewestHealthyRouterDuringRollAndRollback(t *testing.T) { document, err := Build([]Container{ routedContainer("old-release", base, "healthy", "172.20.0.2", labels("old.example.com")), routedContainer("new-container", base.Add(time.Minute), "healthy", "172.20.0.3", labels("new.example.com")), - }, "ob-ingress") + }, "onebox-ingress") if err != nil { t.Fatal(err) } @@ -110,7 +110,7 @@ func TestBuildTCPPassthrough(t *testing.T) { "traefik.tcp.routers.app_db_r0.tls.passthrough": "true", "traefik.tcp.routers.app_db_r0.tls.certresolver": "letsencrypt", "traefik.tcp.services.app_db.loadbalancer.server.port": "5432", - })}, "ob-ingress") + })}, "onebox-ingress") if err != nil { t.Fatal(err) } @@ -130,7 +130,7 @@ func TestBuildRejectsInvalidGeneratedBackend(t *testing.T) { "traefik.http.routers.app_web_r0.rule": "Host(`app.example.com`)", "traefik.http.routers.app_web_r0.service": "app_web", "traefik.http.services.app_web.loadbalancer.server.port": "root", - })}, "ob-ingress") + })}, "onebox-ingress") if err == nil || !strings.Contains(err.Error(), "invalid backend port") { t.Fatalf("invalid port error = %v", err) } diff --git a/internal/discovery/docker_test.go b/internal/discovery/docker_test.go index 6b7c0e1e..ce6d4353 100644 --- a/internal/discovery/docker_test.go +++ b/internal/discovery/docker_test.go @@ -16,7 +16,7 @@ import ( func dockerTestServer(t *testing.T, handler http.Handler) string { t.Helper() - dir, err := os.MkdirTemp("/tmp", "ob-discovery-test-") + dir, err := os.MkdirTemp("/tmp", "onebox-discovery-test-") if err != nil { t.Fatal(err) } @@ -59,7 +59,7 @@ func TestDockerClientReadsOnlyRequiredContainerState(t *testing.T) { "Created":"2026-08-27T12:00:00Z", "Config":{"Env":["SECRET=must-not-cross-boundary"],"Labels":{"traefik.enable":"true"}}, "State":{"Status":"running","Health":{"Status":"healthy"}}, - "NetworkSettings":{"Networks":{"ob-ingress":{"IPAddress":"172.20.0.8"}}} + "NetworkSettings":{"Networks":{"onebox-ingress":{"IPAddress":"172.20.0.8"}}} }`) default: http.NotFound(w, r) @@ -70,7 +70,7 @@ func TestDockerClientReadsOnlyRequiredContainerState(t *testing.T) { if err != nil { t.Fatal(err) } - if len(containers) != 1 || containers[0].Health != "healthy" || containers[0].Networks["ob-ingress"] != "172.20.0.8" { + if len(containers) != 1 || containers[0].Health != "healthy" || containers[0].Networks["onebox-ingress"] != "172.20.0.8" { t.Fatalf("containers = %+v", containers) } if got := fmt.Sprint(containers[0]); strings.Contains(got, "must-not-cross-boundary") { @@ -94,7 +94,7 @@ func TestDockerClientFallsBackToGlobalIPv6Address(t *testing.T) { "Created":"2026-08-27T12:00:00Z", "Config":{"Labels":{"traefik.enable":"true"}}, "State":{"Status":"running"}, - "NetworkSettings":{"Networks":{"ob-ingress":{"IPAddress":"","GlobalIPv6Address":"2001:db8::8"}}} + "NetworkSettings":{"Networks":{"onebox-ingress":{"IPAddress":"","GlobalIPv6Address":"2001:db8::8"}}} }`) default: http.NotFound(w, r) @@ -105,7 +105,7 @@ func TestDockerClientFallsBackToGlobalIPv6Address(t *testing.T) { if err != nil { t.Fatal(err) } - if len(containers) != 1 || containers[0].Networks["ob-ingress"] != "2001:db8::8" { + if len(containers) != 1 || containers[0].Networks["onebox-ingress"] != "2001:db8::8" { t.Fatalf("IPv6-only endpoint = %+v", containers) } } diff --git a/internal/durable/runner.py b/internal/durable/runner.py index 167dc4b2..ba445339 100644 --- a/internal/durable/runner.py +++ b/internal/durable/runner.py @@ -244,16 +244,8 @@ def cleanup_container(config, invocation=None, shutdown_grace=30, state_path=Non rows = json.loads(docker(["inspect"] + ids)) for row in rows: labels = row["Config"].get("Labels") or {} - legacy_job = ( - invocation is None - and not any(key.startswith("ob.execution.") for key in labels) - and row.get("Name") == "/" + config["container"] - and labels.get("com.docker.compose.project") == config["application"] - and labels.get("com.docker.compose.service") == config["job"] - and labels.get("com.docker.compose.oneoff", "").lower() == "true" - ) require( - labels.get("ob.execution.job") == config["job"] or legacy_job, + labels.get("onebox.execution.job") == config["job"], "existing container has no matching job ownership; inspect and remove it manually", ) if invocation is None: @@ -263,7 +255,7 @@ def cleanup_container(config, invocation=None, shutdown_grace=30, state_path=Non ) else: require( - labels.get("ob.execution.invocation") == invocation, + labels.get("onebox.execution.invocation") == invocation, "container belongs to another invocation", ) running = row["State"].get("Running") or row["State"].get("Restarting") @@ -563,9 +555,9 @@ def interrupted(_signum, _frame): "--name", config["container"], "--label", - "ob.execution.job=" + config["job"], + "onebox.execution.job=" + config["job"], "--label", - "ob.execution.invocation=" + invocation, + "onebox.execution.invocation=" + invocation, "--volume", output_dir + ":/onebox-output", ] diff --git a/internal/durable/runner_test.py b/internal/durable/runner_test.py index f6391ebd..4bad52f5 100644 --- a/internal/durable/runner_test.py +++ b/internal/durable/runner_test.py @@ -46,7 +46,7 @@ def setUp(self): self.config = { "application": "sample", "job": "refresh", - "unit": "ob-sample-refresh", + "unit": "onebox-job-refresh", "container": "sample-refresh-1", "defaults": {"SOURCE": "catalog"}, "env_files": [], @@ -338,8 +338,8 @@ def test_cleanup_refuses_another_invocations_container(self): "State": {"Running": True}, "Config": { "Labels": { - "ob.execution.job": "refresh", - "ob.execution.invocation": "other", + "onebox.execution.job": "refresh", + "onebox.execution.invocation": "other", } }, } @@ -358,8 +358,8 @@ def test_owned_running_container_gets_term_before_removal(self): "State": {"Running": True, "Restarting": False}, "Config": { "Labels": { - "ob.execution.job": "refresh", - "ob.execution.invocation": self.invocation, + "onebox.execution.job": "refresh", + "onebox.execution.invocation": self.invocation, } }, } @@ -382,8 +382,8 @@ def test_owned_running_container_records_forced_kill_after_grace(self): "State": {"Running": True, "Restarting": False}, "Config": { "Labels": { - "ob.execution.job": "refresh", - "ob.execution.invocation": self.invocation, + "onebox.execution.job": "refresh", + "onebox.execution.invocation": self.invocation, } }, } @@ -407,59 +407,6 @@ def test_owned_running_container_records_forced_kill_after_grace(self): ) self.assertIn("forced_kill=true", state.read_text()) - def test_legacy_cleanup_requires_stopped_matching_compose_job(self): - original = { - "Id": "legacy", - "Name": "/" + self.config["container"], - "State": {"Running": False, "Restarting": False}, - "Config": { - "Labels": { - "com.docker.compose.project": "sample", - "com.docker.compose.service": "refresh", - "com.docker.compose.oneoff": "True", - } - }, - } - cases = [ - "owned", - "running", - "restarting", - "name", - "project", - "service", - "oneoff", - "unlabeled", - "durable-label", - "invocation", - ] - for case in cases: - with self.subTest(case=case): - row = copy.deepcopy(original) - labels = row["Config"]["Labels"] - invocation = None - if case in ["running", "restarting"]: - row["State"][case.capitalize()] = True - elif case == "name": - row["Name"] = "/unrelated" - elif case in ["project", "service", "oneoff"]: - labels["com.docker.compose." + case] = "other" - elif case == "unlabeled": - labels.clear() - elif case == "durable-label": - labels["ob.execution.invocation"] = "other" - elif case == "invocation": - invocation = self.invocation - with patch.object( - r, "docker", side_effect=["legacy", json.dumps([row]), ""] - ) as docker: - if case == "owned": - r.cleanup_container(self.config) - self.assertEqual(docker.call_args.args[0], ["rm", "legacy"]) - else: - with self.assertRaises(ValueError): - r.cleanup_container(self.config, invocation) - self.assertEqual(docker.call_count, 2) - def test_nonfinite_retention_evidence_refused(self): identity = self.prepare() value = self.store.read(identity) diff --git a/internal/engine/app_dir_claim_test.go b/internal/engine/app_dir_claim_test.go new file mode 100644 index 00000000..cff1e009 --- /dev/null +++ b/internal/engine/app_dir_claim_test.go @@ -0,0 +1,67 @@ +package engine + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/labstack/onebox/internal/app" +) + +// The state directory is a generic name under a basePath the operator chose, +// and destroy removes it whole. Bootstrap must only ever take a directory that +// is new, empty, or already marked as this application's. +func TestClaimAppDirOnlyTakesWhatIsOurs(t *testing.T) { + run := func(t *testing.T, base string) (int, string) { + t.Helper() + out, err := exec.CommandContext(t.Context(), "sh", "-c", claimAppDirCommand(app.Names{App: "shop", BasePath: base}, "shop")).Output() + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode(), string(out) + } else if err != nil { + t.Fatal(err) + } + return 0, string(out) + } + marker := func(base string) string { return filepath.Join(base, "app", app.AppMarkerFile) } + + t.Run("new", func(t *testing.T) { + base := t.TempDir() + if code, _ := run(t, base); code != 0 { + t.Fatalf("exit %d", code) + } + if body, err := os.ReadFile(marker(base)); err != nil || strings.TrimSpace(string(body)) != "shop" { + t.Fatalf("marker = %q, %v", body, err) + } + if code, _ := run(t, base); code != 0 { + t.Fatalf("re-claim of our own directory: exit %d", code) + } + }) + t.Run("unmarked", func(t *testing.T) { + base := t.TempDir() + if err := os.MkdirAll(filepath.Join(base, "app"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "app", "someone-elses"), nil, 0o600); err != nil { + t.Fatal(err) + } + if code, _ := run(t, base); code != appDirUnmarked { + t.Fatalf("unmarked directory: exit %d, want %d", code, appDirUnmarked) + } + }) + t.Run("foreign", func(t *testing.T) { + base := t.TempDir() + if err := os.MkdirAll(filepath.Join(base, "app"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker(base), []byte("blog\n"), 0o600); err != nil { + t.Fatal(err) + } + if code, out := run(t, base); code != appDirForeign || out != "blog" { + t.Fatalf("foreign directory: exit %d, owner %q", code, out) + } + }) +} diff --git a/internal/engine/audit.go b/internal/engine/audit.go index 8dbef947..a5c403be 100644 --- a/internal/engine/audit.go +++ b/internal/engine/audit.go @@ -76,7 +76,7 @@ type AuditRecord struct { } func (e *Engine) AuditSnapshot(ctx context.Context, n int) ([]AuditRecord, error) { - ids, err := journal.List(ctx, e.T, e.names()) + ids, err := journal.List(ctx, e.T, journal.Dir(e.names())) if err != nil { return nil, err } @@ -86,7 +86,7 @@ func (e *Engine) AuditSnapshot(ctx context.Context, n int) ([]AuditRecord, error var rows []auditRow for _, id := range ids { - recs, err := journal.Read(ctx, e.T, e.names(), id) + recs, err := journal.Read(ctx, e.T, journal.Dir(e.names()), id) if err != nil { return nil, err } diff --git a/internal/engine/backup_base_selection_test.go b/internal/engine/backup_base_selection_test.go index c24362ed..34fbc876 100644 --- a/internal/engine/backup_base_selection_test.go +++ b/internal/engine/backup_base_selection_test.go @@ -26,7 +26,7 @@ func baseSelectionEngine(listing string) (*Engine, *transport.Fake) { }} spec := &app.Spec{ Name: "shop", - BasePath: "/var/lib/ob", + BasePath: "/var/lib/onebox", Services: map[string]app.Service{"database": {Driver: "postgres", Version: "18"}}, } e := New(&app.Resolved{Spec: spec, Env: "production"}, nil, fake, diff --git a/internal/engine/backup_credentials.go b/internal/engine/backup_credentials.go index d45b95bf..1097ee4c 100644 --- a/internal/engine/backup_credentials.go +++ b/internal/engine/backup_credentials.go @@ -46,7 +46,7 @@ func (e *Engine) InstallBackupCredentialFile(ctx context.Context, service, targe } } - localStaging, err := os.MkdirTemp("", "ob-backup-credentials-") + localStaging, err := os.MkdirTemp("", "onebox-backup-credentials-") if err != nil { return "", errors.New("create private backup credential staging") } @@ -89,61 +89,6 @@ func (e *Engine) InstallBackupCredentialFile(ctx context.Context, service, targe return destination, nil } -// MigrateBackupCredentialFiles copies the pre-2026.8.6 ambiguous spelling to -// the escaped path before Compose reads it. The old file remains for rollback -// to an older binary; disablement removes both spellings. -func (e *Engine) MigrateBackupCredentialFiles(ctx context.Context) error { - names := e.names() - type migration struct { - current string - legacy string - } - var migrations []migration - legacyUsers := map[string][]string{} - for _, service := range e.Spec.ServiceNames() { - if !e.Spec.ServiceIsProtected(service) { - continue - } - projection, err := e.Spec.EffectiveBackupProjection(service) - if err != nil { - return err - } - files := names.BackupCredentialFiles(service, projection.Policy.Target) - if len(files) == 1 { - continue - } - migrations = append(migrations, migration{current: files[0], legacy: files[1]}) - legacyUsers[files[1]] = append(legacyUsers[files[1]], files[0]) - } - for _, migration := range migrations { - users := legacyUsers[migration.legacy] - if len(users) > 1 { - checks := make([]string, 0, len(users)) - for _, current := range users { - checks = append(checks, "[ -f "+q(current)+" ]") - } - res, err := e.T.Run(ctx, "if [ -f "+q(migration.legacy)+" ]; then "+strings.Join(checks, " && ")+"; fi") - if err != nil { - return err - } - if res.ExitCode != 0 { - return fmt.Errorf("legacy backup credential path %s belongs to more than one service/target pair; re-enable each affected backup so Onebox can establish the escaped credential paths", migration.legacy) - } - continue - } - command := "if [ ! -f " + q(migration.current) + " ] && [ -f " + q(migration.legacy) + " ]; then " + - "install -m 600 " + q(migration.legacy) + " " + q(migration.current) + "; fi" - res, err := e.mutate(ctx, command) - if err != nil { - return err - } - if res.ExitCode != 0 { - return fmt.Errorf("cannot migrate backup credential file %s: %s", migration.legacy, strings.TrimSpace(res.Stderr)) - } - } - return nil -} - func backupCredentialEntries(plaintext []byte) (map[string]bool, error) { entries := make(map[string]bool) for index, line := range strings.Split(string(plaintext), "\n") { diff --git a/internal/engine/backup_credentials_test.go b/internal/engine/backup_credentials_test.go index 027bd91b..724b028f 100644 --- a/internal/engine/backup_credentials_test.go +++ b/internal/engine/backup_credentials_test.go @@ -2,13 +2,11 @@ package engine import ( "context" - "io" "os" "path/filepath" "strings" "testing" - "github.com/labstack/onebox/internal/app" "github.com/labstack/onebox/internal/transport" ) @@ -48,7 +46,7 @@ func TestInstallBackupCredentialFileUsesPrivateTargetFileWithoutCommandLeak(t *t if err != nil { t.Fatalf("install backup credentials: %v", err) } - if path != "/var/lib/ob/example/backup/secrets/database-offsite.env" { + if path != "/var/lib/onebox/app/backup/secrets/database-offsite.env" { t.Fatalf("credential path = %q", path) } if inspector.mode != 0o600 { @@ -119,65 +117,3 @@ func TestBackupCredentialInstallFailureCleansRemotePlaintextStaging(t *testing.T t.Fatalf("remote plaintext staging was not cleaned: %#v", fake.Commands) } } - -func TestMigrateBackupCredentialFilesCopiesAnUnambiguousLegacyPath(t *testing.T) { - cfg := testConfig() - state := app.ServiceRuntimeState{ - BackupState: "enabled", - LastEffective: &app.BackupEffectiveProjection{ - Policy: app.BackupPolicy{Target: "off-site"}, - }, - } - var err error - cfg, err = cfg.WithServiceRuntimeStates(map[string]app.ServiceRuntimeState{"postgres": state}) - if err != nil { - t.Fatal(err) - } - fake := &transport.Fake{} - engine := New(cfg, nil, fake, Options{Out: io.Discard}) - engine.fenceVal = "deploy-1 1" - if err := engine.MigrateBackupCredentialFiles(context.Background()); err != nil { - t.Fatal(err) - } - commands := strings.Join(fake.Commands, "\n") - if !strings.Contains(commands, "install -m 600") || - !strings.Contains(commands, "postgres-off-site.env") || - !strings.Contains(commands, "postgres-off--site.env") { - t.Fatalf("credential migration did not copy legacy to escaped path:\n%s", commands) - } -} - -func TestMigrateBackupCredentialFilesRefusesAnAmbiguousLegacyPath(t *testing.T) { - cfg := testConfig() - service := cfg.Services["postgres"] - delete(cfg.Services, "postgres") - cfg.Services["a-b"] = service - cfg.Services["a"] = service - stateFor := func(target string) app.ServiceRuntimeState { - return app.ServiceRuntimeState{ - BackupState: "enabled", - LastEffective: &app.BackupEffectiveProjection{ - Policy: app.BackupPolicy{Target: target}, - }, - } - } - var err error - cfg, err = cfg.WithServiceRuntimeStates(map[string]app.ServiceRuntimeState{ - "a-b": stateFor("c"), - "a": stateFor("b-c"), - }) - if err != nil { - t.Fatal(err) - } - fake := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - if strings.HasPrefix(command, "if [ -f ") { - return transport.Result{ExitCode: 1}, true - } - return transport.Result{}, false - }} - engine := New(cfg, nil, fake, Options{Out: io.Discard}) - err = engine.MigrateBackupCredentialFiles(context.Background()) - if err == nil || !strings.Contains(err.Error(), "belongs to more than one service/target pair") { - t.Fatalf("ambiguous migration error = %v", err) - } -} diff --git a/internal/engine/backup_identity_test.go b/internal/engine/backup_identity_test.go index d3e2128d..6a0cd9bb 100644 --- a/internal/engine/backup_identity_test.go +++ b/internal/engine/backup_identity_test.go @@ -73,7 +73,7 @@ func TestProtectedDatabaseIdentityRejectsMissingOrReplacedVolume(t *testing.T) { name, actual, want string missing bool }{ - {name: "missing", missing: true, want: "data volume ob_shop_postgres_data is missing"}, + {name: "missing", missing: true, want: "data volume onebox_postgres_data is missing"}, {name: "replaced", actual: "7513211627332151224", want: "belongs to PostgreSQL cluster 7513211627332151224"}, {name: "same", actual: recorded}, } { diff --git a/internal/engine/backup_image_test.go b/internal/engine/backup_image_test.go index 0ed74e01..36aac2e1 100644 --- a/internal/engine/backup_image_test.go +++ b/internal/engine/backup_image_test.go @@ -204,7 +204,7 @@ func TestProtectedImageSelectsTheDigestForThePulledRepository(t *testing.T) { func protectedImageTestEngine(fake *transport.Fake) *Engine { spec := &app.Spec{ Name: "shop", - BasePath: "/var/lib/ob", + BasePath: "/var/lib/onebox", Services: map[string]app.Service{"database": {Driver: "postgres", Version: "18"}}, } resolved := &app.Resolved{Spec: spec, Env: "production"} @@ -262,7 +262,7 @@ func TestPullPolicyDecidesWhetherTheRegistryIsAskedAtAll(t *testing.T) { func pullPolicyTestEngine(fake *transport.Fake, policy, image string) *Engine { spec := &app.Spec{ Name: "shop", - BasePath: "/var/lib/ob", + BasePath: "/var/lib/onebox", Workloads: map[string]app.Workload{ "web": {Role: "application", Image: &app.Image{Reference: image, Pull: policy}}, }, diff --git a/internal/engine/backup_lock.go b/internal/engine/backup_lock.go index 0ddadedd..d365862d 100644 --- a/internal/engine/backup_lock.go +++ b/internal/engine/backup_lock.go @@ -172,12 +172,12 @@ func (e *Engine) writeBackupFence(ctx context.Context, service, operationID stri fenceValue := operationID + " " + strconv.Itoa(epoch) command := `if [ "$(cat ` + q(e.backupLockPath(service)) + ` 2>/dev/null)" = ` + q(lockValue) + ` ]; then ` + atomicEpochWriteCmd(e.backupEpochPath(service), epoch) + `; echo ` + q(fenceValue) + ` > ` + q(e.backupFencePath(service)) + - `; else echo ob-backup-lock-lost >&2; exit 96; fi` + `; else echo onebox-backup-lock-lost >&2; exit 96; fi` result, err := e.T.Run(ctx, command) if err != nil { return err } - if result.ExitCode == 96 && strings.Contains(result.Stderr, "ob-backup-lock-lost") { + if result.ExitCode == 96 && strings.Contains(result.Stderr, "onebox-backup-lock-lost") { return ErrBackupFenced } if result.ExitCode != 0 { @@ -245,12 +245,12 @@ func (e *Engine) BackupMutate(ctx context.Context, service, command string) (tra if e.lockVal == "" || e.fenceVal == "" || lockValue == "" || fenceValue == "" { return transport.Result{}, errors.New("backup mutation requires application and service lock ownership") } - guarded := `if [ "$(cat ` + q(e.backupLockPath(service)) + ` 2>/dev/null)" = ` + q(lockValue) + ` ] && [ "$(cat ` + q(e.backupFencePath(service)) + ` 2>/dev/null)" = ` + q(fenceValue) + ` ]; then ` + command + `; else echo ob-backup-fenced >&2; exit 98; fi` + guarded := `if [ "$(cat ` + q(e.backupLockPath(service)) + ` 2>/dev/null)" = ` + q(lockValue) + ` ] && [ "$(cat ` + q(e.backupFencePath(service)) + ` 2>/dev/null)" = ` + q(fenceValue) + ` ]; then ` + command + `; else echo onebox-backup-fenced >&2; exit 98; fi` result, err := e.mutate(ctx, guarded) if err != nil { return result, err } - if result.ExitCode == 98 && strings.Contains(result.Stderr, "ob-backup-fenced") { + if result.ExitCode == 98 && strings.Contains(result.Stderr, "onebox-backup-fenced") { return result, ErrBackupFenced } return result, nil diff --git a/internal/engine/backup_lock_test.go b/internal/engine/backup_lock_test.go index b12d7671..3f5d9f1c 100644 --- a/internal/engine/backup_lock_test.go +++ b/internal/engine/backup_lock_test.go @@ -14,7 +14,7 @@ import ( func backupLockTestEngine(fake *transport.Fake) *Engine { engine := New( - &app.Resolved{Spec: &app.Spec{Name: "example", BasePath: "/var/lib/ob"}, Env: "production"}, + &app.Resolved{Spec: &app.Spec{Name: "example", BasePath: "/var/lib/onebox"}, Env: "production"}, nil, fake, Options{Out: io.Discard, LockTTL: 10 * time.Second, Sleep: func(time.Duration) {}, Now: func() time.Time { @@ -117,7 +117,7 @@ func TestBackupLockReclaimsStaleHolderWithNewFence(t *testing.T) { func TestBackupMutationRejectsStaleFence(t *testing.T) { fake := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { if strings.Contains(command, "write-database-data") { - return transport.Result{ExitCode: 98, Stderr: "ob-backup-fenced\n"}, true + return transport.Result{ExitCode: 98, Stderr: "onebox-backup-fenced\n"}, true } return transport.Result{}, false }} diff --git a/internal/engine/backup_postgres.go b/internal/engine/backup_postgres.go index fff3f4fc..ae014221 100644 --- a/internal/engine/backup_postgres.go +++ b/internal/engine/backup_postgres.go @@ -432,17 +432,13 @@ func (e *Engine) RemoveBackupCredentials(ctx context.Context, service string, la if last == nil { return nil } - paths := e.names().BackupCredentialFiles(service, last.Policy.Target) - quoted := make([]string, len(paths)) - for i, path := range paths { - quoted[i] = q(path) - } - res, err := e.T.Run(ctx, "rm -f "+strings.Join(quoted, " ")) + path := e.names().BackupCredentialFile(service, last.Policy.Target) + res, err := e.T.Run(ctx, "rm -f "+q(path)) if err != nil { return err } if res.ExitCode != 0 { - return fmt.Errorf("cannot remove the backup credential files %s", strings.Join(paths, ", ")) + return fmt.Errorf("cannot remove the backup credential file %s", path) } return nil } diff --git a/internal/engine/backup_recovery_config_test.go b/internal/engine/backup_recovery_config_test.go index b79770c8..20471f6b 100644 --- a/internal/engine/backup_recovery_config_test.go +++ b/internal/engine/backup_recovery_config_test.go @@ -14,7 +14,7 @@ import ( func recoveryConfigTestEngine(fake *transport.Fake) *Engine { spec := &app.Spec{ Name: "shop", - BasePath: "/var/lib/ob", + BasePath: "/var/lib/onebox", Services: map[string]app.Service{"database": {Driver: "postgres", Version: "18"}}, } return New(&app.Resolved{Spec: spec, Env: "production"}, nil, fake, @@ -31,7 +31,7 @@ func TestRecoveryClearsAnyTargetTheBaseBackupCarried(t *testing.T) { fake := &transport.Fake{} e := recoveryConfigTestEngine(fake) - if err := e.replayRecovery(context.Background(), "shop-database-restore-1", "database", ""); err != nil { + if err := e.replayRecovery(context.Background(), "onebox-database-restore", "database", ""); err != nil { t.Fatalf("replaying to the newest recoverable point: %v", err) } @@ -66,7 +66,7 @@ func TestAStatedRecoveryTargetSurvivesTheClearing(t *testing.T) { fake := &transport.Fake{} e := recoveryConfigTestEngine(fake) - if err := e.replayRecovery(context.Background(), "shop-database-restore-1", "database", "2026-08-20T13:58:00Z"); err != nil { + if err := e.replayRecovery(context.Background(), "onebox-database-restore", "database", "2026-08-20T13:58:00Z"); err != nil { t.Fatalf("replaying to a point in time: %v", err) } @@ -96,7 +96,7 @@ func TestRecoveryStartsWithDeclaredExtensionPreloads(t *testing.T) { }} e.Spec.Services["database"] = service - if err := e.replayRecovery(context.Background(), "shop-database-restore-1", "database", ""); err != nil { + if err := e.replayRecovery(context.Background(), "onebox-database-restore", "database", ""); err != nil { t.Fatal(err) } commands := strings.Join(fake.Commands, "\n") @@ -117,7 +117,7 @@ func TestPromotionRemovesTheRecoveryConfiguration(t *testing.T) { fake := &transport.Fake{} e := recoveryConfigTestEngine(fake) - if err := e.stripRecoveryConfiguration(context.Background(), "shop-database-restore-1"); err != nil { + if err := e.stripRecoveryConfiguration(context.Background(), "onebox-database-restore"); err != nil { t.Fatalf("stripping the recovery configuration: %v", err) } if len(fake.Commands) != 1 { diff --git a/internal/engine/backup_restore.go b/internal/engine/backup_restore.go index 475b1336..eb308de2 100644 --- a/internal/engine/backup_restore.go +++ b/internal/engine/backup_restore.go @@ -465,7 +465,7 @@ func (e *Engine) replayRecovery(ctx context.Context, container, service, targetT return fmt.Errorf("cannot write the recovery configuration: %s", lastLines(res.Stderr, 3)) } start := "docker exec -u postgres " + q(container) + - " pg_ctl -D " + q(app.PgDataPath) + " -l /tmp/ob-recovery.log -w -t 300" + " pg_ctl -D " + q(app.PgDataPath) + " -l /tmp/onebox-recovery.log -w -t 300" serviceSettings := e.Spec.PostgresServiceSettings(service) if len(serviceSettings) > 0 { var postgresOptions []string @@ -480,7 +480,7 @@ func (e *Engine) replayRecovery(ctx context.Context, container, service, targetT return err } if res.ExitCode != 0 { - log, _ := e.T.Run(ctx, "docker exec "+q(container)+" tail -20 /tmp/ob-recovery.log") + log, _ := e.T.Run(ctx, "docker exec "+q(container)+" tail -20 /tmp/onebox-recovery.log") return fmt.Errorf("the recovered cluster did not start: %s", lastLines(log.Stdout, 8)) } return nil @@ -583,8 +583,8 @@ func (e *Engine) ensureRecoveredClientCredential(ctx context.Context, container // credential value, so transport logging and test captures remain safe. command := "docker exec -i -u postgres " + q(container) + " psql -X -v ON_ERROR_STOP=1 -U " + q(app.PgSuperuser) + " -d postgres" - script := "\\getenv ob_managed_password POSTGRES_PASSWORD\n" + - "ALTER ROLE \"" + app.PgSuperuser + "\" PASSWORD :'ob_managed_password';\n" + script := "\\getenv onebox_managed_password POSTGRES_PASSWORD\n" + + "ALTER ROLE \"" + app.PgSuperuser + "\" PASSWORD :'onebox_managed_password';\n" res, err := e.T.RunInput(ctx, command, script) if err != nil { return err @@ -667,7 +667,7 @@ func (e *Engine) promoteRecoveredVolume(ctx context.Context, service, container, preserve := strings.Join([]string{ "docker compose -p " + q(n.ServiceProject(service)) + " -f " + q(n.ServiceFile(service)) + " down", "docker volume create --label " + q("com.docker.compose.project="+n.ServiceProject(service)) + - " --label " + q("ob.app="+e.Spec.Name) + " --label " + q("ob.service="+service) + " " + q(kept), + " --label " + q("onebox.app="+e.Spec.Name) + " --label " + q("onebox.service="+service) + " " + q(kept), "docker run --rm -v " + q(live+":/from") + " -v " + q(kept+":/to") + " alpine sh -c 'cp -a /from/. /to/'", }, " && ") res, err := e.mutate(ctx, preserve) diff --git a/internal/engine/backup_restore_client_test.go b/internal/engine/backup_restore_client_test.go index bdb1bba2..82f9705c 100644 --- a/internal/engine/backup_restore_client_test.go +++ b/internal/engine/backup_restore_client_test.go @@ -55,7 +55,7 @@ func TestRecoveredClientCredentialIsReconciledAndVerified(t *testing.T) { if len(fake.Inputs) != 1 || !strings.Contains(fake.Inputs[0], `ALTER ROLE "onebox"`) { t.Fatalf("reconciliation input = %#v", fake.Inputs) } - if !strings.Contains(fake.Inputs[0], `\getenv ob_managed_password POSTGRES_PASSWORD`) { + if !strings.Contains(fake.Inputs[0], `\getenv onebox_managed_password POSTGRES_PASSWORD`) { t.Fatalf("reconciliation did not read the credential inside psql: %q", fake.Inputs[0]) } } diff --git a/internal/engine/backup_schedule.go b/internal/engine/backup_schedule.go index 8116afcd..8e2b7cbd 100644 --- a/internal/engine/backup_schedule.go +++ b/internal/engine/backup_schedule.go @@ -46,8 +46,7 @@ import ( // project no longer describes, and nothing in the project would explain why. func (e *Engine) SyncBackupSchedules(ctx context.Context) error { n := e.names() - prefixes := n.BackupUnitPrefixesForEnvironment(e.Opts.Environment) - prefix := prefixes[0] + prefix := app.BackupUnitPrefix // flock creates the lock file but not the directory holding it. if res, err := e.T.Run(ctx, "mkdir -p "+q(n.AppDir()+"/backup")); err != nil { return err @@ -66,18 +65,8 @@ func (e *Engine) SyncBackupSchedules(ctx context.Context) error { continue } bare := strings.TrimSuffix(unit, ".timer") - if matchesRuntimePrefix(bare, prefix) { + if strings.HasPrefix(bare, prefix) { installed[bare] = true - continue - } - if matchesAnyPrefix(unit, prefixes[1:]) { - owned, err := e.scheduleUnitBelongsToOwner(ctx, bare, true) - if err != nil { - return err - } - if owned { - installed[bare] = true - } } } @@ -138,7 +127,7 @@ func (e *Engine) SyncBackupSchedules(ctx context.Context) error { service, expression, unit.schedule.Cron) } wanted = append(wanted, wantedUnit{ - name: n.BackupUnitForEnvironment(e.Opts.Environment, service, unit.operation), + name: n.BackupUnit(service, unit.operation), calendar: expression, cron: unit.schedule.Cron, body: backupServiceUnit(e.Spec.Spec.Name, e.Opts.Environment, service, unit.operation, n.BackupRunLock(service), unit.commands), diff --git a/internal/engine/bootstrap.go b/internal/engine/bootstrap.go index ae3e44a4..b18a1b68 100644 --- a/internal/engine/bootstrap.go +++ b/internal/engine/bootstrap.go @@ -34,19 +34,25 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) { } passwords[name] = password } - if err := e.claimHostOwner(ctx); err != nil { + // Refuse a foreign owner before anything else, check the state directory + // before claiming the host, and create the directory only after: a refused + // directory leaves the host unclaimed, and a refused claim leaves nothing + // behind. The lock acquisition below creates the directory. + owner, err := e.readHostOwner(ctx) + if err != nil { return err } - - e.logf("bootstrap: base dirs") - p := release.PathsFor(e.names()) - res, err := e.T.Run(ctx, "mkdir -p "+q(p.Releases)) - if err != nil { - return fmt.Errorf("mkdir %s: %w", p.Releases, err) + if err := e.ownerConflict(owner); err != nil { + return err } - if res.ExitCode != 0 { - return fmt.Errorf("mkdir %s: %s", p.Releases, strings.TrimSpace(res.Stderr)) + if err := e.checkAppDir(ctx); err != nil { + return err } + if err := e.claimHostOwner(ctx, owner); err != nil { + return err + } + e.logf("bootstrap: base dirs") + p := release.PathsFor(e.names()) // one regime for every mutation: bootstrap locks, fences, // and journals like a deploy @@ -58,7 +64,7 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) { if err := e.WriteFence(ctx, releaseID, epoch); err != nil { return err } - jw := &journal.Writer{T: e.T, Names: e.names(), DeployID: releaseID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} + jw := &journal.Writer{T: e.T, Dir: journal.Dir(e.names()), DeployID: releaseID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} if err := jw.Append(ctx, journal.Record{Phase: "bootstrap", Event: "start"}); err != nil { return fmt.Errorf("journal bootstrap start: %w", err) } @@ -162,3 +168,66 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) { e.logf("bootstrap complete — run `ob deploy` for the first release") return nil } + +const ( + appDirForeign = 3 + appDirUnmarked = 4 +) + +// claimAppDir creates the application's state directory, or accepts one that +// carries this application's marker. It is the only place the directory is +// created — bootstrap and every lock acquisition go through it — so nothing +// ever writes into a directory Onebox has not marked as this application's. +func (e *Engine) claimAppDir(ctx context.Context) error { + return e.runAppDirCommand(ctx, claimAppDirCommand(e.names(), e.Spec.Name)) +} + +// checkAppDir refuses a state directory Onebox may not adopt, changing nothing. +func (e *Engine) checkAppDir(ctx context.Context) error { + return e.runAppDirCommand(ctx, checkAppDirCommand(e.names(), e.Spec.Name)) +} + +func (e *Engine) runAppDirCommand(ctx context.Context, command string) error { + n := e.names() + res, err := e.T.Run(ctx, command) + if err != nil { + return fmt.Errorf("claim %s: %w", n.AppDir(), err) + } + switch res.ExitCode { + case 0: + return nil + case appDirForeign: + return fmt.Errorf("%s holds another application's state (%s says %q); choose another basePath", n.AppDir(), app.AppMarkerFile, strings.TrimSpace(res.Stdout)) + case appDirUnmarked: + return fmt.Errorf("%s already exists and was not created by Onebox, or cannot be read; move it aside or choose another basePath — Onebox will not adopt a directory it may later delete", n.AppDir()) + default: + return fmt.Errorf("claim %s: %s", n.AppDir(), strings.TrimSpace(res.Stderr)) + } +} + +// checkAppDirCommand accepts a state directory that is absent, provably empty, +// or marked as this application's, and changes nothing. An existing directory +// it cannot read counts as not empty. +func checkAppDirCommand(n app.Names, application string) string { + dir, marker := q(n.AppDir()), q(n.AppMarker()) + return "if [ -e " + marker + " ]; then owner=$(cat " + marker + ") || exit 1; " + + "[ \"$owner\" = " + q(application) + " ] || { printf '%s' \"$owner\"; exit " + fmt.Sprint(appDirForeign) + "; }; " + + "elif [ -e " + dir + " ] || [ -L " + dir + " ]; then " + + "[ -d " + dir + " ] && [ -r " + dir + " ] && [ -x " + dir + " ] || exit " + fmt.Sprint(appDirUnmarked) + "; " + + "entries=$(ls -A " + dir + ") || exit " + fmt.Sprint(appDirUnmarked) + "; " + + "[ -z \"$entries\" ] || exit " + fmt.Sprint(appDirUnmarked) + "; fi" +} + +// claimAppDirCommand runs the same check, then creates the directory. The +// marker is written only when it is missing, through a temporary file and a +// rename: it is the one proof of ownership destroy trusts, so no interrupted +// write may ever leave it empty. +func claimAppDirCommand(n app.Names, application string) string { + marker := q(n.AppMarker()) + staged := q(n.AppMarker()+".tmp.") + "$$" + // The marker first, then everything else: a claim cut short must never + // leave a non-empty directory without it. + return checkAppDirCommand(n, application) + "; mkdir -p " + q(n.AppDir()) + " || exit 1; " + + "[ -e " + marker + " ] || { printf '%s\\n' " + q(application) + " > " + staged + " && mv -f " + staged + " " + marker + "; } || exit 1; " + + "mkdir -p " + q(n.ReleasesDir()) +} diff --git a/internal/engine/bootstrap_test.go b/internal/engine/bootstrap_test.go index 4aaddf62..9433b63e 100644 --- a/internal/engine/bootstrap_test.go +++ b/internal/engine/bootstrap_test.go @@ -21,7 +21,7 @@ type bootstrapNetworkLocal struct { func (l *bootstrapNetworkLocal) Run(ctx context.Context, command string) (transport.Result, error) { if strings.Contains(command, "docker network inspect --format") { - return transport.Result{Stdout: "abc123|" + l.owner + "|\n"}, nil + return transport.Result{Stdout: "abc123|" + l.owner + "\n"}, nil } return l.Local.Run(ctx, command) } @@ -40,13 +40,13 @@ func TestBootstrapSequence(t *testing.T) { seq := strings.Join(f.Commands, "\n") ordered := []string{ "mkdir -p", // dirs - `link "$tmp" '/var/lib/ob/sample/lock'`, // application lock - "> '/var/lib/ob/sample/fence'", // mutation fence + `link "$tmp" '/var/lib/onebox/app/lock'`, // application lock + "> '/var/lib/onebox/app/fence'", // mutation fence `"phase":"bootstrap","event":"start"`, // durable journal boundary "apt-get install -y something-host-specific", // bootstrap hook "docker version --format '{{.Server.Version}}'", // prerequisites after authored provisioning "docker login 'ghcr.io' -u 'vishr' --password-stdin", // registry (stdin, quoted) - "docker compose -p 'ob_sample_postgres'", // services + "docker compose -p 'onebox_postgres'", // services } last := -1 for _, want := range ordered { @@ -130,6 +130,11 @@ func TestConcurrentBootstrapDoesNotRunSecondHook(t *testing.T) { t.Fatal(err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + restoreHost, hostErr := app.SetTestHostStateDir(filepath.Join(dir, "host")) + if hostErr != nil { + t.Fatal(hostErr) + } + t.Cleanup(restoreHost) entered := filepath.Join(dir, "hook-entered") release := filepath.Join(dir, "release-hook") @@ -212,7 +217,7 @@ func TestBootstrapRefusesMissingRuntimeWithoutImplicitInstaller(t *testing.T) { if runtimeCheck < 0 { t.Fatalf("bootstrap did not check the runtime:\n%s", seq) } - for _, before := range []string{`link "$tmp" '/var/lib/ob/sample/lock'`, "> '/var/lib/ob/sample/fence'", `"phase":"bootstrap","event":"start"`} { + for _, before := range []string{`link "$tmp" '/var/lib/onebox/app/lock'`, "> '/var/lib/onebox/app/fence'", `"phase":"bootstrap","event":"start"`} { if index := strings.Index(seq, before); index < 0 || index > runtimeCheck { t.Fatalf("%q did not precede the runtime check:\n%s", before, seq) } @@ -298,8 +303,8 @@ func TestBootstrapEnsuresManagedProxyBeforeServices(t *testing.T) { seq := strings.Join(f.Commands, "\n") ordered := []string{ "docker login 'ghcr.io'", - "docker compose -p onebox-proxy -f '/var/lib/ob/_host/proxy/compose.yaml' up -d", - "docker compose -p 'ob_sample_postgres'", + "docker compose -p onebox-proxy -f '/var/lib/onebox/_host/proxy/compose.yaml' up -d", + "docker compose -p 'onebox_postgres'", } last := -1 for _, want := range ordered { diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go index 8636a148..32ec52b3 100644 --- a/internal/engine/deploy.go +++ b/internal/engine/deploy.go @@ -156,7 +156,7 @@ func (e *Engine) deployCore(ctx context.Context, releaseID, localStagingDir stri } jw := &journal.Writer{ - T: e.T, Names: e.names(), DeployID: releaseID, Epoch: epoch, + T: e.T, Dir: journal.Dir(e.names()), DeployID: releaseID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, ApprovalDigest: e.Opts.ApprovalDigest, ApprovalClass: e.Opts.ApprovalClass, ApprovedBy: e.Opts.ApprovedBy, ApprovalSource: e.Opts.ApprovalSource, @@ -245,7 +245,7 @@ func (e *Engine) pinnedScheduleDeployConflict() string { // finish:fail; a later successful activation/current release or an explicit // abort clears that historical debt. func (e *Engine) rollbackEffectDebt(ctx context.Context, current string) (bool, error) { - ids, byID, err := journal.Journals(ctx, e.T, e.names()) + ids, byID, err := journal.Journals(ctx, e.T, journal.Dir(e.names())) if err != nil { return false, err } @@ -559,7 +559,7 @@ const retentionSkipped = "release-store cleanup skipped: retention evidence is i // when it deliberately declined to delete anything, so that the journal records // a skip rather than an unqualified success. func (e *Engine) pruneRetention(ctx context.Context) (string, error) { - journalIDs, err := journal.List(ctx, e.T, e.names()) + journalIDs, err := journal.List(ctx, e.T, journal.Dir(e.names())) if err != nil { return "", err } @@ -599,12 +599,12 @@ func (e *Engine) pruneRetention(ctx context.Context) (string, error) { e.logf("pruned %d expired release-store entries", len(decision.Victims)) } } - jvictims, err := journal.PruneCandidates(ctx, e.T, e.names(), e.Spec.Deployment.RetainReleases*2) + jvictims, err := journal.PruneCandidates(ctx, e.T, journal.Dir(e.names()), e.Spec.Deployment.RetainReleases*2) if err != nil { return "", err } for _, id := range jvictims { - if err := e.mutateChecked(ctx, "prune journal "+id, "rm -f "+q(release.PathsFor(e.names()).Base+"/journal/"+id+".jsonl")); err != nil { + if err := e.mutateChecked(ctx, "prune journal "+id, "rm -f "+q(journal.Dir(e.names())+"/"+id+".jsonl")); err != nil { return "", err } } @@ -662,7 +662,7 @@ func (e *Engine) rollbackTo(ctx context.Context, prev, current string, epoch int } replay.fenceVal = e.fenceVal - jw := &journal.Writer{T: e.T, Names: e.names(), DeployID: prev, Epoch: epoch, Operator: journal.DefaultOperator(), Runner: &e.Opts.Runner} + jw := &journal.Writer{T: e.T, Dir: journal.Dir(e.names()), DeployID: prev, Epoch: epoch, Operator: journal.DefaultOperator(), Runner: &e.Opts.Runner} if err := jw.Append(ctx, journal.Record{Phase: "rollback", Event: "start"}); err != nil { return fmt.Errorf("journal rollback start: %w", err) } diff --git a/internal/engine/deploy_test.go b/internal/engine/deploy_test.go index d8e5e437..91c97e3c 100644 --- a/internal/engine/deploy_test.go +++ b/internal/engine/deploy_test.go @@ -20,7 +20,7 @@ import ( // drain guard first, so a rollout can take the container out of rotation before // it stops it. A rollout probes for this, and a fake that did not answer would // exercise the unguardable path in every test. -const guardedHealthcheck = `["CMD-SHELL","[ -f /tmp/ob-drain ] \u0026\u0026 exit 1; curl -fsS 'http://127.0.0.1:80/'"]` +const guardedHealthcheck = `["CMD-SHELL","[ -f /tmp/onebox-drain ] \u0026\u0026 exit 1; curl -fsS 'http://127.0.0.1:80/'"]` const enginePreviousFrontendProject = `apiVersion: onebox.run/v1alpha1 kind: Application @@ -74,7 +74,7 @@ func happyFake() *transport.Fake { newGone, workerGone := false, false name := map[string]string{"OLD1": "web"} for _, c := range f.Commands { - if strings.Contains(c, "docker ps -aq") && strings.Contains(c, "label=ob.app=") && strings.Contains(c, "label=ob.release=") { + if strings.Contains(c, "docker ps -aq") && strings.Contains(c, "label=onebox.app=") && strings.Contains(c, "label=onebox.release=") { seenExactReleaseQuery = true } if strings.Contains(c, "--scale web=") { @@ -99,7 +99,7 @@ func happyFake() *transport.Fake { if strings.Contains(c, "docker rm -f W1") { workerGone = true } - if strings.Contains(c, "ob-drain") { + if strings.Contains(c, "onebox-drain") { drained = true } if i := strings.Index(c, "docker rename "); i >= 0 { @@ -121,9 +121,9 @@ func happyFake() *transport.Fake { case strings.Contains(cmd, "State.Status"): return transport.Result{Stdout: "running\n"}, true case strings.Contains(cmd, "/_host/owner"): - return transport.Result{Stdout: "sample\n"}, true + return transport.Result{Stdout: "sample production\n"}, true case strings.Contains(cmd, "docker network inspect --format"): - return transport.Result{Stdout: "abc123|sample|\n"}, true + return transport.Result{Stdout: "abc123|sample\n"}, true case strings.Contains(cmd, "docker version"): return transport.Result{Stdout: "27.0.3\n"}, true case strings.Contains(cmd, "imagetools inspect --help"): @@ -138,7 +138,7 @@ func happyFake() *transport.Fake { return transport.Result{Stdout: "PG1\n"}, true case strings.Contains(cmd, "inspect") && strings.Contains(cmd, "PG1"): return transport.Result{Stdout: "healthy\n"}, true - case strings.Contains(cmd, "compose.service='web'") && strings.Contains(cmd, "ob.release="): + case strings.Contains(cmd, "compose.service='web'") && strings.Contains(cmd, "onebox.release="): if scaled && (!newGone || scaleCount > initialScaleCount) { return transport.Result{Stdout: "NEW1\n"}, true } @@ -169,7 +169,7 @@ func happyFake() *transport.Fake { // Recreate drain observes the old worker after signalling it. The happy // fixture models a worker that exits promptly and can be replaced. return transport.Result{Stdout: "false\n"}, true - case strings.Contains(cmd, "service='worker'") && strings.Contains(cmd, "ob.release="): + case strings.Contains(cmd, "service='worker'") && strings.Contains(cmd, "onebox.release="): if !workerGone || recreateCount > initialRecreateCount { return transport.Result{Stdout: "W1\n"}, true } @@ -179,7 +179,7 @@ func happyFake() *transport.Fake { return transport.Result{Stdout: "W1\n"}, true } return transport.Result{}, true - case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "label=ob.app=") && strings.Contains(cmd, "label=ob.release="): + case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "label=onebox.app=") && strings.Contains(cmd, "label=onebox.release="): var ids []string if initialScaleCount > 0 && !newGone { ids = append(ids, "NEW1") @@ -196,7 +196,7 @@ func happyFake() *transport.Fake { return transport.Result{Stdout: ""}, true case strings.Contains(cmd, "ls -1"): return transport.Result{Stdout: "20260101-000000-aaa111\n"}, true - case strings.Contains(cmd, "ob.snapshot.yml"): + case strings.Contains(cmd, "onebox.snapshot.yml"): return transport.Result{Stdout: engineProject}, true case strings.Contains(cmd, "/journal/"+engineTestPreviousReleaseID+".jsonl"): return transport.Result{Stdout: `{"deploy_id":"` + engineTestPreviousReleaseID + `","phase":"activation","event":"result","status":"ok","detail":"release=` + engineTestPreviousReleaseID + `"}` + "\n"}, true @@ -234,7 +234,7 @@ func TestDeployRefusesUnsupportedPredecessorSnapshotBeforeRuntimeMutation(t *tes switch { case strings.Contains(command, "readlink"): return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true - case strings.Contains(command, "/"+engineTestPreviousReleaseID+"/ob.snapshot.yml"): + case strings.Contains(command, "/"+engineTestPreviousReleaseID+"/onebox.snapshot.yml"): return transport.Result{Stdout: strings.Replace(enginePreviousFrontendProject, app.APIVersion, "onebox.run/v2", 1)}, true } return base(command) @@ -260,7 +260,7 @@ func TestDeployRetainsPlannedWorkloadWithoutRuntimeMutation(t *testing.T) { revision := "sha256:" + strings.Repeat("a", 64) base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "docker ps --filter label=ob.app=") && strings.Contains(command, "--format") { + if strings.Contains(command, "docker ps --filter label=onebox.app=") && strings.Contains(command, "--format") { return transport.Result{Stdout: "OLD1|web|R0||Up (healthy)\nW1|worker|R0|" + revision + "|Up\n"}, true } return base(command) @@ -394,15 +394,15 @@ func TestDeployJournalsAndFencesLifecycle(t *testing.T) { last = i } // every mutation is fence-guarded - for _, mut := range []string{"--scale web=2", "touch /tmp/ob-drain", "docker stop -t 30 OLD1", "--force-recreate --timeout 30 worker", "ln -sfn"} { + for _, mut := range []string{"--scale web=2", "touch /tmp/onebox-drain", "docker stop -t 30 OLD1", "--force-recreate --timeout 30 worker", "ln -sfn"} { for _, c := range f.Commands { - if strings.Contains(c, mut) && !strings.Contains(c, "ob-fenced") { + if strings.Contains(c, mut) && !strings.Contains(c, "onebox-fenced") { t.Fatalf("mutation not fence-guarded: %s", c) } } } // lock released at the end - if !strings.Contains(seq, "rm -f '/var/lib/ob/sample/lock'") { + if !strings.Contains(seq, "rm -f '/var/lib/onebox/app/lock'") { t.Fatal("lock never released") } } @@ -496,7 +496,7 @@ func TestDeployPhaseOrder(t *testing.T) { } last = i } - if len(f.Uploads) != 1 || !strings.Contains(f.Uploads[0], "/var/lib/ob/sample/releases/20260101-000000-aaa111") { + if len(f.Uploads) != 1 || !strings.Contains(f.Uploads[0], "/var/lib/onebox/app/releases/20260101-000000-aaa111") { t.Fatalf("transfer missing: %v", f.Uploads) } } @@ -508,13 +508,13 @@ func TestDeployRetiresWorkloadRemovedByRenameAfterActivation(t *testing.T) { draining := false removed := false for _, previous := range f.Commands { - draining = draining || strings.Contains(previous, "docker exec FRONT1 touch /tmp/ob-drain") + draining = draining || strings.Contains(previous, "docker exec FRONT1 touch /tmp/onebox-drain") removed = removed || strings.Contains(previous, "docker rm FRONT1") } switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true - case strings.Contains(cmd, "/"+engineTestPreviousReleaseID+"/ob.snapshot.yml"): + case strings.Contains(cmd, "/"+engineTestPreviousReleaseID+"/onebox.snapshot.yml"): return transport.Result{Stdout: enginePreviousFrontendProject}, true case strings.Contains(cmd, "docker ps -q") && strings.Contains(cmd, "service='frontend'"): if removed { @@ -537,7 +537,7 @@ func TestDeployRetiresWorkloadRemovedByRenameAfterActivation(t *testing.T) { seq := strings.Join(f.Commands, "\n") verifyAt := strings.Index(seq, "curl -fsS -m 5") activateAt := strings.Index(seq, "ln -sfn 'releases/"+engineTestDeployReleaseID+"'") - drainAt := strings.Index(seq, "docker exec FRONT1 touch /tmp/ob-drain") + drainAt := strings.Index(seq, "docker exec FRONT1 touch /tmp/onebox-drain") removeAt := strings.Index(seq, "docker rm FRONT1") if verifyAt < 0 || activateAt < verifyAt || drainAt < activateAt || removeAt < drainAt { t.Fatalf("old workload must drain only after verify and activation:\n%s", seq) @@ -601,7 +601,7 @@ func TestDeployRefusesWhileAForeignJobContainerRuns(t *testing.T) { f := happyFake() inner := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "label='ob.operation'") { + if strings.Contains(cmd, "label='onebox.operation'") { return transport.Result{Stdout: "abc123def456 other-op 2\n"}, true } return inner(cmd) @@ -623,7 +623,7 @@ func TestDeployKeepsAndExplainsTheLockWhenItRefuses(t *testing.T) { f := happyFake() inner := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "label='ob.operation'") { + if strings.Contains(cmd, "label='onebox.operation'") { return transport.Result{Stdout: "abc123def456 other-op 2\n"}, true } return inner(cmd) @@ -634,7 +634,7 @@ func TestDeployKeepsAndExplainsTheLockWhenItRefuses(t *testing.T) { t.Fatal("expected a refusal") } for _, c := range f.Commands { - if strings.Contains(c, "rm -f '/var/lib/ob/sample/lock'") { + if strings.Contains(c, "rm -f '/var/lib/onebox/app/lock'") { t.Fatalf("the lock was released over a live container:\n%s", c) } } diff --git a/internal/engine/drain_budget_test.go b/internal/engine/drain_budget_test.go index 3d6fa045..4af25b42 100644 --- a/internal/engine/drain_budget_test.go +++ b/internal/engine/drain_budget_test.go @@ -90,7 +90,7 @@ func flippingFake(clock *virtualClock, flipAfter time.Duration, baked string) *t return transport.Result{Stdout: "\n"}, true case strings.Contains(cmd, "State.Status"): return transport.Result{Stdout: "running\n"}, true - case strings.Contains(cmd, "docker ps -q") && strings.Contains(cmd, "ob.release="): + case strings.Contains(cmd, "docker ps -q") && strings.Contains(cmd, "onebox.release="): return transport.Result{Stdout: strings.Join(news, "\n") + "\n"}, true case strings.Contains(cmd, "compose.service='web'"): return transport.Result{Stdout: strings.Join(append(append([]string{}, olds...), news...), "\n") + "\n"}, true @@ -135,7 +135,7 @@ func TestDrainBudgetCoversTheFlipTheGeneratedHealthcheckProduces(t *testing.T) { fake := flippingFake(clock, flip, bakedHealthcheckJSON("5s", 3)) out := &bytes.Buffer{} e := New(config, testProject(t), fake, Options{Out: out, Sleep: clock.sleep, Now: clock.now}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("roll: %v", err) } assertDrained(t, out.String()) @@ -191,7 +191,7 @@ func TestDrainBudgetCoversAContainerBakedBeforeTheChange(t *testing.T) { fake := flippingFake(clock, 4*dockerDefaultInterval-time.Millisecond, bakedHealthcheckJSON("", 0)) out := &bytes.Buffer{} e := New(config, testProject(t), fake, Options{Out: out, Sleep: clock.sleep, Now: clock.now}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("roll: %v", err) } assertDrained(t, out.String()) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 6326aa71..f9017911 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -129,6 +129,10 @@ func New(a *app.Resolved, c *ctypes.Project, t transport.Transport, o Options) * if o.Out == nil { o.Out = os.Stdout } + // The resolved project already knows which environment it describes. + if o.Environment == "" && a != nil { + o.Environment = a.Env + } if o.Sleep == nil { o.Sleep = time.Sleep } diff --git a/internal/engine/epoch.go b/internal/engine/epoch.go index 7f13add0..042a67c5 100644 --- a/internal/engine/epoch.go +++ b/internal/engine/epoch.go @@ -47,7 +47,7 @@ func (e *Engine) nextEpoch(ctx context.Context, epochPath string) (int, error) { // by permissions or replaced with another kind of filesystem object. func epochProbeCmd(epochPath string) string { p := q(epochPath) - return ": ob-epoch-probe; if [ ! -e " + p + " ] && [ ! -L " + p + " ]; then " + + return ": onebox-epoch-probe; if [ ! -e " + p + " ] && [ ! -L " + p + " ]; then " + app.UndeterminedArm(epochPath) + "exit " + strconv.Itoa(app.ProbeAbsent) + "; fi; " + "if [ ! -f " + p + " ] || [ -L " + p + " ]; then exit " + strconv.Itoa(app.ProbeNotRegular) + "; fi; " + "if [ ! -r " + p + " ]; then exit " + strconv.Itoa(app.ProbeUnreadable) + "; fi; cat " + p diff --git a/internal/engine/epoch_test.go b/internal/engine/epoch_test.go index 1ee82e89..540f3bf6 100644 --- a/internal/engine/epoch_test.go +++ b/internal/engine/epoch_test.go @@ -194,7 +194,7 @@ func TestApplicationLockEpochMatrix(t *testing.T) { for _, test := range epochAcquisitionCases() { t.Run(test.name, func(t *testing.T) { fake := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - if command == epochProbeCmd("/var/lib/ob/sample/epoch") { + if command == epochProbeCmd("/var/lib/onebox/app/epoch") { return test.result, true } return transport.Result{}, false @@ -210,7 +210,7 @@ func TestBackupLockEpochMatrix(t *testing.T) { for _, test := range epochAcquisitionCases() { t.Run(test.name, func(t *testing.T) { fake := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - if command == epochProbeCmd("/var/lib/ob/example/backup/locks/database.epoch") { + if command == epochProbeCmd("/var/lib/onebox/app/backup/locks/database.epoch") { return test.result, true } return transport.Result{}, false @@ -241,7 +241,7 @@ func assertEpochAcquisition(t *testing.T, fake *transport.Fake, got int, err err func TestApplicationEpochPersistenceFailureReleasesLock(t *testing.T) { fake := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - if strings.Contains(command, "mktemp '/var/lib/ob/sample/epoch.tmp.XXXXXX'") { + if strings.Contains(command, "mktemp '/var/lib/onebox/app/epoch.tmp.XXXXXX'") { return transport.Result{ExitCode: 23, Stderr: "rename interrupted"}, true } return transport.Result{}, false @@ -250,14 +250,14 @@ func TestApplicationEpochPersistenceFailureReleasesLock(t *testing.T) { if _, err := engine.AcquireLock(context.Background(), "operation", false); err == nil { t.Fatal("acquisition succeeded after epoch persistence failed") } - if engine.lockVal != "" || !strings.Contains(strings.Join(fake.Commands, "\n"), "then rm -f '/var/lib/ob/sample/lock'") { + if engine.lockVal != "" || !strings.Contains(strings.Join(fake.Commands, "\n"), "then rm -f '/var/lib/onebox/app/lock'") { t.Fatalf("failed acquisition left its lock published:\n%s", strings.Join(fake.Commands, "\n")) } } func TestBackupEpochPersistenceFailureReleasesLock(t *testing.T) { fake := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - if strings.Contains(command, "mktemp '/var/lib/ob/example/backup/locks/database.epoch.tmp.XXXXXX'") { + if strings.Contains(command, "mktemp '/var/lib/onebox/app/backup/locks/database.epoch.tmp.XXXXXX'") { return transport.Result{ExitCode: 23, Stderr: "rename interrupted"}, true } return transport.Result{}, false @@ -267,7 +267,7 @@ func TestBackupEpochPersistenceFailureReleasesLock(t *testing.T) { t.Fatal("backup acquisition succeeded after epoch persistence failed") } if engine.backupLockVals["database"] != "" || engine.backupFenceVals["database"] != "" || - !strings.Contains(strings.Join(fake.Commands, "\n"), "then rm -f '/var/lib/ob/example/backup/locks/database.lock'") { + !strings.Contains(strings.Join(fake.Commands, "\n"), "then rm -f '/var/lib/onebox/app/backup/locks/database.lock'") { t.Fatalf("failed backup acquisition left its lock or fence published:\n%s", strings.Join(fake.Commands, "\n")) } } diff --git a/internal/engine/finalize_test.go b/internal/engine/finalize_test.go index c27f865c..97e4ee8f 100644 --- a/internal/engine/finalize_test.go +++ b/internal/engine/finalize_test.go @@ -93,15 +93,15 @@ func buildActivatedFake(t *testing.T, activationResult bool, tail ...journal.Rec base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): + case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal"): return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + lines}, true case strings.Contains(cmd, "test -d"): return transport.Result{ExitCode: 0}, true case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/" + engineTestDeployReleaseID + "\n"}, true - case strings.Contains(cmd, "ob.snapshot.yml"): + case strings.Contains(cmd, "onebox.snapshot.yml"): return transport.Result{Stdout: engineProjectWithPostDeployHook}, true - case strings.Contains(cmd, "docker ps --filter label=ob.app="): + case strings.Contains(cmd, "docker ps --filter label=onebox.app="): return transport.Result{Stdout: "NEW1|web|" + engineTestDeployReleaseID + "|Up 2 minutes (healthy)\n" + "W1|worker|" + engineTestDeployReleaseID + "|Up 2 minutes\n"}, true } @@ -321,7 +321,7 @@ func TestARefusedFinalizeLeavesTheCheckpointIntact(t *testing.T) { // A workload is no longer running, so the live check refuses. base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "docker ps --filter label=ob.app=") { + if strings.Contains(cmd, "docker ps --filter label=onebox.app=") { return transport.Result{Stdout: "NEW1|web|" + engineTestDeployReleaseID + "|Up (healthy)\n"}, true } return base(cmd) @@ -387,7 +387,7 @@ func TestFinalizeRefusesWhenActivationEvidenceDisagrees(t *testing.T) { arrange: func(f *transport.Fake) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "docker ps --filter label=ob.app=") { + if strings.Contains(cmd, "docker ps --filter label=onebox.app=") { return transport.Result{Stdout: "NEW1|web|" + engineTestDeployReleaseID + "|Up (healthy)\n" + "W1|worker|" + engineTestPreviousReleaseID + "|Up\n"}, true } @@ -401,7 +401,7 @@ func TestFinalizeRefusesWhenActivationEvidenceDisagrees(t *testing.T) { arrange: func(f *transport.Fake) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "docker ps --filter label=ob.app=") { + if strings.Contains(cmd, "docker ps --filter label=onebox.app=") { return transport.Result{Stdout: "NEW1|web|" + engineTestDeployReleaseID + "|Up (healthy)\n"}, true } return base(cmd) @@ -441,7 +441,7 @@ func TestFinalizeRefusesWithoutJournaledActivation(t *testing.T) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): + case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal"): return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + lines}, true case strings.Contains(cmd, "test -d"): return transport.Result{ExitCode: 0}, true @@ -467,7 +467,7 @@ func TestRetentionEvidenceRefusalIsReportedAndDoesNotFailTheDeploy(t *testing.T) f := happyFake() base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "/var/lib/ob/sample/activation.json") && strings.Contains(cmd, "printf 'mode=%s") { + if strings.Contains(cmd, "/var/lib/onebox/app/activation.json") && strings.Contains(cmd, "printf 'mode=%s") { return transport.Result{Stdout: "mode=600\n{ this is not a checkpoint"}, true } return base(cmd) @@ -497,7 +497,7 @@ func TestRetentionEvidenceRefusalIsReportedAndDoesNotFailTheDeploy(t *testing.T) // Journals are the evidence that protects release directories with no // readable manifest. The run that just declared the evidence incomplete must // not delete them either. - if strings.Contains(seq, "rm -f '/var/lib/ob/sample/journal/") { + if strings.Contains(seq, "rm -f '/var/lib/onebox/app/journal/") { t.Fatalf("a refused retention must not prune journals:\n%s", seq) } if !strings.Contains(seq, `"event":"finish","status":"ok"`) { @@ -516,9 +516,9 @@ func TestRetentionDeletionFailureStillFailsTheDeploy(t *testing.T) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "ls -1A") || strings.Contains(cmd, "ls -1 '/var/lib/ob/sample/releases'"): + case strings.Contains(cmd, "ls -1A") || strings.Contains(cmd, "ls -1 '/var/lib/onebox/app/releases'"): return transport.Result{Stdout: "20250101-000000-old\n20260101-000000-aaa111\n"}, true - case strings.Contains(cmd, "rm -rf '/var/lib/ob/sample/releases/20250101-000000-old'"): + case strings.Contains(cmd, "rm -rf '/var/lib/onebox/app/releases/20250101-000000-old'"): return transport.Result{ExitCode: 1, Stderr: "read-only file system"}, true } return base(cmd) @@ -658,7 +658,7 @@ func TestResumeRefusesASupersededReleaseBeforeAnyEffect(t *testing.T) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): + case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal"): return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + jr}, true case strings.Contains(cmd, "test -d"): return transport.Result{ExitCode: 0}, true diff --git a/internal/engine/findincomplete_test.go b/internal/engine/findincomplete_test.go index 37f5e083..88ea0b4e 100644 --- a/internal/engine/findincomplete_test.go +++ b/internal/engine/findincomplete_test.go @@ -12,7 +12,7 @@ import ( // mirrors journal.journalMarker (unexported) — this test simulates the remote // bulk-read output that Journals parses. -const journalMarkerLine = "@@ob-journal@@" +const journalMarkerLine = "@@onebox-journal@@" // A crash left R1 half-done, then R2 deployed cleanly. R2 rolled every role and // activated its own release, so nothing about R1 is still completable: resuming diff --git a/internal/engine/gate.go b/internal/engine/gate.go index a310a2df..c1c285d9 100644 --- a/internal/engine/gate.go +++ b/internal/engine/gate.go @@ -223,9 +223,9 @@ func jobRunLabels(operationID string, epoch int) string { const ( // JobOperationLabel carries the operation id of the run that created a // one-off job container. - JobOperationLabel = "ob.operation" + JobOperationLabel = "onebox.operation" // JobEpochLabel carries the lock epoch that run held. - JobEpochLabel = "ob.epoch" + JobEpochLabel = "onebox.epoch" ) func injectComposeJobLabels(command, operationID string, epoch int) (string, bool) { @@ -327,7 +327,7 @@ func (e *Engine) jobRollbackPolicySafe(service string) bool { } // removeNewcomers stops and removes every container of the given release -// (identified by the ob.release label the render injected). +// (identified by the onebox.release label the render injected). func (e *Engine) removeNewcomers(ctx context.Context, releaseID string) error { for _, roleName := range e.Spec.ReleaseOrder() { ids, err := e.newcomerIDs(ctx, roleName, releaseID, "") diff --git a/internal/engine/gate_test.go b/internal/engine/gate_test.go index 618a9e24..8635a78a 100644 --- a/internal/engine/gate_test.go +++ b/internal/engine/gate_test.go @@ -67,9 +67,9 @@ func TestAutoRollbackUsesPreviousReleaseSnapshot(t *testing.T) { switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true - case strings.Contains(cmd, "/releases/"+engineTestPreviousReleaseID+"/ob.snapshot.yml"): + case strings.Contains(cmd, "/releases/"+engineTestPreviousReleaseID+"/onebox.snapshot.yml"): return transport.Result{Stdout: oldSnapshot}, true - case strings.Contains(cmd, "service='worker'") && strings.Contains(cmd, "ob.release='"+engineTestPreviousReleaseID+"'"): + case strings.Contains(cmd, "service='worker'") && strings.Contains(cmd, "onebox.release='"+engineTestPreviousReleaseID+"'"): return transport.Result{}, true case strings.Contains(cmd, "curl -fsS"): return transport.Result{ExitCode: 22, Stderr: "500"}, true @@ -117,7 +117,7 @@ func TestAutoRollbackStopsWhenIntentCannotBeJournaled(t *testing.T) { func TestRemoveNewcomersRejectsRemoteRemovalFailure(t *testing.T) { f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "service='web'") && strings.Contains(cmd, "ob.release='R1'"): + case strings.Contains(cmd, "service='web'") && strings.Contains(cmd, "onebox.release='R1'"): return transport.Result{Stdout: "NEW1\n"}, true case strings.Contains(cmd, "docker stop -t 10 NEW1"): return transport.Result{ExitCode: 55, Stderr: "daemon refused"}, true @@ -201,7 +201,7 @@ func TestJobDoesNotRunWhenIntentCannotBeJournaled(t *testing.T) { return nil } e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - jw := &journal.Writer{T: f, Names: e.Names(), DeployID: "R1", Epoch: 1} + jw := &journal.Writer{T: f, Dir: journal.Dir(e.Names()), DeployID: "R1", Epoch: 1} err := e.runJobs(context.Background(), jw, nil, "/remote", "/remote/compose.yaml") if err == nil || !strings.Contains(err.Error(), "journal unavailable") { t.Fatalf("intent journal failure must stop the job: %v", err) @@ -222,7 +222,7 @@ func TestLifecycleHookDoesNotRunWhenIntentCannotBeJournaled(t *testing.T) { cfg := testConfig() cfg.Hooks["pre_release"] = app.Command{Run: "echo SHOULD_NOT_RUN"} e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - jw := &journal.Writer{T: f, Names: e.Names(), DeployID: "R1", Epoch: 1} + jw := &journal.Writer{T: f, Dir: journal.Dir(e.Names()), DeployID: "R1", Epoch: 1} err := e.runRollbackEffectHook(context.Background(), jw, nil, "pre_release", "/remote", "/remote/compose.yaml") if err == nil || !strings.Contains(err.Error(), "journal unavailable") { t.Fatalf("intent journal failure must stop the hook: %v", err) @@ -317,7 +317,7 @@ func TestFailedDeployRollbackDebtSurvivesNextDeploy(t *testing.T) { ) base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal") { + if strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal") { return transport.Result{Stdout: journalMarkerLine + "R1.jsonl\n" + failed + journalMarkerLine + "R1-service.jsonl\n" + maintenance}, true } @@ -411,7 +411,7 @@ func TestMigrateComposeJobGetsPrivateWritableBoundResultFile(t *testing.T) { found := false for _, c := range f.Commands { const ( - resultDir = "/var/lib/ob/sample/releases/" + engineTestDeployReleaseID + "/.job-migrate-result" + resultDir = "/var/lib/onebox/app/releases/" + engineTestDeployReleaseID + "/.job-migrate-result" resultFile = resultDir + "/result" ) privateDir := strings.Index(c, "install -d -m 700 '"+resultDir+"'") @@ -440,8 +440,8 @@ func TestJobContainerCarriesItsOperationIdentity(t *testing.T) { } seq := strings.Join(f.Commands, "\n") for _, want := range []string{ - "--label 'ob.operation=20260909-053225-abc-job_run-deadbeef'", - "--label 'ob.epoch=7'", + "--label 'onebox.operation=20260909-053225-abc-job_run-deadbeef'", + "--label 'onebox.epoch=7'", } { if !strings.Contains(seq, want) { t.Fatalf("job container missing %s:\n%s", want, seq) @@ -451,7 +451,7 @@ func TestJobContainerCarriesItsOperationIdentity(t *testing.T) { func TestInjectComposeJobLabelsOnlyTouchesAComposeRun(t *testing.T) { got, ok := injectComposeJobLabels("docker compose -f x.yml run --rm migrate", "op-1", 2) - if !ok || !strings.Contains(got, "--label 'ob.operation=op-1'") || !strings.Contains(got, "--label 'ob.epoch=2'") { + if !ok || !strings.Contains(got, "--label 'onebox.operation=op-1'") || !strings.Contains(got, "--label 'onebox.epoch=2'") { t.Fatalf("compose run = %q ok=%v", got, ok) } // A hook that is not a compose run has no container to label. diff --git a/internal/engine/hooks_test.go b/internal/engine/hooks_test.go index cd0b3297..90c2aea4 100644 --- a/internal/engine/hooks_test.go +++ b/internal/engine/hooks_test.go @@ -21,7 +21,7 @@ func TestLocalHookRunsOnRunnerNotHost(t *testing.T) { cfg := testConfig() cfg.Hooks["publish"] = app.Command{Run: "echo $ONEBOX_RELEASE_ID > out.txt", Local: true} e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, LocalDir: dir}) - if err := e.RunHook(context.Background(), "publish", "/var/lib/ob/sample/releases/R9", "x"); err != nil { + if err := e.RunHook(context.Background(), "publish", "/var/lib/onebox/app/releases/R9", "x"); err != nil { t.Fatal(err) } if len(f.Commands) != 0 { diff --git a/internal/engine/host_environment_test.go b/internal/engine/host_environment_test.go index 31cfe932..e71c63ad 100644 --- a/internal/engine/host_environment_test.go +++ b/internal/engine/host_environment_test.go @@ -58,19 +58,6 @@ func TestRequireHostOwnerAcceptsItsOwnEnvironment(t *testing.T) { } } -// A record written before the environment field existed identifies the -// application and nothing more. Refusing on it would strand every host claimed -// by an older ob, so the application check still applies and the environment -// check waits for bootstrap to complete the record. -func TestRequireHostOwnerAcceptsARecordThatPredatesEnvironments(t *testing.T) { - for _, env := range []string{"production", "staging"} { - e := engineForEnv(t, env, ownerFake("sample")) - if err := e.RequireHostOwner(context.Background()); err != nil { - t.Fatalf("legacy record with env %q: %v", env, err) - } - } -} - // A different application is still refused with the code it always used; the // new check must not swallow the older one. func TestRequireHostOwnerStillRefusesADifferentApplication(t *testing.T) { @@ -88,37 +75,35 @@ func TestHostOwnerRecordRoundTrips(t *testing.T) { want hostOwner ok bool }{ - {"sample production", hostOwner{App: "sample", Environment: "production"}, true}, - {"sample", hostOwner{App: "sample"}, true}, - {" sample production ", hostOwner{App: "sample", Environment: "production"}, true}, + {"sample production", hostOwner{Application: "sample", Environment: "production"}, true}, + {"sample", hostOwner{}, false}, + {" sample production ", hostOwner{Application: "sample", Environment: "production"}, true}, {"", hostOwner{}, false}, {"sample production extra", hostOwner{}, false}, {"Sample production", hostOwner{}, false}, {"sample Production", hostOwner{}, false}, } { - got, ok := parseHostOwner(tc.record) + got, ok := app.ParseHostOwnerRecord(tc.record) if ok != tc.ok || got != tc.want { - t.Fatalf("parseHostOwner(%q) = %+v,%v want %+v,%v", tc.record, got, ok, tc.want, tc.ok) + t.Fatalf("ParseHostOwnerRecord(%q) = %+v,%v want %+v,%v", tc.record, got, ok, tc.want, tc.ok) } - if ok && got.record() != strings.Join(strings.Fields(tc.record), " ") { - t.Fatalf("record() = %q, does not round-trip %q", got.record(), tc.record) + if ok && got.String() != strings.Join(strings.Fields(tc.record), " ") { + t.Fatalf("String() = %q, does not round-trip %q", got.String(), tc.record) } } } -// The engine derives every host path from Opts.Environment, so an engine built -// without one silently reports on the project's default base_path instead of -// the environment's. cmd/ob's connect() omitted it, which meant `ob status`, -// `ob audit` and `ob logs` read the wrong directory for any environment with a -// base_path override — and would have compared an empty environment against the -// host owner record. +// The engine derives every host path from Opts.Environment. cmd/ob's connect() +// once omitted it, which meant `ob status`, `ob audit` and `ob logs` read the +// project's default base_path instead of the environment's. An engine built +// without one now takes the environment the project was resolved for. func TestEnvironmentSelectsTheBasePath(t *testing.T) { spec, err := app.LoadBytes([]byte(`apiVersion: onebox.run/v1alpha1 kind: Application metadata: name: sample spec: - basePath: /var/lib/ob + basePath: /var/lib/onebox environments: production: {server: root@h} staging: {server: root@h2, basePath: /srv/staging} @@ -133,11 +118,11 @@ spec: t.Fatal(err) } staging := New(resolved, nil, nil, Options{Out: io.Discard, Environment: "staging"}).names().AppDir() - if staging != "/srv/staging/sample" { - t.Fatalf("staging AppDir = %q, want /srv/staging/sample", staging) + if staging != "/srv/staging/app" { + t.Fatalf("staging AppDir = %q, want /srv/staging/app", staging) } empty := New(resolved, nil, nil, Options{Out: io.Discard}).names().AppDir() - if empty == staging { - t.Fatal("an engine with no environment resolved the same path as staging; this test can no longer detect the defect") + if empty != staging { + t.Fatalf("an engine with no environment resolved %q, not the resolved environment's %q", empty, staging) } } diff --git a/internal/engine/host_owner.go b/internal/engine/host_owner.go index 47bcbdb0..dfd6ed53 100644 --- a/internal/engine/host_owner.go +++ b/internal/engine/host_owner.go @@ -2,6 +2,7 @@ package engine import ( "context" + "errors" "fmt" "strings" @@ -47,37 +48,9 @@ func (e *HostEnvironmentMismatchError) Error() string { func (e *HostEnvironmentMismatchError) Code() string { return "host_environment_mismatch" } -// hostOwner is the parsed owner record: an application, and the environment -// that claimed the host. -// -// A record written before the environment was recorded carries the application -// alone. That is not treated as a failure — it predates the field — but it also -// cannot prove which environment owns the host, so it is upgraded in place the -// next time bootstrap runs. Until then the application check still applies. -type hostOwner struct { - App string - Environment string -} - -func (o hostOwner) legacy() bool { return o.Environment == "" } - -func parseHostOwner(record string) (hostOwner, bool) { - // One parser, shared with preflight. Two readings of the same file drift, - // and the drift showed: preflight read the first two fields and ignored the - // rest, so a three-field record passed there and failed here. - parsed, ok := app.ParseHostOwnerRecord(record) - if !ok { - return hostOwner{}, false - } - return hostOwner{App: parsed.Application, Environment: parsed.Environment}, true -} - -func (o hostOwner) record() string { - if o.legacy() { - return o.App - } - return o.App + " " + o.Environment -} +// hostOwner is app.HostOwnerRecord: one type, one parser and one writer for a +// record the engine writes and preflight reads. +type hostOwner = app.HostOwnerRecord func (e *Engine) readHostOwner(ctx context.Context) (hostOwner, error) { path := proxy.HostPaths(e.names()).Owner @@ -109,7 +82,7 @@ func (e *Engine) readHostOwner(ctx context.Context) (hostOwner, error) { return hostOwner{}, fmt.Errorf("read host owner record %s failed (exit %d): %s", path, result.ExitCode, strings.TrimSpace(result.Stderr)) } record := strings.TrimSpace(result.Stdout) - owner, ok := parseHostOwner(record) + owner, ok := app.ParseHostOwnerRecord(record) if !ok { // An empty record is the reachable case: a claim interrupted between // the noclobber open and the write leaves a zero-byte file, and from @@ -132,25 +105,20 @@ func (e *Engine) RequireHostOwner(ctx context.Context) error { if err != nil { return err } - if owner.App == "" { + if owner.Application == "" { return fmt.Errorf("host has no Onebox application owner; run `ob bootstrap` for %q first", e.Spec.Name) } - if owner.App != e.Spec.Name { - return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.App} - } - // A record from before the environment was written down cannot say which - // environment owns the host, and refusing on that would strand every host - // claimed by an older ob. The application check still holds; bootstrap - // upgrades the record when it next runs. - if owner.legacy() { - return nil + return e.ownerConflict(owner) +} + +// ownerConflict is the one comparison of an owner record with this engine: +// another application, or this application in another environment. +func (e *Engine) ownerConflict(owner hostOwner) error { + if owner.Application != "" && owner.Application != e.Spec.Name { + return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.Application} } - if owner.Environment != e.Opts.Environment { - return &HostEnvironmentMismatchError{ - Application: e.Spec.Name, - Requesting: e.Opts.Environment, - Owner: owner.Environment, - } + if owner.Application == e.Spec.Name && owner.Environment != e.Opts.Environment { + return &HostEnvironmentMismatchError{Application: e.Spec.Name, Requesting: e.Opts.Environment, Owner: owner.Environment} } return nil } @@ -158,58 +126,40 @@ func (e *Engine) RequireHostOwner(ctx context.Context) error { // claimHostOwner is bootstrap's only host-ownership transition. It checks for // a foreign owner before acquiring a lock, then rechecks under the host lock so // two first-contact attempts cannot both claim the same machine. -func (e *Engine) claimHostOwner(ctx context.Context) error { - owner, err := e.readHostOwner(ctx) - if err != nil { +func (e *Engine) claimHostOwner(ctx context.Context, owner hostOwner) error { + // The record names the environment, and one without it is unreadable: a + // claim written with an empty environment would lock every command out of + // the host until someone removed the file by hand. + if e.Opts.Environment == "" { + return errors.New("cannot claim the host without an environment") + } + if err := e.ownerConflict(owner); err != nil { return err } - if owner.App != "" && owner.App != e.Spec.Name { - return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.App} - } - if owner.App == e.Spec.Name && !owner.legacy() { - if owner.Environment != e.Opts.Environment { - return &HostEnvironmentMismatchError{ - Application: e.Spec.Name, - Requesting: e.Opts.Environment, - Owner: owner.Environment, - } - } + if owner.Application == e.Spec.Name { return nil } - // Either unclaimed, or claimed by this application under a record that - // predates the environment field. Both take the lock: the first to write a - // full record, the second to upgrade one in place. + // Unclaimed: take the lock and recheck, so two first-contact attempts + // cannot both claim the same machine. if err := e.acquireHostLock(ctx, e.Opts.ForceLock); err != nil { return err } defer e.releaseHostLock(ctx) - owner, err = e.readHostOwner(ctx) + owner, err := e.readHostOwner(ctx) if err != nil { return err } - if owner.App != "" && owner.App != e.Spec.Name { - return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.App} + if err := e.ownerConflict(owner); err != nil { + return err } - if owner.App == e.Spec.Name && !owner.legacy() { - if owner.Environment != e.Opts.Environment { - return &HostEnvironmentMismatchError{ - Application: e.Spec.Name, - Requesting: e.Opts.Environment, - Owner: owner.Environment, - } - } + if owner.Application == e.Spec.Name { return nil } - claim := hostOwner{App: e.Spec.Name, Environment: e.Opts.Environment} + claim := hostOwner{Application: e.Spec.Name, Environment: e.Opts.Environment} path := proxy.HostPaths(e.names()).Owner // `set -C` refuses to clobber, which is what makes a first claim a race - // nobody wins twice. Upgrading a legacy record is a rewrite of a file that - // already exists, so it cannot use the same guard — it runs under the host - // lock, having just re-read the record it is replacing. - write := "umask 077 && set -C && printf '%s\\n' " + q(claim.record()) + " > " + q(path) - if owner.legacy() { - write = "umask 077 && printf '%s\\n' " + q(claim.record()) + " > " + q(path) - } + // nobody wins twice. + write := "umask 077 && set -C && printf '%s\\n' " + q(claim.String()) + " > " + q(path) result, err := e.hostMutate(ctx, write) if err != nil { return err diff --git a/internal/engine/host_owner_test.go b/internal/engine/host_owner_test.go index db0b391d..8a44f67d 100644 --- a/internal/engine/host_owner_test.go +++ b/internal/engine/host_owner_test.go @@ -38,7 +38,7 @@ func TestForeignHostOwnerBlocksMutationsBeforeEffects(t *testing.T) { t.Run(test.name, func(t *testing.T) { fake := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { if strings.Contains(command, "_host/owner") { - return transport.Result{Stdout: "another-app\n"}, true + return transport.Result{Stdout: "another-app production\n"}, true } return transport.Result{}, false }} @@ -135,16 +135,15 @@ func TestClaimHostOwnerRechecksUnderLock(t *testing.T) { reads := 0 fake := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { if strings.Contains(command, "_host/owner") && strings.Contains(command, "cat ") { + // The caller read the host unclaimed (hostOwner{} below); another + // claim lands before the lock, so the read under it finds it. reads++ - if reads == 1 { - return transport.Result{ExitCode: 3}, true - } - return transport.Result{Stdout: "another-app\n"}, true + return transport.Result{Stdout: "another-app production\n"}, true } return transport.Result{}, false }} engine := New(testConfig(), testProject(t), fake, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - err := engine.claimHostOwner(context.Background()) + err := engine.claimHostOwner(context.Background(), hostOwner{}) if err == nil || !strings.Contains(err.Error(), "another-app") { t.Fatalf("concurrent owner claim was accepted: %v", err) } @@ -167,7 +166,7 @@ func TestClaimHostOwnerReportsAtomicWriteFailure(t *testing.T) { } }} engine := New(testConfig(), testProject(t), fake, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := engine.claimHostOwner(context.Background()); err == nil || !strings.Contains(err.Error(), "record host owner") { + if err := engine.claimHostOwner(context.Background(), hostOwner{}); err == nil || !strings.Contains(err.Error(), "record host owner") { t.Fatalf("owner write failure was hidden: %v", err) } } @@ -204,3 +203,18 @@ func TestMigrationGateGuidanceMatchesTheRefusedCommand(t *testing.T) { } } } + +func TestClaimHostOwnerRefusesAnEmptyEnvironment(t *testing.T) { + fake := &transport.Fake{} + engine := New(testConfig(), testProject(t), fake, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + engine.Opts.Environment = "" + err := engine.claimHostOwner(context.Background(), hostOwner{}) + if err == nil || !strings.Contains(err.Error(), "without an environment") { + t.Fatalf("claim without an environment = %v", err) + } + for _, command := range fake.Commands { + if strings.Contains(command, "_host/owner") && strings.Contains(command, "printf") { + t.Fatalf("an unreadable owner record was written: %s", command) + } + } +} diff --git a/internal/engine/job.go b/internal/engine/job.go index 30d340d6..95c82727 100644 --- a/internal/engine/job.go +++ b/internal/engine/job.go @@ -106,7 +106,7 @@ func (e *Engine) RunJobWithJournalID(ctx context.Context, request JobRunRequest) } writer := &journal.Writer{ - T: e.T, Names: e.names(), DeployID: operationID, Epoch: epoch, + T: e.T, Dir: journal.Dir(e.names()), DeployID: operationID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, ApprovalDigest: e.Opts.ApprovalDigest, ApprovalClass: e.Opts.ApprovalClass, ApprovedBy: e.Opts.ApprovedBy, ApprovalSource: e.Opts.ApprovalSource, diff --git a/internal/engine/job_containers_test.go b/internal/engine/job_containers_test.go index a37d8f25..8067dae4 100644 --- a/internal/engine/job_containers_test.go +++ b/internal/engine/job_containers_test.go @@ -14,7 +14,7 @@ import ( func jobContainerFake(running []string) *transport.Fake { return &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "label='ob.operation'"): + case strings.Contains(cmd, "label='onebox.operation'"): return transport.Result{Stdout: strings.Join(running, "\n") + "\n"}, true } return transport.Result{}, false @@ -81,7 +81,7 @@ func TestRefuseCatchesAnotherInvocationOfTheSameOperation(t *testing.T) { func TestRefuseDoesNotExemptAContainerWithNoEpoch(t *testing.T) { f := jobContainerFake([]string{"abc123def456 J1 "}) err := jobContainerEngine(t, f).refuseForeignJobContainers(context.Background(), "J1", 4) - if err == nil || !strings.Contains(err.Error(), "carrying no ob.epoch label") { + if err == nil || !strings.Contains(err.Error(), "carrying no onebox.epoch label") { t.Fatalf("unlabelled epoch = %v, want a refusal saying it cannot be placed", err) } } diff --git a/internal/engine/job_history.go b/internal/engine/job_history.go index 9942a595..fac70d0f 100644 --- a/internal/engine/job_history.go +++ b/internal/engine/job_history.go @@ -60,7 +60,7 @@ func (e *Engine) JobHistory(ctx context.Context, name string, n int) ([]JobHisto } } - ids, journals, err := journal.Journals(ctx, e.T, e.names()) + ids, journals, err := journal.Journals(ctx, e.T, journal.Dir(e.names())) if err != nil { return nil, err } diff --git a/internal/engine/job_history_test.go b/internal/engine/job_history_test.go index 1b2e0d3a..c4836e06 100644 --- a/internal/engine/job_history_test.go +++ b/internal/engine/job_history_test.go @@ -13,10 +13,10 @@ func TestJobHistoryMergesTimerAndOperatorStoresByOperation(t *testing.T) { hostRecords := `{"run":"11111111111111111111111111111111","job":"nightly","trigger":"operator","operation":"op-host","release":"release-a","started_at":"2026-09-16T03:00:00Z","finished_at":"2026-09-16T03:00:04Z","duration_s":4,"attempts":1,"exit_status":0,"outcome":"success","inputs":{"SOURCE":"prices"}} {"run":"22222222222222222222222222222222","job":"nightly","trigger":"timer","release":"release-a","started_at":"2026-09-16T02:00:00Z","finished_at":"2026-09-16T02:00:03Z","duration_s":3,"attempts":1,"exit_status":1,"outcome":"failure","inputs":{}} ` - journals := `@@ob-journal@@op-direct.jsonl + journals := `@@onebox-journal@@op-direct.jsonl {"deploy_id":"op-direct","phase":"job","event":"start","status":"ok","ts":"2026-09-16T04:00:00Z","operator":"bob@example","service":"nightly","release_id":"release-a"} {"deploy_id":"op-direct","phase":"job","event":"finish","status":"ok","ts":"2026-09-16T04:00:05Z","service":"nightly"} -@@ob-journal@@op-host.jsonl +@@onebox-journal@@op-host.jsonl {"deploy_id":"op-host","phase":"schedule-run","event":"start","status":"ok","ts":"2026-09-16T02:59:59Z","operator":"alice@example","target":"nightly"} {"deploy_id":"op-host","phase":"schedule-run","event":"finish","status":"ok","ts":"2026-09-16T03:00:04Z","target":"nightly"} ` @@ -24,7 +24,7 @@ func TestJobHistoryMergesTimerAndOperatorStoresByOperation(t *testing.T) { switch { case strings.Contains(cmd, "journalctl"): return transport.Result{Stdout: hostRecords}, true - case strings.Contains(cmd, "@@ob-journal@@"): + case strings.Contains(cmd, "@@onebox-journal@@"): return transport.Result{Stdout: journals}, true default: return transport.Result{}, false diff --git a/internal/engine/job_test.go b/internal/engine/job_test.go index 921fa63f..603fcf73 100644 --- a/internal/engine/job_test.go +++ b/internal/engine/job_test.go @@ -190,7 +190,7 @@ func TestRunJobRefusesWhileAForeignJobContainerRuns(t *testing.T) { target := currentJobFake(runtime) inner := target.Dynamic target.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "label='ob.operation'") { + if strings.Contains(cmd, "label='onebox.operation'") { return transport.Result{Stdout: "abc123def456 other-op 2\n"}, true } return inner(cmd) @@ -217,7 +217,7 @@ func TestRunJobKeepsTheLockWhenItRefuses(t *testing.T) { target := currentJobFake(runtime) inner := target.Dynamic target.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "label='ob.operation'") { + if strings.Contains(cmd, "label='onebox.operation'") { return transport.Result{Stdout: "abc123def456 other-op 2\n"}, true } return inner(cmd) @@ -230,7 +230,7 @@ func TestRunJobKeepsTheLockWhenItRefuses(t *testing.T) { t.Fatal("expected a refusal") } for _, c := range target.Commands { - if strings.Contains(c, "rm -f '/var/lib/ob/sample/lock'") { + if strings.Contains(c, "rm -f '/var/lib/onebox/app/lock'") { t.Fatalf("the lock was released over a live container:\n%s", c) } } @@ -244,7 +244,7 @@ func TestRunJobExplainsWhyItKeptTheLock(t *testing.T) { target := currentJobFake(runtime) inner := target.Dynamic target.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "label='ob.operation'") { + if strings.Contains(cmd, "label='onebox.operation'") { return transport.Result{Stdout: "abc123def456 other-op 2\n"}, true } return inner(cmd) @@ -273,7 +273,7 @@ func TestRunJobKeepsTheLockWhenItCannotAskTheHost(t *testing.T) { target := currentJobFake(runtime) inner := target.Dynamic target.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "label='ob.operation'") { + if strings.Contains(cmd, "label='onebox.operation'") { return transport.Result{ExitCode: 1, Stderr: "Cannot connect to the Docker daemon"}, true } return inner(cmd) @@ -287,7 +287,7 @@ func TestRunJobKeepsTheLockWhenItCannotAskTheHost(t *testing.T) { t.Fatal("an unanswerable host must refuse") } for _, c := range target.Commands { - if strings.Contains(c, "rm -f '/var/lib/ob/sample/lock'") { + if strings.Contains(c, "rm -f '/var/lib/onebox/app/lock'") { t.Fatalf("the lock was released without an answer:\n%s", c) } } diff --git a/internal/engine/lock.go b/internal/engine/lock.go index b10487e8..a65f0c7b 100644 --- a/internal/engine/lock.go +++ b/internal/engine/lock.go @@ -68,10 +68,8 @@ func (e *Engine) AcquireLock(ctx context.Context, deployID string, force bool) ( // the one operation that may supply an explicit compatible lease policy. func (e *Engine) acquireLock(ctx context.Context, deployID string, force bool, leasePolicy pinnedScheduleLeasePolicy) (int, error) { e.lockVal = "" - if res, err := e.T.Run(ctx, "mkdir -p "+q(e.base())); err != nil { + if err := e.claimAppDir(ctx); err != nil { return 0, err - } else if res.ExitCode != 0 { - return 0, fmt.Errorf("mkdir %s: %s", e.base(), res.Stderr) } for range 4 { @@ -95,14 +93,6 @@ func (e *Engine) acquireLock(ctx context.Context, deployID string, force bool, l return 0, scheduleErr } useScheduleLock := e.hasScheduleFlock(ctx) - useLegacyScheduleLock := false - if !useScheduleLock { - // The current spec may have just removed its last schedule while an - // old unit is already starting. Preserve the pre-upgrade rendezvous - // with the short-option interface in that transition. Its ambiguous - // nonzero exits fail visibly below instead of being called contention. - useLegacyScheduleLock = e.hasFlock(ctx) - } if len(jobs) > 0 && !useScheduleLock { return 0, errors.New("scheduled jobs require a compatible util-linux flock at /usr/bin/flock so lock contention can be distinguished from host failures; install util-linux or upgrade it and deploy again") } @@ -115,9 +105,6 @@ func (e *Engine) acquireLock(ctx context.Context, deployID string, force bool, l // starting while that removal deploy begins. create = "/usr/bin/flock --exclusive --timeout " + strconv.Itoa(scheduleRendezvousWaitSeconds) + " --conflict-exit-code " + strconv.Itoa(flockConflictExitCode) + " " + q(e.names().ScheduleRunLock()) + " /bin/sh -c " + q(create) - } else if useLegacyScheduleLock { - create = "/usr/bin/flock -x -w " + strconv.Itoa(scheduleRendezvousWaitSeconds) + " " + - q(e.names().ScheduleRunLock()) + " /bin/sh -c " + q(create) } res, err := e.T.Run(ctx, create) @@ -338,12 +325,12 @@ func (e *Engine) WriteFence(ctx context.Context, deployID string, epoch int) err return fmt.Errorf("write fence: app lock is not owned") } val := deployID + " " + strconv.Itoa(epoch) - cmd := `if [ "$(cat ` + q(e.lockPath()) + ` 2>/dev/null)" = ` + q(e.lockVal) + ` ]; then echo ` + q(val) + ` > ` + q(e.fencePath()) + `; else echo ob-lock-lost >&2; exit 96; fi` + cmd := `if [ "$(cat ` + q(e.lockPath()) + ` 2>/dev/null)" = ` + q(e.lockVal) + ` ]; then echo ` + q(val) + ` > ` + q(e.fencePath()) + `; else echo onebox-lock-lost >&2; exit 96; fi` res, err := e.T.Run(ctx, cmd) if err != nil { return err } - if res.ExitCode == 96 && strings.Contains(res.Stderr, "ob-lock-lost") { + if res.ExitCode == 96 && strings.Contains(res.Stderr, "onebox-lock-lost") { return ErrFenced } if res.ExitCode != 0 { @@ -359,12 +346,12 @@ func (e *Engine) mutate(ctx context.Context, cmd string) (res transport.Result, if e.fenceVal == "" { return e.T.Run(ctx, cmd) } - guarded := `if [ "$(cat ` + q(e.fencePath()) + ` 2>/dev/null)" = ` + q(e.fenceVal) + ` ]; then ` + cmd + `; else echo ob-fenced >&2; exit 97; fi` + guarded := `if [ "$(cat ` + q(e.fencePath()) + ` 2>/dev/null)" = ` + q(e.fenceVal) + ` ]; then ` + cmd + `; else echo onebox-fenced >&2; exit 97; fi` res, err = e.T.Run(ctx, guarded) if err != nil { return res, err } - if res.ExitCode == 97 && strings.Contains(res.Stderr, "ob-fenced") { + if res.ExitCode == 97 && strings.Contains(res.Stderr, "onebox-fenced") { return res, ErrFenced } return res, nil @@ -377,12 +364,12 @@ func (e *Engine) mutateInput(ctx context.Context, cmd, input string) (res transp if e.fenceVal == "" { return e.T.RunInput(ctx, cmd, input) } - guarded := `if [ "$(cat ` + q(e.fencePath()) + ` 2>/dev/null)" = ` + q(e.fenceVal) + ` ]; then ` + cmd + `; else echo ob-fenced >&2; exit 97; fi` + guarded := `if [ "$(cat ` + q(e.fencePath()) + ` 2>/dev/null)" = ` + q(e.fenceVal) + ` ]; then ` + cmd + `; else echo onebox-fenced >&2; exit 97; fi` res, err = e.T.RunInput(ctx, guarded, input) if err != nil { return res, err } - if res.ExitCode == 97 && strings.Contains(res.Stderr, "ob-fenced") { + if res.ExitCode == 97 && strings.Contains(res.Stderr, "onebox-fenced") { return res, ErrFenced } return res, nil @@ -395,7 +382,7 @@ func (e *Engine) mutateStream(ctx context.Context, cmd string, stdout, stderr io if e.fenceVal == "" { return e.T.RunStream(ctx, cmd, stdout, stderr) } - guarded := `if [ "$(cat ` + q(e.fencePath()) + ` 2>/dev/null)" = ` + q(e.fenceVal) + ` ]; then ` + cmd + `; else echo ob-fenced >&2; exit 97; fi` + guarded := `if [ "$(cat ` + q(e.fencePath()) + ` 2>/dev/null)" = ` + q(e.fenceVal) + ` ]; then ` + cmd + `; else echo onebox-fenced >&2; exit 97; fi` return e.T.RunStream(ctx, guarded, stdout, stderr) } diff --git a/internal/engine/lock_test.go b/internal/engine/lock_test.go index 8288726d..0ea92803 100644 --- a/internal/engine/lock_test.go +++ b/internal/engine/lock_test.go @@ -24,7 +24,7 @@ func lockEngine(t *testing.T, f *transport.Fake) *Engine { func TestAcquireLockHappyPath(t *testing.T) { f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "cat '/var/lib/ob/sample/epoch'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/app/epoch'") { return transport.Result{Stdout: "6\n"}, true } return transport.Result{}, false @@ -38,12 +38,12 @@ func TestAcquireLockHappyPath(t *testing.T) { t.Fatalf("epoch: %d", epoch) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "lock.candidate.XXXXXX") || !strings.Contains(seq, `link "$tmp" '/var/lib/ob/sample/lock'`) { + if !strings.Contains(seq, "lock.candidate.XXXXXX") || !strings.Contains(seq, `link "$tmp" '/var/lib/onebox/app/lock'`) { t.Fatalf("atomic lock publication missing:\n%s", seq) } - if !strings.Contains(seq, "mktemp '/var/lib/ob/sample/epoch.tmp.XXXXXX'") || + if !strings.Contains(seq, "mktemp '/var/lib/onebox/app/epoch.tmp.XXXXXX'") || !strings.Contains(seq, "printf '%s\\n' 7") || - !strings.Contains(seq, `mv -f "$tmp" '/var/lib/ob/sample/epoch'`) { + !strings.Contains(seq, `mv -f "$tmp" '/var/lib/onebox/app/epoch'`) { t.Fatalf("epoch not persisted:\n%s", seq) } } @@ -176,37 +176,14 @@ func TestAcquireLockReportsScheduleRendezvousFailure(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "lock creation or schedule rendezvous failed (exit 74): flock: I/O error") { t.Fatalf("error = %v, want preserved flock failure", err) } - if strings.Contains(strings.Join(f.Commands, "\n"), "cat '/var/lib/ob/sample/lock'") { + if strings.Contains(strings.Join(f.Commands, "\n"), "cat '/var/lib/onebox/app/lock'") { t.Fatalf("infrastructure failure was treated as a held application lock:\n%s", strings.Join(f.Commands, "\n")) } } -func TestAcquireLockKeepsLegacyRendezvousAfterLastScheduleIsRemoved(t *testing.T) { - f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { - switch { - case strings.Contains(cmd, "command -v flock") && strings.Contains(cmd, "--conflict-exit-code"): - return transport.Result{}, true // flock exists, but lacks the strict schedule interface - case strings.Contains(cmd, "command -v flock"): - return transport.Result{Stdout: "ok\n"}, true - case strings.Contains(cmd, "/usr/bin/flock -x -w 10"): - return transport.Result{ExitCode: 1, Stderr: "legacy rendezvous unavailable\n"}, true - } - return transport.Result{}, false - }} - e := lockEngine(t, f) // no jobs in the current spec - _, err := e.AcquireLock(context.Background(), "R9", false) - if err == nil || !strings.Contains(err.Error(), "legacy rendezvous unavailable") { - t.Fatalf("legacy schedule rendezvous failure was not preserved: %v", err) - } - sequence := strings.Join(f.Commands, "\n") - if !strings.Contains(sequence, "/usr/bin/flock -x -w 10") || strings.Contains(sequence, "/usr/bin/flock --exclusive --timeout 10 --conflict-exit-code 200") { - t.Fatalf("last-schedule transition did not use the legacy-compatible rendezvous:\n%s", sequence) - } -} - func TestReleaseLockRemovesOnlyOwnedToken(t *testing.T) { f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "cat '/var/lib/ob/sample/epoch'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/app/epoch'") { return transport.Result{Stdout: "2\n"}, true } return transport.Result{}, false @@ -221,9 +198,9 @@ func TestReleaseLockRemovesOnlyOwnedToken(t *testing.T) { t.Fatalf("release commands = %v", f.Commands) } command := f.Commands[0] - if !strings.Contains(command, "$(cat '/var/lib/ob/sample/lock'") || + if !strings.Contains(command, "$(cat '/var/lib/onebox/app/lock'") || !strings.Contains(command, `"deploy_id":"R-owned"`) || - !strings.Contains(command, "then rm -f '/var/lib/ob/sample/lock'") { + !strings.Contains(command, "then rm -f '/var/lib/onebox/app/lock'") { t.Fatalf("release is not ownership-conditional: %s", command) } } @@ -233,7 +210,7 @@ func TestAcquireLockHeldFreshRefuses(t *testing.T) { if strings.Contains(cmd, "lock.candidate.XXXXXX") { return transport.Result{ExitCode: applicationLockHeldExitCode, Stderr: "cannot overwrite"}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/sample/lock'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/app/lock'") { return transport.Result{Stdout: `{"owner":"alice@laptop","deploy_id":"R8","epoch":6}`}, true } if strings.Contains(cmd, "date +%s") { // age computation @@ -259,7 +236,7 @@ func TestAcquireLockStaleTTLTakesOver(t *testing.T) { } return transport.Result{}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/sample/lock'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/app/lock'") { return transport.Result{Stdout: `{"owner":"dead@runner","deploy_id":"R7","epoch":5}`}, true } if strings.Contains(cmd, "date +%s") { @@ -271,7 +248,7 @@ func TestAcquireLockStaleTTLTakesOver(t *testing.T) { if _, err := e.AcquireLock(context.Background(), "R9", false); err != nil { t.Fatalf("stale lock should be taken over: %v\n%s", err, strings.Join(f.Commands, "\n")) } - if !strings.Contains(strings.Join(f.Commands, "\n"), "rm -f '/var/lib/ob/sample/lock'") { + if !strings.Contains(strings.Join(f.Commands, "\n"), "rm -f '/var/lib/onebox/app/lock'") { t.Fatal("stale lock not removed") } } @@ -287,10 +264,10 @@ func TestAcquireLockSameDeployReclaims(t *testing.T) { } return transport.Result{}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/sample/lock'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/app/lock'") { return transport.Result{Stdout: `{"owner":"dead@runner","deploy_id":"R9","epoch":6}`}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/sample/epoch'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/app/epoch'") { return transport.Result{Stdout: "6\n"}, true } if strings.Contains(cmd, "date +%s") { @@ -316,7 +293,7 @@ func TestAcquireLockReReadsEpochAfterBreakingStaleLock(t *testing.T) { f := &transport.Fake{} f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "cat '/var/lib/ob/sample/epoch'"): + case strings.Contains(cmd, "cat '/var/lib/onebox/app/epoch'"): epochReads++ if epochReads == 1 { return transport.Result{Stdout: "5\n"}, true // stale holder's value @@ -328,7 +305,7 @@ func TestAcquireLockReReadsEpochAfterBreakingStaleLock(t *testing.T) { return transport.Result{ExitCode: applicationLockHeldExitCode}, true // held → forces a break + retry } return transport.Result{}, true // win on retry - case strings.Contains(cmd, "cat '/var/lib/ob/sample/lock'"): + case strings.Contains(cmd, "cat '/var/lib/onebox/app/lock'"): return transport.Result{Stdout: `{"owner":"dead@runner","deploy_id":"R7","epoch":5}`}, true case strings.Contains(cmd, "date +%s"): return transport.Result{Stdout: "999999\n"}, true // past TTL → take over @@ -442,13 +419,13 @@ func TestLockAgeCmdFailsClosedWhenUnobservable(t *testing.T) { } func TestLockAgeCmdIsPortable(t *testing.T) { - got := lockAgeCmd("/var/lib/ob/sample/lock") + got := lockAgeCmd("/var/lib/onebox/app/lock") for _, want := range []string{ - "[ -L '/var/lib/ob/sample/lock' ] && [ ! -e '/var/lib/ob/sample/lock' ]", // dangling symlink → refuse, portably - "stat -c %Y '/var/lib/ob/sample/lock'", // GNU - "stat -f %m '/var/lib/ob/sample/lock'", // BSD/macOS fallback - "[ -e '/var/lib/ob/sample/lock' ] || [ -L '/var/lib/ob/sample/lock' ]", // present → refuse (echo 0) - "then date +%s; else echo 0; fi", // absence established → take over; not established → refuse + "[ -L '/var/lib/onebox/app/lock' ] && [ ! -e '/var/lib/onebox/app/lock' ]", // dangling symlink → refuse, portably + "stat -c %Y '/var/lib/onebox/app/lock'", // GNU + "stat -f %m '/var/lib/onebox/app/lock'", // BSD/macOS fallback + "[ -e '/var/lib/onebox/app/lock' ] || [ -L '/var/lib/onebox/app/lock' ]", // present → refuse (echo 0) + "then date +%s; else echo 0; fi", // absence established → take over; not established → refuse } { if !strings.Contains(got, want) { t.Fatalf("lockAgeCmd missing %q:\n%s", want, got) @@ -506,7 +483,7 @@ func TestForceBreakPrintsHolderJournalTail(t *testing.T) { } return transport.Result{}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/sample/lock'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/app/lock'") { return transport.Result{Stdout: `{"owner":"bob@ci","deploy_id":"R8","epoch":6}`}, true } if strings.Contains(cmd, "date +%s") { @@ -539,7 +516,7 @@ func TestMutateWrapsWithFenceAndTranslates97(t *testing.T) { t.Fatal(err) } last := f.Commands[len(f.Commands)-1] - if !strings.Contains(last, `[ "$(cat '/var/lib/ob/sample/fence' 2>/dev/null)" = 'R9 7' ]`) { + if !strings.Contains(last, `[ "$(cat '/var/lib/onebox/app/fence' 2>/dev/null)" = 'R9 7' ]`) { t.Fatalf("fence guard missing: %s", last) } if !strings.Contains(last, "docker stop OLD1") { @@ -547,7 +524,7 @@ func TestMutateWrapsWithFenceAndTranslates97(t *testing.T) { } f.Dynamic = func(cmd string) (transport.Result, bool) { - return transport.Result{ExitCode: 97, Stderr: "ob-fenced"}, true + return transport.Result{ExitCode: 97, Stderr: "onebox-fenced"}, true } _, err = e.mutate(context.Background(), "docker stop OLD1") if !errors.Is(err, ErrFenced) { @@ -581,7 +558,7 @@ func TestMutateStreamExecutesOnlyWhileFenceMatches(t *testing.T) { stdout.Reset() stderr.Reset() err := e.mutateStream(context.Background(), "printf forbidden", &stdout, &stderr) - if err == nil || stdout.Len() != 0 || !strings.Contains(stderr.String(), "ob-fenced") { + if err == nil || stdout.Len() != 0 || !strings.Contains(stderr.String(), "onebox-fenced") { t.Fatalf("stale fence executed stream: err=%v stdout=%q stderr=%q", err, stdout.String(), stderr.String()) } } @@ -611,11 +588,11 @@ func TestHeartbeatTouchesLock(t *testing.T) { seq := strings.Join(f.Commands, "\n") // `touch -c` refreshes the mtime but never creates the file — a lock another // runner deleted on takeover must not be resurrected. - if !strings.Contains(seq, "touch -c '/var/lib/ob/sample/lock'") { + if !strings.Contains(seq, "touch -c '/var/lib/onebox/app/lock'") { t.Fatalf("heartbeat never touched lock:\n%s", seq) } // and it only refreshes while the fence still names this runner. - if !strings.Contains(seq, "cat '/var/lib/ob/sample/fence'") { + if !strings.Contains(seq, "cat '/var/lib/onebox/app/fence'") { t.Fatalf("heartbeat must be fence-guarded:\n%s", seq) } } @@ -631,21 +608,21 @@ func TestStatePathsFollowTheDeclaredBasePath(t *testing.T) { cfg.BasePath = "/srv/ob" e := New(cfg, testProject(t), &transport.Fake{}, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) - if got := e.base(); got != "/srv/ob/sample" { - t.Errorf("lock/fence base = %q, want /srv/ob/sample", got) + if got := e.base(); got != "/srv/ob/app" { + t.Errorf("lock/fence base = %q, want /srv/ob/app", got) } - if got := release.PathsFor(e.Names()).Releases; got != "/srv/ob/sample/releases" { - t.Errorf("releases = %q, want /srv/ob/sample/releases", got) + if got := release.PathsFor(e.Names()).Releases; got != "/srv/ob/app/releases" { + t.Errorf("releases = %q, want /srv/ob/app/releases", got) } - if got := proxy.HostPaths(e.Names()).Base; got != "/srv/ob/_host" { - t.Errorf("host scope = %q, want /srv/ob/_host", got) + if got := proxy.HostPaths(e.Names()).Base; got != app.HostStateDir { + t.Errorf("host scope = %q, want the fixed %s: basePath must not move host ownership", got, app.HostStateDir) } // And an environment may move it again. env := cfg.Environments["production"] env.BasePath = "/mnt/data/ob" cfg.Environments["production"] = env e2 := New(cfg, testProject(t), &transport.Fake{}, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) - if got := e2.base(); got != "/mnt/data/ob/sample" { + if got := e2.base(); got != "/mnt/data/ob/app" { t.Errorf("environment base_path ignored: %q", got) } } diff --git a/internal/engine/networks.go b/internal/engine/networks.go index e827e4e2..7a76e415 100644 --- a/internal/engine/networks.go +++ b/internal/engine/networks.go @@ -13,31 +13,29 @@ import ( // remain attached while one release is torn down. func (e *Engine) EnsureApplicationNetwork(ctx context.Context) error { n := e.names() - return e.ensureOwnedNetwork(ctx, n.ApplicationNetwork(), n.ComposeProject(), "") + return e.ensureOwnedNetwork(ctx, n.ApplicationNetwork()) } // ensureServiceNetwork establishes the long-lived network shared by workloads -// and supporting services. A legacy state directory is accepted as migration -// evidence because older Onebox versions created this network without labels. +// and supporting services. func (e *Engine) ensureServiceNetwork(ctx context.Context, n app.Names) error { - return e.ensureOwnedNetwork(ctx, n.ServiceNetwork(), "", n.ServiceDir()) + return e.ensureOwnedNetwork(ctx, n.ServiceNetwork()) } -// ensureOwnedNetwork creates a labelled network or accepts a network whose -// legacy ownership is independently provable. Docker cannot add labels to an -// existing network, and recreating one would sever live endpoints, so legacy -// networks remain intact. A derived name alone is never -// evidence: silently adopting a hand-created network is the bug this boundary -// exists to prevent. -func (e *Engine) ensureOwnedNetwork(ctx context.Context, name, legacyComposeProject, legacyStateDir string) error { - exists, err := e.ownedNetworkExists(ctx, name, legacyComposeProject, legacyStateDir) +// ensureOwnedNetwork creates a labelled network or accepts one this +// application provably owns: it carries the application's label, or +// app.Names.ComposeCreatedApplicationNetwork says Compose created it for the +// application's own project. A derived name alone is never evidence: silently +// adopting a hand-created network is the bug this boundary exists to prevent. +func (e *Engine) ensureOwnedNetwork(ctx context.Context, name string) error { + exists, err := e.ownedNetworkExists(ctx, name) if err != nil { return err } if exists { return nil } - created, createErr := e.mutate(ctx, "docker network create --label "+q("ob.app="+e.Spec.Name)+" "+q(name)) + created, createErr := e.mutate(ctx, "docker network create --label "+q("onebox.app="+e.Spec.Name)+" "+q(name)) if createErr != nil { return createErr } @@ -52,12 +50,8 @@ func (e *Engine) ensureOwnedNetwork(ctx context.Context, name, legacyComposeProj // either remove them or stop before deleting state and releasing host ownership. func (e *Engine) removeOwnedNetworks(ctx context.Context) error { n := e.names() - networks := []struct { - name, legacyComposeProject, legacyStateDir string - }{ - {n.ApplicationNetwork(), n.ComposeProject(), ""}, - } - // `ob_` is reserved only when the app has services. A project that + networks := []string{n.ApplicationNetwork()} + // `onebox_services` is reserved only when the app has services. A project that // never declared one must not have full destroy blocked by an unrelated, // unlabelled network at that otherwise-unused name. Durable service state // also includes the network for projects that removed services from the @@ -71,24 +65,22 @@ func (e *Engine) removeOwnedNetworks(ctx context.Context) error { includeServiceNetwork = state.ExitCode == 0 } if includeServiceNetwork { - networks = append(networks, struct { - name, legacyComposeProject, legacyStateDir string - }{n.ServiceNetwork(), "", n.ServiceDir()}) + networks = append(networks, n.ServiceNetwork()) } for _, network := range networks { - exists, err := e.ownedNetworkExists(ctx, network.name, network.legacyComposeProject, network.legacyStateDir) + exists, err := e.ownedNetworkExists(ctx, network) if err != nil { return err } if !exists { continue } - removed, removeErr := e.mutate(ctx, "docker network rm "+q(network.name)) + removed, removeErr := e.mutate(ctx, "docker network rm "+q(network)) if removeErr != nil { return removeErr } if removed.ExitCode != 0 { - return fmt.Errorf("network %s: cannot remove owned network: %s; detach its remaining endpoints, then retry destroy", network.name, strings.TrimSpace(removed.Stderr)) + return fmt.Errorf("network %s: cannot remove owned network: %s; detach its remaining endpoints, then retry destroy", network, strings.TrimSpace(removed.Stderr)) } } return nil @@ -97,12 +89,12 @@ func (e *Engine) removeOwnedNetworks(ctx context.Context) error { // ownedNetworkExists reports absence and otherwise proves that an existing // network belongs to this application before a caller creates, uses, or removes // it. The same proof must guard every lifecycle transition. -func (e *Engine) ownedNetworkExists(ctx context.Context, name, legacyComposeProject, legacyStateDir string) (bool, error) { +func (e *Engine) ownedNetworkExists(ctx context.Context, name string) (bool, error) { // `docker network inspect --format` prints a backslash-t literally on some // Docker releases (unlike the list formatter). Use a delimiter that the // formatter does not have to interpret; none of these validated identities // can contain a pipe. - inspect := "docker network inspect --format '{{.Id}}|{{index .Labels \"ob.app\"}}|{{index .Labels \"com.docker.compose.project\"}}' " + q(name) + inspect := "docker network inspect --format '{{.Id}}|{{index .Labels \"onebox.app\"}}|{{index .Labels \"com.docker.compose.project\"}}' " + q(name) res, err := e.T.Run(ctx, inspect) if err != nil { return false, err @@ -125,29 +117,17 @@ func (e *Engine) ownedNetworkExists(ctx context.Context, name, legacyComposeProj if len(fields) > 1 { owner = networkLabel(fields[1]) } - if owner != "" { - if owner != e.Spec.Name { - return false, fmt.Errorf("network %s is owned by application %s; refusing to adopt it", name, owner) - } - return true, nil - } - - legacyOwned := false - if len(fields) > 2 && legacyComposeProject != "" { - legacyOwned = networkLabel(fields[2]) == legacyComposeProject + if owner == "" && len(fields) > 2 && e.names().ComposeCreatedApplicationNetwork(name, networkLabel(fields[2])) { + owner = e.Spec.Name } - if !legacyOwned && legacyStateDir != "" { - state, stateErr := e.T.Run(ctx, "test -d "+q(legacyStateDir)) - if stateErr != nil { - return false, stateErr - } - legacyOwned = state.ExitCode == 0 - } - if !legacyOwned { + switch owner { + case "": return false, fmt.Errorf("network %s exists without Onebox ownership; refusing to adopt it", name) + case e.Spec.Name: + return true, nil + default: + return false, fmt.Errorf("network %s is owned by application %s; refusing to adopt it", name, owner) } - - return true, nil } func networkLabel(value string) string { diff --git a/internal/engine/networks_test.go b/internal/engine/networks_test.go index e3ed307d..1e9e2e19 100644 --- a/internal/engine/networks_test.go +++ b/internal/engine/networks_test.go @@ -23,7 +23,7 @@ func TestApplicationNetworkIsCreatedWithOwnership(t *testing.T) { t.Fatal(err) } commands := strings.Join(f.Commands, "\n") - if !strings.Contains(commands, "docker network create --label 'ob.app=sample' 'sample_default'") { + if !strings.Contains(commands, "docker network create --label 'onebox.app=sample' 'sample_default'") { t.Fatalf("application network was not created with ownership:\n%s", commands) } } @@ -66,59 +66,21 @@ func TestApplicationNetworkRefusesForeignOwner(t *testing.T) { } } -func TestLegacyComposeNetworkIsAcceptedByIdentity(t *testing.T) { - f := happyFake() - base := f.Dynamic - f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "network inspect") && strings.Contains(command, "sample_default") { - return transport.Result{Stdout: "abc123||sample\n"}, true - } - return base(command) - } - e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.EnsureApplicationNetwork(context.Background()); err != nil { - t.Fatal(err) - } - commands := strings.Join(f.Commands, "\n") - if strings.Contains(commands, "network create") { - t.Fatalf("legacy application network was replaced:\n%s", commands) - } -} - -func TestLegacyServiceNetworkRequiresServiceStateBeforeAcceptance(t *testing.T) { - for _, tt := range []struct { - name string - stateExit int - wantErr bool - }{ - {name: "legacy state", stateExit: 0}, - {name: "no state", stateExit: 1, wantErr: true}, - } { - t.Run(tt.name, func(t *testing.T) { +func TestUnlabelledNetworkIsRefused(t *testing.T) { + for _, name := range []string{"sample_default", "onebox_services"} { + t.Run(name, func(t *testing.T) { f := happyFake() base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "network inspect") && strings.Contains(command, "ob_sample") { - return transport.Result{Stdout: "def456||\n"}, true - } - if strings.Contains(command, "test -d '/var/lib/ob/sample/services'") { - return transport.Result{ExitCode: tt.stateExit}, true + if strings.Contains(command, "network inspect") && strings.Contains(command, name) { + return transport.Result{Stdout: "def456|\n"}, true } return base(command) } - e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) - err := e.EnsureServiceConnections(context.Background()) - if tt.wantErr { - if err == nil || !strings.Contains(err.Error(), "refusing to adopt") { - t.Fatalf("missing legacy state error = %v", err) - } - return - } - if err != nil { - t.Fatal(err) - } - if strings.Contains(strings.Join(f.Commands, "\n"), "network create") { - t.Fatalf("legacy service network was replaced:\n%s", strings.Join(f.Commands, "\n")) + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + _, err := e.ownedNetworkExists(context.Background(), name) + if err == nil || !strings.Contains(err.Error(), "refusing to adopt") { + t.Fatalf("unlabelled network error = %v", err) } }) } @@ -139,7 +101,7 @@ func TestRemoveOwnedNetworksRefusesAttachedEndpoints(t *testing.T) { t.Fatalf("attached endpoint error = %v", err) } commands := strings.Join(f.Commands, "\n") - if strings.Contains(commands, "docker network rm 'ob_sample'") { + if strings.Contains(commands, "docker network rm 'onebox_services'") { t.Fatalf("teardown continued after the application network could not be removed:\n%s", commands) } } @@ -150,11 +112,11 @@ func TestRemoveOwnedNetworksIgnoresServiceNameWithoutServiceState(t *testing.T) f := happyFake() base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "test -d '/var/lib/ob/sample/services'") { + if strings.Contains(command, "test -d '/var/lib/onebox/app/services'") { return transport.Result{ExitCode: 1}, true } - if strings.Contains(command, "network inspect") && strings.Contains(command, "ob_sample") { - return transport.Result{Stdout: "def456||\n"}, true + if strings.Contains(command, "network inspect") && strings.Contains(command, "onebox_services") { + return transport.Result{Stdout: "def456|\n"}, true } return base(command) } @@ -163,10 +125,50 @@ func TestRemoveOwnedNetworksIgnoresServiceNameWithoutServiceState(t *testing.T) t.Fatal(err) } commands := strings.Join(f.Commands, "\n") - if strings.Contains(commands, "network inspect") && strings.Contains(commands, "ob_sample") { + if strings.Contains(commands, "network inspect") && strings.Contains(commands, "onebox_services") { t.Fatalf("destroy inspected an undeclared service-network name:\n%s", commands) } - if strings.Contains(commands, "network rm 'ob_sample'") { + if strings.Contains(commands, "network rm 'onebox_services'") { t.Fatalf("destroy removed an undeclared service-network name:\n%s", commands) } } + +// A Compose file that runs its own proxy beside the workloads makes Compose +// create the application network before Onebox does. Its project label is the +// proof of ownership for that network, and for no other. +func TestComposeProjectOwnsOnlyTheApplicationNetwork(t *testing.T) { + for _, tc := range []struct { + name string + network string + ensure func(*Engine) error + wantErr bool + }{ + {"application network", "sample_default", func(e *Engine) error { return e.EnsureApplicationNetwork(context.Background()) }, false}, + {"service network", "onebox_services", func(e *Engine) error { return e.EnsureServiceConnections(context.Background()) }, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := happyFake() + base := f.Dynamic + f.Dynamic = func(command string) (transport.Result, bool) { + if strings.Contains(command, "network inspect") && strings.Contains(command, tc.network) { + return transport.Result{Stdout: "abc123||sample\n"}, true + } + return base(command) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + err := tc.ensure(e) + if tc.wantErr { + if err == nil || !strings.Contains(err.Error(), "refusing to adopt") { + t.Fatalf("project label adopted %s: %v", tc.network, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(f.Commands, "\n"), "network create") { + t.Fatalf("the application's Compose network was replaced:\n%s", strings.Join(f.Commands, "\n")) + } + }) + } +} diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 23ac0fc4..d0a42ff9 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/labstack/onebox/internal/app" "github.com/labstack/onebox/internal/journal" "github.com/labstack/onebox/internal/proxy" "github.com/labstack/onebox/internal/release" @@ -128,9 +129,9 @@ func (e *Engine) Destroy(ctx context.Context, removeVolumes, removeProxy bool) e return err } // External means release-independent, not ownerless. A full destroy removes - // both app-scoped networks before deleting the evidence that proves legacy - // ownership. Docker refuses removal while any unmanaged endpoint remains; - // propagate that refusal so state and host ownership stay recoverable. + // both app-scoped networks before deleting state. Docker refuses removal + // while any unmanaged endpoint remains; propagate that refusal so state and + // host ownership stay recoverable. if removeVolumes { if err := e.removeOwnedNetworks(ctx); err != nil { return err @@ -139,7 +140,13 @@ func (e *Engine) Destroy(ctx context.Context, removeVolumes, removeProxy bool) e // state dir last (takes the lock, fence, and journals with it — that is // the point of destroy) base := release.PathsFor(e.names()).Base + // The marker is the proof this directory is ours to delete. A destroy that + // keeps anything keeps the marker too, so the destroy that finishes the + // job can still prove it. sweep := "rm -rf " + q(base) + if !removeVolumes { + sweep = fmt.Sprintf("find %s -mindepth 1 -maxdepth 1 ! -name %s -exec rm -rf {} +", q(base), q(app.AppMarkerFile)) + } keepingCredentials := !removeVolumes && len(e.Spec.Services) > 0 if keepingCredentials { // A service credential is generated once, on the target, and exists @@ -151,10 +158,14 @@ func (e *Engine) Destroy(ctx context.Context, removeVolumes, removeProxy bool) e // // So the key stays with the lock. Everything else — releases, // journals, locks, fences — goes. - sweep = fmt.Sprintf("find %s -mindepth 1 -maxdepth 1 ! -name services -exec rm -rf {} +", q(base)) + sweep = fmt.Sprintf("find %s -mindepth 1 -maxdepth 1 ! -name services ! -name %s -exec rm -rf {} +", q(base), q(app.AppMarkerFile)) } - if res, err := e.mutate(ctx, sweep); err != nil { + marker := q(e.names().AppMarker()) + guarded := "if [ -e " + q(base) + " ]; then [ \"$(cat " + marker + " 2>/dev/null)\" = " + q(e.Spec.Name) + " ] || exit " + fmt.Sprint(appDirUnmarked) + "; " + sweep + "; fi" + if res, err := e.mutate(ctx, guarded); err != nil { return err + } else if res.ExitCode == appDirUnmarked { + return fmt.Errorf("remove state dir: %s does not carry this application's %s marker, so Onebox will not delete it; remove it by hand if it is Onebox's", base, app.AppMarkerFile) } else if res.ExitCode != 0 { return fmt.Errorf("remove state dir: %s", res.Stderr) } @@ -219,10 +230,8 @@ func (e *Engine) Destroy(ctx context.Context, removeVolumes, removeProxy bool) e down := "if [ -f " + q(hp.Compose) + " ]; then docker compose -p " + proxy.Project + " -f " + q(hp.Compose) + " down || exit $?; fi; " + "proxy_orphans=$(docker ps -aq --filter name=^" + proxy.ContainerName + "$ --filter label=com.docker.compose.project=" + proxy.Project + " --filter label=com.docker.compose.service=proxy) || exit $?; " + "discovery_orphans=$(docker ps -aq --filter name=^" + proxy.DiscoveryContainerName + "$ --filter label=com.docker.compose.project=" + proxy.Project + " --filter label=com.docker.compose.service=discovery) || exit $?; " + - "legacy_discovery_orphans=$(docker ps -aq --filter name=^" + proxy.LegacyDiscoveryContainerName + "$ --filter label=com.docker.compose.project=" + proxy.Project + " --filter label=com.docker.compose.service=discovery) || exit $?; " + "if [ -n \"$proxy_orphans\" ]; then docker rm -f $proxy_orphans || exit $?; fi; " + - "if [ -n \"$discovery_orphans\" ]; then docker rm -f $discovery_orphans || exit $?; fi; " + - "if [ -n \"$legacy_discovery_orphans\" ]; then docker rm -f $legacy_discovery_orphans; fi" + "if [ -n \"$discovery_orphans\" ]; then docker rm -f $discovery_orphans || exit $?; fi" if res, err := e.hostMutate(ctx, down); err != nil { return err } else if res.ExitCode != 0 { @@ -401,7 +410,7 @@ func (e *Engine) ExecInAudited(ctx context.Context, operationID, name, command, containerID = ids[0] commandDigest := HashBytes([]byte(command)) writer := &journal.Writer{ - T: e.T, Names: e.names(), DeployID: operationID, Epoch: epoch, Operator: journal.DefaultOperator(), + T: e.T, Dir: journal.Dir(e.names()), DeployID: operationID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner, } invocation := journal.Record{ diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index e045b8d0..699b9df8 100644 --- a/internal/engine/ops_test.go +++ b/internal/engine/ops_test.go @@ -19,7 +19,7 @@ func opsFake(remoteSecretsHash string) *transport.Fake { if strings.Contains(cmd, "readlink") { return transport.Result{Stdout: "releases/R7\n"}, true } - if strings.Contains(cmd, "/releases/R7/ob.snapshot.yml") { + if strings.Contains(cmd, "/releases/R7/onebox.snapshot.yml") { return transport.Result{Stdout: engineProject}, true } if strings.Contains(cmd, "sha256sum") { @@ -48,10 +48,10 @@ func TestDestroySequence(t *testing.T) { } // Volumes are kept, so the credentials that open them are kept too. The // alternative is data nobody can ever read again. - if strings.Contains(seq, "rm -rf '/var/lib/ob/sample'") { + if strings.Contains(seq, "rm -rf '/var/lib/onebox/app'") { t.Fatalf("kept volumes lost their credentials:\n%s", seq) } - if !strings.Contains(seq, "systemctl disable --now ob-sample-") && + if !strings.Contains(seq, "systemctl disable --now onebox-job-") && strings.Contains(seq, "list-unit-files") { // no timers installed in this fixture; the sweep still has to run if !strings.Contains(seq, "list-unit-files --no-legend --type=timer") { @@ -64,7 +64,7 @@ func TestDestroyRefusesActivePinnedScheduleLease(t *testing.T) { f := opsFake("x") base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, ".ob-schedule.lease") { + if strings.Contains(command, ".onebox-schedule.lease") { return transport.Result{Stdout: "20260828-120000-running\n"}, true } return base(command) @@ -75,10 +75,10 @@ func TestDestroyRefusesActivePinnedScheduleLease(t *testing.T) { t.Fatalf("destroy error = %v", err) } commands := strings.Join(f.Commands, "\n") - if strings.Contains(commands, "down --remove-orphans") || strings.Contains(commands, "rm -rf '/var/lib/ob/sample'") { + if strings.Contains(commands, "down --remove-orphans") || strings.Contains(commands, "rm -rf '/var/lib/onebox/app'") { t.Fatalf("destroy mutated the application while a release was leased:\n%s", commands) } - if !strings.Contains(commands, "rm -f '/var/lib/ob/sample/lock'") { + if !strings.Contains(commands, "rm -f '/var/lib/onebox/app/lock'") { t.Fatalf("destroy retained its application lock after refusing:\n%s", commands) } } @@ -92,13 +92,13 @@ func TestDestroyWithVolumesRemovesEverything(t *testing.T) { t.Fatalf("destroy: %v", err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "rm -rf '/var/lib/ob/sample'") { + if !strings.Contains(seq, "rm -rf '/var/lib/onebox/app'") { t.Fatalf("state dir not removed:\n%s", seq) } - if !strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { + if !strings.Contains(seq, "rm -f '/var/lib/onebox/_host/owner'") { t.Fatalf("complete teardown without a managed proxy retained host ownership:\n%s", seq) } - for _, network := range []string{"sample_default", "ob_sample"} { + for _, network := range []string{"sample_default", "onebox_services"} { if !strings.Contains(seq, "docker network rm '"+network+"'") { t.Fatalf("complete teardown retained network %s:\n%s", network, seq) } @@ -120,7 +120,7 @@ func TestDestroyStopsBeforeStateRemovalWhenNetworkHasEndpoints(t *testing.T) { t.Fatalf("destroy endpoint error = %v", err) } seq := strings.Join(f.Commands, "\n") - if strings.Contains(seq, "rm -rf '/var/lib/ob/sample'") || strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { + if strings.Contains(seq, "rm -rf '/var/lib/onebox/app'") || strings.Contains(seq, "rm -f '/var/lib/onebox/_host/owner'") { t.Fatalf("destroy discarded recovery state after network removal failed:\n%s", seq) } } @@ -140,7 +140,7 @@ func TestDestroyStopsBeforeStateRemovalWhenNetworkInspectFails(t *testing.T) { t.Fatalf("destroy inspect error = %v", err) } seq := strings.Join(f.Commands, "\n") - if strings.Contains(seq, "rm -rf '/var/lib/ob/sample'") || strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { + if strings.Contains(seq, "rm -rf '/var/lib/onebox/app'") || strings.Contains(seq, "rm -f '/var/lib/onebox/_host/owner'") { t.Fatalf("destroy discarded recovery state after network inspection failed:\n%s", seq) } } @@ -153,7 +153,7 @@ func TestDestroyUsesTheCurrentReleaseEnvironment(t *testing.T) { f := opsFake("x") base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "/releases/R7/ob.snapshot.yml") { + if strings.Contains(command, "/releases/R7/onebox.snapshot.yml") { return transport.Result{Stdout: engineProject + "\n runtime:\n envFiles: [legacy.env]\n"}, true } return base(command) @@ -163,7 +163,7 @@ func TestDestroyUsesTheCurrentReleaseEnvironment(t *testing.T) { t.Fatalf("destroy: %v\n%s", err, strings.Join(f.Commands, "\n")) } commands := strings.Join(f.Commands, "\n") - want := "--env-file '/var/lib/ob/sample/releases/R7/legacy.env' down --remove-orphans -v" + want := "--env-file '/var/lib/onebox/app/releases/R7/legacy.env' down --remove-orphans -v" if !strings.Contains(commands, want) { t.Fatalf("destroy did not use the current release's interpolation environment; want %q:\n%s", want, commands) } @@ -173,7 +173,7 @@ func TestDestroyRefusesMissingCurrentReleaseSnapshot(t *testing.T) { f := opsFake("x") base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "/releases/R7/ob.snapshot.yml") { + if strings.Contains(command, "/releases/R7/onebox.snapshot.yml") { return transport.Result{ExitCode: 1, Stderr: "not found"}, true } return base(command) @@ -184,7 +184,7 @@ func TestDestroyRefusesMissingCurrentReleaseSnapshot(t *testing.T) { t.Fatalf("destroy error = %v", err) } commands := strings.Join(f.Commands, "\n") - if strings.Contains(commands, "down --remove-orphans") || strings.Contains(commands, "docker volume rm") || strings.Contains(commands, "rm -rf '/var/lib/ob/sample'") { + if strings.Contains(commands, "down --remove-orphans") || strings.Contains(commands, "docker volume rm") || strings.Contains(commands, "rm -rf '/var/lib/onebox/app'") { t.Fatalf("destroy mutated release state without its snapshot:\n%s", commands) } } @@ -193,7 +193,7 @@ func TestDestroyReleasesAppLockOnEarlyFailure(t *testing.T) { f := opsFake("x") base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "> '/var/lib/ob/sample/fence'") { + if strings.Contains(command, "> '/var/lib/onebox/app/fence'") { return transport.Result{ExitCode: 70, Stderr: "fence is read-only"}, true } return base(command) @@ -202,7 +202,7 @@ func TestDestroyReleasesAppLockOnEarlyFailure(t *testing.T) { if err := e.Destroy(context.Background(), false, false); err == nil { t.Fatal("destroy succeeded after fence failure") } - if !strings.Contains(strings.Join(f.Commands, "\n"), "rm -f '/var/lib/ob/sample/lock'") { + if !strings.Contains(strings.Join(f.Commands, "\n"), "rm -f '/var/lib/onebox/app/lock'") { t.Fatalf("destroy retained app lock after early failure:\n%s", strings.Join(f.Commands, "\n")) } } @@ -223,7 +223,7 @@ func TestLogsAndExecShapes(t *testing.T) { t.Fatal(err) } seq = strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "docker compose -p ob_sample_postgres -f '/var/lib/ob/sample/services/postgres.yaml' logs --tail 20 postgres") { + if !strings.Contains(seq, "docker compose -p onebox_postgres -f '/var/lib/onebox/app/services/postgres.yaml' logs --tail 20 postgres") { t.Fatalf("service logs shape wrong:\n%s", seq) } if _, err := e.ExecInAudited(context.Background(), "exec-workload", "web", "alembic current", "inspect migration state", &out, io.Discard); err != nil { @@ -233,7 +233,7 @@ func TestLogsAndExecShapes(t *testing.T) { if !strings.Contains(seq, "docker exec OLD1 sh -c 'alembic current'") { t.Fatalf("exec shape wrong:\n%s", seq) } - if !strings.Contains(seq, `cat '/var/lib/ob/sample/fence'`) || !strings.Contains(seq, `then docker exec OLD1`) { + if !strings.Contains(seq, `cat '/var/lib/onebox/app/fence'`) || !strings.Contains(seq, `then docker exec OLD1`) { t.Fatalf("exec is not guarded by the acquired mutation fence:\n%s", seq) } if _, err := e.ExecInAudited(context.Background(), "exec-service", "postgres", "psql --version", "verify client version", &out, io.Discard); err != nil { @@ -348,7 +348,7 @@ func TestDestroyKeepsHostProxyWithoutFlag(t *testing.T) { if strings.Contains(seq, "/proxy/apps") { t.Fatalf("destroy must not consult a cross-application proxy registry:\n%s", seq) } - if strings.Contains(seq, "-p onebox-proxy -f '/var/lib/ob/_host/proxy/compose.yaml' down") { + if strings.Contains(seq, "-p onebox-proxy -f '/var/lib/onebox/_host/proxy/compose.yaml' down") { t.Fatalf("without --proxy the host proxy must survive:\n%s", seq) } } @@ -360,19 +360,18 @@ func TestDestroyProxyTeardownForSoleOwner(t *testing.T) { t.Fatalf("destroy --proxy: %v\n%s", err, strings.Join(f.Commands, "\n")) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "docker compose -p onebox-proxy -f '/var/lib/ob/_host/proxy/compose.yaml' down") { + if !strings.Contains(seq, "docker compose -p onebox-proxy -f '/var/lib/onebox/_host/proxy/compose.yaml' down") { t.Fatalf("sole owner with --proxy must tear the proxy down:\n%s", seq) } for _, selector := range []string{ "name=^onebox-proxy$ --filter label=com.docker.compose.project=onebox-proxy --filter label=com.docker.compose.service=proxy", "name=^onebox-discovery$ --filter label=com.docker.compose.project=onebox-proxy --filter label=com.docker.compose.service=discovery", - "name=^onebox-proxy-discovery$ --filter label=com.docker.compose.project=onebox-proxy --filter label=com.docker.compose.service=discovery", } { if !strings.Contains(seq, selector) { t.Fatalf("proxy teardown must sweep owned orphan %s even when Compose state is missing:\n%s", selector, seq) } } - if !strings.Contains(seq, "rm -rf '/var/lib/ob/_host/proxy'") { + if !strings.Contains(seq, "rm -rf '/var/lib/onebox/_host/proxy'") { t.Fatalf("proxy state dir must go with it:\n%s", seq) } } @@ -384,7 +383,7 @@ func TestCompleteDestroyReleasesHostOwnership(t *testing.T) { t.Fatalf("complete destroy: %v", err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { + if !strings.Contains(seq, "rm -f '/var/lib/onebox/_host/owner'") { t.Fatalf("complete teardown must release the sole owner record:\n%s", seq) } } @@ -450,12 +449,12 @@ func TestRemoveServicesRemovesPreservedRestoreVolumes(t *testing.T) { case strings.Contains(cmd, "docker ps -aq"): return transport.Result{}, true case strings.Contains(cmd, "docker volume ls") && strings.Contains(cmd, "label=com.docker.compose.project"): - return transport.Result{Stdout: "ob_sample_postgres_data\n"}, true + return transport.Result{Stdout: "onebox_postgres_data\n"}, true case strings.Contains(cmd, "docker volume ls") && strings.Contains(cmd, "before-restore"): return transport.Result{Stdout: strings.Join([]string{ - "ob_sample_postgres_data-before-restore-20260822T160242Z", - "ob_sample_postgres_data-before-restore-not-a-timestamp", - "ob_other_postgres_data-before-restore-20260822T160242Z", + "onebox_postgres_data-before-restore-20260822T160242Z", + "onebox_postgres_data-before-restore-not-a-timestamp", + "onebox_other_data-before-restore-20260822T160242Z", }, "\n")}, true } return transport.Result{}, false @@ -466,10 +465,10 @@ func TestRemoveServicesRemovesPreservedRestoreVolumes(t *testing.T) { t.Fatal(err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "docker volume rm ob_sample_postgres_data ob_sample_postgres_data-before-restore-20260822T160242Z") { + if !strings.Contains(seq, "docker volume rm onebox_postgres_data onebox_postgres_data-before-restore-20260822T160242Z") { t.Fatalf("destroy did not remove the live and preserved volumes:\n%s", seq) } - if strings.Contains(seq, "docker volume rm ob_other") || strings.Contains(seq, "docker volume rm ob_sample_postgres_data-before-restore-not") { + if strings.Contains(seq, "docker volume rm onebox_other") || strings.Contains(seq, "docker volume rm onebox_postgres_data-before-restore-not") { t.Fatalf("destroy removed a volume whose ownership was not proved:\n%s", seq) } } @@ -504,7 +503,7 @@ func TestDestroyRefusesFailedSweepDiscovery(t *testing.T) { if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("destroy error = %v, want %q", err, test.want) } - if commands := strings.Join(f.Commands, "\n"); strings.Contains(commands, "rm -rf '/var/lib/ob/sample'") { + if commands := strings.Join(f.Commands, "\n"); strings.Contains(commands, "rm -rf '/var/lib/onebox/app'") { t.Fatalf("destroy removed state after failed discovery:\n%s", commands) } }) @@ -522,7 +521,7 @@ func TestDestroyKeepsHostOwnershipWhileDataRemains(t *testing.T) { t.Fatalf("destroy: %v", err) } seq := strings.Join(f.Commands, "\n") - if strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { + if strings.Contains(seq, "rm -f '/var/lib/onebox/_host/owner'") { t.Fatalf("ownership was released while volumes were kept:\n%s", seq) } } @@ -565,3 +564,65 @@ func TestDestroyTellsTheOperatorHowToReleaseTheHost(t *testing.T) { t.Fatalf("retention notice demanded --proxy for an unmanaged proxy:\n%s", out.String()) } } + +// Destroy deletes the state directory whole, so it must first see this +// application's marker in it: without one, the directory may not be Onebox's. +func TestDestroyRefusesAnUnmarkedStateDirectory(t *testing.T) { + f := opsFake("x") + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + if strings.Contains(cmd, app.AppMarkerFile) && strings.Contains(cmd, "rm -rf") { + return transport.Result{ExitCode: appDirUnmarked}, true + } + return base(cmd) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + err := e.Destroy(context.Background(), true, false) + if err == nil || !strings.Contains(err.Error(), "will not delete it") { + t.Fatalf("destroy of an unmarked directory = %v", err) + } + if strings.Contains(strings.Join(f.Commands, "\n"), "host ownership released") { + t.Fatal("host ownership was released after the state directory was refused") + } +} + +// A destroy that keeps anything keeps the marker, so the destroy that finishes +// the job can still prove the directory is this application's. +func TestPlainDestroyKeepsTheStateDirectoryMarker(t *testing.T) { + f := opsFake("x") + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + if err := e.Destroy(context.Background(), false, false); err != nil { + t.Fatalf("destroy: %v", err) + } + var sweep string + for _, cmd := range f.Commands { + if strings.Contains(cmd, "-mindepth 1 -maxdepth 1") { + sweep = cmd + } + } + if !strings.Contains(sweep, "! -name '"+app.AppMarkerFile+"'") { + t.Fatalf("plain destroy removed the marker:\n%s", sweep) + } +} + +// Every lock acquisition claims the state directory, so no command writes its +// lock, fence or journal into a directory Onebox has not marked. +func TestAcquireLockClaimsTheStateDirectory(t *testing.T) { + f := opsFake("x") + base := f.Dynamic + f.Dynamic = func(cmd string) (transport.Result, bool) { + if strings.Contains(cmd, app.AppMarkerFile) && strings.Contains(cmd, "ls -A") { + return transport.Result{ExitCode: appDirUnmarked}, true + } + return base(cmd) + } + e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + if _, err := e.AcquireLock(context.Background(), "R9", false); err == nil || !strings.Contains(err.Error(), "was not created by Onebox") { + t.Fatalf("lock acquisition in an unmarked directory = %v", err) + } + for _, cmd := range f.Commands { + if strings.Contains(cmd, "/lock") && !strings.Contains(cmd, app.AppMarkerFile) { + t.Fatalf("wrote the lock after the directory was refused: %s", cmd) + } + } +} diff --git a/internal/engine/payload_digest_shell_test.go b/internal/engine/payload_digest_shell_test.go index 7ab3d63d..a1e6094b 100644 --- a/internal/engine/payload_digest_shell_test.go +++ b/internal/engine/payload_digest_shell_test.go @@ -33,9 +33,9 @@ func TestRemotePayloadDigestAgreesWithTheLocalWalk(t *testing.T) { t.Fatal(err) } for name, body := range map[string]string{ - "compose.yaml": "services: {}\n", - "ob.snapshot.yml": "app: sample\n", - "server/.env": "KEY=one\n", + "compose.yaml": "services: {}\n", + "onebox.snapshot.yml": "app: sample\n", + "server/.env": "KEY=one\n", } { if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { t.Fatal(err) @@ -163,7 +163,7 @@ func TestRemotePayloadDigestFailsWhenTheReleaseDirectoryIsUnsearchable(t *testin if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "ob.snapshot.yml"), []byte("app: sample\n"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "onebox.snapshot.yml"), []byte("app: sample\n"), 0o600); err != nil { t.Fatal(err) } if err := os.Chmod(dir, 0o000); err != nil { diff --git a/internal/engine/plan.go b/internal/engine/plan.go index 0e8fcd19..41d75d2f 100644 --- a/internal/engine/plan.go +++ b/internal/engine/plan.go @@ -307,11 +307,11 @@ const FidelityContract = `Plan fidelity (highest to lowest): hooks verbatim commands — their effects are unplannable` // releaseLabelLine matches the one rendered line that changes on EVERY -// deploy by construction: the ob.release stamp. -var releaseLabelLine = regexp.MustCompile(`(?m)^\s*ob\.release: \S+\n?`) +// deploy by construction: the onebox.release stamp. +var releaseLabelLine = regexp.MustCompile(`(?m)^\s*onebox\.release: \S+\n?`) // OnlyReleaseLabelsChanged reports whether two rendered composes are -// byte-identical once the ob.release label lines are removed — i.e. the +// byte-identical once the onebox.release label lines are removed — i.e. the // planned deploy has no material change, only a new release identity. Used // by the plan to say "nothing changed" plainly instead of encoding it as // label-noise hunks. Empty inputs (first deploy) compare honestly: an empty @@ -326,7 +326,7 @@ func OnlyReleaseLabelsChanged(live, planned string) bool { // local and remote digests permanently unequal — every plan then reports a // change, no deploy short-circuits, and pre-release migrations re-run. // -// `.ob-secret-generations/` is deliberately NOT excluded. Staging writes the +// `.onebox-secret-generations/` is deliberately NOT excluded. Staging writes the // bound generation's decrypted payload there (see stageExecution), so // excluding it would drop every secret byte from the digest: a rotated secret // reusing the live generation would hash identically, the deploy would @@ -579,7 +579,7 @@ func (e *Engine) DescribeWorkloadPlans(remoteCompose string, plans map[string]Wo " docker rm -f (compose counts them toward --scale)", step+fmt.Sprintf("%s up -d --no-deps --no-recreate --scale %s=<+1> %s", cc, svc, svc), " wait healthy (ready gate)", - " ├─ healthy → converge → docker exec touch /tmp/ob-drain → wait unhealthy → converge", + " ├─ healthy → converge → docker exec touch /tmp/onebox-drain → wait unhealthy → converge", fmt.Sprintf(" │ └─ docker stop -t %d && docker rm && rename into the freed slot", role.StopGraceSeconds()), " └─ unhealthy/timeout → docker rm -f ; existing keep serving; deploy halts", ) diff --git a/internal/engine/plan_drain_test.go b/internal/engine/plan_drain_test.go index ff3c50b7..e280a409 100644 --- a/internal/engine/plan_drain_test.go +++ b/internal/engine/plan_drain_test.go @@ -22,7 +22,7 @@ func TestPlanPromisesADrainWaitOnlyWhenTheDeployTakesOne(t *testing.T) { // Scoped to this workload: the fixture's worker authors a drain wait of its // own, and that line is correct. - lines := strings.Join(newPlanEngine(t, config).Describe("/var/lib/ob/sample/releases/R1/compose.yaml"), "\n") + lines := strings.Join(newPlanEngine(t, config).Describe("/var/lib/onebox/app/releases/R1/compose.yaml"), "\n") if strings.Contains(lines, "") { t.Fatalf("the plan promises a drain step the deploy does not take:\n%s", lines) } @@ -30,7 +30,7 @@ func TestPlanPromisesADrainWaitOnlyWhenTheDeployTakesOne(t *testing.T) { withWait := workload withWait.Drain = &app.Drain{Signal: "USR1", Wait: "12s"} config.Workloads["web"] = withWait - lines = strings.Join(newPlanEngine(t, config).Describe("/var/lib/ob/sample/releases/R1/compose.yaml"), "\n") + lines = strings.Join(newPlanEngine(t, config).Describe("/var/lib/onebox/app/releases/R1/compose.yaml"), "\n") if !strings.Contains(lines, "--signal=USR1 ; wait up to 12s for exit") { t.Fatalf("the plan omits the drain step the deploy does take:\n%s", lines) } @@ -52,7 +52,7 @@ func TestPlanShowsTheDrainStepForTheDefaultSignalToo(t *testing.T) { workload.Drain = &app.Drain{Wait: "15s"} // no signal: TERM config.Workloads["web"] = workload - lines := strings.Join(newPlanEngine(t, config).Describe("/var/lib/ob/sample/releases/R1/compose.yaml"), "\n") + lines := strings.Join(newPlanEngine(t, config).Describe("/var/lib/onebox/app/releases/R1/compose.yaml"), "\n") if !strings.Contains(lines, "--signal=TERM ; wait up to 15s for exit") { t.Fatalf("the plan hides the drain step recreate will take:\n%s", lines) } diff --git a/internal/engine/plan_test.go b/internal/engine/plan_test.go index 62e1e54c..0f3d123e 100644 --- a/internal/engine/plan_test.go +++ b/internal/engine/plan_test.go @@ -269,9 +269,9 @@ func TestDescribeShowsBranchesAndHooks(t *testing.T) { } func TestOnlyReleaseLabelsChanged(t *testing.T) { - live := "services:\n server:\n labels:\n ob.app: sample\n ob.release: 20260704-203351-f65179e\n image: x:1\n" - relabel := "services:\n server:\n labels:\n ob.app: sample\n ob.release: 20260704-214927-f65179e\n image: x:1\n" - changed := "services:\n server:\n labels:\n ob.app: sample\n ob.release: 20260704-214927-f65179e\n image: x:2\n" + live := "services:\n server:\n labels:\n onebox.app: sample\n onebox.release: 20260704-203351-f65179e\n image: x:1\n" + relabel := "services:\n server:\n labels:\n onebox.app: sample\n onebox.release: 20260704-214927-f65179e\n image: x:1\n" + changed := "services:\n server:\n labels:\n onebox.app: sample\n onebox.release: 20260704-214927-f65179e\n image: x:2\n" if !OnlyReleaseLabelsChanged(live, relabel) { t.Fatal("label-only change must be detected as content-identical") @@ -302,7 +302,7 @@ func TestPayloadDigests(t *testing.T) { } } write("compose.yaml", "services: {}\n") // excluded: compared label-invariantly - write("ob.snapshot.yml", "app: sample\n") + write("onebox.snapshot.yml", "app: sample\n") write("server/.env", "KEY=one\n") d1, err := LocalPayloadDigest(testConfig(), dir) @@ -359,7 +359,7 @@ func TestPayloadDigests(t *testing.T) { // A release directory is a staging directory plus what the lifecycle writes to // it afterwards. If those extras count as payload the two digests can never be // equal and no deploy is ever a no-op. The inverse matters just as much: -// .ob-secret-generations IS staged, so excluding it would make a rotated secret +// .onebox-secret-generations IS staged, so excluding it would make a rotated secret // hash identically and deploy as a no-op. func TestPayloadDigestSpansStagedSecretsButNotReleaseMetadata(t *testing.T) { spec := testConfig() @@ -374,15 +374,15 @@ func TestPayloadDigestSpansStagedSecretsButNotReleaseMetadata(t *testing.T) { } } const generation = "sg-000000000000000000000000" - secretPath := app.SecretGenerationPath(generation, ".ob-decrypted-sops-app.enc.env") + secretPath := app.SecretGenerationPath(generation, ".onebox-decrypted-sops-app.enc.env") staging := t.TempDir() write(staging, "compose.yaml", "services: {}\n") - write(staging, "ob.snapshot.yml", "app: sample\n") + write(staging, "onebox.snapshot.yml", "app: sample\n") write(staging, secretPath, "TOKEN=value\n") released := t.TempDir() - for _, rel := range []string{"compose.yaml", "ob.snapshot.yml", secretPath} { + for _, rel := range []string{"compose.yaml", "onebox.snapshot.yml", secretPath} { body, err := os.ReadFile(filepath.Join(staging, filepath.FromSlash(rel))) if err != nil { t.Fatal(err) @@ -429,7 +429,7 @@ func TestLocalAndRemotePayloadSelectionAgree(t *testing.T) { // because find's -path lets * cross a slash, a near-miss suffix, and a // regular file carrying a directory's reserved name. for _, rel := range []string{ - "ob.snapshot.yml", + "onebox.snapshot.yml", "compose.yaml", "nested/compose.yaml", "manifest.json", @@ -574,7 +574,7 @@ func TestRefreshIgnoresRunningJobContainers(t *testing.T) { inner := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "label=ob.app='sample'") && strings.Contains(cmd, "docker ps"): + case strings.Contains(cmd, "label=onebox.app='sample'") && strings.Contains(cmd, "docker ps"): return transport.Result{Stdout: "J1|migrate|R0|rev1|Up 3 seconds\n"}, true case strings.Contains(cmd, "service='migrate'") && strings.Contains(cmd, "docker ps"): return transport.Result{Stdout: "J1\n"}, true diff --git a/internal/engine/preflight.go b/internal/engine/preflight.go index dcca4248..1d2e9d4b 100644 --- a/internal/engine/preflight.go +++ b/internal/engine/preflight.go @@ -225,7 +225,7 @@ func (e *Engine) healthDiagnosis(ctx context.Context, id string) string { // container is present but not serving, so not strictly running). type svcContainer struct { id string - release string // the ob.release label ("" for a non-ob container) + release string // the onebox.release label ("" for a non-ob container) revision string // the stable per-workload runtime revision health string // healthy | unhealthy | starting | none | down (not running) } @@ -235,7 +235,7 @@ type svcContainer struct { // service in docker's newest-first order. This is the whole of status's app // side: `docker ps` already carries the release label and a health hint in // `.Status`, so no per-container `docker inspect` is needed. (A single batched -// inspect emitting BOTH the ob.release label and health was tried and can't be +// inspect emitting BOTH the onebox.release label and health was tried and can't be // relied on: over multiple containers, a template that reads .Config.Labels and // guards .State.Health errors — "map has no entry for key Health" — on any // container without a healthcheck. A single-id health inspect is fine, but that @@ -246,8 +246,8 @@ func (e *Engine) projectContainers(ctx context.Context) (map[string][]svcContain // looked only in the application's project would report a database that is // running perfectly well as missing. res, err := e.T.Run(ctx, - "docker ps --filter label=ob.app="+q(e.Spec.Name)+ - " --format '{{.ID}}|{{.Label \"com.docker.compose.service\"}}|{{.Label \"ob.release\"}}|{{.Label \""+app.WorkloadRevisionLabel+"\"}}|{{.Status}}'") + "docker ps --filter label=onebox.app="+q(e.Spec.Name)+ + " --format '{{.ID}}|{{.Label \"com.docker.compose.service\"}}|{{.Label \"onebox.release\"}}|{{.Label \""+app.WorkloadRevisionLabel+"\"}}|{{.Status}}'") if err != nil { return nil, err } diff --git a/internal/engine/preflight_containers_test.go b/internal/engine/preflight_containers_test.go index 94c36105..65bb895d 100644 --- a/internal/engine/preflight_containers_test.go +++ b/internal/engine/preflight_containers_test.go @@ -9,7 +9,7 @@ import ( "github.com/labstack/onebox/internal/transport" ) -// projectContainers reads "id|service|ob.release|status" lines. +// projectContainers reads "id|service|onebox.release|status" lines. func containerEngine(t *testing.T, psOut string) (*Engine, *transport.Fake) { t.Helper() f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { diff --git a/internal/engine/preflight_test.go b/internal/engine/preflight_test.go index afa643ae..093a6045 100644 --- a/internal/engine/preflight_test.go +++ b/internal/engine/preflight_test.go @@ -19,7 +19,7 @@ func fakeEngine(t *testing.T, f *transport.Fake) *Engine { func TestPreflightHappyPath(t *testing.T) { f := &transport.Fake{Script: []transport.Rule{ - {Match: regexp.MustCompile(`_host/owner`), Result: transport.Result{Stdout: "sample\n"}}, + {Match: regexp.MustCompile(`_host/owner`), Result: transport.Result{Stdout: "sample production\n"}}, {Match: regexp.MustCompile(`docker version`), Result: transport.Result{Stdout: "27.0.3\n"}}, {Match: regexp.MustCompile(`docker compose version`), Result: transport.Result{Stdout: "2.29.1\n"}}, {Match: regexp.MustCompile(`imagetools inspect --help`), Result: transport.Result{Stdout: "Usage: docker buildx imagetools inspect [OPTIONS] NAME\n --format string\n"}}, @@ -35,7 +35,7 @@ func TestPreflightHappyPath(t *testing.T) { func TestPreflightFailsOnStoppedService(t *testing.T) { f := &transport.Fake{Script: []transport.Rule{ - {Match: regexp.MustCompile(`_host/owner`), Result: transport.Result{Stdout: "sample\n"}}, + {Match: regexp.MustCompile(`_host/owner`), Result: transport.Result{Stdout: "sample production\n"}}, {Match: regexp.MustCompile(`docker version`), Result: transport.Result{Stdout: "27.0.3\n"}}, {Match: regexp.MustCompile(`docker compose version`), Result: transport.Result{Stdout: "2.29.1\n"}}, {Match: regexp.MustCompile(`imagetools inspect --help`), Result: transport.Result{Stdout: "Usage: docker buildx imagetools inspect [OPTIONS] NAME\n --format string\n"}}, @@ -64,7 +64,7 @@ func TestPreflightManagedProxyMustRun(t *testing.T) { return &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { switch { case strings.Contains(cmd, "_host/owner"): - return transport.Result{Stdout: "sample\n"}, true + return transport.Result{Stdout: "sample production\n"}, true case strings.Contains(cmd, "docker version"): return transport.Result{Stdout: "27.0.3\n"}, true case strings.Contains(cmd, "docker compose version"): @@ -108,7 +108,7 @@ func TestPreflightRequiresProxyDiscoveryController(t *testing.T) { f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { switch { case strings.Contains(cmd, "_host/owner"): - return transport.Result{Stdout: "sample\n"}, true + return transport.Result{Stdout: "sample production\n"}, true case strings.Contains(cmd, "docker version"): return transport.Result{Stdout: "27.0.3\n"}, true case strings.Contains(cmd, "docker compose version"): @@ -140,7 +140,7 @@ func TestPreflightRequiresProxyDiscoveryController(t *testing.T) { // gates now ask the same questions. func TestPreflightRefusesIncompatibleBuildx(t *testing.T) { f := &transport.Fake{Script: []transport.Rule{ - {Match: regexp.MustCompile(`_host/owner`), Result: transport.Result{Stdout: "sample\n"}}, + {Match: regexp.MustCompile(`_host/owner`), Result: transport.Result{Stdout: "sample production\n"}}, {Match: regexp.MustCompile(`docker version`), Result: transport.Result{Stdout: "27.0.3\n"}}, {Match: regexp.MustCompile(`docker compose version`), Result: transport.Result{Stdout: "2.29.1\n"}}, {Match: regexp.MustCompile(`imagetools inspect --help`), Result: transport.Result{Stdout: "Usage: docker buildx imagetools inspect [OPTIONS] NAME\n"}}, diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index 0a62bf68..4125246d 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -13,8 +14,6 @@ import ( "github.com/labstack/onebox/internal/journal" "github.com/labstack/onebox/internal/proxy" - - "github.com/labstack/onebox/internal/app" ) // EnsureProxy converges the HOST-scoped managed proxy (design: one Traefik @@ -51,13 +50,14 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo } // Lock order is safe by construction: every acquirer holds either the - // host lock alone (proxy apply) or its OWN app lock first (bootstrap) — - // two apps never contend on an app lock, so no cycle exists. + // host lock alone (proxy apply) or its own app lock first (bootstrap). A + // host has one application, so no two acquirers contend on an app lock and + // no cycle exists. if err := e.acquireHostLock(ctx, breakLock); err != nil { return err } defer e.releaseHostLock(ctx) - jw := &journal.Writer{T: e.T, Names: app.Names{App: app.HostNamespace, BasePath: e.names().BasePath}, DeployID: deployID, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} + jw := &journal.Writer{T: e.T, Dir: e.names().HostJournalDir(), DeployID: deployID, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} if err := jw.Append(ctx, journal.Record{Phase: "proxy-apply", Event: "start", Detail: "hash=" + hash}); err != nil { return fmt.Errorf("journal proxy apply start: %w", err) } @@ -69,7 +69,9 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo } if journalErr := jw.Append(ctx, finish); journalErr != nil { err = errors.Join(err, fmt.Errorf("journal proxy apply finish: %w", journalErr)) + return } + e.pruneHostJournal(ctx) }() res, err := e.hostMutate(ctx, "find "+q(hp.Dir)+" -mindepth 1 -maxdepth 1 -type d -name '.staged-*' -exec rm -rf -- {} + 2>/dev/null || true") if err != nil { @@ -230,6 +232,35 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo return nil } +// pruneHostJournal keeps the host journal to the application journal's window. +// Every proxy check writes to it — an unchanged proxy included — so it runs on +// every one, after the finish record. The host journal holds only proxy +// applies, which nothing recovers from, so it keeps the newest files by name +// and needs no record to be readable: a torn file ages out like any other. It +// is housekeeping, in one round trip: a failure is reported and never turns +// an applied proxy into a failed one. +func (e *Engine) pruneHostJournal(ctx context.Context) { + keep := e.Spec.Deployment.RetainReleases * 2 + if keep < 1 { + return + } + res, err := e.hostMutate(ctx, pruneHostJournalCommand(e.names().HostJournalDir(), keep)) + if err == nil && res.ExitCode != 0 { + err = errors.New(strings.TrimSpace(res.Stderr)) + } + if err != nil { + e.logf("proxy: host journal not pruned: %v", err) + } +} + +// pruneHostJournalCommand removes all but the newest keep journal files. +// Journal ids begin with a timestamp, so name order is age order. +func pruneHostJournalCommand(dir string, keep int) string { + return "if [ -d " + q(dir) + " ]; then cd " + q(dir) + " || exit 1; " + + "ls -1 | grep '\\.jsonl$' | sort -r | sed '1," + strconv.Itoa(keep) + "d' | " + + "while IFS= read -r f; do rm -f -- \"$f\" || exit 1; done; fi" +} + func (e *Engine) proxyContainerIDs(ctx context.Context) ([]string, error) { res, err := e.T.Run(ctx, "docker ps -q --filter label=com.docker.compose.project="+q(proxy.Project)+ " --filter label=com.docker.compose.service=proxy") @@ -251,7 +282,7 @@ func (e *Engine) discoveryContainerIDs(ctx context.Context) ([]string, error) { // ProxyApply is the CLI verb: converge the host proxy outside any deploy. func (e *Engine) ProxyApply(ctx context.Context, deployID string) error { if !e.Spec.Proxy.Managed { - return fmt.Errorf("proxy is not managed (proxy.managed: true enables ob-owned Traefik)") + return fmt.Errorf("proxy is not managed (proxy.managed: true enables Onebox-owned Traefik)") } if err := e.RequireHostOwner(ctx); err != nil { return err diff --git a/internal/engine/proxy_test.go b/internal/engine/proxy_test.go index 19d84fd4..0f556002 100644 --- a/internal/engine/proxy_test.go +++ b/internal/engine/proxy_test.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "errors" + "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -133,19 +135,19 @@ func TestEnsureProxyFreshHost(t *testing.T) { t.Fatalf("%v\n%s", err, strings.Join(f.Commands, "\n")) } seq := strings.Join(f.Commands, "\n") - if len(f.Uploads) != 1 || !strings.Contains(f.Uploads[0], "/var/lib/ob/_host/proxy") { + if len(f.Uploads) != 1 || !strings.Contains(f.Uploads[0], "/var/lib/onebox/_host/proxy") { t.Fatalf("payload must upload to the host proxy dir: %v", f.Uploads) } - if !strings.Contains(seq, "docker compose -p onebox-proxy -f '/var/lib/ob/_host/proxy/compose.yaml' up -d") { + if !strings.Contains(seq, "docker compose -p onebox-proxy -f '/var/lib/onebox/_host/proxy/compose.yaml' up -d") { t.Fatalf("fresh host must up the proxy project:\n%s", seq) } if strings.Contains(seq, "/proxy/apps") { t.Fatalf("proxy convergence must not create a cross-application registry:\n%s", seq) } - if !strings.Contains(seq, "test -f '/var/lib/ob/_host/proxy/acme/acme.json' ||") { + if !strings.Contains(seq, "test -f '/var/lib/onebox/_host/proxy/acme/acme.json' ||") { t.Fatalf("acme.json creation must be guarded (never touch an existing one):\n%s", seq) } - if !strings.Contains(seq, "/var/lib/ob/_host/journal") { + if !strings.Contains(seq, "/var/lib/onebox/_host/journal") { t.Fatalf("host journal must record the converge:\n%s", seq) } // secrets rule: .env content never appears in any command @@ -178,7 +180,7 @@ func TestEnsureProxyUnchangedIsNoOp(t *testing.T) { e, hash, _ := proxyFixture(t, f) ps := proxyPS(f, true) f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/config.hash'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/config.hash'") { return transport.Result{Stdout: hash + "\n"}, true } return ps(cmd) @@ -204,7 +206,7 @@ func TestEnsureProxyRepairsMissingDiscoveryController(t *testing.T) { ps := proxyPS(f, true) f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/config.hash'"): + case strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/config.hash'"): return transport.Result{Stdout: hash + "\n"}, true case strings.Contains(cmd, "com.docker.compose.service=discovery") && !strings.Contains(cmd, "up -d"): return transport.Result{Stdout: ""}, true @@ -225,9 +227,9 @@ func TestEnsureProxyRepairsMissingDiscoveryOutput(t *testing.T) { ps := proxyPS(f, true) f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/config.hash'"): + case strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/config.hash'"): return transport.Result{Stdout: hash + "\n"}, true - case strings.Contains(cmd, "test -s '/var/lib/ob/_host/proxy/dynamic/onebox.yml'"): + case strings.Contains(cmd, "test -s '/var/lib/onebox/_host/proxy/dynamic/onebox.yml'"): for _, command := range f.Commands { if strings.Contains(command, "up -d --force-recreate discovery") { return transport.Result{}, true @@ -252,10 +254,10 @@ func TestEnsureProxyConfigOnlyChangeRestarts(t *testing.T) { rendered := string(proxy.RenderComposeForApp("", proxy.DiscoveryImage("dev"), "sample", "", true, nil)) ps := proxyPS(f, true) f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/config.hash'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/config.hash'") { return transport.Result{Stdout: "deadbeef\n"}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/compose.yaml'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/compose.yaml'") { return transport.Result{Stdout: rendered}, true } return ps(cmd) @@ -278,13 +280,13 @@ func TestEnsureProxyConfigOnlyChangeRestarts(t *testing.T) { if !strings.Contains(f.Uploads[0], ".staged") { t.Fatalf("upload must land in the staging dir, not the live one: %v", f.Uploads) } - if !strings.Contains(seq, "mv '/var/lib/ob/_host/proxy/.staged-") || !strings.Contains(seq, "/config' '/var/lib/ob/_host/proxy/config'") { + if !strings.Contains(seq, "mv '/var/lib/onebox/_host/proxy/.staged-") || !strings.Contains(seq, "/config' '/var/lib/onebox/_host/proxy/config'") { t.Fatalf("config must swap in atomically:\n%s", seq) } // applied-state marker written ONLY after health confirms — an interrupted // converge must be retried, never mistaken for "unchanged" iHealth := strings.LastIndex(seq, "docker inspect") - iHash := strings.Index(seq, "> '/var/lib/ob/_host/proxy/config.hash'") + iHash := strings.Index(seq, "> '/var/lib/onebox/_host/proxy/config.hash'") if iHash < 0 || iHash < iHealth { t.Fatalf("config.hash must be written after the health check:\n%s", seq) } @@ -296,10 +298,10 @@ func TestEnsureProxyFailedConvergeLeavesHashUnwritten(t *testing.T) { rendered := string(proxy.RenderComposeForApp("", proxy.DiscoveryImage("dev"), "sample", "", true, nil)) ps := proxyPS(f, true) f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/config.hash'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/config.hash'") { return transport.Result{Stdout: "deadbeef\n"}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/compose.yaml'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/compose.yaml'") { return transport.Result{Stdout: rendered}, true } if strings.Contains(cmd, "docker restart") { @@ -311,7 +313,7 @@ func TestEnsureProxyFailedConvergeLeavesHashUnwritten(t *testing.T) { t.Fatal("failed restart must error") } seq := strings.Join(f.Commands, "\n") - if strings.Contains(seq, "> '/var/lib/ob/_host/proxy/config.hash'") { + if strings.Contains(seq, "> '/var/lib/onebox/_host/proxy/config.hash'") { t.Fatalf("failed converge must NOT record the applied hash (retry depends on it):\n%s", seq) } } @@ -321,7 +323,7 @@ func TestProxyApplyRefusesForeignOwnerBeforeMutation(t *testing.T) { e, _, _ := proxyFixture(t, f) f.Dynamic = func(cmd string) (transport.Result, bool) { if strings.Contains(cmd, "_host/owner") { - return transport.Result{Stdout: "another-app\n"}, true + return transport.Result{Stdout: "another-app production\n"}, true } return transport.Result{}, false } @@ -341,7 +343,42 @@ func TestEnsureProxyReleasesHostLock(t *testing.T) { e, _, _ := proxyFixture(t, f) f.Dynamic = proxyPS(f, false) _ = e.EnsureProxy(context.Background(), "R6", false) - if !strings.Contains(strings.Join(f.Commands, "\n"), "rm -f '/var/lib/ob/_host/lock'") { + if !strings.Contains(strings.Join(f.Commands, "\n"), "rm -f '/var/lib/onebox/_host/lock'") { t.Fatalf("host lock must be released on error:\n%s", strings.Join(f.Commands, "\n")) } } + +// The host journal ages out by name, in one command, whatever its files hold: +// a torn record must not stop pruning for the life of the host. +func TestHostJournalPrunesOldestFiles(t *testing.T) { + dir := t.TempDir() + for i := range 25 { + body := `{"phase":"proxy-apply"}` + "\n" + if i == 3 { + body = `{"phase":"pro` // torn + } + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("20260901-1200%02d-nogit-proxy.jsonl", i)), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "unrelated.txt"), nil, 0o600); err != nil { + t.Fatal(err) + } + if out, err := exec.CommandContext(t.Context(), "sh", "-c", pruneHostJournalCommand(dir, 20)).CombinedOutput(); err != nil { + t.Fatalf("prune: %v\n%s", err, out) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, entry := range entries { + names = append(names, entry.Name()) + } + if len(names) != 21 || names[0] != "20260901-120005-nogit-proxy.jsonl" || names[20] != "unrelated.txt" { + t.Fatalf("after pruning: %v", names) + } + if out, err := exec.CommandContext(t.Context(), "sh", "-c", pruneHostJournalCommand(filepath.Join(dir, "absent"), 20)).CombinedOutput(); err != nil { + t.Fatalf("an absent journal is nothing to prune: %v\n%s", err, out) + } +} diff --git a/internal/engine/proxylock.go b/internal/engine/proxylock.go index 7a42e778..e73d2607 100644 --- a/internal/engine/proxylock.go +++ b/internal/engine/proxylock.go @@ -102,12 +102,12 @@ func (e *Engine) hostMutate(ctx context.Context, cmd string) (res transport.Resu if e.hostLockVal == "" { return res, fmt.Errorf("host mutation attempted without owning the host lock") } - guarded := `if [ "$(cat ` + q(proxy.HostPaths(e.names()).Lock) + ` 2>/dev/null)" = ` + q(e.hostLockVal) + ` ]; then ` + cmd + `; else echo ob-host-fenced >&2; exit 96; fi` + guarded := `if [ "$(cat ` + q(proxy.HostPaths(e.names()).Lock) + ` 2>/dev/null)" = ` + q(e.hostLockVal) + ` ]; then ` + cmd + `; else echo onebox-host-fenced >&2; exit 96; fi` res, err = e.mutate(ctx, guarded) if err != nil { return res, err } - if res.ExitCode == 96 && strings.Contains(res.Stderr, "ob-host-fenced") { + if res.ExitCode == 96 && strings.Contains(res.Stderr, "onebox-host-fenced") { return res, ErrFenced } return res, nil diff --git a/internal/engine/proxylock_test.go b/internal/engine/proxylock_test.go index e26664a4..562ee45e 100644 --- a/internal/engine/proxylock_test.go +++ b/internal/engine/proxylock_test.go @@ -15,11 +15,11 @@ func TestHostLockHappyPath(t *testing.T) { t.Fatalf("%v\n%s", err, strings.Join(f.Commands, "\n")) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "set -C") || !strings.Contains(seq, "/var/lib/ob/_host/lock") { + if !strings.Contains(seq, "set -C") || !strings.Contains(seq, "/var/lib/onebox/_host/lock") { t.Fatalf("noclobber host lock creation missing:\n%s", seq) } e.releaseHostLock(context.Background()) - if !strings.Contains(strings.Join(f.Commands, "\n"), "rm -f '/var/lib/ob/_host/lock'") { + if !strings.Contains(strings.Join(f.Commands, "\n"), "rm -f '/var/lib/onebox/_host/lock'") { t.Fatalf("release must remove the host lock:\n%s", strings.Join(f.Commands, "\n")) } } @@ -29,7 +29,7 @@ func TestHostLockHeldFreshRefuses(t *testing.T) { if strings.Contains(cmd, "set -C") { return transport.Result{ExitCode: 1, Stderr: "cannot overwrite"}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/_host/lock'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/_host/lock'") { return transport.Result{Stdout: `{"owner":"alice@laptop","deploy_id":"unlock","epoch":0}`}, true } if strings.Contains(cmd, "date +%s") { @@ -51,11 +51,11 @@ func TestHostLockExpiredTakesOver(t *testing.T) { if strings.Contains(cmd, "set -C") && !broke { return transport.Result{ExitCode: 1, Stderr: "cannot overwrite"}, true } - if strings.Contains(cmd, "rm -f '/var/lib/ob/_host/lock'") { + if strings.Contains(cmd, "rm -f '/var/lib/onebox/_host/lock'") { broke = true return transport.Result{}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/_host/lock'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/_host/lock'") { return transport.Result{Stdout: `{"owner":"bob@ci","deploy_id":"other","ttl_s":600}`}, true } if strings.Contains(cmd, "date +%s") { @@ -76,11 +76,11 @@ func TestHostLockForceBreaks(t *testing.T) { if strings.Contains(cmd, "set -C") && !broke { return transport.Result{ExitCode: 1, Stderr: "cannot overwrite"}, true } - if strings.Contains(cmd, "rm -f '/var/lib/ob/_host/lock'") { + if strings.Contains(cmd, "rm -f '/var/lib/onebox/_host/lock'") { broke = true return transport.Result{}, true } - if strings.Contains(cmd, "cat '/var/lib/ob/_host/lock'") { + if strings.Contains(cmd, "cat '/var/lib/onebox/_host/lock'") { return transport.Result{Stdout: `{"owner":"bob@ci","deploy_id":"other","ttl_s":600}`}, true } if strings.Contains(cmd, "date +%s") { diff --git a/internal/engine/proxystatus.go b/internal/engine/proxystatus.go index 20f92fff..5d00f703 100644 --- a/internal/engine/proxystatus.go +++ b/internal/engine/proxystatus.go @@ -25,7 +25,7 @@ type proxyRaw struct { discovery bool // isolated Docker discovery controller is running applied string // config hash the host applied owner string // sole application identity from the host owner record - ownerEnv string // environment identity when the record is not legacy + ownerEnv string // environment identity from the owner record acme []string // raw ACME stores; parsed at render, and keys never leave localHash string // hash of the locally staged config (computed offline) // Why a read could not be trusted, when it could not. Recorded rather diff --git a/internal/engine/proxystatus_test.go b/internal/engine/proxystatus_test.go index baee5500..a7c7b2c0 100644 --- a/internal/engine/proxystatus_test.go +++ b/internal/engine/proxystatus_test.go @@ -72,16 +72,16 @@ func statusProxyEngine(t *testing.T, appliedHash *string, acme string, proxyHeal return transport.Result{Stdout: "PD1\n"}, true case strings.Contains(cmd, "project='onebox-proxy'"): // proxy id + health in one ps return transport.Result{Stdout: "PX1|Up 2 days (" + proxyHealth + ")\n"}, true - case strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/config.hash'"): + case strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/config.hash'"): return transport.Result{Stdout: *appliedHash + "\n"}, true - case strings.Contains(cmd, "cat '/var/lib/ob/_host/owner'"): - return transport.Result{Stdout: "sample\n"}, true - case strings.Contains(cmd, "cat '/var/lib/ob/_host/proxy/acme/acme.json'"): + case strings.Contains(cmd, "cat '/var/lib/onebox/_host/owner'"): + return transport.Result{Stdout: "sample production\n"}, true + case strings.Contains(cmd, "cat '/var/lib/onebox/_host/proxy/acme/acme.json'"): return transport.Result{Stdout: acme}, true - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app='sample'"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app='sample'"): return transport.Result{Stdout: "S1|web|R7|Up (healthy)\n" + "W1|worker|R7|Up (healthy)\nPG1|postgres|R7|Up (healthy)\n"}, true - case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): + case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal"): return transport.Result{Stdout: ""}, true } return transport.Result{}, false @@ -283,7 +283,7 @@ func TestStatusUnmanagedProxyUnchanged(t *testing.T) { switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/R7\n"}, true - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app='sample'"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app='sample'"): return transport.Result{Stdout: "S1|web|R7|Up (healthy)\n" + "W1|worker|R7|Up (healthy)\nPG1|postgres|R7|Up (healthy)\n"}, true } diff --git a/internal/engine/recovery.go b/internal/engine/recovery.go index 534eb944..3b01686c 100644 --- a/internal/engine/recovery.go +++ b/internal/engine/recovery.go @@ -25,10 +25,7 @@ func (e *Engine) engineFromReleaseSnapshot(ctx context.Context, releaseID string func (e *Engine) engineFromReleaseSnapshotFor(ctx context.Context, releaseID, operation string) (*Engine, error) { names := e.names() environment := e.Opts.Environment - if environment == "" { - environment = e.Spec.Env - } - path := release.PathsFor(names).Releases + "/" + releaseID + "/ob.snapshot.yml" + path := release.PathsFor(names).Releases + "/" + releaseID + "/onebox.snapshot.yml" res, err := e.T.Run(ctx, "cat "+q(path)) if err != nil { return nil, fmt.Errorf("read release %s snapshot: %w", releaseID, err) @@ -193,7 +190,7 @@ func (e *Engine) recoverInterrupted(ctx context.Context, request recoveryRequest } func (e *Engine) exactReleaseContainerIDs(ctx context.Context, releaseID string) ([]string, error) { - result, err := e.T.Run(ctx, "docker ps -aq --filter label=ob.app="+q(e.Spec.Name)+" --filter label=ob.release="+q(releaseID)) + result, err := e.T.Run(ctx, "docker ps -aq --filter label=onebox.app="+q(e.Spec.Name)+" --filter label=onebox.release="+q(releaseID)) if err != nil { return nil, err } diff --git a/internal/engine/recovery_test.go b/internal/engine/recovery_test.go index f49f2d7a..4c92628e 100644 --- a/internal/engine/recovery_test.go +++ b/internal/engine/recovery_test.go @@ -46,7 +46,7 @@ func seedInterruptedRecoveryState(t *testing.T, engine *Engine) { } func recoveryWriter(engine *Engine) *journal.Writer { - return &journal.Writer{T: engine.T, Names: engine.Names(), DeployID: engineTestDeployReleaseID, Epoch: 2} + return &journal.Writer{T: engine.T, Dir: journal.Dir(engine.Names()), DeployID: engineTestDeployReleaseID, Epoch: 2} } func TestRecoveryRetryKeepsCheckpointUntilHealthyAndSweepsStaleRoles(t *testing.T) { @@ -57,7 +57,7 @@ func TestRecoveryRetryKeepsCheckpointUntilHealthyAndSweepsStaleRoles(t *testing. switch { case strings.Contains(command, "readlink"): return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true - case strings.Contains(command, "docker ps -aq") && strings.Contains(command, "label=ob.release='"+engineTestDeployReleaseID+"'"): + case strings.Contains(command, "docker ps -aq") && strings.Contains(command, "label=onebox.release='"+engineTestDeployReleaseID+"'"): var ids []string for _, pair := range []struct{ id, marker string }{{"NEW1", "docker rm -f NEW1"}, {"STALE1", "docker rm -f STALE1"}} { removed := false @@ -145,7 +145,7 @@ func TestRecoveryMigrationGateStopsBeforeMutationUnlessBreakGlass(t *testing.T) if strings.Contains(command, "readlink") { return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true } - if strings.Contains(command, "docker ps -aq") && strings.Contains(command, "label=ob.release='"+engineTestDeployReleaseID+"'") { + if strings.Contains(command, "docker ps -aq") && strings.Contains(command, "label=onebox.release='"+engineTestDeployReleaseID+"'") { return transport.Result{}, true } return base(command) @@ -195,7 +195,7 @@ func TestFinalizeRecoveredFirstReleaseClearsCurrentAndFailsManifest(t *testing.T if err != nil || stored.State != release.StateFailed || stored.OperationOutcome != release.OutcomeFailed { t.Fatalf("finalized manifest = %+v, %v", stored, err) } - if !strings.Contains(strings.Join(target.Commands, "\n"), "rm -f '/var/lib/ob/sample/current'") { + if !strings.Contains(strings.Join(target.Commands, "\n"), "rm -f '/var/lib/onebox/app/current'") { t.Fatal("first-release recovery did not clear current") } } @@ -254,7 +254,7 @@ func TestFinalizeRecoveredReleaseReactivatesSupersededPredecessor(t *testing.T) func TestRecoverySnapshotRejectsAnotherApplication(t *testing.T) { target := happyFake() target.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "/ob.snapshot.yml") { + if strings.Contains(command, "/onebox.snapshot.yml") { return transport.Result{Stdout: strings.Replace(engineProject, "name: sample", "name: other", 1)}, true } return transport.Result{}, false @@ -272,13 +272,13 @@ func TestRecoveryEngineUsesSnapshotChoreography(t *testing.T) { target := happyFake() base := target.Dynamic target.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "/ob.snapshot.yml") { + if strings.Contains(command, "/onebox.snapshot.yml") { return transport.Result{Stdout: snapshot}, true } if strings.Contains(command, "readlink") { return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true } - if strings.Contains(command, "service='legacy'") && strings.Contains(command, "ob.release=") { + if strings.Contains(command, "service='legacy'") && strings.Contains(command, "onebox.release=") { return transport.Result{}, true } if strings.Contains(command, "service='legacy'") { @@ -326,8 +326,8 @@ func TestRestoreReleaseRolesRetainsMatchingTargetRevisions(t *testing.T) { target.Dynamic = func(command string) (transport.Result, bool) { switch { case strings.Contains(command, "/compose.yaml") && strings.HasPrefix(command, "cat "): - return transport.Result{Stdout: "services:\n web:\n labels:\n ob.workload-revision: " + webRevision + "\n worker:\n labels:\n ob.workload-revision: " + workerRevision + "\n"}, true - case strings.Contains(command, "docker ps --filter label=ob.app="): + return transport.Result{Stdout: "services:\n web:\n labels:\n onebox.workload-revision: " + webRevision + "\n worker:\n labels:\n onebox.workload-revision: " + workerRevision + "\n"}, true + case strings.Contains(command, "docker ps --filter label=onebox.app="): return transport.Result{Stdout: "WEB1|web|older-release|" + webRevision + "|Up 1 hour (healthy)\n" + "WORKER1|worker|oldest-release|" + workerRevision + "|Up 1 hour\n"}, true } diff --git a/internal/engine/recreate_test.go b/internal/engine/recreate_test.go index 02ee9ed7..329678a5 100644 --- a/internal/engine/recreate_test.go +++ b/internal/engine/recreate_test.go @@ -339,7 +339,7 @@ func TestRunHookSetsComposeEnvAndFailsHard(t *testing.T) { cfg := testConfig() cfg.Hooks["migrate"] = app.Command{Run: "docker compose run --rm --no-deps migrate"} e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - err := e.RunHook(context.Background(), "migrate", "/var/lib/ob/sample/releases/R1", "/var/lib/ob/sample/releases/R1/compose.yaml") + err := e.RunHook(context.Background(), "migrate", "/var/lib/onebox/app/releases/R1", "/var/lib/onebox/app/releases/R1/compose.yaml") if err == nil || !strings.Contains(err.Error(), "alembic exploded") { t.Fatalf("hook failure must halt deploy with stderr, got %v", err) } diff --git a/internal/engine/release_state_test.go b/internal/engine/release_state_test.go index 0bf7fa2d..451e15c2 100644 --- a/internal/engine/release_state_test.go +++ b/internal/engine/release_state_test.go @@ -105,7 +105,7 @@ func TestActivateManifestPersistsEveryBoundaryInOrder(t *testing.T) { // gone, and nothing has journalled the activation — a state finalize // refuses on every retry while the release is healthy and live. The // caller clears it once that evidence is durable. - if strings.Contains(commands, "rm -f '/var/lib/ob/sample/activation.json'") { + if strings.Contains(commands, "rm -f '/var/lib/onebox/app/activation.json'") { t.Fatalf("activation cleared its own checkpoint before any evidence was journalled:\n%s", commands) } if _, err := release.ReadActivationCheckpoint(context.Background(), target, engine.Names()); err != nil { @@ -156,7 +156,7 @@ func TestActivateManifestCrashAfterSupersedingPredecessorKeepsCheckpoint(t *test if err != nil || storedPrevious.State != release.StateSuperseded { t.Fatalf("predecessor = %+v, %v", storedPrevious, err) } - if strings.Contains(strings.Join(target.Commands, "\n"), "rm -f '/var/lib/ob/sample/activation.json'") { + if strings.Contains(strings.Join(target.Commands, "\n"), "rm -f '/var/lib/onebox/app/activation.json'") { t.Fatal("failed final checkpoint was cleared") } } @@ -202,7 +202,7 @@ func TestActivateManifestCrashLeavesLastDurableBoundary(t *testing.T) { if err != nil || stored.State != test.wantManifest { t.Fatalf("manifest = %+v, %v; want %s", stored, err, test.wantManifest) } - if strings.Contains(strings.Join(target.Commands, "\n"), "rm -f '/var/lib/ob/sample/activation.json'") { + if strings.Contains(strings.Join(target.Commands, "\n"), "rm -f '/var/lib/onebox/app/activation.json'") { t.Fatal("failed activation cleared its recovery checkpoint") } }) @@ -242,7 +242,7 @@ func TestActivateManifestPredecessorWriteFailureLeavesTwoServingManifestsRecover t.Fatalf("manifest %s = %+v, %v; want recoverable serving state", releaseID, stored, err) } } - if strings.Contains(strings.Join(target.Commands, "\n"), "rm -f '/var/lib/ob/sample/activation.json'") { + if strings.Contains(strings.Join(target.Commands, "\n"), "rm -f '/var/lib/onebox/app/activation.json'") { t.Fatal("two-serving-manifest crash state lost its recovery checkpoint") } } diff --git a/internal/engine/resume.go b/internal/engine/resume.go index 9c0c603f..f62dad10 100644 --- a/internal/engine/resume.go +++ b/internal/engine/resume.go @@ -28,7 +28,7 @@ var ErrNoIncomplete = errors.New("no incomplete deploy found in the journal") // actionable and `runPhases` refuses it on its superseded manifest before any // effect runs. func (e *Engine) FindIncomplete(ctx context.Context) (journal.Summary, error) { - ids, byID, err := journal.Journals(ctx, e.T, e.names()) + ids, byID, err := journal.Journals(ctx, e.T, journal.Dir(e.names())) if err != nil { return journal.Summary{}, err } @@ -46,7 +46,7 @@ func (e *Engine) FindIncomplete(ctx context.Context) (journal.Summary, error) { } // Resume continues an interrupted deploy from the journal: completed phases -// and roles skip; the half-rolled role is adopted via its ob.release label. +// and roles skip; the half-rolled role is adopted via its onebox.release label. // A NEW lock epoch is taken, which fences the old runner if it still lives. // // A deploy interrupted AFTER activation is resumed too, but nothing is @@ -140,7 +140,7 @@ func (e *Engine) abort(ctx context.Context, s journal.Summary, force bool) (err return err } jw := &journal.Writer{ - T: e.T, Names: e.names(), DeployID: s.DeployID, Epoch: epoch, + T: e.T, Dir: journal.Dir(e.names()), DeployID: s.DeployID, Epoch: epoch, Operator: journal.DefaultOperator(), Runner: &e.Opts.Runner, ApprovalDigest: s.ApprovalDigest, ApprovalClass: s.ApprovalClass, ApprovedBy: s.ApprovedBy, ApprovalSource: s.ApprovalSource, diff --git a/internal/engine/resume_test.go b/internal/engine/resume_test.go index 3e657336..c58ffe67 100644 --- a/internal/engine/resume_test.go +++ b/internal/engine/resume_test.go @@ -46,13 +46,13 @@ func interruptedFakeWithPolicy(gateDetail string, policySafe bool) *transport.Fa return transport.Result{Stdout: "\n"}, true case strings.Contains(cmd, "State.Status"): return transport.Result{Stdout: "running\n"}, true - case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): + case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal"): return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + jr}, true case strings.Contains(cmd, "test -d"): return transport.Result{ExitCode: 0}, true case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true - case strings.Contains(cmd, "ls -1 '/var/lib/ob/sample/releases'"): + case strings.Contains(cmd, "ls -1 '/var/lib/onebox/app/releases'"): return transport.Result{Stdout: engineTestPreviousReleaseID + "\n" + engineTestDeployReleaseID + "\n"}, true } return base(cmd) @@ -104,10 +104,10 @@ func TestResumePreservesJournaledRetainAction(t *testing.T) { ) base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal") { + if strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal") { return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + jr}, true } - if strings.Contains(cmd, "docker ps --filter label=ob.app=") && strings.Contains(cmd, "--format") { + if strings.Contains(cmd, "docker ps --filter label=onebox.app=") && strings.Contains(cmd, "--format") { return transport.Result{Stdout: "OLD1|web|R0||Up (healthy)\nW1|worker|R0|" + revision + "|Up\n"}, true } return base(cmd) @@ -137,9 +137,9 @@ func TestResumeUsesInterruptedReleaseSnapshotAfterConfigEdit(t *testing.T) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): + case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal"): return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + jr}, true - case strings.Contains(cmd, "/releases/"+engineTestDeployReleaseID+"/ob.snapshot.yml"): + case strings.Contains(cmd, "/releases/"+engineTestDeployReleaseID+"/onebox.snapshot.yml"): return transport.Result{Stdout: oldSnapshot}, true case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true @@ -169,7 +169,7 @@ func TestResumeRefusesMissingInterruptedSnapshot(t *testing.T) { f := interruptedFake("changed=false") base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "/releases/"+engineTestDeployReleaseID+"/ob.snapshot.yml") { + if strings.Contains(cmd, "/releases/"+engineTestDeployReleaseID+"/onebox.snapshot.yml") { return transport.Result{ExitCode: 1, Stderr: "No such file"}, true } return base(cmd) @@ -179,7 +179,7 @@ func TestResumeRefusesMissingInterruptedSnapshot(t *testing.T) { if id != engineTestDeployReleaseID || err == nil || !strings.Contains(err.Error(), "snapshot unavailable") { t.Fatalf("resume id/error = %q, %v", id, err) } - if strings.Contains(strings.Join(f.Commands, "\n"), "ob-fenced") { + if strings.Contains(strings.Join(f.Commands, "\n"), "onebox-fenced") { t.Fatalf("resume must fail before mutation:\n%s", strings.Join(f.Commands, "\n")) } } @@ -203,9 +203,9 @@ func interruptedBeforeMigrationFake(allowUnknown bool) *transport.Fake { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): + case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal"): return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + jr}, true - case strings.Contains(cmd, "ob.snapshot.yml"): + case strings.Contains(cmd, "onebox.snapshot.yml"): return transport.Result{Stdout: strings.Replace(engineProject, "dataEffect: Unknown", "dataEffect: Migration", 1)}, true case strings.Contains(cmd, "test -d"): return transport.Result{ExitCode: 0}, true @@ -253,7 +253,7 @@ func TestResumeWithNothingIncomplete(t *testing.T) { f := happyFake() base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal") { + if strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal") { return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + journalLines( journal.Record{DeployID: engineTestDeployReleaseID, Phase: "deploy", Event: "start"}, journal.Record{DeployID: engineTestDeployReleaseID, Phase: "deploy", Event: "finish", Status: "ok"}, @@ -310,7 +310,7 @@ func TestAbortExpandOnlyDoesNotCoverLifecycleHook(t *testing.T) { func testAbortReplaysPreviousRelease(t *testing.T, gateDetail string, policySafe bool) { t.Helper() f := interruptedFakeWithPolicy(gateDetail, policySafe) - // abort path: web rolled to R1 — its container carries ob.release='R1'; + // abort path: web rolled to R1 — its container carries onebox.release='R1'; // replaying R0 must drain it. The fake: newcomer query for R0 returns the // R0 container only after R0's up --scale ran. base := f.Dynamic @@ -331,7 +331,7 @@ func testAbortReplaysPreviousRelease(t *testing.T, gateDetail string, policySafe return false } f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "ob.release='"+engineTestPreviousReleaseID+"'") && strings.Contains(cmd, "service='web'") { + if strings.Contains(cmd, "onebox.release='"+engineTestPreviousReleaseID+"'") && strings.Contains(cmd, "service='web'") { if r0Scaled() { return transport.Result{Stdout: "PREV1\n"}, true } @@ -339,7 +339,7 @@ func testAbortReplaysPreviousRelease(t *testing.T, gateDetail string, policySafe } // live server set: OLD1 (the R1 container being replaced) until removed, // plus the R0 newcomer PREV1 once the R0 scale ran. - if strings.Contains(cmd, "compose.service='web'") && !strings.Contains(cmd, "ob.release=") { + if strings.Contains(cmd, "compose.service='web'") && !strings.Contains(cmd, "onebox.release=") { var ids []string if !oldGone() { ids = append(ids, "OLD1") @@ -349,10 +349,10 @@ func testAbortReplaysPreviousRelease(t *testing.T, gateDetail string, policySafe } return transport.Result{Stdout: strings.Join(ids, "\n") + "\n"}, true } - if strings.Contains(cmd, "ob.release='"+engineTestPreviousReleaseID+"'") && strings.Contains(cmd, "service='worker'") { + if strings.Contains(cmd, "onebox.release='"+engineTestPreviousReleaseID+"'") && strings.Contains(cmd, "service='worker'") { return transport.Result{Stdout: ""}, true // worker never completed → recreate from R0 } - if strings.Contains(cmd, "ob.release='"+engineTestDeployReleaseID+"'") { + if strings.Contains(cmd, "onebox.release='"+engineTestDeployReleaseID+"'") { return transport.Result{Stdout: ""}, true // straggler sweep finds none } if strings.Contains(cmd, "inspect") && strings.Contains(cmd, "PREV1") { @@ -415,24 +415,24 @@ func TestAbortUsesBothReleaseSnapshotsAfterConfigEdit(t *testing.T) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal"): + case strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal"): return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + jr}, true - case strings.Contains(cmd, "/releases/"+engineTestDeployReleaseID+"/ob.snapshot.yml"): + case strings.Contains(cmd, "/releases/"+engineTestDeployReleaseID+"/onebox.snapshot.yml"): return transport.Result{Stdout: interruptedWebSnapshot}, true - case strings.Contains(cmd, "/releases/"+engineTestPreviousReleaseID+"/ob.snapshot.yml"): + case strings.Contains(cmd, "/releases/"+engineTestPreviousReleaseID+"/onebox.snapshot.yml"): return transport.Result{Stdout: oldSnapshot}, true case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true - case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "label=ob.release='"+engineTestDeployReleaseID+"'"): + case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "label=onebox.release='"+engineTestDeployReleaseID+"'"): for _, recorded := range f.Commands { if strings.Contains(recorded, "docker rm -f NEW1") { return transport.Result{}, true } } return transport.Result{Stdout: "NEW1\n"}, true - case strings.Contains(cmd, "service='worker'") && strings.Contains(cmd, "ob.release='"+engineTestPreviousReleaseID+"'"): + case strings.Contains(cmd, "service='worker'") && strings.Contains(cmd, "onebox.release='"+engineTestPreviousReleaseID+"'"): return transport.Result{}, true - case strings.Contains(cmd, "service='web'") && strings.Contains(cmd, "ob.release='"+engineTestDeployReleaseID+"'"): + case strings.Contains(cmd, "service='web'") && strings.Contains(cmd, "onebox.release='"+engineTestDeployReleaseID+"'"): return transport.Result{Stdout: "NEW1\n"}, true } return base(cmd) @@ -489,7 +489,7 @@ func TestAbortRefusesUnreadablePreviousSnapshot(t *testing.T) { f := interruptedFake(gate.detail) base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "/releases/"+engineTestPreviousReleaseID+"/ob.snapshot.yml") { + if strings.Contains(cmd, "/releases/"+engineTestPreviousReleaseID+"/onebox.snapshot.yml") { return prev.res, true } return base(cmd) @@ -547,7 +547,7 @@ func TestResumeRefusesADeploySupersededByANewerOne(t *testing.T) { ) base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/ob/sample/journal") { + if strings.Contains(cmd, "for f in") && strings.Contains(cmd, "/var/lib/onebox/app/journal") { return transport.Result{Stdout: journalMarkerLine + engineTestDeployReleaseID + ".jsonl\n" + jr}, true } return base(cmd) diff --git a/internal/engine/roll.go b/internal/engine/roll.go index 3e58f093..2a27ce26 100644 --- a/internal/engine/roll.go +++ b/internal/engine/roll.go @@ -46,7 +46,7 @@ func (e *Engine) composeCmdForProject(remoteComposePath, remoteProjectDir string return cmd } -// newcomerIDs finds RUNNING containers of a specific release — the ob.release +// newcomerIDs finds RUNNING containers of a specific release — the onebox.release // label render injects is what makes resume possible. Running-only is what the // surge loop needs: a newcomer that exited has not converged, and counting it // toward the desired count would end the roll with a dead replica. @@ -65,7 +65,7 @@ func (e *Engine) newcomerIDsAnyState(ctx context.Context, svc, releaseID, genera // generation narrows a newcomer further than the release label can. Rotating a // secret replaces containers WITHIN one release, so every container in that -// roll — old and new — carries the same ob.release. Only the generation label +// roll — old and new — carries the same onebox.release. Only the generation label // tells them apart, and without it the first pass would adopt the containers it // is supposed to replace. func (e *Engine) newcomerIDsWith(ctx context.Context, svc, releaseID, generation string, anyState bool) ([]string, error) { @@ -75,9 +75,9 @@ func (e *Engine) newcomerIDsWith(ctx context.Context, svc, releaseID, generation } filters := " --filter label=com.docker.compose.project=" + q(e.Spec.Name) + " --filter label=com.docker.compose.service=" + q(svc) + - " --filter label=ob.release=" + q(releaseID) + " --filter label=onebox.release=" + q(releaseID) if generation != "" { - filters += " --filter label=ob.secret-generation=" + q(generation) + filters += " --filter label=onebox.secret-generation=" + q(generation) } res, err := e.T.Run(ctx, ps+filters) if err != nil { @@ -433,12 +433,10 @@ func (e *Engine) nameOf(ctx context.Context, id string) (string, error) { // slotNames is the target name set, from the naming contract. // // It is the contract's names and not Compose's, and not a local invention -// either. Container names are host-global: two applications that each have a -// `web` workload would both want `web-1`, and the second would fail to start -// or, worse, be renamed over the first. The contract carries the application -// in every name for exactly that reason, and preflight checks those names for -// collisions — so a rollout that used different ones would be checking for -// collisions it then does not create, and creating collisions it never checked. +// either. Preflight checks the contract's names for collisions with containers +// Onebox does not own — anything else the operator runs on the host — so a +// rollout that used different ones would be checking for collisions it then +// does not create, and creating collisions it never checked. func (e *Engine) slotNames(workload string, desired int) []string { n := e.names() out := make([]string, desired) diff --git a/internal/engine/roll_test.go b/internal/engine/roll_test.go index ed155edd..56c6ee72 100644 --- a/internal/engine/roll_test.go +++ b/internal/engine/roll_test.go @@ -95,7 +95,7 @@ func replicaFakeWithStopped(desired int, oldIDs []string, oldNames map[string]st // -aq before -q: "docker ps -aq" does not contain "docker ps -q". case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "status=exited"): return lines(stopped), true - case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "ob.release="): + case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "onebox.release="): return lines(news), true case strings.Contains(cmd, "docker ps -aq") && strings.Contains(cmd, "service='web'"): return lines(append(append(append([]string{}, olds...), news...), stopped...)), true @@ -107,7 +107,7 @@ func replicaFakeWithStopped(desired int, oldIDs []string, oldNames map[string]st } } return transport.Result{Stdout: "running\n"}, true - case strings.Contains(cmd, "docker ps -q") && strings.Contains(cmd, "ob.release="): + case strings.Contains(cmd, "docker ps -q") && strings.Contains(cmd, "onebox.release="): return transport.Result{Stdout: strings.Join(news, "\n") + "\n"}, true case strings.Contains(cmd, "docker ps -q") && strings.Contains(cmd, "service='web'"): return transport.Result{Stdout: strings.Join(append(append([]string{}, olds...), news...), "\n") + "\n"}, true @@ -146,7 +146,7 @@ func noSleep(time.Duration) {} func TestRollRoleRenamesSurvivorToService(t *testing.T) { f := rollFake() e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("roll: %v\n%s", err, strings.Join(f.Commands, "\n")) } early, rm, final := -1, -1, -1 @@ -174,14 +174,14 @@ func TestRollRoleRenamesSurvivorToService(t *testing.T) { func TestRollRoleResumeAdoptsExistingNewcomer(t *testing.T) { f := replicaFake(1, []string{"OLD1"}, map[string]string{"OLD1": "web"}, true) e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("resume roll: %v\n%s", err, strings.Join(f.Commands, "\n")) } seq := strings.Join(f.Commands, "\n") if strings.Contains(seq, "--scale") || strings.Contains(seq, "pull --quiet") { t.Fatalf("resume must not re-scale or re-pull:\n%s", seq) } - if !strings.Contains(seq, "touch /tmp/ob-drain") || !strings.Contains(seq, "docker stop -t 30 OLD1") { + if !strings.Contains(seq, "touch /tmp/onebox-drain") || !strings.Contains(seq, "docker stop -t 30 OLD1") { t.Fatalf("resume must continue drain+stop of old:\n%s", seq) } } @@ -189,15 +189,15 @@ func TestRollRoleResumeAdoptsExistingNewcomer(t *testing.T) { func TestRollRoleCommandSequence(t *testing.T) { f := rollFake() e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("roll: %v\n%s", err, strings.Join(f.Commands, "\n")) } seq := strings.Join(f.Commands, "\n") ordered := []string{ - "docker compose -p sample --project-directory '/var/lib/ob/sample/releases/R1' -f '/var/lib/ob/sample/releases/R1/compose.yaml' pull --quiet web", + "docker compose -p sample --project-directory '/var/lib/onebox/app/releases/R1' -f '/var/lib/onebox/app/releases/R1/compose.yaml' pull --quiet web", "up -d --no-deps --no-recreate --scale web=2 web", "docker rename NEW1 sample-web-new", - "docker exec OLD1 touch /tmp/ob-drain", + "docker exec OLD1 touch /tmp/onebox-drain", "docker stop -t 30 OLD1", "docker rm OLD1", } @@ -213,7 +213,7 @@ func TestRollRoleCommandSequence(t *testing.T) { last = i } // Drain MUST precede stop so SIGTERM never races the proxy. - if strings.Index(seq, "ob-drain") > strings.Index(seq, "docker stop") { + if strings.Index(seq, "onebox-drain") > strings.Index(seq, "docker stop") { t.Fatal("drain must happen before stop") } } @@ -252,7 +252,7 @@ func TestRollRoleTwoReplicasCleanSlots(t *testing.T) { r.Replicas = 2 cfg.Workloads["web"] = r e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("2-replica roll: %v\n%s", err, strings.Join(f.Commands, "\n")) } seq := strings.Join(f.Commands, "\n") @@ -278,7 +278,7 @@ func TestRollRoleDrainGraceConfigurable(t *testing.T) { r.Drain = &app.Drain{Grace: "8s"} cfg.Workloads["web"] = r e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("roll: %v", err) } seq := strings.Join(f.Commands, "\n") @@ -290,7 +290,7 @@ func TestRollRoleDrainGraceConfigurable(t *testing.T) { } } -// The ob-side health poll defaults to 2s — matching the generated healthcheck +// The runner-side health poll defaults to 2s — matching the generated healthcheck // cadence — so joins and drain flips are detected promptly; within stays 120s. // Declared values still win (asserted by the sequence tests). func TestReadyTimingDefaults(t *testing.T) { @@ -320,7 +320,7 @@ func TestRollRoleReplacesStoppedReplicas(t *testing.T) { stopped := []string{"STOP1", "STOP2", "STOP3"} f := replicaFakeWithStopped(3, nil, nil, false, stopped) e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("roll over stopped replicas: %v", err) } joined := strings.Join(f.Commands, "\n") @@ -345,7 +345,7 @@ func TestRollRoleReplacesStoppedReplicas(t *testing.T) { func TestRollRoleSweepsOnlyStoppedReplicas(t *testing.T) { f := replicaFakeWithStopped(1, []string{"OLD1"}, map[string]string{"OLD1": "web"}, false, []string{"STOP1"}) e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml"); err != nil { + if err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml"); err != nil { t.Fatalf("roll: %v", err) } joined := strings.Join(f.Commands, "\n") @@ -377,7 +377,7 @@ func TestRollRoleReportsNewcomerThatExited(t *testing.T) { return inner(cmd) } e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - err := e.RollRole(context.Background(), "web", "/var/lib/ob/sample/releases/R1/compose.yaml") + err := e.RollRole(context.Background(), "web", "/var/lib/onebox/app/releases/R1/compose.yaml") if err == nil || !strings.Contains(err.Error(), "exited before becoming healthy") { t.Fatalf("roll error = %v, want the newcomer's own exit", err) } diff --git a/internal/engine/rollback_test.go b/internal/engine/rollback_test.go index 5f9759e6..5cc0fef5 100644 --- a/internal/engine/rollback_test.go +++ b/internal/engine/rollback_test.go @@ -72,7 +72,7 @@ func TestRollbackReplaysSnapshotChoreography(t *testing.T) { if strings.Contains(cmd, "ls -1") { return transport.Result{Stdout: "20260101-000000-aaa111\n20260102-000000-bbb222\n"}, true } - if strings.Contains(cmd, "ob.snapshot.yml") { + if strings.Contains(cmd, "onebox.snapshot.yml") { return transport.Result{Stdout: oldSnapshot}, true } return base(cmd) @@ -111,7 +111,7 @@ func TestRollbackRefusesWithoutUsableSnapshot(t *testing.T) { return transport.Result{Stdout: "releases/20260102-000000-bbb222\n"}, true case strings.Contains(cmd, "ls -1"): return transport.Result{Stdout: "20260101-000000-aaa111\n20260102-000000-bbb222\n"}, true - case strings.Contains(cmd, "ob.snapshot.yml"): + case strings.Contains(cmd, "onebox.snapshot.yml"): return tt.snapshot, true } return base(cmd) @@ -121,7 +121,7 @@ func TestRollbackRefusesWithoutUsableSnapshot(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("rollback error = %v, want %q", err, tt.want) } - if strings.Contains(strings.Join(f.Commands, "\n"), "ob-fenced") { + if strings.Contains(strings.Join(f.Commands, "\n"), "onebox-fenced") { t.Fatalf("rollback must fail before mutation:\n%s", strings.Join(f.Commands, "\n")) } }) @@ -170,7 +170,7 @@ func TestRepeatedRollbackFollowsTheNewPredecessor(t *testing.T) { } return transport.Result{Stdout: "releases/" + current + "\n"}, true } - if strings.Contains(command, "/releases/"+rollbackPreviousID+"/ob.snapshot.yml") { + if strings.Contains(command, "/releases/"+rollbackPreviousID+"/onebox.snapshot.yml") { return transport.Result{Stdout: oldSnapshot}, true } return base(command) diff --git a/internal/engine/schedule.go b/internal/engine/schedule.go index 145e66f6..f093a300 100644 --- a/internal/engine/schedule.go +++ b/internal/engine/schedule.go @@ -44,8 +44,7 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { return err } n := e.names() - prefixes := n.ScheduledJobUnitPrefixes() - prefix := prefixes[0] + prefix := app.JobUnitPrefix // What is installed now, so anything no longer declared can go. res, err := e.T.Run(ctx, "systemctl list-unit-files --no-legend --type=timer 2>/dev/null | awk '{print $1}'") @@ -55,30 +54,12 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { installed := map[string]bool{} for _, line := range strings.Split(res.Stdout, "\n") { unit := strings.TrimSpace(line) - // Backups own their own namespace and reconciles it separately. Its - // units begin "ob-backup-", which also begins with this prefix when - // the application is literally named "backup" — belt and braces, - // because the failure mode is a deploy silently deleting every - // scheduled backup. - if strings.HasPrefix(unit, app.BackupUnitPrefix) { - continue - } if !strings.HasSuffix(unit, ".timer") || !unitName.MatchString(unit) { continue } bare := strings.TrimSuffix(unit, ".timer") - if matchesRuntimePrefix(bare, prefix) { + if strings.HasPrefix(bare, prefix) { installed[bare] = true - continue - } - if matchesAnyPrefix(unit, prefixes[1:]) { - owned, err := e.scheduleUnitBelongsToOwner(ctx, bare, false) - if err != nil { - return err - } - if owned { - installed[bare] = true - } } } @@ -243,7 +224,7 @@ func scheduleRunnerScript(application string, job app.ScheduledJob, names app.Na projectDir := q(names.CurrentLink()) compose := "/usr/bin/docker compose -p " + q(application) + " --project-directory " + projectDir + " -f " + projectDir + "/" + q("compose.yaml") + scheduleRuntimeEnvArgs(projectDir, runtimeEnvFiles) + - " run --rm --no-deps \"$@\" --name " + q(container) + " " + q(job.Name) + " run --rm --no-deps \"$@\" --label " + q(ExecutionJobLabel+"="+job.Name) + " --name " + q(container) + " " + q(job.Name) lines := []string{ "#!/bin/sh", "# Written by Onebox. Edits are overwritten on the next deploy.", @@ -274,13 +255,18 @@ func scheduleRunnerScript(application string, job app.ScheduledJob, names app.Na return strings.Join(lines, "\n") } +// ExecutionJobLabel names the job a one-off container runs. Every scheduled run +// carries it, durable or not, so a container a crash left behind is still +// provably this job's when the durable runner has to reclaim it. +const ExecutionJobLabel = "onebox.execution.job" + func pinnedScheduleRunnerScript(application string, job app.ScheduledJob, names app.Names, applicationLock string, runtimeEnvFiles []app.EnvFile, lockTTL time.Duration, triggerUnit bool) string { scheduleDir := names.AppDir() + "/schedule" container := names.Container(job.Name, 1) projectDir := `"$release_dir"` compose := "/usr/bin/docker compose -p " + q(application) + " --project-directory " + projectDir + " -f " + projectDir + "/" + q("compose.yaml") + scheduleRuntimeEnvArgs(projectDir, runtimeEnvFiles) + - " run --rm --no-deps \"$@\" --name " + q(container) + " " + q(job.Name) + " run --rm --no-deps \"$@\" --label " + q(ExecutionJobLabel+"="+job.Name) + " --name " + q(container) + " " + q(job.Name) lines := []string{ "#!/bin/sh", "# Written by Onebox. Edits are overwritten on the next deploy.", @@ -296,8 +282,8 @@ func pinnedScheduleRunnerScript(application string, job app.ScheduledJob, names "release=${release_dir##*/}", "if ! printf '%s\\n' \"$release\" | grep -Eq '^[0-9]{8}-[0-9]{6}-[0-9A-Za-z_-]+$'; then echo 'onebox: current release identity is invalid' >&2; exit 1; fi", "if [ ! -f \"$release_dir/compose.yaml\" ]; then echo 'onebox: pinned release has no compose.yaml' >&2; exit 1; fi", - "exec 7>>\"$release_dir/.ob-schedule.lease\"", - "chmod 600 \"$release_dir/.ob-schedule.lease\"", + "exec 7>>\"$release_dir/.onebox-schedule.lease\"", + "chmod 600 \"$release_dir/.onebox-schedule.lease\"", "/usr/bin/flock --shared 7", // The immutable release is leased, so the writer rendezvous is complete. // Container cleanup and state bookkeeping are per-job work and must not @@ -667,7 +653,7 @@ const scheduleNotificationTimestamp = "__ONEBOX_SCHEDULE_TIMESTAMP__" // and such an entry has no _SYSTEMD_UNIT at all; `journalctl -u` would never // find it. Explicit fields survive that race, and `logger --journald` is // util-linux, which flock already requires. -const scheduleRunIdentifier = "ob-run" +const scheduleRunIdentifier = "onebox-run" // scheduleRunRecordLines finalises the run the runner started. This lives in // ExecStopPost because only systemd knows how the run ended: a timed-out @@ -758,9 +744,6 @@ func (e *Engine) scheduleNotifier(job app.ScheduledJob) (string, error) { cleanup = durableContainerStop(e.names().Container(job.Name, 1), job.ShutdownGrace) } environment := e.Opts.Environment - if environment == "" { - environment = e.Spec.Env - } lines := []string{ "#!/bin/sh", "# Written by Onebox. Edits are overwritten on the next deploy.", @@ -936,15 +919,11 @@ func (e *Engine) RemoveSchedules(ctx context.Context) error { // Both namespaces this application installs into. // // Backup timers are deliberately named outside the job scheduler's - // namespace — app.BackupTimerForEnvironment explains why: a deploy used to - // treat them as "no longer declared" and delete every scheduled backup. - // Teardown is the opposite case and needs both, and matching only the job - // prefix meant `ob destroy` left ob-backup---- - // timers loaded and firing against a release directory it had just - // deleted. They belong to this application and they go with it. - n := e.names() - jobPrefixes := n.ScheduledJobUnitPrefixes() - backupPrefixes := n.BackupUnitPrefixes() + // namespace — app.JobUnitPrefix explains why. Teardown is the opposite case + // and needs both: matching only the job prefix once left backup timers + // loaded and firing against a release directory `ob destroy` had just + // deleted. They belong to this application and they go with it: the host + // owner record keeps a host to one application. res, err := e.T.Run(ctx, "systemctl list-unit-files --no-legend --type=timer 2>/dev/null | awk '{print $1}'") if err != nil { return err @@ -956,26 +935,7 @@ func (e *Engine) RemoveSchedules(ctx context.Context) error { continue } unit = strings.TrimSuffix(unit, ".timer") - var owned bool - if strings.HasPrefix(unit, app.BackupUnitPrefix) { - switch { - case matchesRuntimePrefix(unit, backupPrefixes[0]): - owned = true - case matchesAnyPrefix(unit, backupPrefixes[1:]): - owned, err = e.scheduleUnitBelongsToOwner(ctx, unit, true) - } - } else { - switch { - case matchesRuntimePrefix(unit, jobPrefixes[0]): - owned = true - case matchesAnyPrefix(unit, jobPrefixes[1:]): - owned, err = e.scheduleUnitBelongsToOwner(ctx, unit, false) - } - } - if err != nil { - return err - } - if owned { + if strings.HasPrefix(unit, app.JobUnitPrefix) || strings.HasPrefix(unit, app.BackupUnitPrefix) { units = append(units, unit) } } @@ -1019,49 +979,3 @@ func (e *Engine) removeScheduleUnit(ctx context.Context, unit string) error { } return errors.Join(errs...) } - -func matchesAnyPrefix(name string, prefixes []string) bool { - for _, prefix := range prefixes { - if strings.HasPrefix(name, prefix) { - return true - } - } - return false -} - -// matchesRuntimePrefix distinguishes a component boundary from the first half -// of an escaped hyphen. For example, ob-acme- owns ob-acme-nightly but not -// ob-acme--web-nightly, whose application component is acme-web. -func matchesRuntimePrefix(name, prefix string) bool { - return strings.HasPrefix(name, prefix) && len(name) > len(prefix) && name[len(prefix)] != '-' -} - -// scheduleUnitBelongsToOwner resolves an ambiguous old unit name from the -// unambiguous owner embedded in its service body. New backup units include the -// environment as well; the application-only suffix remains migration input for -// units written before environments were recorded there. -// Missing or unfamiliar files are left alone: ownership must be proved before -// reconciliation removes a host-global unit. -func (e *Engine) scheduleUnitBelongsToOwner(ctx context.Context, unit string, backup bool) (bool, error) { - res, err := e.T.Run(ctx, "cat "+q("/etc/systemd/system/"+unit+".service")+" 2>/dev/null") - if err != nil { - return false, fmt.Errorf("inspect legacy schedule %s: %w", unit, err) - } - if res.ExitCode != 0 { - return false, nil - } - for _, line := range strings.Split(res.Stdout, "\n") { - if backup { - if strings.HasPrefix(line, "Description=Onebox backup ") && - (strings.HasSuffix(line, " ("+e.Spec.Name+"/"+e.Opts.Environment+")") || - strings.HasSuffix(line, " ("+e.Spec.Name+")")) { - return true, nil - } - continue - } - if strings.HasPrefix(line, "Description=Onebox scheduled job ") && strings.HasSuffix(line, " for "+e.Spec.Name) { - return true, nil - } - } - return false, nil -} diff --git a/internal/engine/schedule_apply.go b/internal/engine/schedule_apply.go index 60e6e242..304365ba 100644 --- a/internal/engine/schedule_apply.go +++ b/internal/engine/schedule_apply.go @@ -53,7 +53,7 @@ func (e *Engine) ScheduleApply(ctx context.Context, operationID string) (err err } jw := &journal.Writer{ - T: e.T, Names: e.names(), DeployID: operationID, Epoch: epoch, + T: e.T, Dir: journal.Dir(e.names()), DeployID: operationID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner, } diff --git a/internal/engine/schedule_execution.go b/internal/engine/schedule_execution.go index 1cf1440a..95c13d71 100644 --- a/internal/engine/schedule_execution.go +++ b/internal/engine/schedule_execution.go @@ -31,7 +31,7 @@ func invalidateExecutionCommand(root string) string { } func durableContainerStop(container string, grace time.Duration) string { - return "if [ \"$(/usr/bin/docker inspect --format '{{ index .Config.Labels \"ob.execution.invocation\" }}' " + q(container) + " 2>/dev/null)\" = \"${INVOCATION_ID:-missing}\" ]; then " + scheduleContainerStop(container, grace) + "; fi" + return "if [ \"$(/usr/bin/docker inspect --format '{{ index .Config.Labels \"onebox.execution.invocation\" }}' " + q(container) + " 2>/dev/null)\" = \"${INVOCATION_ID:-missing}\" ]; then " + scheduleContainerStop(container, grace) + "; fi" } type executionDefinition struct { @@ -126,7 +126,7 @@ func (e *Engine) durableScheduleRunner(job app.ScheduledJob, envFiles []app.EnvF "release_dir=$(readlink -f "+q(n.CurrentLink())+")", "release=${release_dir##*/}", "[ \"${release_dir%/*}\" = "+q(n.ReleasesDir())+" ] || exit 1", - "exec 7>>\"$release_dir/.ob-schedule.lease\"", "chmod 600 \"$release_dir/.ob-schedule.lease\"", "/usr/bin/flock --shared 7") + "exec 7>>\"$release_dir/.onebox-schedule.lease\"", "chmod 600 \"$release_dir/.onebox-schedule.lease\"", "/usr/bin/flock --shared 7") lines = append(lines, scheduleRunPreamble(true)...) lines = append(lines, schedulePlannedBindingLines()...) lines = append(lines, "phase=running", "write_state 1", @@ -219,7 +219,7 @@ func (e *Engine) ExecutionAbandon(ctx context.Context, operation, id string) (er if err := e.WriteFence(ctx, operation, epoch); err != nil { return err } - writer := &journal.Writer{T: e.T, Names: e.names(), DeployID: operation, Epoch: epoch, + writer := &journal.Writer{T: e.T, Dir: journal.Dir(e.names()), DeployID: operation, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} record := journal.Record{Phase: "execution-abandon", Event: "start", Status: "ok", Target: id, TargetKind: "job"} if err := writer.Append(ctx, record); err != nil { diff --git a/internal/engine/schedule_execution_test.go b/internal/engine/schedule_execution_test.go index 19e1effe..965ebd3f 100644 --- a/internal/engine/schedule_execution_test.go +++ b/internal/engine/schedule_execution_test.go @@ -86,7 +86,7 @@ func TestDurableNotifierGracefullyStopsOnlyItsInvocation(t *testing.T) { t.Fatal(err) } for _, want := range []string{ - `ob.execution.invocation`, `${INVOCATION_ID:-missing}`, + `onebox.execution.invocation`, `${INVOCATION_ID:-missing}`, `docker kill --signal TERM 'sample-refresh-1'`, `deadline=$(($(date -u '+%s')+12))`, `docker kill --signal KILL 'sample-refresh-1'`, @@ -95,8 +95,8 @@ func TestDurableNotifierGracefullyStopsOnlyItsInvocation(t *testing.T) { t.Fatalf("durable notifier is missing %q:\n%s", want, script) } } - state := strings.Index(script, "state='/var/lib/ob/sample/schedule/refresh.state'") - cleanup := strings.Index(script, "ob.execution.invocation") + state := strings.Index(script, "state='/var/lib/onebox/app/schedule/refresh.state'") + cleanup := strings.Index(script, "onebox.execution.invocation") if state < 0 || cleanup < 0 || state >= cleanup { t.Fatalf("durable notifier must initialize state before fallback cleanup:\n%s", script) } diff --git a/internal/engine/schedule_history_test.go b/internal/engine/schedule_history_test.go index f988415a..a9d63717 100644 --- a/internal/engine/schedule_history_test.go +++ b/internal/engine/schedule_history_test.go @@ -56,7 +56,7 @@ func TestScheduleHistoryReadsTheUnitJournalNewestFirst(t *testing.T) { t.Fatalf("records = %#v", records) } seq := strings.Join(f.Commands, "\n") - for _, want := range []string{"journalctl SYSLOG_IDENTIFIER=ob-run ONEBOX_UNIT='ob-sample-nightly'", "-o cat", "-r", "-n 20", "--no-pager"} { + for _, want := range []string{"journalctl SYSLOG_IDENTIFIER=onebox-run ONEBOX_UNIT='onebox-job-nightly'", "-o cat", "-r", "-n 20", "--no-pager"} { if !strings.Contains(seq, want) { t.Fatalf("history read is missing %q:\n%s", want, seq) } @@ -78,7 +78,7 @@ func TestScheduleListReadsTimerState(t *testing.T) { if err != nil { t.Fatal(err) } - if len(listing) != 1 || listing[0].Unit != "ob-sample-nightly" || listing[0].TimerState != "active" || + if len(listing) != 1 || listing[0].Unit != "onebox-job-nightly" || listing[0].TimerState != "active" || listing[0].NextRun != "Sat 2026-09-06 02:00:00 UTC" || listing[0].LastTrigger != "Fri 2026-09-05 02:00:00 UTC" || listing[0].Cron != "0 2 * * *" || listing[0].MaxAttempts != 1 || listing[0].RetryBudget != "0s" { t.Fatalf("listing = %#v", listing) @@ -88,7 +88,7 @@ func TestScheduleListReadsTimerState(t *testing.T) { func TestScheduleLogsTargetsOneInvocation(t *testing.T) { e, f := scheduledFixture(t) f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "SYSLOG_IDENTIFIER=ob-run") { + if strings.Contains(cmd, "SYSLOG_IDENTIFIER=onebox-run") { return transport.Result{Stdout: sampleRunRecords}, true } return transport.Result{}, false diff --git a/internal/engine/schedule_pause.go b/internal/engine/schedule_pause.go index 5da63ea0..3a420bf0 100644 --- a/internal/engine/schedule_pause.go +++ b/internal/engine/schedule_pause.go @@ -87,7 +87,7 @@ func (e *Engine) setSchedulePause(ctx context.Context, operationID, name, reason phase, verb = "schedule-pause", "paused" } writer := &journal.Writer{ - T: e.T, Names: e.names(), DeployID: operationID, Epoch: epoch, Operator: journal.DefaultOperator(), + T: e.T, Dir: journal.Dir(e.names()), DeployID: operationID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner, } record := journal.Record{Phase: phase, Event: "start", Status: "ok", Target: name, TargetKind: "job", Reason: reason} diff --git a/internal/engine/schedule_pause_test.go b/internal/engine/schedule_pause_test.go index 7b0f6db9..19d85191 100644 --- a/internal/engine/schedule_pause_test.go +++ b/internal/engine/schedule_pause_test.go @@ -35,7 +35,7 @@ func TestSchedulePauseStopsTheTimerAndRecordsWhy(t *testing.T) { t.Fatalf("pause: %v\n%s", err, strings.Join(f.Commands, "\n")) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "systemctl disable --now 'ob-sample-nightly.timer'") { + if !strings.Contains(seq, "systemctl disable --now 'onebox-job-nightly.timer'") { t.Fatalf("the timer was not stopped:\n%s", seq) } if !strings.Contains(seq, "nightly.paused") { @@ -83,10 +83,10 @@ func TestScheduleResumeClearsTheMarkerAndStartsTheTimer(t *testing.T) { t.Fatalf("resume: %v", err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "rm -f '/var/lib/ob/sample/schedule/nightly.paused'") { + if !strings.Contains(seq, "rm -f '/var/lib/onebox/app/schedule/nightly.paused'") { t.Fatalf("the marker was not removed:\n%s", seq) } - if !strings.Contains(seq, "systemctl enable --now 'ob-sample-nightly.timer'") { + if !strings.Contains(seq, "systemctl enable --now 'onebox-job-nightly.timer'") { t.Fatalf("the timer was not started:\n%s", seq) } if !strings.Contains(seq, `"phase":"schedule-resume"`) { @@ -116,14 +116,14 @@ func TestSyncSchedulesLeavesAPausedTimerStopped(t *testing.T) { t.Fatalf("sync: %v", err) } seq := strings.Join(f.Commands, "\n") - if strings.Contains(seq, "systemctl enable --now ob-sample-nightly.timer") { + if strings.Contains(seq, "systemctl enable --now onebox-job-nightly.timer") { t.Fatalf("reconciliation re-enabled a paused timer:\n%s", seq) } - if !strings.Contains(seq, "systemctl disable --now ob-sample-nightly.timer") { + if !strings.Contains(seq, "systemctl disable --now onebox-job-nightly.timer") { t.Fatalf("a paused job's timer was left running:\n%s", seq) } // The units are still written, so a fix lands even while paused. - if artifacts := strings.Join(f.Inputs, "\n"); !strings.Contains(artifacts, "ob-sample-nightly.run") && + if artifacts := strings.Join(f.Inputs, "\n"); !strings.Contains(artifacts, "onebox-job-nightly.run") && !strings.Contains(artifacts, "TimeoutStartSec") { t.Fatalf("a paused job stopped receiving unit updates:\n%s", artifacts) } @@ -224,7 +224,7 @@ func TestPausedJobsCountsAMarkerWithNoFields(t *testing.T) { } // Reading the fields is not enough: the host has to be asked whether the // file is there, because a marker with no readable fields is still a pause. - if !strings.Contains(strings.Join(f.Commands, "\n"), "[ -e '/var/lib/ob/sample/schedule/nightly.paused' ]") { + if !strings.Contains(strings.Join(f.Commands, "\n"), "[ -e '/var/lib/onebox/app/schedule/nightly.paused' ]") { t.Fatalf("the read does not test whether the marker exists:\n%s", strings.Join(f.Commands, "\n")) } } @@ -250,10 +250,10 @@ func TestScheduleResumeClearsTheMarkerBeforeStartingTheTimer(t *testing.T) { } removed, enabled := -1, -1 for i, cmd := range f.Commands { - if removed < 0 && strings.Contains(cmd, "rm -f '/var/lib/ob/sample/schedule/nightly.paused'") { + if removed < 0 && strings.Contains(cmd, "rm -f '/var/lib/onebox/app/schedule/nightly.paused'") { removed = i } - if enabled < 0 && strings.Contains(cmd, "systemctl enable --now 'ob-sample-nightly.timer'") { + if enabled < 0 && strings.Contains(cmd, "systemctl enable --now 'onebox-job-nightly.timer'") { enabled = i } } @@ -287,7 +287,7 @@ func TestSchedulePauseRefusesToOverwriteAnExistingPause(t *testing.T) { t.Fatalf("the refusal does not say what the standing pause is: %v", err) } for _, cmd := range f.Commands { - if strings.Contains(cmd, "cat > '/var/lib/ob/sample/schedule/nightly.paused'") { + if strings.Contains(cmd, "cat > '/var/lib/onebox/app/schedule/nightly.paused'") { t.Fatalf("the standing marker was overwritten: %s", cmd) } } @@ -344,10 +344,10 @@ func TestSyncSchedulesRemovesThePauseMarkerOfAnUndeclaredJob(t *testing.T) { t.Fatalf("sync: %v", err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "'/var/lib/ob/sample/schedule/retired.paused'") { + if !strings.Contains(seq, "'/var/lib/onebox/app/schedule/retired.paused'") { t.Fatalf("the orphaned marker was left behind:\n%s", seq) } - if strings.Contains(seq, "rm -f '/var/lib/ob/sample/schedule/nightly.paused'") { + if strings.Contains(seq, "rm -f '/var/lib/onebox/app/schedule/nightly.paused'") { t.Fatalf("reconciliation deleted a declared job's pause:\n%s", seq) } } diff --git a/internal/engine/schedule_run.go b/internal/engine/schedule_run.go index 3e168581..406bf78e 100644 --- a/internal/engine/schedule_run.go +++ b/internal/engine/schedule_run.go @@ -168,7 +168,7 @@ func (e *Engine) scheduleRun(ctx context.Context, operationID, name string, inpu }() writer := &journal.Writer{ - T: e.T, Names: e.names(), DeployID: operationID, Epoch: epoch, Operator: journal.DefaultOperator(), + T: e.T, Dir: journal.Dir(e.names()), DeployID: operationID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner, ApprovalDigest: e.Opts.ApprovalDigest, ApprovalClass: e.Opts.ApprovalClass, ApprovedBy: e.Opts.ApprovedBy, ApprovalSource: e.Opts.ApprovalSource, diff --git a/internal/engine/schedule_run_test.go b/internal/engine/schedule_run_test.go index 3df4c36b..da34a42b 100644 --- a/internal/engine/schedule_run_test.go +++ b/internal/engine/schedule_run_test.go @@ -35,13 +35,13 @@ func TestScheduleRunWritesInputsJournalsThenStartsAfterReleasingTheLock(t *testi if err != nil { t.Fatalf("schedule run: %v\n%s", err, strings.Join(f.Commands, "\n")) } - if !result.Started || result.Unit != "ob-sample-sync" || result.Inputs["SOURCE"] != "prices" || result.Operation != "20260905-151200-schedule_run-7c1e" { + if !result.Started || result.Unit != "onebox-job-sync" || result.Inputs["SOURCE"] != "prices" || result.Operation != "20260905-151200-schedule_run-7c1e" { t.Fatalf("result = %#v", result) } seq := strings.Join(f.Commands, "\n") inputs := strings.Index(seq, "sync.inputs") - start := strings.Index(seq, "systemctl start --no-block 'ob-sample-sync.service'") - release := strings.LastIndex(seq, "rm -f '/var/lib/ob/sample/lock'") + start := strings.Index(seq, "systemctl start --no-block 'onebox-job-sync.service'") + release := strings.LastIndex(seq, "rm -f '/var/lib/onebox/app/lock'") if inputs < 0 || start < 0 || release < 0 || !(inputs < release && release < start) { t.Fatalf("expected inputs write, lock release, then start:\n%s", seq) } @@ -107,7 +107,7 @@ func TestPlannedJobRunStagesItsExactBindingAndDetachesToSystemd(t *testing.T) { if err != nil { t.Fatalf("planned job run: %v\n%s", err, strings.Join(f.Commands, "\n")) } - if !result.Started || result.Operation != operation || result.Unit != "ob-sample-refresh" { + if !result.Started || result.Operation != operation || result.Unit != "onebox-job-refresh" { t.Fatalf("result = %#v", result) } written := strings.Join(f.Inputs, "\n") @@ -121,10 +121,10 @@ func TestPlannedJobRunStagesItsExactBindingAndDetachesToSystemd(t *testing.T) { } } commands := strings.Join(f.Commands, "\n") - if !strings.Contains(commands, "grep -Fq '"+sealedManualJobBindingMarker+"' '/etc/systemd/system/ob-sample-refresh.run'") { + if !strings.Contains(commands, "grep -Fq '"+sealedManualJobBindingMarker+"' '/etc/systemd/system/onebox-job-refresh.run'") { t.Fatalf("planned job did not verify the installed runner protocol:\n%s", commands) } - if !strings.Contains(commands, "systemctl start --no-block 'ob-sample-refresh.service'") { + if !strings.Contains(commands, "systemctl start --no-block 'onebox-job-refresh.service'") { t.Fatalf("planned job was not detached to systemd:\n%s", commands) } for _, want := range []string{`"approval_digest":"approval-digest"`, `"approval_class":"strong"`} { @@ -197,9 +197,9 @@ func TestScheduleRunWaitReportsTheRecordAndFailsOnAnyOtherOutcome(t *testing.T) return transport.Result{Stdout: "ok\n"}, true case strings.Contains(cmd, "systemctl is-active"): return transport.Result{Stdout: "inactive\n"}, true - case strings.Contains(cmd, "systemctl start 'ob-sample-sync.service'"): + case strings.Contains(cmd, "systemctl start 'onebox-job-sync.service'"): return transport.Result{}, true - case strings.Contains(cmd, "SYSLOG_IDENTIFIER=ob-run"): + case strings.Contains(cmd, "SYSLOG_IDENTIFIER=onebox-run"): // Newest first: a stale record from an earlier run precedes ours, // and must not be mistaken for it. return transport.Result{Stdout: `{"run":"ffffffffffffffffffffffffffffffff","job":"sync","trigger":"timer","operation":"","started_at":"2026-09-05T14:00:01Z","finished_at":"2026-09-05T14:00:02Z","duration_s":1,"attempts":1,"exit_status":0,"outcome":"success","inputs":{}}` + "\n" + @@ -237,7 +237,7 @@ func TestScheduleRunDiscardsItsInputsWhenTheStartFails(t *testing.T) { case strings.Contains(cmd, "systemctl is-active"): return transport.Result{Stdout: "inactive\n"}, true case strings.Contains(cmd, "systemctl start"): - return transport.Result{ExitCode: 5, Stderr: "Unit ob-sample-sync.service not found."}, true + return transport.Result{ExitCode: 5, Stderr: "Unit onebox-job-sync.service not found."}, true } return base(cmd) } @@ -247,7 +247,7 @@ func TestScheduleRunDiscardsItsInputsWhenTheStartFails(t *testing.T) { t.Fatalf("start failure was not reported: %v", err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "rm -f '/var/lib/ob/sample/schedule/sync.inputs'") { + if !strings.Contains(seq, "rm -f '/var/lib/onebox/app/schedule/sync.inputs'") { t.Fatalf("a failed start left the inputs file pending:\n%s", seq) } } @@ -348,7 +348,7 @@ func TestScheduleRunJournalsAFailedRequestAsFailed(t *testing.T) { case strings.Contains(cmd, "systemctl is-active"): return transport.Result{Stdout: "inactive\n"}, true case strings.Contains(cmd, "systemctl start"): - return transport.Result{ExitCode: 5, Stderr: "Unit ob-sample-sync.service not found."}, true + return transport.Result{ExitCode: 5, Stderr: "Unit onebox-job-sync.service not found."}, true } return base(cmd) } diff --git a/internal/engine/schedule_test.go b/internal/engine/schedule_test.go index 68c95f67..98c43be0 100644 --- a/internal/engine/schedule_test.go +++ b/internal/engine/schedule_test.go @@ -44,7 +44,7 @@ func TestSyncSchedulesRetainsManualScheduledJob(t *testing.T) { t.Fatalf("sync schedules: %v", err) } seq := strings.Join(f.Commands, "\n") - for _, want := range []string{"ob-sample-nightly.service", "ob-sample-nightly.timer", "systemctl enable --now ob-sample-nightly.timer"} { + for _, want := range []string{"onebox-job-nightly.service", "onebox-job-nightly.timer", "systemctl enable --now onebox-job-nightly.timer"} { if !strings.Contains(seq, want) { t.Fatalf("manual scheduled job omitted %q:\n%s", want, seq) } @@ -75,7 +75,7 @@ func TestScheduleApplyUpgradesLegacyUnitsUnderRegime(t *testing.T) { case strings.Contains(cmd, "list-unit-files"): // v2026.8.5 installed this timer, but its service had no bounded // runner or failure notifier. Presence must not make apply skip it. - return transport.Result{Stdout: "ob-sample-nightly.timer\n"}, true + return transport.Result{Stdout: "onebox-job-nightly.timer\n"}, true case strings.Contains(cmd, "systemd-analyze calendar"): return transport.Result{Stdout: "ok\n"}, true case strings.Contains(cmd, "command -v flock"): @@ -92,9 +92,9 @@ func TestScheduleApplyUpgradesLegacyUnitsUnderRegime(t *testing.T) { seq := strings.Join(f.Commands, "\n") for _, want := range []string{ `"phase":"schedule-apply","event":"start"`, - "systemctl enable --now ob-sample-nightly.timer", + "systemctl enable --now onebox-job-nightly.timer", `"phase":"schedule-apply","event":"finish","status":"ok"`, - "rm -f '/var/lib/ob/sample/lock'", + "rm -f '/var/lib/onebox/app/lock'", } { if !strings.Contains(seq, want) { t.Errorf("schedule apply is missing %q:\n%s", want, seq) @@ -102,8 +102,8 @@ func TestScheduleApplyUpgradesLegacyUnitsUnderRegime(t *testing.T) { } artifacts := strings.Join(f.Inputs, "\n") for _, want := range []string{ - "ExecStart=/bin/sh /etc/systemd/system/ob-sample-nightly.run", - "ExecStopPost=/bin/sh /etc/systemd/system/ob-sample-nightly.notify", + "ExecStart=/bin/sh /etc/systemd/system/onebox-job-nightly.run", + "ExecStopPost=/bin/sh /etc/systemd/system/onebox-job-nightly.notify", "TimeoutStartSec=45m", "flock --exclusive --nonblock", "Persistent=false", @@ -113,8 +113,8 @@ func TestScheduleApplyUpgradesLegacyUnitsUnderRegime(t *testing.T) { } } for _, command := range f.Commands { - if strings.Contains(command, "/etc/systemd/system/ob-sample-nightly") && - strings.Contains(command, ".ob-tmp") && !strings.Contains(command, "ob-fenced") { + if strings.Contains(command, "/etc/systemd/system/onebox-job-nightly") && + strings.Contains(command, ".onebox-tmp") && !strings.Contains(command, "onebox-fenced") { t.Errorf("schedule artifact write escaped the fence: %s", command) } } @@ -140,7 +140,7 @@ func TestScheduleApplyRefusesBeforeFirstRelease(t *testing.T) { t.Fatalf("error = %v, want first-release refusal", err) } seq := strings.Join(f.Commands, "\n") - if len(f.Inputs) != 0 || !strings.Contains(seq, "rm -f '/var/lib/ob/sample/lock'") { + if len(f.Inputs) != 0 || !strings.Contains(seq, "rm -f '/var/lib/onebox/app/lock'") { t.Fatalf("schedule apply wrote units or leaked its lock before refusing:\n%s", seq) } } @@ -170,7 +170,7 @@ func TestScheduleApplyStopsBeforeUnitWritesWhenJournalStartFails(t *testing.T) { t.Fatalf("error = %v, want journal refusal", err) } for _, command := range f.Commands { - if strings.Contains(command, "/etc/systemd/system/ob-sample-nightly") { + if strings.Contains(command, "/etc/systemd/system/onebox-job-nightly") { t.Fatalf("unit mutation followed failed journal start: %s", command) } } @@ -182,25 +182,25 @@ func TestScheduledJobUnitContract(t *testing.T) { Calendar: "*-*-* 02:00:00", Timeout: "45m", ShutdownGrace: 12 * time.Second, CatchUp: false, DeployLock: "exclusive", } - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} - runner := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} + runner := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) service := scheduleServiceUnit("sample", job, - "/etc/systemd/system/ob-sample-nightly.run", - "/etc/systemd/system/ob-sample-nightly.notify") + "/etc/systemd/system/onebox-job-nightly.run", + "/etc/systemd/system/onebox-job-nightly.notify") timer := scheduleTimerUnit("sample", job) for _, want := range []string{ - "exec 9>'/var/lib/ob/sample/schedule/nightly.lock'", + "exec 9>'/var/lib/onebox/app/schedule/nightly.lock'", "flock --exclusive --nonblock --conflict-exit-code 200 9", - "exec 8>'/var/lib/ob/sample/schedule.lock'", + "exec 8>'/var/lib/onebox/app/schedule.lock'", "flock --exclusive --timeout 10 --conflict-exit-code 200 8", - "/var/lib/ob/sample/lock", + "/var/lib/onebox/app/lock", "application operation holds the deploy lock", "docker compose", "--project-directory", - "/var/lib/ob/sample/current", + "/var/lib/onebox/app/current", "compose.yaml", - `run --rm --no-deps "$@" --name 'sample-nightly-1'`, + `run --rm --no-deps "$@" --label 'onebox.execution.job=nightly' --name 'sample-nightly-1'`, "docker rm -f 'sample-nightly-1'", "docker kill --signal TERM 'sample-nightly-1'", "docker kill --signal KILL 'sample-nightly-1'", @@ -241,8 +241,8 @@ func TestScheduledJobUnitContract(t *testing.T) { } for _, want := range []string{ "Type=oneshot", - "ExecStart=/bin/sh /etc/systemd/system/ob-sample-nightly.run", - "ExecStopPost=/bin/sh /etc/systemd/system/ob-sample-nightly.notify", + "ExecStart=/bin/sh /etc/systemd/system/onebox-job-nightly.run", + "ExecStopPost=/bin/sh /etc/systemd/system/onebox-job-nightly.notify", "TimeoutStartSec=45m", } { if !strings.Contains(service, want) { @@ -282,8 +282,8 @@ func TestScheduleRendezvousWaitReservesShortJobTimeout(t *testing.T) { } } - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} - runner := scheduleRunnerScript("sample", app.ScheduledJob{Name: "quick", Timeout: "1s", DeployLock: "pinned"}, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} + runner := scheduleRunnerScript("sample", app.ScheduledJob{Name: "quick", Timeout: "1s", DeployLock: "pinned"}, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) if !strings.Contains(runner, "flock --shared --nonblock --conflict-exit-code 200 8") || !strings.Contains(runner, "skip 'the scheduling rendezvous is busy'") { t.Fatalf("short-timeout runner can outlive its rendezvous budget:\n%s", runner) @@ -292,28 +292,28 @@ func TestScheduleRendezvousWaitReservesShortJobTimeout(t *testing.T) { func TestPinnedScheduledJobRunnerLeasesImmutableRelease(t *testing.T) { job := app.ScheduledJob{Name: "refresh", DeployLock: "pinned"} - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} - runner := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", []app.EnvFile{ + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} + runner := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", []app.EnvFile{ {File: "config/runtime.env"}, {File: "secrets/runtime.env", Provider: "sops"}, }, 10*time.Minute, true) for _, want := range []string{ - "exec 9>'/var/lib/ob/sample/schedule/refresh.lock'", + "exec 9>'/var/lib/onebox/app/schedule/refresh.lock'", "flock --exclusive --nonblock --conflict-exit-code 200 9", - "exec 8>'/var/lib/ob/sample/schedule.lock'", + "exec 8>'/var/lib/onebox/app/schedule.lock'", "flock --shared --timeout 10 --conflict-exit-code 200 8", - "release_dir=$(readlink -f '/var/lib/ob/sample/current')", - "exec 7>>\"$release_dir/.ob-schedule.lease\"", + "release_dir=$(readlink -f '/var/lib/onebox/app/current')", + "exec 7>>\"$release_dir/.onebox-schedule.lease\"", "flock --shared 7", "flock --unlock 8", "trap cleanup 0", "pinned release has no compose.yaml", - "/var/lib/ob/sample/schedule/refresh.state", + "/var/lib/onebox/app/schedule/refresh.state", "--project-directory \"$release_dir\"", "-f \"$release_dir\"/'compose.yaml'", "--env-file \"$release_dir\"/'config/runtime.env'", - `run --rm --no-deps "$@" --name 'sample-refresh-1' 'refresh'`, + `run --rm --no-deps "$@" --label 'onebox.execution.job=refresh' --name 'sample-refresh-1' 'refresh'`, "docker rm -f 'sample-refresh-1'", } { if !strings.Contains(runner, want) { @@ -610,6 +610,10 @@ func TestPinnedScheduledJobLockProtocol(t *testing.T) { if err := os.MkdirAll(releaseDir, 0o700); err != nil { t.Fatal(err) } + // What bootstrap writes; no lock is taken in an unmarked directory. + if err := os.WriteFile(names.AppMarker(), []byte("sample\n"), 0o600); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(releaseDir, "compose.yaml"), []byte("services: {}\n"), 0o600); err != nil { t.Fatal(err) } @@ -672,7 +676,7 @@ func TestPinnedScheduledJobLockProtocol(t *testing.T) { } assertLock(names.ScheduleRunLock(), true) assertLock(names.ScheduledJobRunLock(job.Name), false) - assertLock(filepath.Join(releaseDir, ".ob-schedule.lease"), false) + assertLock(filepath.Join(releaseDir, ".onebox-schedule.lease"), false) leases, err := release.ActiveScheduleLeases(ctx, transport.NewLocal(), names) if err != nil || len(leases) != 1 || leases[0] != releaseID { t.Fatalf("active release lease was not observable: leases=%v err=%v", leases, err) @@ -711,7 +715,7 @@ func TestPinnedScheduledJobLockProtocol(t *testing.T) { if _, err := os.Stat(removed); err != nil { t.Fatalf("completed run did not clean its named container: %v", err) } - assertLock(filepath.Join(releaseDir, ".ob-schedule.lease"), true) + assertLock(filepath.Join(releaseDir, ".onebox-schedule.lease"), true) leases, err = release.ActiveScheduleLeases(ctx, transport.NewLocal(), names) if err != nil || len(leases) != 0 { t.Fatalf("completed release remained leased: leases=%v err=%v", leases, err) @@ -743,7 +747,7 @@ func TestScheduledJobFailureNotifierUsesConfiguredWebhooks(t *testing.T) { } for _, want := range []string{ `${SERVICE_RESULT:-success}`, - "exec 9>'/var/lib/ob/sample/schedule/nightly.lock'", + "exec 9>'/var/lib/onebox/app/schedule/nightly.lock'", "flock --exclusive --nonblock 9", "docker rm -f 'sample-nightly-1'", `ts=$(date -u`, @@ -847,7 +851,7 @@ func TestRemoveSchedulesRemovesFilesAndReloadsAfterFailedDisable(t *testing.T) { f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { switch { case strings.Contains(cmd, "list-unit-files"): - return transport.Result{Stdout: "ob-sample-nightly.timer\n"}, true + return transport.Result{Stdout: "onebox-job-nightly.timer\n"}, true case strings.Contains(cmd, "systemctl disable --now"): return transport.Result{ExitCode: 5, Stderr: "unit is busy"}, true } @@ -855,103 +859,29 @@ func TestRemoveSchedulesRemovesFilesAndReloadsAfterFailedDisable(t *testing.T) { }} e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) err := e.RemoveSchedules(context.Background()) - if err == nil || !strings.Contains(err.Error(), "disable schedule ob-sample-nightly failed (exit 5): unit is busy") { + if err == nil || !strings.Contains(err.Error(), "disable schedule onebox-job-nightly failed (exit 5): unit is busy") { t.Fatalf("remove schedules error = %v", err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "rm -f /etc/systemd/system/ob-sample-nightly.timer /etc/systemd/system/ob-sample-nightly.service /etc/systemd/system/ob-sample-nightly.run /etc/systemd/system/ob-sample-nightly.notify") { + if !strings.Contains(seq, "rm -f /etc/systemd/system/onebox-job-nightly.timer /etc/systemd/system/onebox-job-nightly.service /etc/systemd/system/onebox-job-nightly.run /etc/systemd/system/onebox-job-nightly.notify") { t.Fatalf("disable failure stranded the unit files:\n%s", seq) } if !strings.Contains(seq, "systemctl daemon-reload") { t.Fatalf("systemd was not reloaded after removing the unit files:\n%s", seq) } - if strings.Contains(seq, "systemctl disable --now ob-sample-nightly.timer >/dev/null 2>&1") { + if strings.Contains(seq, "systemctl disable --now onebox-job-nightly.timer >/dev/null 2>&1") { t.Fatalf("disable stderr was discarded instead of captured:\n%s", seq) } } -func TestRuntimePrefixStopsAtEscapedComponentBoundary(t *testing.T) { - tests := []struct { - name string - unit string - prefix string - want bool - }{ - {"job owned", "ob-acme-nightly", "ob-acme-", true}, - {"hyphenated job owner", "ob-acme--web-nightly", "ob-acme--web-", true}, - {"job belongs to hyphen extension", "ob-acme--web-nightly", "ob-acme-", false}, - {"backup environment owned", "ob-backup-acme-prod-postgres-backup", "ob-backup-acme-prod-", true}, - {"hyphenated backup environment owned", "ob-backup-acme-prod--eu-postgres-backup", "ob-backup-acme-prod--eu-", true}, - {"backup belongs to hyphen extension", "ob-backup-acme-prod--eu-postgres-backup", "ob-backup-acme-prod-", false}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := matchesRuntimePrefix(test.unit, test.prefix); got != test.want { - t.Fatalf("matchesRuntimePrefix(%q, %q) = %t, want %t", test.unit, test.prefix, got, test.want) - } - }) - } -} - -func TestScheduleReconciliationDoesNotCrossEscapedApplicationBoundary(t *testing.T) { - listed := strings.Join([]string{ - "ob-acme--web-nightly.timer", - "ob-backup-acme--web-production-postgres-backup.timer", - "", - }, "\n") - for _, test := range []struct { - name string - run func(*Engine) error - }{ - {"sync", func(e *Engine) error { return e.SyncSchedules(context.Background()) }}, - {"remove", func(e *Engine) error { return e.RemoveSchedules(context.Background()) }}, - } { - t.Run(test.name, func(t *testing.T) { - f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "list-unit-files") { - return transport.Result{Stdout: listed}, true - } - return transport.Result{}, false - }} - cfg := testConfig() - cfg.Spec.Name = "acme" - e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := test.run(e); err != nil { - t.Fatal(err) - } - if seq := strings.Join(f.Commands, "\n"); strings.Contains(seq, "rm -f") { - t.Fatalf("%s removed a hyphen-extension application's schedule:\n%s", test.name, seq) - } - }) - } -} - -func TestBackupScheduleSyncDoesNotCrossEscapedEnvironmentBoundary(t *testing.T) { - f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "list-unit-files") { - return transport.Result{Stdout: "ob-backup-acme-prod--eu-postgres-backup.timer\n"}, true - } - return transport.Result{}, false - }} - cfg := testConfig() - cfg.Spec.Name = "acme" - e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "prod"}) - if err := e.SyncBackupSchedules(context.Background()); err != nil { - t.Fatal(err) - } - if seq := strings.Join(f.Commands, "\n"); strings.Contains(seq, "rm -f") { - t.Fatalf("backup sync removed a hyphen-extension environment's schedule:\n%s", seq) - } -} - func TestScheduleSyncIgnoresInvalidHostListedUnitNames(t *testing.T) { for _, test := range []struct { name string listed string run func(*Engine) error }{ - {"job", "ob-sample-nightly;touch.timer\n", func(e *Engine) error { return e.SyncSchedules(context.Background()) }}, - {"backup", "ob-backup-sample-production-postgres;touch.timer\n", func(e *Engine) error { return e.SyncBackupSchedules(context.Background()) }}, + {"job", "onebox-job-nightly;touch.timer\n", func(e *Engine) error { return e.SyncSchedules(context.Background()) }}, + {"backup", "onebox-backup-production-postgres;touch.timer\n", func(e *Engine) error { return e.SyncBackupSchedules(context.Background()) }}, } { t.Run(test.name, func(t *testing.T) { f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { @@ -973,17 +903,14 @@ func TestScheduleSyncIgnoresInvalidHostListedUnitNames(t *testing.T) { // A deploy must not delete the backup timers. // -// SyncSchedules owns "ob--*" and removes what the project no longer -// declares. Backup timers were named inside that namespace, so every deploy +// SyncSchedules owns JobUnitPrefix and removes what the project no longer +// declares. Backup timers were once named inside that namespace, so every deploy // reclaimed them as stale and silently stopped all scheduled backups — the only // trace being a line saying the schedule was "no longer declared". func TestSyncSchedulesLeavesBackupTimersAlone(t *testing.T) { - if !strings.HasPrefix(app.BackupUnitPrefix, "ob-") { - t.Fatalf("backup prefix %q is expected to sit under the ob- namespace", app.BackupUnitPrefix) - } - backupTimer := app.Names{App: "example", BasePath: "/var/lib/ob"}. - BackupTimerForEnvironment("production", "database", "backup") - if strings.HasPrefix(backupTimer, "ob-example-") { + backupTimer := app.Names{App: "example", BasePath: "/var/lib/onebox"}. + BackupUnit("database", "backup") + if strings.HasPrefix(backupTimer, app.JobUnitPrefix) { t.Fatalf("backup timer %q is inside the job scheduler's namespace and a deploy would delete it", backupTimer) } } @@ -993,17 +920,17 @@ func TestSyncSchedulesLeavesBackupTimersAlone(t *testing.T) { // Backup timers are named outside the job scheduler's namespace on purpose — // a deploy used to treat them as "no longer declared" and delete every // scheduled backup. Teardown is the opposite case: matching only the job -// prefix left `ob destroy` with ob-backup--… timers still loaded, firing +// prefix left `ob destroy` with backup timers still loaded, firing // against a release directory the same command had just deleted. func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { if strings.Contains(cmd, "list-unit-files") { return transport.Result{Stdout: strings.Join([]string{ - "ob-sample-nightly.timer", - "ob-backup-sample-production-postgres-backup.timer", - "ob-backup-sample-production-postgres-verify.timer", - // Another application's, and a stranger's. Neither is ours. - "ob-backup-other-production-postgres-backup.timer", + "onebox-job-nightly.timer", + "onebox-backup-postgres-backup.timer", + "onebox-backup-postgres-verify.timer", + // Outside Onebox's namespaces, and a stranger's. Neither is ours. + "backup-other-production-postgres-backup.timer", "logrotate.timer", "", }, "\n")}, true @@ -1016,57 +943,21 @@ func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { } seq := strings.Join(f.Commands, "\n") for _, want := range []string{ - "ob-sample-nightly", - "ob-backup-sample-production-postgres-backup", - "ob-backup-sample-production-postgres-verify", + "onebox-job-nightly", + "onebox-backup-postgres-backup", + "onebox-backup-postgres-verify", } { if !strings.Contains(seq, "rm -f /etc/systemd/system/"+want+".timer") { t.Errorf("teardown left %s installed:\n%s", want, seq) } } - for _, never := range []string{"ob-backup-other-production", "logrotate"} { + for _, never := range []string{"backup-other-production", "logrotate"} { if strings.Contains(seq, never) { t.Errorf("teardown removed a unit that is not this application's (%s):\n%s", never, seq) } } } -func TestScheduleOwnershipComesFromServiceBody(t *testing.T) { - tests := []struct { - name string - backup bool - body string - want bool - }{ - {"owned job", false, "Description=Onebox scheduled job nightly for help-desk\n", true}, - {"other job", false, "Description=Onebox scheduled job nightly for help\n", false}, - {"owned backup current", true, "Description=Onebox backup verify for database (help-desk/production)\n", true}, - {"owned backup legacy", true, "Description=Onebox backup verify for database (help-desk)\n", true}, - {"other environment backup", true, "Description=Onebox backup verify for database (help-desk/staging)\n", false}, - {"other backup", true, "Description=Onebox backup verify for database (help)\n", false}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { - if strings.HasPrefix(cmd, "cat ") { - return transport.Result{Stdout: test.body}, true - } - return transport.Result{}, false - }} - cfg := testConfig() - cfg.Spec.Name = "help-desk" - e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) - got, err := e.scheduleUnitBelongsToOwner(context.Background(), "legacy", test.backup) - if err != nil { - t.Fatal(err) - } - if got != test.want { - t.Fatalf("ownership = %t, want %t", got, test.want) - } - }) - } -} - func TestBackupServiceUnitRecordsEnvironmentOwnership(t *testing.T) { body := backupServiceUnit("sample", "production", "postgres", "backup", "/tmp/lock", []string{"true"}) if !strings.Contains(body, "Description=Onebox backup backup for postgres (sample/production)") { @@ -1074,26 +965,8 @@ func TestBackupServiceUnitRecordsEnvironmentOwnership(t *testing.T) { } } -func TestAppNamedBackupDoesNotOwnEveryBackupTimer(t *testing.T) { - f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "list-unit-files") { - return transport.Result{Stdout: "ob-backup-other-production-postgres-backup.timer\n"}, true - } - return transport.Result{}, false - }} - cfg := testConfig() - cfg.Spec.Name = "backup" - e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.RemoveSchedules(context.Background()); err != nil { - t.Fatal(err) - } - if seq := strings.Join(f.Commands, "\n"); strings.Contains(seq, "rm -f") { - t.Fatalf("app named backup removed another application's timer:\n%s", seq) - } -} - func TestScheduledJobRunnersRecordRunStateForTheNotifier(t *testing.T) { - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} for _, tc := range []struct { name string job app.ScheduledJob @@ -1102,9 +975,9 @@ func TestScheduledJobRunnersRecordRunStateForTheNotifier(t *testing.T) { {"pinned", app.ScheduledJob{Name: "nightly", Cron: "0 2 * * *", Timezone: "UTC", Calendar: "*-*-* 02:00:00", Timeout: "45m", DeployLock: "pinned"}}, } { t.Run(tc.name, func(t *testing.T) { - runner := scheduleRunnerScript("sample", tc.job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + runner := scheduleRunnerScript("sample", tc.job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) for _, want := range []string{ - "state='/var/lib/ob/sample/schedule/nightly.state'", + "state='/var/lib/onebox/app/schedule/nightly.state'", "write_state() {", "started_epoch=%s", "trigger=%s", @@ -1126,7 +999,7 @@ func TestScheduledJobRunnersRecordRunStateForTheNotifier(t *testing.T) { }) } service := scheduleServiceUnit("sample", app.ScheduledJob{Name: "nightly", Timeout: "45m"}, - "/etc/systemd/system/ob-sample-nightly.run", "/etc/systemd/system/ob-sample-nightly.notify") + "/etc/systemd/system/onebox-job-nightly.run", "/etc/systemd/system/onebox-job-nightly.notify") if strings.Contains(service, "SuccessExitStatus") { t.Errorf("a skip is recorded by the runner and exits 0; the unit needs no exit-status remap:\n%s", service) } @@ -1141,7 +1014,7 @@ func TestScheduledJobNotifierWritesOneRunRecordToTheJournal(t *testing.T) { t.Fatal(err) } for _, want := range []string{ - "state='/var/lib/ob/sample/schedule/nightly.state'", + "state='/var/lib/onebox/app/schedule/nightly.state'", `started_epoch=*) started_epoch=${line#started_epoch=}`, `rm -f "$state"`, `result=${SERVICE_RESULT:-success}`, @@ -1153,8 +1026,8 @@ func TestScheduledJobNotifierWritesOneRunRecordToTheJournal(t *testing.T) { `"run":"%s","job":"%s","trigger":"%s","operation":"%s","release":"%s"`, `"duration_s":%s,"attempts":%s,"exit_status":%s,"outcome":"%s","forced_kill":%s,"reason":"%s","inputs":{%s}`, `"${INVOCATION_ID:-}" 'nightly'`, - `SYSLOG_IDENTIFIER=ob-run\nONEBOX_APP=%s\nONEBOX_UNIT=%s\nONEBOX_JOB=%s`, - `"$record" 'sample' 'ob-sample-nightly' 'nightly' | logger --journald`, + `SYSLOG_IDENTIFIER=onebox-run\nONEBOX_APP=%s\nONEBOX_UNIT=%s\nONEBOX_JOB=%s`, + `"$record" 'sample' 'onebox-job-nightly' 'nightly' | logger --journald`, } { if !strings.Contains(script, want) { t.Errorf("notifier is missing %q:\n%s", want, script) @@ -1175,7 +1048,7 @@ func runNotifier(t *testing.T, job app.ScheduledJob, notifications map[string]ap t.Helper() base := t.TempDir() if state != "" { - scheduleDir := filepath.Join(base, "sample", "schedule") + scheduleDir := filepath.Join(base, "app", "schedule") if err := os.MkdirAll(scheduleDir, 0o700); err != nil { t.Fatal(err) } @@ -1201,7 +1074,7 @@ func runNotifierIn(t *testing.T, base string, job app.ScheduledJob, notification if err != nil { t.Fatal(err) } - scheduleDir := filepath.Join(base, "sample", "schedule") + scheduleDir := filepath.Join(base, "app", "schedule") if err := os.MkdirAll(scheduleDir, 0o700); err != nil { t.Fatal(err) } @@ -1212,8 +1085,8 @@ func runNotifierIn(t *testing.T, base string, job app.ScheduledJob, notification // keeps only the MESSAGE line, as `journalctl -o cat` would show it. stub := "#!/bin/sh\n[ \"$1\" = --journald ] || exit 9\n" + "fields=$(cat)\n" + - "printf '%s\\n' \"$fields\" | grep -q '^SYSLOG_IDENTIFIER=ob-run$' || exit 8\n" + - "printf '%s\\n' \"$fields\" | grep -q '^ONEBOX_UNIT=ob-sample-nightly$' || exit 7\n" + + "printf '%s\\n' \"$fields\" | grep -q '^SYSLOG_IDENTIFIER=onebox-run$' || exit 8\n" + + "printf '%s\\n' \"$fields\" | grep -q '^ONEBOX_UNIT=onebox-job-nightly$' || exit 7\n" + "printf '%s\\n' \"$fields\" | grep -q '^ONEBOX_JOB=nightly$' || exit 6\n" + "printf '%s\\n' \"$fields\" | sed -n 's/^MESSAGE=//p' >>" + record + "\n" if err := os.WriteFile(filepath.Join(bin, "logger"), []byte(stub), 0o755); err != nil { @@ -1269,7 +1142,7 @@ func TestScheduledJobNotifierRecordsEachOutcomeAndRemovesState(t *testing.T) { "failure": {state, map[string]string{"SERVICE_RESULT": "exit-code", "EXIT_STATUS": "1"}, "failure", float64(1), 2}, "timeout": {state, map[string]string{"SERVICE_RESULT": "timeout", "EXIT_STATUS": "TERM"}, "timeout", nil, 2}, "forced kill": {forced, map[string]string{"SERVICE_RESULT": "timeout", "EXIT_STATUS": "KILL"}, "timeout", nil, 2}, - "skipped": {"skipped=another run of this job is still in progress\noperation=\ninputs=\n", map[string]string{"SERVICE_RESULT": "success", "EXIT_STATUS": "0", "TRIGGER_UNIT": "ob-sample-nightly.timer"}, "skipped", float64(0), 0}, + "skipped": {"skipped=another run of this job is still in progress\noperation=\ninputs=\n", map[string]string{"SERVICE_RESULT": "success", "EXIT_STATUS": "0", "TRIGGER_UNIT": "onebox-job-nightly.timer"}, "skipped", float64(0), 0}, "job exits 75": {state, map[string]string{"SERVICE_RESULT": "exit-code", "EXIT_STATUS": "75"}, "failure", float64(75), 2}, "no state": {"", map[string]string{"SERVICE_RESULT": "exit-code", "EXIT_STATUS": "3"}, "failure", float64(3), 0}, } { @@ -1391,8 +1264,8 @@ ActiveState=active func TestScheduledJobRunnerRetriesWithCappedDoublingBackoff(t *testing.T) { job := app.ScheduledJob{Name: "nightly", Timeout: "45m", DeployLock: "exclusive", RetryAttempts: 3, RetryBackoff: 30 * time.Second, RetryMaxBackoff: 10 * time.Minute} - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} - runner := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} + runner := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) for _, want := range []string{ "max_attempts=3", "backoff=30", "max_backoff=600", "attempt=1", "while :; do", "write_state \"$attempt\"", @@ -1404,11 +1277,11 @@ func TestScheduledJobRunnerRetriesWithCappedDoublingBackoff(t *testing.T) { t.Errorf("runner is missing %q:\n%s", want, runner) } } - single := scheduleRunnerScript("sample", app.ScheduledJob{Name: "nightly", Timeout: "1h", DeployLock: "exclusive", RetryAttempts: 1}, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + single := scheduleRunnerScript("sample", app.ScheduledJob{Name: "nightly", Timeout: "1h", DeployLock: "exclusive", RetryAttempts: 1}, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) if strings.Contains(single, "max_attempts=") { t.Errorf("a single-attempt job must not carry a retry loop:\n%s", single) } - pinned := scheduleRunnerScript("sample", app.ScheduledJob{Name: "nightly", Timeout: "1h", DeployLock: "pinned", RetryAttempts: 2, RetryBackoff: time.Second, RetryMaxBackoff: time.Minute}, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + pinned := scheduleRunnerScript("sample", app.ScheduledJob{Name: "nightly", Timeout: "1h", DeployLock: "pinned", RetryAttempts: 2, RetryBackoff: time.Second, RetryMaxBackoff: time.Minute}, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) if !strings.Contains(pinned, "max_attempts=") || strings.Index(pinned, "flock --unlock 8") > strings.Index(pinned, "max_attempts=") { t.Errorf("pinned runner must release the schedule mutex before its attempt loop:\n%s", pinned) } @@ -1516,16 +1389,16 @@ func TestScheduledJobNotifierSendsOnlySelectedOutcomesWithTheRunID(t *testing.T) func TestScheduledJobRunnerConsumesManualInputsWithoutShellInterpolation(t *testing.T) { job := app.ScheduledJob{Name: "sync", Timeout: "45m", DeployLock: "pinned", RetryAttempts: 1, Inputs: map[string]app.JobInput{"SOURCE": {Enum: []string{"catalog"}, Default: "catalog"}}} - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} - runner := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} + runner := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) for _, want := range []string{ - "inputs_file='/var/lib/ob/sample/schedule/sync.inputs'", + "inputs_file='/var/lib/onebox/app/schedule/sync.inputs'", `if [ -z "${TRIGGER_UNIT:-}" ] && [ -f "$inputs_file" ]; then`, `while IFS= read -r line || [ -n "$line" ]; do`, `ONEBOX_OPERATION=*) operation=${line#ONEBOX_OPERATION=} ;;`, `[A-Z]*=*) set -- "$@" -e "$line"`, `rm -f "$inputs_file"`, - `run --rm --no-deps "$@" --name 'sample-sync-1' 'sync'`, + `run --rm --no-deps "$@" --label 'onebox.execution.job=sync' --name 'sample-sync-1' 'sync'`, } { if !strings.Contains(runner, want) { t.Errorf("runner is missing %q:\n%s", want, runner) @@ -1544,8 +1417,8 @@ func TestScheduledJobRunnerConsumesManualInputsWithoutShellInterpolation(t *test if strings.Index(runner, "inputs_file=") > strings.Index(runner, "exec 9>") { t.Fatalf("inputs are consumed after the lock:\n%s", runner) } - exclusive := scheduleRunnerScript("sample", app.ScheduledJob{Name: "sync", Timeout: "1h", DeployLock: "exclusive", RetryAttempts: 1}, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) - if !strings.Contains(exclusive, "inputs_file=") || !strings.Contains(exclusive, `run --rm --no-deps "$@" --name`) { + exclusive := scheduleRunnerScript("sample", app.ScheduledJob{Name: "sync", Timeout: "1h", DeployLock: "exclusive", RetryAttempts: 1}, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) + if !strings.Contains(exclusive, "inputs_file=") || !strings.Contains(exclusive, `run --rm --no-deps "$@" --label`) { t.Fatalf("exclusive runner does not consume inputs:\n%s", exclusive) } } @@ -1604,7 +1477,7 @@ func TestScheduleInputsLinesParseTheFileIntoArguments(t *testing.T) { `printf 'operation=%s\n' "$operation"`, `printf 'json=%s\n' "$inputs_json"`, ), "\n") - for _, trigger := range []string{"", "ob-sample-sync.timer"} { + for _, trigger := range []string{"", "onebox-job-sync.timer"} { command := exec.CommandContext(context.Background(), "sh", "-s") command.Stdin = strings.NewReader(script) command.Env = []string{"PATH=" + os.Getenv("PATH")} @@ -1746,9 +1619,9 @@ func TestScheduleStatusDegradesWhenTheJournalCannotBeRead(t *testing.T) { // A firing that cannot take the job lock must leave the running job's state // alone: that file is the evidence its own notifier turns into the record. func TestScheduledJobRunnerDoesNotClobberARunningJobsState(t *testing.T) { - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} job := app.ScheduledJob{Name: "nightly", Timeout: "1h", DeployLock: "exclusive", RetryAttempts: 1} - runner := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + runner := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) // The note is keyed to this activation, so it cannot be mistaken for the // state file of the run that holds the lock. if !strings.Contains(runner, `skip_marker="$state.skip.${INVOCATION_ID:-}"`) || @@ -1776,10 +1649,10 @@ func TestScheduledJobRunnerDoesNotClobberARunningJobsState(t *testing.T) { // The container name is fixed, so a corpse from one attempt would fail every // attempt after it. func TestScheduledJobRunnerClearsTheContainerBetweenAttempts(t *testing.T) { - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} job := app.ScheduledJob{Name: "nightly", Timeout: "45m", DeployLock: "exclusive", RetryAttempts: 3, RetryBackoff: time.Second, RetryMaxBackoff: time.Minute} - runner := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + runner := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) loop := runner[strings.Index(runner, "while :; do"):] if !strings.Contains(loop, "docker rm -f 'sample-nightly-1'") { t.Fatalf("no cleanup inside the attempt loop:\n%s", loop) @@ -1789,10 +1662,10 @@ func TestScheduledJobRunnerClearsTheContainerBetweenAttempts(t *testing.T) { // On a systemd without TRIGGER_UNIT the runner cannot see the trigger, and // says so rather than calling every timer firing an operator's run. func TestScheduledJobRunnerRecordsAnUnknownTriggerOnAnOlderSystemd(t *testing.T) { - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} job := app.ScheduledJob{Name: "nightly", Timeout: "1h", DeployLock: "exclusive", RetryAttempts: 1} - modern := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) - older := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, false) + modern := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) + older := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, false) if !strings.Contains(modern, "else trigger=operator; fi") { t.Fatalf("a host that sets TRIGGER_UNIT must name the operator:\n%s", modern) } @@ -1856,7 +1729,7 @@ func TestSyncSchedulesRequiresSystemd252OnlyForInputs(t *testing.T) { // evidence of the one that did. func TestScheduledJobNotifierReadsAStandAsideNoteAndSpareTheRunningState(t *testing.T) { base := t.TempDir() - scheduleDir := filepath.Join(base, "sample", "schedule") + scheduleDir := filepath.Join(base, "app", "schedule") if err := os.MkdirAll(scheduleDir, 0o700); err != nil { t.Fatal(err) } @@ -1892,9 +1765,9 @@ func TestScheduledJobNotifierReadsAStandAsideNoteAndSpareTheRunningState(t *test // it with the same expression or the note is invisible, and the notifier goes // back to the state file belonging to the run that is still going. func TestScheduleSkipMarkerIsNamedIdenticallyOnBothSides(t *testing.T) { - names := app.Names{App: "sample", BasePath: "/var/lib/ob"} + names := app.Names{App: "sample", BasePath: "/var/lib/onebox"} job := app.ScheduledJob{Name: "nightly", Timeout: "1h", DeployLock: "exclusive", RetryAttempts: 1} - runner := scheduleRunnerScript("sample", job, names, "/var/lib/ob/sample/lock", nil, 10*time.Minute, true) + runner := scheduleRunnerScript("sample", job, names, "/var/lib/onebox/app/lock", nil, 10*time.Minute, true) cfg := testConfig() e := New(cfg, testProject(t), &transport.Fake{TargetName: "root@example.internal"}, Options{Environment: "production", Out: &bytes.Buffer{}, Sleep: noSleep}) notifier, err := e.scheduleNotifier(job) diff --git a/internal/engine/secret_generation_rolling_test.go b/internal/engine/secret_generation_rolling_test.go index c294f971..aff9d90f 100644 --- a/internal/engine/secret_generation_rolling_test.go +++ b/internal/engine/secret_generation_rolling_test.go @@ -120,10 +120,10 @@ func newRollingGenerationFakeWith(t *testing.T, project string, generations map[ } switch { case strings.Contains(command, "_host/owner"): - return transport.Result{Stdout: "shop\n"}, true + return transport.Result{Stdout: "shop production\n"}, true case strings.Contains(command, "readlink"): return transport.Result{Stdout: "releases/20260809-120000-current\n"}, true - case strings.Contains(command, "/ob.snapshot.yml"): + case strings.Contains(command, "/onebox.snapshot.yml"): return transport.Result{Stdout: state.project}, true case strings.HasPrefix(strings.TrimSpace(command), "cat ") && strings.Contains(command, "/compose.yaml"): return transport.Result{Stdout: currentGenerationCompose(oldSecretGeneration)}, true @@ -152,12 +152,12 @@ func newRollingGenerationFakeWith(t *testing.T, project string, generations map[ return transport.Result{Stdout: state.worker + "\n"}, true case strings.Contains(command, "compose.service='web'"): generation := "" - if i := strings.Index(command, "ob.secret-generation='"); i >= 0 { - generation = command[i+len("ob.secret-generation='"):] + if i := strings.Index(command, "onebox.secret-generation='"); i >= 0 { + generation = command[i+len("onebox.secret-generation='"):] generation = generation[:strings.IndexByte(generation, '\'')] } return transport.Result{Stdout: strings.Join(webIDs(generation), "\n") + "\n"}, true - case strings.Contains(command, "ob.secret-generation"): + case strings.Contains(command, "onebox.secret-generation"): id := lastField(command) if generation, ok := state.generations[id]; ok { return transport.Result{Stdout: generation + "\n"}, true @@ -285,7 +285,7 @@ func TestForceSecretGenerationIsNoOpWhenAlreadyConverged(t *testing.T) { checkpoint, err := release.NewSecretCheckpoint( "20260809-120000-current", oldSecretGeneration, newSecretGeneration, []string{"web", "worker"}, - []string{".ob-decrypted-sops-web.enc.env", ".ob-decrypted-sops-worker.enc.env"}, + []string{".onebox-decrypted-sops-web.enc.env", ".onebox-decrypted-sops-worker.enc.env"}, time.Date(2026, 8, 9, 11, 0, 0, 0, time.UTC), ) if err != nil { @@ -310,7 +310,7 @@ func TestForceSecretGenerationRefusesWhenTheLabelCannotBeRead(t *testing.T) { fake, _ := newRollingGenerationFake(t) inner := fake.Dynamic fake.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "ob.secret-generation") && strings.HasSuffix(strings.TrimSpace(command), "W1") { + if strings.Contains(command, "onebox.secret-generation") && strings.HasSuffix(strings.TrimSpace(command), "W1") { return transport.Result{ExitCode: 1, Stderr: "no such object"}, true } return inner(command) @@ -320,7 +320,7 @@ func TestForceSecretGenerationRefusesWhenTheLabelCannotBeRead(t *testing.T) { checkpoint, err := release.NewSecretCheckpoint( "20260809-120000-current", oldSecretGeneration, newSecretGeneration, []string{"web", "worker"}, - []string{".ob-decrypted-sops-web.enc.env", ".ob-decrypted-sops-worker.enc.env"}, + []string{".onebox-decrypted-sops-web.enc.env", ".onebox-decrypted-sops-worker.enc.env"}, time.Date(2026, 8, 9, 11, 0, 0, 0, time.UTC), ) if err != nil { @@ -353,7 +353,7 @@ func TestForceSecretGenerationResumesAPartlyRolledWorkload(t *testing.T) { checkpoint, err := release.NewSecretCheckpoint( "20260809-120000-current", oldSecretGeneration, newSecretGeneration, []string{"web", "worker"}, - []string{".ob-decrypted-sops-web.enc.env", ".ob-decrypted-sops-worker.enc.env"}, + []string{".onebox-decrypted-sops-web.enc.env", ".onebox-decrypted-sops-worker.enc.env"}, time.Date(2026, 8, 9, 11, 0, 0, 0, time.UTC), ) if err != nil { diff --git a/internal/engine/secret_generation_test.go b/internal/engine/secret_generation_test.go index 3ccfc153..62d50355 100644 --- a/internal/engine/secret_generation_test.go +++ b/internal/engine/secret_generation_test.go @@ -56,10 +56,10 @@ func newGenerationFake(t *testing.T, unchanged bool) (*transport.Fake, *generati fake.Dynamic = func(command string) (transport.Result, bool) { switch { case strings.Contains(command, "_host/owner"): - return transport.Result{Stdout: "shop\n"}, true + return transport.Result{Stdout: "shop production\n"}, true case strings.Contains(command, "readlink"): return transport.Result{Stdout: "releases/20260809-120000-current\n"}, true - case strings.Contains(command, "/ob.snapshot.yml"): + case strings.Contains(command, "/onebox.snapshot.yml"): return transport.Result{Stdout: generationProject}, true case strings.HasPrefix(strings.TrimSpace(command), "cat ") && strings.Contains(command, "/compose.yaml"): return transport.Result{Stdout: currentGenerationCompose(oldSecretGeneration)}, true @@ -87,7 +87,7 @@ func newGenerationFake(t *testing.T, unchanged bool) (*transport.Fake, *generati case strings.Contains(command, "docker ps -q"): workload := generationWorkload(command) return transport.Result{Stdout: state.containers[workload] + "\n"}, true - case strings.Contains(command, "ob.secret-generation"): + case strings.Contains(command, "onebox.secret-generation"): for identifier, generation := range state.generations { if strings.HasSuffix(command, " "+identifier) { return transport.Result{Stdout: generation + "\n"}, true @@ -112,7 +112,7 @@ func generationWorkload(command string) string { } func generationFromEngineSecretCommand(command string) string { - const marker = "/.ob-secret-generations/" + const marker = "/.onebox-secret-generations/" start := strings.Index(command, marker) if start < 0 { return "" @@ -126,8 +126,8 @@ func generationFromEngineSecretCommand(command string) string { func requireGenerationProjectDirectory(t *testing.T, commands []string, generation string) { t.Helper() - releaseDir := "/srv/onebox/shop/releases/20260809-120000-current" - composePath := releaseDir + "/.ob-secret-generations/" + generation + "/compose.yaml" + releaseDir := "/srv/onebox/app/releases/20260809-120000-current" + composePath := releaseDir + "/.onebox-secret-generations/" + generation + "/compose.yaml" projectArg := "--project-directory '" + releaseDir + "'" found := false for _, command := range commands { @@ -147,11 +147,11 @@ func requireGenerationProjectDirectory(t *testing.T, commands []string, generati func currentGenerationCompose(generation string) string { return fmt.Sprintf(`services: web: - env_file: [.ob-secret-generations/%[1]s/.ob-decrypted-sops-web.enc.env] - labels: {ob.app: shop, ob.release: 20260809-120000-current, ob.secret-generation: %[1]s} + env_file: [.onebox-secret-generations/%[1]s/.onebox-decrypted-sops-web.enc.env] + labels: {onebox.app: shop, onebox.release: 20260809-120000-current, onebox.secret-generation: %[1]s} worker: - env_file: [.ob-secret-generations/%[1]s/.ob-decrypted-sops-worker.enc.env] - labels: {ob.app: shop, ob.release: 20260809-120000-current, ob.secret-generation: %[1]s} + env_file: [.onebox-secret-generations/%[1]s/.onebox-decrypted-sops-worker.enc.env] + labels: {onebox.app: shop, onebox.release: 20260809-120000-current, onebox.secret-generation: %[1]s} `, generation) } @@ -168,8 +168,8 @@ func generationEngine(t *testing.T, fake *transport.Fake, output *bytes.Buffer) func generationPayloads() []SecretPayload { return []SecretPayload{ - {Path: ".ob-decrypted-sops-web.enc.env", Bytes: []byte("WEB=TOP_SECRET_NEW\n")}, - {Path: ".ob-decrypted-sops-worker.enc.env", Bytes: []byte("WORKER=TOP_SECRET_NEW\n")}, + {Path: ".onebox-decrypted-sops-web.enc.env", Bytes: []byte("WEB=TOP_SECRET_NEW\n")}, + {Path: ".onebox-decrypted-sops-worker.enc.env", Bytes: []byte("WORKER=TOP_SECRET_NEW\n")}, } } @@ -179,7 +179,7 @@ func seedSecretCheckpoint(t *testing.T, fake *transport.Fake, engine *Engine, ph checkpoint, err := release.NewSecretCheckpoint( "20260809-120000-current", oldSecretGeneration, newSecretGeneration, []string{"web", "worker"}, - []string{".ob-decrypted-sops-web.enc.env", ".ob-decrypted-sops-worker.enc.env"}, + []string{".onebox-decrypted-sops-web.enc.env", ".onebox-decrypted-sops-worker.enc.env"}, at, ) if err != nil { @@ -215,8 +215,8 @@ func seedSelectiveSecretCheckpoint(t *testing.T, fake *transport.Fake, engine *E checkpoint, err := release.NewSelectiveSecretCheckpoint( "20260809-120000-current", oldSecretGeneration, newSecretGeneration, []string{"web"}, - []string{".ob-decrypted-sops-web.enc.env", ".ob-decrypted-sops-worker.enc.env"}, - []string{".ob-decrypted-sops-web.enc.env"}, + []string{".onebox-decrypted-sops-web.enc.env", ".onebox-decrypted-sops-worker.enc.env"}, + []string{".onebox-decrypted-sops-web.enc.env"}, at, ) if err != nil { @@ -298,7 +298,7 @@ func TestSecretGenerationReplacesOnlyChangedConsumers(t *testing.T) { fake, state := newGenerationFake(t, false) baseDynamic := fake.Dynamic fake.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "cmp -s") && strings.Contains(command, ".ob-decrypted-sops-worker.enc.env") { + if strings.Contains(command, "cmp -s") && strings.Contains(command, ".onebox-decrypted-sops-worker.enc.env") { return transport.Result{}, true } return baseDynamic(command) @@ -449,7 +449,7 @@ func TestSecretGenerationCheckpointFailureRemovesInstalledCandidate(t *testing.T } commands := strings.Join(fake.Commands, "\n") installed := strings.Index(commands, "cp -R") - removed := strings.LastIndex(commands, "rm -rf '/srv/onebox/shop/releases/20260809-120000-current/.ob-secret-generations/"+newSecretGeneration+"'") + removed := strings.LastIndex(commands, "rm -rf '/srv/onebox/app/releases/20260809-120000-current/.onebox-secret-generations/"+newSecretGeneration+"'") if installed < 0 || removed <= installed { t.Fatalf("installed plaintext candidate survived checkpoint failure:\n%s", commands) } @@ -574,7 +574,7 @@ func TestSecretGenerationPostCommitSweepFailureKeepsTheNewGeneration(t *testing. fake, state := newGenerationFake(t, false) base := fake.Dynamic fake.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "rm -rf") && strings.Contains(command, "/.ob-secret-generations/"+oldSecretGeneration) { + if strings.Contains(command, "rm -rf") && strings.Contains(command, "/.onebox-secret-generations/"+oldSecretGeneration) { return transport.Result{ExitCode: 73, Stderr: "injected retired generation sweep failure"}, true } return base(command) diff --git a/internal/engine/secretspush.go b/internal/engine/secretspush.go index 09f2647f..f865163d 100644 --- a/internal/engine/secretspush.go +++ b/internal/engine/secretspush.go @@ -137,7 +137,7 @@ func (e *Engine) SecretsPushBatchWithInputs(ctx context.Context, payloads []Secr if err := runtimeEngine.cleanupSecretUploads(ctx); err != nil { return result, err } - jw := &journal.Writer{T: runtimeEngine.T, Names: runtimeEngine.names(), DeployID: current, Epoch: epoch, Operator: journal.DefaultOperator(), Runner: &runtimeEngine.Opts.Runner} + jw := &journal.Writer{T: runtimeEngine.T, Dir: journal.Dir(runtimeEngine.names()), DeployID: current, Epoch: epoch, Operator: journal.DefaultOperator(), Runner: &runtimeEngine.Opts.Runner} journalStarted := false startJournal := func(detail string) error { if err := jw.Append(ctx, journal.Record{Phase: "secrets-push", Event: "start", Detail: detail}); err != nil { @@ -435,9 +435,6 @@ func secretCheckpointMatchesGraph(checkpoint release.SecretCheckpoint, spec *app if !slices.Equal(checkpoint.PayloadPaths, paths) { return false } - if checkpoint.SchemaVersion == release.LegacySecretCheckpointSchemaVersion { - return len(checkpoint.ChangedPaths) == 0 && slices.Equal(checkpoint.AffectedWorkloads, allWorkloads) - } changed := map[string]bool{} for _, changedPath := range checkpoint.ChangedPaths { changed[changedPath] = true @@ -477,7 +474,7 @@ func (e *Engine) freshSecretGeneration(exclude string) (string, error) { } func stageSecretPayloads(payloads []SecretPayload) (string, func(), error) { - directory, err := os.MkdirTemp("", "ob-secret-generation") + directory, err := os.MkdirTemp("", "onebox-secret-generation") if err != nil { return "", nil, err } @@ -842,7 +839,7 @@ func (e *Engine) workloadOnSecretGeneration(ctx context.Context, workload, gener // containerSecretGeneration reads one container's generation label. A failure // to read it is an error, distinct from reading a value that does not match. func (e *Engine) containerSecretGeneration(ctx context.Context, containerID string) (string, error) { - result, err := e.T.Run(ctx, "docker inspect -f '{{ index .Config.Labels \"ob.secret-generation\" }}' "+containerID) + result, err := e.T.Run(ctx, "docker inspect -f '{{ index .Config.Labels \"onebox.secret-generation\" }}' "+containerID) if err != nil { return "", err } diff --git a/internal/engine/secretspush_test.go b/internal/engine/secretspush_test.go index 6658f037..3401f51b 100644 --- a/internal/engine/secretspush_test.go +++ b/internal/engine/secretspush_test.go @@ -72,10 +72,10 @@ func TestSecretsPushRefusesExactDeclarationGraphDriftBeforeMutation(t *testing.T f := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { switch { case strings.Contains(command, "_host/owner"): - return transport.Result{Stdout: "shop\n"}, true + return transport.Result{Stdout: "shop production\n"}, true case strings.Contains(command, "readlink"): return transport.Result{Stdout: "releases/20260809-120000-current\n"}, true - case strings.Contains(command, "/ob.snapshot.yml"): + case strings.Contains(command, "/onebox.snapshot.yml"): return transport.Result{Stdout: deployedProject}, true default: return transport.Result{}, true @@ -87,9 +87,9 @@ func TestSecretsPushRefusesExactDeclarationGraphDriftBeforeMutation(t *testing.T }) _, err := e.SecretsPushBatch(context.Background(), []SecretPayload{ - {Path: ".ob-decrypted-sops-shared.enc.env", Bytes: []byte("SHARED=changed\n")}, - {Path: ".ob-decrypted-sops-first.enc.env", Bytes: []byte("FIRST=changed\n")}, - {Path: ".ob-decrypted-sops-second.enc.env", Bytes: []byte("SECOND=changed\n")}, + {Path: ".onebox-decrypted-sops-shared.enc.env", Bytes: []byte("SHARED=changed\n")}, + {Path: ".onebox-decrypted-sops-first.enc.env", Bytes: []byte("FIRST=changed\n")}, + {Path: ".onebox-decrypted-sops-second.enc.env", Bytes: []byte("SECOND=changed\n")}, }) var drift *SecretDeclarationDriftError if !errors.As(err, &drift) { @@ -114,7 +114,7 @@ func TestValidateSecretPayloadsRefusesIncompleteOrUnsafeGraphs(t *testing.T) { tests := map[string][]SecretPayload{ "missing": append([]SecretPayload(nil), valid[:len(valid)-1]...), "duplicate": append(append([]SecretPayload(nil), valid...), valid[0]), - "unknown": append(append([]SecretPayload(nil), valid...), SecretPayload{Path: ".ob-unknown", Bytes: []byte("value")}), + "unknown": append(append([]SecretPayload(nil), valid...), SecretPayload{Path: ".onebox-unknown", Bytes: []byte("value")}), "absolute": {{Path: "/tmp/secret", Bytes: []byte("value")}}, "traversal": {{Path: "../secret", Bytes: []byte("value")}}, } @@ -130,7 +130,7 @@ func TestValidateSecretPayloadsRefusesIncompleteOrUnsafeGraphs(t *testing.T) { func TestCurrentSecretEngineKeepsDeployedOperationalSettings(t *testing.T) { deployed := strings.Replace(secretGraphProject, " web:\n image: nginx\n", " web:\n image: nginx\n replicas: 3\n", 1) target := &transport.Fake{Dynamic: func(command string) (transport.Result, bool) { - if strings.Contains(command, "/ob.snapshot.yml") { + if strings.Contains(command, "/onebox.snapshot.yml") { return transport.Result{Stdout: deployed}, true } return transport.Result{}, false diff --git a/internal/engine/service_apply.go b/internal/engine/service_apply.go index 99dcf491..cd0fcc24 100644 --- a/internal/engine/service_apply.go +++ b/internal/engine/service_apply.go @@ -139,7 +139,7 @@ func (e *Engine) ServiceApply(ctx context.Context, releaseID string, allowDestru if err := e.WriteFence(ctx, releaseID, epoch); err != nil { return err } - jw := &journal.Writer{T: e.T, Names: e.names(), DeployID: releaseID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} + jw := &journal.Writer{T: e.T, Dir: journal.Dir(e.names()), DeployID: releaseID, Epoch: epoch, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} if err := jw.Append(ctx, journal.Record{Phase: "service-apply", Event: "start", Detail: strings.Join(e.Spec.ServiceNames(), ",")}); err != nil { return fmt.Errorf("journal service apply start: %w", err) } diff --git a/internal/engine/service_apply_test.go b/internal/engine/service_apply_test.go index 54850cdd..ff3c9da1 100644 --- a/internal/engine/service_apply_test.go +++ b/internal/engine/service_apply_test.go @@ -51,14 +51,14 @@ func TestServiceApplyConvergesUnderRegime(t *testing.T) { // Its own project, not the application's: a release must not be able to // stop it and a rollback must not be able to remove its volume. - if !strings.Contains(seq, "docker compose -p 'ob_sample_postgres'") { + if !strings.Contains(seq, "docker compose -p 'onebox_postgres'") { t.Fatalf("service did not converge in its own project:\n%s", seq) } if strings.Contains(seq, "docker compose -p sample -f") && strings.Contains(seq, "postgres") { t.Fatalf("service converged inside the application's project:\n%s", seq) } for _, c := range f.Commands { - if strings.Contains(c, "ob_sample_postgres' -f") && !strings.Contains(c, "ob-fenced") { + if strings.Contains(c, "onebox_postgres' -f") && !strings.Contains(c, "onebox-fenced") { t.Fatalf("converge not fenced: %s", c) } } @@ -84,7 +84,7 @@ func TestServiceApplyStopsWhenJournalStartFails(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "journal service apply start") { t.Fatalf("service apply error = %v", err) } - if strings.Contains(strings.Join(f.Commands, "\n"), "docker compose -p 'ob_sample_postgres'") { + if strings.Contains(strings.Join(f.Commands, "\n"), "docker compose -p 'onebox_postgres'") { t.Fatalf("service apply mutated after journal failure:\n%s", strings.Join(f.Commands, "\n")) } } @@ -99,10 +99,10 @@ func TestServiceApplyEstablishesCredentialWithoutTravelling(t *testing.T) { t.Fatal(err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "/var/lib/ob/sample/services/postgres.secret.env") { + if !strings.Contains(seq, "/var/lib/onebox/app/services/postgres.secret.env") { t.Fatalf("no credential established:\n%s", seq) } - if !strings.Contains(seq, "if [ -s '/var/lib/ob/sample/services/postgres.secret.env' ]") { + if !strings.Contains(seq, "if [ -s '/var/lib/onebox/app/services/postgres.secret.env' ]") { t.Fatalf("credential is not established conditionally — a re-apply would rotate it:\n%s", seq) } if !strings.Contains(seq, "POSTGRES_URL") { @@ -115,13 +115,13 @@ func TestServiceApplyEstablishesCredentialWithoutTravelling(t *testing.T) { func TestServiceApplyRefusesDestructiveMounts(t *testing.T) { // The running service uses a volume the planned document no longer names. - f := accFake("volume=pgdata bind=/var/lib/ob/sample/releases/R0/conf") + f := accFake("volume=pgdata bind=/var/lib/onebox/app/releases/R0/conf") e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep, Environment: "production"}) err := e.ServiceApply(context.Background(), "R9-acc", false) if err == nil || !strings.Contains(err.Error(), "pgdata") { t.Fatalf("want destructive refusal naming pgdata, got %v", err) } - if strings.Contains(strings.Join(f.Commands, "\n"), "ob_sample_postgres' -f") { + if strings.Contains(strings.Join(f.Commands, "\n"), "onebox_postgres' -f") { t.Fatal("must not converge after refusal") } // A per-release payload bind changes every release by construction and is @@ -188,7 +188,7 @@ func TestAnUnsafeMajorUpgradeIsRefusedBeforeConverging(t *testing.T) { if !strings.Contains(err.Error(), "cannot be opened") { t.Fatalf("the refusal must say what would happen: %v", err) } - if strings.Contains(strings.Join(f.Commands, "\n"), "ob_sample_postgres' -f") { + if strings.Contains(strings.Join(f.Commands, "\n"), "onebox_postgres' -f") { t.Fatal("it must refuse before replacing the container") } }) @@ -230,8 +230,8 @@ func TestAVolumeWithoutItsCredentialIsRefused(t *testing.T) { if strings.Contains(cmd, "postgres.secret.env") && strings.Contains(cmd, "test -f") { return transport.Result{Stdout: ""}, true // no credential } - if strings.Contains(cmd, "volume ls -q") && strings.Contains(cmd, "ob_sample_postgres_data") { - return transport.Result{Stdout: "ob_sample_postgres_data\n"}, true // data is there + if strings.Contains(cmd, "volume ls -q") && strings.Contains(cmd, "onebox_postgres_data") { + return transport.Result{Stdout: "onebox_postgres_data\n"}, true // data is there } return base(cmd) } diff --git a/internal/engine/services.go b/internal/engine/services.go index 316fc80a..b7e5cf3c 100644 --- a/internal/engine/services.go +++ b/internal/engine/services.go @@ -94,9 +94,6 @@ func (e *Engine) applyServices(ctx context.Context, names []string, syncSchedule if err := e.ValidateProtectedDatabaseIdentities(ctx); err != nil { return err } - if err := e.MigrateBackupCredentialFiles(ctx); err != nil { - return fmt.Errorf("backup credentials: %w", err) - } // This runs before rendering or Compose mutation. Removing the declaration // does not DROP an extension, so unloading a library it still needs would be // a silent behavioral change and, for some extensions, a startup failure. @@ -439,17 +436,17 @@ func (e *Engine) ensureServiceSecret(ctx context.Context, n app.Names, name stri // shell. It writes beside the target and renames, so an interrupted run cannot // leave a half-written Compose file that the next apply would try to use. func (e *Engine) writeServiceFile(ctx context.Context, path string, body []byte) error { - tmp := path + ".ob-tmp" + tmp := path + ".onebox-tmp" cmd := "umask 077 && cat > " + q(tmp) + " && mv -f " + q(tmp) + " " + q(path) if e.fenceVal != "" { cmd = `if [ "$(cat ` + q(e.fencePath()) + ` 2>/dev/null)" = ` + q(e.fenceVal) + ` ]; then ` + - cmd + `; else echo ob-fenced >&2; exit 97; fi` + cmd + `; else echo onebox-fenced >&2; exit 97; fi` } res, err := e.T.RunInput(ctx, cmd, string(body)) if err != nil { return err } - if res.ExitCode == 97 && strings.Contains(res.Stderr, "ob-fenced") { + if res.ExitCode == 97 && strings.Contains(res.Stderr, "onebox-fenced") { return ErrFenced } if res.ExitCode != 0 { diff --git a/internal/engine/status.go b/internal/engine/status.go index ecdf8ea4..274ae779 100644 --- a/internal/engine/status.go +++ b/internal/engine/status.go @@ -14,7 +14,7 @@ import ( // Status prints recorded vs actual per role — divergence is the point // of divergence. Recorded = the current symlink; actual = what each role's -// container says via its ob.release label and health. +// container says via its onebox.release label and health. // // The host is high-latency and every command is a full SSH round trip (the // docker work itself is negligible), so status is round-trip-bound. It fires @@ -111,7 +111,7 @@ func (e *Engine) Status(ctx context.Context) error { for _, c := range cs { actual := c.release if actual == "" || actual == "" { - actual = "(not ob-deployed)" + actual = "(not deployed by Onebox)" } state := e.ui.OK("in sync") if !workloadReleaseMatches(c.release, c.revision, recordedRelease, expectedRevisions[roleName]) { @@ -140,7 +140,7 @@ func (e *Engine) Status(ctx context.Context) error { for _, container := range orphan.Containers { actual := container.Release if actual == "" || actual == "" { - actual = "(not ob-deployed)" + actual = "(not deployed by Onebox)" } e.ui.Println(fmt.Sprintf(row, orphan.Service, "orphan", actual, container.Health, e.ui.Warn("UNDECLARED ⚠"))) } diff --git a/internal/engine/status_snapshot_test.go b/internal/engine/status_snapshot_test.go index 7f8a802c..7faddba0 100644 --- a/internal/engine/status_snapshot_test.go +++ b/internal/engine/status_snapshot_test.go @@ -82,12 +82,12 @@ func TestStatusSnapshotAcceptsRetainedWorkloadFromEarlierRelease(t *testing.T) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): return transport.Result{Stdout: "S1|web|R2|" + webRevision + "|Up (healthy)\n" + "W1|worker|R1|" + pinnedWorkerRevision + "|Up (healthy)\n" + "PG1|postgres|R2||Up (healthy)\n"}, true case strings.Contains(cmd, "/releases/R2/compose.yaml"): - return transport.Result{Stdout: "services:\n worker:\n labels:\n ob.workload-revision: " + pinnedWorkerRevision + "\n"}, true + return transport.Result{Stdout: "services:\n worker:\n labels:\n onebox.workload-revision: " + pinnedWorkerRevision + "\n"}, true } return base(cmd) } @@ -105,7 +105,7 @@ func TestStatusSnapshotTreatsUnreadableActiveRuntimeAsPartial(t *testing.T) { pinnedWorkerRevision := "sha256:" + strings.Repeat("b", 64) base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app") { + if strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app") { return transport.Result{Stdout: "S1|web|R2||Up (healthy)\n" + "W1|worker|R1|" + pinnedWorkerRevision + "|Up (healthy)\n" + "PG1|postgres|R2||Up (healthy)\n"}, true @@ -149,7 +149,7 @@ func TestStatusSnapshotReportsUndeclaredAppContainer(t *testing.T) { f := statusFake("R2", "R2") base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app") { + if strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app") { return transport.Result{Stdout: "S1|web|R2|Up (healthy)\n" + "W1|worker|R2|Up (healthy)\nPG1|postgres|R2|Up (healthy)\n" + "OLD2|frontend|R1|Up (healthy)\nOLD1|frontend|R1|Up (healthy)\n"}, true @@ -183,7 +183,7 @@ func TestStatusSnapshotReportsObservedDivergenceAndIncompleteDeploy(t *testing.T switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/R2\n"}, true - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): // Deliberately reverse the web ids: the public result must be stable. return transport.Result{Stdout: "S2|web|R1|Up (healthy)\n" + "S1|web|R2|Up (unhealthy)\n" + diff --git a/internal/engine/status_test.go b/internal/engine/status_test.go index 210b6ea0..09add4e2 100644 --- a/internal/engine/status_test.go +++ b/internal/engine/status_test.go @@ -18,8 +18,8 @@ func statusFake(webRelease, recorded string) *transport.Fake { return transport.Result{Stdout: "releases/" + recorded + "\n"}, true // one ownership-filtered docker ps → every container Onebox owns for // this application, workloads and services alike - // one docker ps carries id|service|ob.release|status for every container - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + // one docker ps carries id|service|onebox.release|status for every container + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): return transport.Result{Stdout: "S1|web|" + webRelease + "|Up (healthy)\n" + "W1|worker|" + recorded + "|Up (healthy)\nPG1|postgres|" + recorded + "|Up (healthy)\n"}, true case strings.Contains(cmd, "ls -1"): // no journals @@ -76,12 +76,12 @@ func TestStatusAcceptsDigestPinnedRetainedWorkload(t *testing.T) { base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { switch { - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): return transport.Result{Stdout: "S1|web|R2||Up (healthy)\n" + "W1|worker|R1|" + pinnedWorkerRevision + "|Up (healthy)\n" + "PG1|postgres|R2||Up (healthy)\n"}, true case strings.Contains(cmd, "/releases/R2/compose.yaml"): - return transport.Result{Stdout: "services:\n worker:\n labels:\n ob.workload-revision: " + pinnedWorkerRevision + "\n"}, true + return transport.Result{Stdout: "services:\n worker:\n labels:\n onebox.workload-revision: " + pinnedWorkerRevision + "\n"}, true } return base(cmd) } @@ -100,7 +100,7 @@ func TestStatusFlagsUndeclaredAppContainer(t *testing.T) { f := statusFake("R2", "R2") base := f.Dynamic f.Dynamic = func(cmd string) (transport.Result, bool) { - if strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app") { + if strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app") { return transport.Result{Stdout: "S1|web|R2|Up (healthy)\n" + "W1|worker|R2|Up (healthy)\nPG1|postgres|R2|Up (healthy)\n" + "OLD1|frontend|R1|Up (healthy)\n"}, true @@ -126,7 +126,7 @@ func TestStatusFlagsNotRunning(t *testing.T) { switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/R2\n"}, true - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): // only the web role's container is up: worker + postgres are gone return transport.Result{Stdout: "S1|web|R2|Up (healthy)\n"}, true case strings.Contains(cmd, "ls -1"): @@ -152,7 +152,7 @@ func TestStatusFlagsUnhealthyRole(t *testing.T) { switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/R2\n"}, true - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): return transport.Result{Stdout: "S1|web|R2|Up (unhealthy)\n" + "W1|worker|R2|Up (healthy)\nPG1|postgres|R2|Up (healthy)\n"}, true case strings.Contains(cmd, "ls -1"): @@ -178,7 +178,7 @@ func TestStatusFlagsCrashLoopingRole(t *testing.T) { switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/R2\n"}, true - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): return transport.Result{Stdout: "S1|web|R2|Restarting (1) 3 seconds ago\n" + "W1|worker|R2|Up (healthy)\nPG1|postgres|R2|Up (healthy)\n"}, true case strings.Contains(cmd, "ls -1"): @@ -208,7 +208,7 @@ func TestStatusFlagsCrashLoopingService(t *testing.T) { switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/R2\n"}, true - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): return transport.Result{Stdout: "S1|web|R2|Up (healthy)\n" + "W1|worker|R2|Up (healthy)\nPG1|postgres|R2|Restarting (1) 2 seconds ago\n"}, true case strings.Contains(cmd, "ls -1"): @@ -245,7 +245,7 @@ func TestStatusSurfacesReadError(t *testing.T) { switch { case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/R2\n"}, true - case strings.Contains(cmd, "--format") && strings.Contains(cmd, "ob.app"): + case strings.Contains(cmd, "--format") && strings.Contains(cmd, "onebox.app"): return transport.Result{Stdout: "S1;reboot|web|R2|Up (healthy)\n"}, true case strings.Contains(cmd, "ls -1"): return transport.Result{Stdout: ""}, true diff --git a/internal/engine/workload_plan_test.go b/internal/engine/workload_plan_test.go index bf99ca66..05cf2d9a 100644 --- a/internal/engine/workload_plan_test.go +++ b/internal/engine/workload_plan_test.go @@ -12,7 +12,7 @@ func TestValidateRetainedWorkloadsRefusesRevisionDrift(t *testing.T) { f := happyFake() base := f.Dynamic f.Dynamic = func(command string) (transport.Result, bool) { - if strings.Contains(command, "docker ps --filter label=ob.app=") && strings.Contains(command, "--format") { + if strings.Contains(command, "docker ps --filter label=onebox.app=") && strings.Contains(command, "--format") { return transport.Result{Stdout: "W1|worker|R0|sha256:" + strings.Repeat("b", 64) + "|Up\n"}, true } return base(command) diff --git a/internal/journal/dangling_journal_dir_test.go b/internal/journal/dangling_journal_dir_test.go index 8b74c2f4..f0d84169 100644 --- a/internal/journal/dangling_journal_dir_test.go +++ b/internal/journal/dangling_journal_dir_test.go @@ -20,7 +20,7 @@ func TestJournalReadRefusesDanglingDirectory(t *testing.T) { base := t.TempDir() names := app.Names{App: "sample", BasePath: base} - journalDir := dir(names) + journalDir := Dir(names) if err := os.MkdirAll(filepath.Dir(journalDir), 0o700); err != nil { t.Fatal(err) } @@ -29,7 +29,7 @@ func TestJournalReadRefusesDanglingDirectory(t *testing.T) { } fake := &transport.Fake{} - if _, _, err := Journals(context.Background(), fake, names); err != nil { + if _, _, err := Journals(context.Background(), fake, Dir(names)); err != nil { t.Fatalf("capture command: %v", err) } if len(fake.Commands) == 0 { @@ -50,14 +50,14 @@ func TestJournalReadRefusesDanglingDirectory(t *testing.T) { func TestJournalReadRefusesDanglingEntry(t *testing.T) { base := t.TempDir() names := app.Names{App: "sample", BasePath: base} - if err := os.MkdirAll(dir(names), 0o700); err != nil { + if err := os.MkdirAll(Dir(names), 0o700); err != nil { t.Fatal(err) } - if err := os.Symlink(filepath.Join(base, "gone"), filepath.Join(dir(names), "R1.jsonl")); err != nil { + if err := os.Symlink(filepath.Join(base, "gone"), filepath.Join(Dir(names), "R1.jsonl")); err != nil { t.Fatal(err) } fake := &transport.Fake{} - if _, _, err := Journals(context.Background(), fake, names); err != nil { + if _, _, err := Journals(context.Background(), fake, Dir(names)); err != nil { t.Fatalf("capture command: %v", err) } if exit := shellExit(t, fake.Commands[len(fake.Commands)-1]); exit != 2 { @@ -74,7 +74,7 @@ func TestJournalReadRefusesUnsearchableAncestor(t *testing.T) { } base := t.TempDir() names := app.Names{App: "sample", BasePath: base} - journalDir := dir(names) + journalDir := Dir(names) if err := os.MkdirAll(journalDir, 0o700); err != nil { t.Fatal(err) } @@ -88,7 +88,7 @@ func TestJournalReadRefusesUnsearchableAncestor(t *testing.T) { t.Cleanup(func() { _ = os.Chmod(locked, 0o700) }) fake := &transport.Fake{} - if _, _, err := Journals(context.Background(), fake, names); err != nil { + if _, _, err := Journals(context.Background(), fake, Dir(names)); err != nil { t.Fatalf("capture command: %v", err) } if exit := shellExit(t, fake.Commands[len(fake.Commands)-1]); exit != app.ProbeUndetermined { @@ -105,7 +105,7 @@ func TestJournalReadRefusesUnreadableDirectory(t *testing.T) { } base := t.TempDir() names := app.Names{App: "sample", BasePath: base} - journalDir := dir(names) + journalDir := Dir(names) if err := os.MkdirAll(journalDir, 0o700); err != nil { t.Fatal(err) } @@ -119,7 +119,7 @@ func TestJournalReadRefusesUnreadableDirectory(t *testing.T) { t.Cleanup(func() { _ = os.Chmod(journalDir, 0o700) }) fake := &transport.Fake{} - if _, _, err := Journals(context.Background(), fake, names); err != nil { + if _, _, err := Journals(context.Background(), fake, Dir(names)); err != nil { t.Fatalf("capture command: %v", err) } if exit := shellExit(t, fake.Commands[len(fake.Commands)-1]); exit != 2 { @@ -133,15 +133,15 @@ func TestJournalReadRefusesUnreadableDirectory(t *testing.T) { func TestJournalReadRefusesNonRegularEntry(t *testing.T) { base := t.TempDir() names := app.Names{App: "sample", BasePath: base} - if err := os.MkdirAll(dir(names), 0o700); err != nil { + if err := os.MkdirAll(Dir(names), 0o700); err != nil { t.Fatal(err) } // A directory where a journal belongs. - if err := os.Mkdir(filepath.Join(dir(names), "R1.jsonl"), 0o700); err != nil { + if err := os.Mkdir(filepath.Join(Dir(names), "R1.jsonl"), 0o700); err != nil { t.Fatal(err) } fake := &transport.Fake{} - if _, _, err := Journals(context.Background(), fake, names); err != nil { + if _, _, err := Journals(context.Background(), fake, Dir(names)); err != nil { t.Fatalf("capture command: %v", err) } if exit := shellExit(t, fake.Commands[len(fake.Commands)-1]); exit != 2 { @@ -154,11 +154,11 @@ func TestJournalReadRefusesNonRegularEntry(t *testing.T) { func TestJournalReadAcceptsRealDirectory(t *testing.T) { base := t.TempDir() names := app.Names{App: "sample", BasePath: base} - if err := os.MkdirAll(dir(names), 0o700); err != nil { + if err := os.MkdirAll(Dir(names), 0o700); err != nil { t.Fatal(err) } fake := &transport.Fake{} - if _, _, err := Journals(context.Background(), fake, names); err != nil { + if _, _, err := Journals(context.Background(), fake, Dir(names)); err != nil { t.Fatalf("capture command: %v", err) } if exit := shellExit(t, fake.Commands[len(fake.Commands)-1]); exit != 0 { diff --git a/internal/journal/journal.go b/internal/journal/journal.go index 9f6d818f..52774cbb 100644 --- a/internal/journal/journal.go +++ b/internal/journal/journal.go @@ -1,5 +1,5 @@ // Package journal implements the append-only deploy journal at -// /var/lib/ob//journal/.jsonl, one sync per record. It is +// /app/journal/.jsonl, one sync per record. It is // the mechanism behind resume, abort, fencing forensics, and audit — a spec, // not a noun. package journal @@ -97,9 +97,10 @@ type WorkloadPlanEvidence struct { type Writer struct { T transport.Transport - // Names carries the resolved layout, so a journal is written where the - // release it describes actually lives. - Names app.Names + // Dir is the journal directory: Dir(names) for an application's journal, + // names.HostJournalDir() for the host's. Every reader takes the same directory, so + // a journal is read and pruned exactly where it was written. + Dir string DeployID string Epoch int Operator string @@ -115,8 +116,10 @@ type Writer struct { MigrationBackup *MigrationBackupEvidence } -func dir(n app.Names) string { return release.PathsFor(n).Base + "/journal" } -func file(n app.Names, id string) string { return dir(n) + "/" + id + ".jsonl" } +// Dir is an application's journal directory, beside the releases it describes. +func Dir(n app.Names) string { return release.PathsFor(n).Base + "/journal" } + +func file(dir, id string) string { return dir + "/" + id + ".jsonl" } func DefaultOperator() string { user := os.Getenv("USER") @@ -182,8 +185,11 @@ func (w *Writer) Append(ctx context.Context, r Record) error { if err != nil { return err } - f := file(w.Names, w.DeployID) - cmd := "mkdir -p " + q(dir(w.Names)) + " && printf '%s\\n' " + q(string(b)) + " >> " + q(f) + " && sync " + q(f) + if w.Dir == "" { + return errors.New("journal writer has no directory") + } + f := file(w.Dir, w.DeployID) + cmd := "mkdir -p " + q(w.Dir) + " && printf '%s\\n' " + q(string(b)) + " >> " + q(f) + " && sync " + q(f) res, err := w.T.Run(ctx, cmd) if err != nil { return err @@ -196,8 +202,8 @@ func (w *Writer) Append(ctx context.Context, r Record) error { // Read returns the records of one deploy; unparseable lines are tolerated // (the journal is forensic — a torn write must not block recovery). -func Read(ctx context.Context, t transport.Transport, n app.Names, deployID string) ([]Record, error) { - res, err := t.Run(ctx, "cat "+q(file(n, deployID))+" 2>/dev/null || true") +func Read(ctx context.Context, t transport.Transport, dir, deployID string) ([]Record, error) { + res, err := t.Run(ctx, "cat "+q(file(dir, deployID))+" 2>/dev/null || true") if err != nil { return nil, err } @@ -216,8 +222,8 @@ func Read(ctx context.Context, t transport.Transport, n app.Names, deployID stri } // List returns deploy ids with journals, oldest first (ids sort by time). -func List(ctx context.Context, t transport.Transport, n app.Names) ([]string, error) { - res, err := t.Run(ctx, "ls -1 "+q(dir(n))+" 2>/dev/null || true") +func List(ctx context.Context, t transport.Transport, dir string) ([]string, error) { + res, err := t.Run(ctx, "ls -1 "+q(dir)+" 2>/dev/null || true") if err != nil { return nil, err } @@ -235,7 +241,7 @@ func List(ctx context.Context, t transport.Transport, n app.Names) ([]string, er // journalMarker prefixes each file's contents in the bulk read below. Journal // records are single-line JSON objects (they start with '{'), so a line // starting with this marker is unambiguous. -const journalMarker = "@@ob-journal@@" +const journalMarker = "@@onebox-journal@@" // Journals returns every deploy's records keyed by id, plus the ids oldest // first, in a SINGLE round trip. FindIncomplete is the caller that needs this @@ -243,7 +249,7 @@ const journalMarker = "@@ob-journal@@" // high-latency host, paid in full even when no deploy is incomplete. A per-file // marker lets one command carry them all while parsing and Summarize stay here. // (Audit reads per-file — it is not on the status hot path.) -func Journals(ctx context.Context, t transport.Transport, n app.Names) ([]string, map[string][]Record, error) { +func Journals(ctx context.Context, t transport.Transport, dir string) ([]string, map[string][]Record, error) { // A missing journal directory is a valid never-deployed state. Existing but // unreadable directories/files fail so status cannot report false completeness. // -e follows symlinks, so the -L arm is what keeps a dangling journal-dir @@ -252,7 +258,7 @@ func Journals(ctx context.Context, t transport.Transport, n app.Names) ([]string // a crash can leave a journal's last record un-terminated, and without it // that record's line would swallow the following file's marker, losing an // entire deploy's records to one torn write. - cmd := "if [ -d " + q(dir(n)) + " ]; then cd " + q(dir(n)) + " || exit; " + + cmd := "if [ -d " + q(dir) + " ]; then cd " + q(dir) + " || exit; " + // Searchable but not readable: cd succeeds and the glob cannot // enumerate, so the loop never runs and the read looks like a // never-deployed host. Same false completeness, one step over. @@ -264,11 +270,11 @@ func Journals(ctx context.Context, t transport.Transport, n app.Names) ([]string // catches a directory or device sitting where a journal belongs; // -L catches the dangling link -e cannot see. "if [ -e \"$f\" ] || [ -L \"$f\" ]; then exit 2; fi; continue; fi; echo " + q(journalMarker) + - "\"$f\"; cat \"$f\" || exit; echo; done; elif [ -e " + q(dir(n)) + " ] || [ -L " + q(dir(n)) + " ]; then exit 2; else " + + "\"$f\"; cat \"$f\" || exit; echo; done; elif [ -e " + q(dir) + " ] || [ -L " + q(dir) + " ]; then exit 2; else " + // An unsearchable ancestor hides the directory as thoroughly as a // missing one, and answering "never deployed" there strands an // interrupted deploy: FindIncomplete reports nothing to resume. - app.UndeterminedArm(dir(n)) + "true; fi" + app.UndeterminedArm(dir) + "true; fi" res, err := t.Run(ctx, cmd) if err != nil { return nil, nil, err @@ -277,11 +283,11 @@ func Journals(ctx context.Context, t transport.Transport, n app.Names) ([]string // would print an exit code and no cause. switch res.ExitCode { case 2: - return nil, nil, fmt.Errorf("read deployment journals failed: %s exists but a journal there could not be read; inspect the deployment state directory", dir(n)) + return nil, nil, fmt.Errorf("read deployment journals failed: %s exists but a journal there could not be read; inspect the deployment state directory", dir) case app.ProbeStatePathNotDirectory: - return nil, nil, fmt.Errorf("read deployment journals failed: the path that should hold %s is not a directory; inspect the deployment state directory", dir(n)) + return nil, nil, fmt.Errorf("read deployment journals failed: the path that should hold %s is not a directory; inspect the deployment state directory", dir) case app.ProbeUndetermined: - return nil, nil, fmt.Errorf("read deployment journals failed: a directory holding %s cannot be searched, so a never-deployed host cannot be told from an unreadable one; verify access, then retry", dir(n)) + return nil, nil, fmt.Errorf("read deployment journals failed: a directory holding %s cannot be searched, so a never-deployed host cannot be told from an unreadable one; verify access, then retry", dir) } if res.ExitCode != 0 { return nil, nil, fmt.Errorf("read deployment journals failed (exit %d): %s", res.ExitCode, strings.TrimSpace(res.Stderr)) @@ -314,11 +320,11 @@ func Journals(ctx context.Context, t transport.Transport, n app.Names) ([]string // PruneCandidates returns journal ids beyond independent deploy and auxiliary // keep windows, oldest first. High-frequency exec/job/service activity must not // evict the deploy history needed for recovery and audit. -func PruneCandidates(ctx context.Context, t transport.Transport, n app.Names, keep int) ([]string, error) { +func PruneCandidates(ctx context.Context, t transport.Transport, dir string, keep int) ([]string, error) { if keep < 1 { return nil, errors.New("journal retention keep window must be positive") } - ids, byID, err := Journals(ctx, t, n) + ids, byID, err := Journals(ctx, t, dir) if err != nil { return nil, err } diff --git a/internal/journal/journal_test.go b/internal/journal/journal_test.go index 30799534..4c3e06bb 100644 --- a/internal/journal/journal_test.go +++ b/internal/journal/journal_test.go @@ -13,7 +13,7 @@ import ( func TestAppendCommandShape(t *testing.T) { f := &transport.Fake{} - w := &Writer{T: f, Names: app.Names{App: "sample", BasePath: app.DefaultBasePath}, DeployID: "R1", Epoch: 3, GitSHA: "abc1234", ConfigHash: "sha256:x"} + w := &Writer{T: f, Dir: Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath}), DeployID: "R1", Epoch: 3, GitSHA: "abc1234", ConfigHash: "sha256:x"} if err := w.Append(context.Background(), Record{Phase: "release", Role: "web", Event: "result", Status: "ok"}); err != nil { t.Fatal(err) } @@ -22,9 +22,9 @@ func TestAppendCommandShape(t *testing.T) { } cmd := f.Commands[0] for _, want := range []string{ - "mkdir -p '/var/lib/ob/sample/journal'", - ">> '/var/lib/ob/sample/journal/R1.jsonl'", - "sync '/var/lib/ob/sample/journal/R1.jsonl'", + "mkdir -p '/var/lib/onebox/app/journal'", + ">> '/var/lib/onebox/app/journal/R1.jsonl'", + "sync '/var/lib/onebox/app/journal/R1.jsonl'", `"deploy_id":"R1"`, `"epoch":3`, `"role":"web"`, @@ -41,7 +41,7 @@ func TestAppendCommandShape(t *testing.T) { func TestAppendRedactsFailureDetails(t *testing.T) { f := &transport.Fake{} - w := &Writer{T: f, Names: app.Names{App: "sample", BasePath: app.DefaultBasePath}, DeployID: "R1", Epoch: 1} + w := &Writer{T: f, Dir: Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath}), DeployID: "R1", Epoch: 1} if err := w.Append(context.Background(), Record{ Phase: "verify", Event: "result", Status: "fail", Detail: "request failed: Authorization=Bearer super-secret-token", @@ -65,7 +65,7 @@ func TestAppendRedactsFailureDetails(t *testing.T) { func TestAppendScopesAuthorizationContextToEvidenceRecords(t *testing.T) { f := &transport.Fake{} w := &Writer{ - T: f, Names: app.Names{App: "sample", BasePath: app.DefaultBasePath}, DeployID: "R1", Epoch: 1, + T: f, Dir: Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath}), DeployID: "R1", Epoch: 1, ApprovalDigest: "sha256:approval", ApprovedBy: "operator@example", MigrationBackup: &MigrationBackupEvidence{ Mode: "override", OverrideReason: "incident INC-42", ProtectedResources: []string{"database/postgres"}, @@ -117,7 +117,7 @@ func TestReadAndSummary(t *testing.T) { } return transport.Result{}, false }} - got, err := Read(context.Background(), f, app.Names{App: "sample", BasePath: app.DefaultBasePath}, "R2") + got, err := Read(context.Background(), f, Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath}), "R2") if err != nil { t.Fatal(err) } @@ -137,7 +137,7 @@ func TestReadAndSummary(t *testing.T) { if !s.Done["transfer"] || !s.Done["job:migrate"] || !s.Done["release:web"] || s.Done["release:worker"] { t.Fatalf("done: %+v", s.Done) } - ids, err := List(context.Background(), f, app.Names{App: "sample", BasePath: app.DefaultBasePath}) + ids, err := List(context.Background(), f, Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath})) if err != nil || len(ids) != 2 || ids[1] != "R2" { t.Fatalf("list: %v %v", ids, err) } diff --git a/internal/journal/journals_test.go b/internal/journal/journals_test.go index 5196042a..6e1d08e7 100644 --- a/internal/journal/journals_test.go +++ b/internal/journal/journals_test.go @@ -42,14 +42,14 @@ func TestJournalsOneRoundTrip(t *testing.T) { return transport.Result{}, false }} - ids, byID, err := Journals(context.Background(), f, app.Names{App: "sample", BasePath: app.DefaultBasePath}) + ids, byID, err := Journals(context.Background(), f, Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath})) if err != nil { t.Fatal(err) } if len(f.Commands) != 1 { t.Fatalf("want exactly 1 round trip, got %d: %v", len(f.Commands), f.Commands) } - if !strings.Contains(got, "'/var/lib/ob/sample/journal'") { + if !strings.Contains(got, "'/var/lib/onebox/app/journal'") { t.Fatalf("command must target the app's journal dir: %s", got) } if len(ids) != 2 || ids[0] != "R1" || ids[1] != "R2" { @@ -84,7 +84,7 @@ func TestJournalsTornLastRecordDoesNotSwallowNextFile(t *testing.T) { } return transport.Result{}, false }} - ids, byID, err := Journals(context.Background(), f, app.Names{App: "sample", BasePath: app.DefaultBasePath}) + ids, byID, err := Journals(context.Background(), f, Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath})) if err != nil { t.Fatal(err) } @@ -103,7 +103,7 @@ func TestJournalsTornLastRecordDoesNotSwallowNextFile(t *testing.T) { // output, and Journals returns nothing rather than erroring. func TestJournalsNoJournalDir(t *testing.T) { f := &transport.Fake{} // default: empty stdout, exit 0 - ids, byID, err := Journals(context.Background(), f, app.Names{App: "sample", BasePath: app.DefaultBasePath}) + ids, byID, err := Journals(context.Background(), f, Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath})) if err != nil { t.Fatal(err) } @@ -132,7 +132,7 @@ func TestPruneCandidatesKeepsIndependentDeployAndAuxiliaryWindows(t *testing.T) } return transport.Result{}, false }} - victims, err := PruneCandidates(context.Background(), fake, app.Names{App: "sample", BasePath: app.DefaultBasePath}, 2) + victims, err := PruneCandidates(context.Background(), fake, Dir(app.Names{App: "sample", BasePath: app.DefaultBasePath}), 2) if err != nil { t.Fatal(err) } diff --git a/internal/onebox/backup_read.go b/internal/onebox/backup_read.go index b0076b1a..d2c08d47 100644 --- a/internal/onebox/backup_read.go +++ b/internal/onebox/backup_read.go @@ -37,7 +37,7 @@ func (s *Service) BackupStatusGeneration(ctx context.Context, service, generatio // A half-finished disablement is answered as itself. The runtime that reads // the repository is removed partway through disabling, so a status read in // that state used to surface wal-g's own message — "stat - // /opt/onebox/backup/ob-wal-g: no such file or directory" — which describes + // /opt/onebox/backup/onebox-wal-g: no such file or directory" — which describes // a missing file rather than the state the service is in or the way out of // it. current, err := currentBackupLifecycleState(ctx, e, lp.resolved.Spec.Name, s.environment, service) diff --git a/internal/onebox/bootstrap_test.go b/internal/onebox/bootstrap_test.go index efb7a7dc..91df5f0e 100644 --- a/internal/onebox/bootstrap_test.go +++ b/internal/onebox/bootstrap_test.go @@ -48,7 +48,7 @@ func TestBootstrapAcceptsBuildSourceWithoutStagingApplicationPayload(t *testing. TargetName: "deploy@example.invalid", Dynamic: func(command string) (transport.Result, bool) { if strings.Contains(command, "/_host/owner") { - return transport.Result{Stdout: "demo\n"}, true + return transport.Result{Stdout: "demo production\n"}, true } if strings.Contains(command, "imagetools inspect --help") { return transport.Result{Stdout: "Usage: docker buildx imagetools inspect [OPTIONS] NAME\n --format string\n"}, true diff --git a/internal/onebox/exec_test.go b/internal/onebox/exec_test.go index 0e0ac167..37551313 100644 --- a/internal/onebox/exec_test.go +++ b/internal/onebox/exec_test.go @@ -122,7 +122,7 @@ func TestExecLocksFencesAndJournalsTheExactContainer(t *testing.T) { Dynamic: func(command string) (transport.Result, bool) { switch { case strings.Contains(command, "_host/owner"): - return transport.Result{Stdout: "shop\n"}, true + return transport.Result{Stdout: "shop production\n"}, true case strings.Contains(command, "docker ps -q"): return transport.Result{Stdout: "bbbbbbbbbbbb\naaaaaaaaaaaa\n"}, true case strings.Contains(command, "docker exec aaaaaaaaaaaa "): @@ -174,7 +174,7 @@ func TestExecClassifiesCancellation(t *testing.T) { Dynamic: func(command string) (transport.Result, bool) { switch { case strings.Contains(command, "_host/owner"): - return transport.Result{Stdout: "shop\n"}, true + return transport.Result{Stdout: "shop production\n"}, true case strings.Contains(command, "docker ps -q"): return transport.Result{Stdout: "aaaaaaaaaaaa\n"}, true default: diff --git a/internal/onebox/execution_boundary_test.go b/internal/onebox/execution_boundary_test.go index 17262d2f..5e7e4cb5 100644 --- a/internal/onebox/execution_boundary_test.go +++ b/internal/onebox/execution_boundary_test.go @@ -689,7 +689,7 @@ func TestExecuteRecoveryCorrelatesOperationWithJournalEvidence(t *testing.T) { baseDynamic := fake.Dynamic fake.Dynamic = func(command string) (transport.Result, bool) { if strings.Contains(command, "for f in *.jsonl") { - return transport.Result{Stdout: "@@ob-journal@@R-INCOMPLETE.jsonl\n" + + return transport.Result{Stdout: "@@onebox-journal@@R-INCOMPLETE.jsonl\n" + `{"deploy_id":"R-INCOMPLETE","phase":"deploy","event":"start","ts":"2026-07-12T19:00:00Z"}` + "\n"}, true } return baseDynamic(command) diff --git a/internal/onebox/job_plan_test.go b/internal/onebox/job_plan_test.go index 32dfdd28..61ad7a02 100644 --- a/internal/onebox/job_plan_test.go +++ b/internal/onebox/job_plan_test.go @@ -352,7 +352,7 @@ func TestExecuteScheduledDestructiveJobDetachesToHostUnit(t *testing.T) { t.Fatalf("detached result = %+v", result) } commands := strings.Join(fake.Commands, "\n") - if !strings.Contains(commands, "systemctl start --no-block 'ob-demo-maintenance.service'") { + if !strings.Contains(commands, "systemctl start --no-block 'onebox-job-maintenance.service'") { t.Fatalf("job was not submitted to its host unit:\n%s", commands) } if strings.Contains(commands, "ONEBOX_RESULT_FILE=/run/onebox/job-result") { diff --git a/internal/onebox/plan_deploy.go b/internal/onebox/plan_deploy.go index ea059016..3aab0375 100644 --- a/internal/onebox/plan_deploy.go +++ b/internal/onebox/plan_deploy.go @@ -287,7 +287,7 @@ func readLiveComposeState(ctx context.Context, e *engine.Engine, currentRelease // patch into a document afterwards, and no second place where the runtime can // differ from what `ob preview` showed. func stageExecution(ctx context.Context, lp *loadedProject, environment, releaseID, secretGeneration string, secretRevisions map[string]string, images app.Images) (string, func(), error) { - staging, err := os.MkdirTemp("", "ob-"+lp.resolved.Name) + staging, err := os.MkdirTemp("", "onebox-"+lp.resolved.Name) if err != nil { return "", nil, err } diff --git a/internal/onebox/secrets_push_test.go b/internal/onebox/secrets_push_test.go index 644307a0..4b03600c 100644 --- a/internal/onebox/secrets_push_test.go +++ b/internal/onebox/secrets_push_test.go @@ -44,19 +44,19 @@ func pushFake() *transport.Fake { Dynamic: func(cmd string) (transport.Result, bool) { switch { case strings.Contains(cmd, "_host/owner"): - return transport.Result{Stdout: "shop\n"}, true + return transport.Result{Stdout: "shop production\n"}, true case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/20260712-180000-current\n"}, true - case strings.Contains(cmd, "/ob.snapshot.yml"): + case strings.Contains(cmd, "/onebox.snapshot.yml"): return transport.Result{Stdout: pushProjectYAML}, true case strings.HasPrefix(strings.TrimSpace(cmd), "cat ") && strings.Contains(cmd, "/compose.yaml"): return transport.Result{Stdout: `services: web: - env_file: [.ob-secret-generations/sg-111111111111111111111111/.ob-decrypted-sops-api.enc.env] - labels: {ob.secret-generation: sg-111111111111111111111111} + env_file: [.onebox-secret-generations/sg-111111111111111111111111/.onebox-decrypted-sops-api.enc.env] + labels: {onebox.secret-generation: sg-111111111111111111111111} jobs: - env_file: [.ob-secret-generations/sg-111111111111111111111111/.ob-decrypted-sops-worker.enc.env] - labels: {ob.secret-generation: sg-111111111111111111111111} + env_file: [.onebox-secret-generations/sg-111111111111111111111111/.onebox-decrypted-sops-worker.enc.env] + labels: {onebox.secret-generation: sg-111111111111111111111111} `}, true case strings.Contains(cmd, "cmp -s"): return transport.Result{ExitCode: 1}, true @@ -76,7 +76,7 @@ func pushFake() *transport.Fake { workload = "jobs" } return transport.Result{Stdout: containerByWorkload[workload] + "\n"}, true - case strings.Contains(cmd, "ob.secret-generation"): + case strings.Contains(cmd, "onebox.secret-generation"): for identifier, generation := range containerGeneration { if strings.HasSuffix(cmd, " "+identifier) { return transport.Result{Stdout: generation + "\n"}, true @@ -94,7 +94,7 @@ func pushFake() *transport.Fake { } func generationFromSecretCommand(command string) string { - const marker = "/.ob-secret-generations/" + const marker = "/.onebox-secret-generations/" start := strings.Index(command, marker) if start < 0 { return "" diff --git a/internal/onebox/service_test.go b/internal/onebox/service_test.go index be4052fe..d256e73a 100644 --- a/internal/onebox/service_test.go +++ b/internal/onebox/service_test.go @@ -80,11 +80,11 @@ func serviceFake() *transport.Fake { Dynamic: func(cmd string) (transport.Result, bool) { switch { case strings.Contains(cmd, "/_host/owner"): - return transport.Result{Stdout: "demo\n"}, true + return transport.Result{Stdout: "demo production\n"}, true case strings.Contains(cmd, "readlink"): return transport.Result{Stdout: "releases/R0\n"}, true // Ahead of the project-container probe, which also uses --format. - case strings.Contains(cmd, "docker ps --filter label='ob.operation'"): + case strings.Contains(cmd, "docker ps --filter label='onebox.operation'"): return transport.Result{Stdout: "\n"}, true case strings.Contains(cmd, "docker ps") && strings.Contains(cmd, "--format"): return transport.Result{Stdout: "S1|web|R0|Up (healthy)\nPG1|database|R0|Up (healthy)\n"}, true @@ -155,7 +155,7 @@ func TestPlanDeployBindsAndRendersEveryRuntimeImage(t *testing.T) { t.Fatalf("rendered runtime does not use %s pin:\n%s", workload, plan.Artifact.RenderedCompose) } } - if !strings.Contains(plan.Artifact.RenderedCompose, "ob.workload: database") { + if !strings.Contains(plan.Artifact.RenderedCompose, "onebox.workload: database") { t.Fatalf("pinning the adopted Compose service dropped authored/overlay keys:\n%s", plan.Artifact.RenderedCompose) } } @@ -263,21 +263,21 @@ spec: liveCompose := `services: web: image: ` + imageWeb + ` - env_file: [.ob-secret-generations/` + oldGeneration + `/.ob-decrypted-sops-web.enc.env] - labels: {ob.app: demo, ob.release: R0, ob.workload: web, ob.secret-generation: ` + oldGeneration + `} + env_file: [.onebox-secret-generations/` + oldGeneration + `/.onebox-decrypted-sops-web.enc.env] + labels: {onebox.app: demo, onebox.release: R0, onebox.workload: web, onebox.secret-generation: ` + oldGeneration + `} worker: image: ` + imageWorker + ` - labels: {ob.app: demo, ob.release: R0, ob.workload: worker} + labels: {onebox.app: demo, onebox.release: R0, onebox.workload: worker} ` fake := serviceFake() baseDynamic := fake.Dynamic fake.Dynamic = func(command string) (transport.Result, bool) { switch { - case strings.Contains(command, "ob.snapshot.yml"): + case strings.Contains(command, "onebox.snapshot.yml"): return transport.Result{Stdout: project(false)}, true case strings.Contains(command, "cat ") && strings.Contains(command, "compose.yaml"): return transport.Result{Stdout: liveCompose}, true - case strings.Contains(command, "docker ps --filter label='ob.operation'"): + case strings.Contains(command, "docker ps --filter label='onebox.operation'"): return transport.Result{Stdout: "\n"}, true case strings.Contains(command, "docker ps") && strings.Contains(command, "--format"): return transport.Result{Stdout: "S1|web|R0|Up\nW1|worker|R0|Up\n"}, true diff --git a/internal/onebox/staging_secrets_test.go b/internal/onebox/staging_secrets_test.go index 3289e610..11472a28 100644 --- a/internal/onebox/staging_secrets_test.go +++ b/internal/onebox/staging_secrets_test.go @@ -162,7 +162,7 @@ func TestEveryEncryptedEntryIsStagedUnderItsOwnName(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(runtime), "ob.secret-generation: sg-000000000000000000000001") || + if !strings.Contains(string(runtime), "onebox.secret-generation: sg-000000000000000000000001") || !strings.Contains(string(runtime), app.SecretGenerationDirectory+"/sg-000000000000000000000001/") { t.Fatalf("initial deployment runtime does not bind the opaque generation:\n%s", runtime) } @@ -184,7 +184,7 @@ func TestEveryEncryptedEntryIsStagedUnderItsOwnName(t *testing.T) { // A plaintext entry is referenced under its own name, never a staged one. // // Routing every entry through StagedPath would have the runtime reference -// `.ob-decrypted-…` for a file that is checked in and never decrypted. +// `.onebox-decrypted-…` for a file that is checked in and never decrypted. func TestAPlaintextEntryKeepsItsOwnName(t *testing.T) { fakeSops(t) configPath := twoEncryptedEntries(t) @@ -206,7 +206,7 @@ func TestAPlaintextEntryKeepsItsOwnName(t *testing.T) { if !strings.Contains(string(body), "shared.env") { t.Error("the plaintext entry is not referenced") } - if strings.Contains(string(body), ".ob-decrypted-sops-shared.env") { + if strings.Contains(string(body), ".onebox-decrypted-sops-shared.env") { t.Error("a plaintext entry was given a decrypted name; nothing writes that file") } } @@ -291,7 +291,7 @@ spec: } defer cleanup() - projectionPath := ".ob-external-database_web.env" + projectionPath := ".onebox-external-database_web.env" generationPath := filepath.FromSlash(app.SecretGenerationPath("sg-000000000000000000000001", projectionPath)) projected, err := os.ReadFile(filepath.Join(staging, generationPath)) if err != nil { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index dbc3bee0..f557e435 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,6 +1,6 @@ // Package proxy renders and identifies the HOST-scoped managed proxy (design: // "The proxy is owned — managed or external, never assumed"). One Traefik per -// host, shared by every ob app on it, living under /var/lib/ob/_host/ — +// host, owned by its one application, living under /var/lib/onebox/_host/ — // a name no app can take (app names match ^[a-z][a-z0-9-]*$). // // The app may supply Traefik configuration as a flat dir (proxy.config). @@ -41,10 +41,9 @@ const ( DiscoveryImageRepository = "ghcr.io/labstack/onebox-discovery" // Project is the compose project name; ContainerName the fixed container // name — both host-global, which is the point. - Project = app.ProxyProject - ContainerName = app.ProxyProject - DiscoveryContainerName = "onebox-discovery" - LegacyDiscoveryContainerName = app.ProxyProject + "-discovery" + Project = app.ProxyProject + ContainerName = app.ProxyProject + DiscoveryContainerName = "onebox-discovery" ) var releaseVersion = regexp.MustCompile(`^v[0-9]{4}\.[0-9]{1,2}\.[0-9]+$`) @@ -59,29 +58,29 @@ func DiscoveryImage(version string) string { return DiscoveryImageRepository + ":edge" } -// Paths is the host-scoped layout, sibling of the sole application directory. +// Paths is the host-scoped layout, under app.HostStateDir. type Paths struct { - Base string // /_host - Lock string // /_host/lock - Journal string // /_host/journal - Dir string // /_host/proxy - Compose string // /_host/proxy/compose.yaml - ConfigDir string // /_host/proxy/config - Dynamic string // /_host/proxy/dynamic - Acme string // /_host/proxy/acme - Hash string // /_host/proxy/config.hash - Owner string // /_host/owner + Base string // /var/lib/onebox/_host + Lock string // …/_host/lock + Journal string // …/_host/journal + Dir string // …/_host/proxy + Compose string // …/_host/proxy/compose.yaml + ConfigDir string // …/_host/proxy/config + Dynamic string // …/_host/proxy/dynamic + Acme string // …/_host/proxy/acme + Hash string // …/_host/proxy/config.hash + Owner string // …/_host/owner } -// HostPaths is the host-scoped layout, resolved from the same base as -// everything else this application writes. The owner record prevents another -// application identity from adopting the same host-scoped state. +// HostPaths is the host-scoped layout. It does not follow basePath: there is +// one per host, and the owner record in it is what keeps a host to one +// application. func HostPaths(n app.Names) Paths { base := n.HostDir() return Paths{ Base: base, Lock: base + "/lock", - Journal: base + "/journal", + Journal: n.HostJournalDir(), Dir: base + "/proxy", Compose: base + "/proxy/compose.yaml", ConfigDir: base + "/proxy/config", @@ -457,7 +456,7 @@ func StageForAppManaged(localCfgDir, stagingDir, image, discoveryImage, applicat } } if name != staticName && dynamicConfigExtension(name) { - if err := validateDynamicOwnership(name, b, application); err != nil { + if err := validateDynamicOwnership(name, b); err != nil { return "", fmt.Errorf("proxy.config %s: %w", name, err) } } @@ -617,7 +616,7 @@ func validateSocketlessStaticConfig(body []byte, requireExactCertificateResolver // Before socketless discovery those generated names lived under @docker, so an // identically named @file object could coexist; accepting it now would make // Traefik discard the conflicting objects during upgrade. -func validateDynamicOwnership(name string, body []byte, application string) error { +func validateDynamicOwnership(name string, body []byte) error { var document map[string]any var err error switch strings.ToLower(filepath.Ext(name)) { @@ -631,7 +630,7 @@ func validateDynamicOwnership(name string, body []byte, application string) erro if err != nil { return fmt.Errorf("parse dynamic configuration: %w", err) } - reservedPrefix := app.Join(application, "") + reservedPrefix := app.Join(app.Namespace, "") for _, protocol := range []string{"http", "tcp"} { section, _ := document[protocol].(map[string]any) for _, kind := range []string{"routers", "services"} { diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 476f837c..5bd0f88b 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -38,23 +38,27 @@ func writeCfg(t *testing.T, files map[string]string) string { } func TestPathsHostScoped(t *testing.T) { - // The base comes from the project's resolved names, so an app declaring - // base_path puts the host proxy beside its own state rather than in a - // second tree nothing else reads. + // basePath moves an application's state, never the host's: the owner record + // here is what keeps a host to one application, so it cannot be per basePath. p := HostPaths(app.Names{App: "sample", BasePath: "/tmp/obbase"}) - if p.Base != "/tmp/obbase/_host" { + if p.Base != app.HostStateDir { t.Fatalf("base: %s", p.Base) } - if p.Compose != "/tmp/obbase/_host/proxy/compose.yaml" || p.Owner != "/tmp/obbase/_host/owner" { + if p.Compose != app.HostStateDir+"/proxy/compose.yaml" || p.Owner != app.HostStateDir+"/owner" || p.Journal != app.HostStateDir+"/journal" { t.Fatalf("paths: %+v", p) } - if p.Lock != "/tmp/obbase/_host/lock" || p.Acme != "/tmp/obbase/_host/proxy/acme" { - t.Fatalf("paths: %+v", p) + restore, err := app.SetTestHostStateDir("/tmp/fixture-host") + if err != nil { + t.Fatal(err) + } + t.Cleanup(restore) + if got := HostPaths(app.Names{App: "sample", BasePath: "/tmp/obbase"}).Owner; got != "/tmp/fixture-host/owner" { + t.Fatalf("test host state override ignored: %s", got) } } func TestRenderCompose(t *testing.T) { - b := string(RenderCompose("traefik:v3.7", "ob-ingress", true, nil)) + b := string(RenderCompose("traefik:v3.7", "onebox-ingress", true, nil)) for _, want := range []string{ "container_name: onebox-proxy", "image: traefik:v3.7", @@ -68,7 +72,7 @@ func TestRenderCompose(t *testing.T) { "cap_add: [NET_BIND_SERVICE]", "config/.env", `["CMD", "traefik", "healthcheck"]`, - "name: ob-ingress", + "name: onebox-ingress", } { if !strings.Contains(b, want) { t.Fatalf("rendered compose missing %q:\n%s", want, b) @@ -85,7 +89,7 @@ func TestRenderCompose(t *testing.T) { if err := yaml.Unmarshal([]byte(b), &parsed); err != nil { t.Fatalf("rendered compose is not valid YAML: %v\n%s", err, b) } - noEnv := string(RenderCompose("traefik:v3.7", "ob-ingress", false, nil)) + noEnv := string(RenderCompose("traefik:v3.7", "onebox-ingress", false, nil)) if strings.Contains(noEnv, ".env") { t.Fatalf("env_file must be omitted without .env:\n%s", noEnv) } @@ -650,7 +654,7 @@ func TestCertExpiries(t *testing.T) { // same one every time. func TestDefaultStaticConfigIsWrittenWhenNoneIsDeclared(t *testing.T) { staging := t.TempDir() - hash, err := Stage("", staging, "traefik:v3.7", "ob-ingress", nil, true) + hash, err := Stage("", staging, "traefik:v3.7", "onebox-ingress", nil, true) if err != nil { t.Fatalf("a project without proxy.config must still bootstrap: %v", err) } @@ -700,7 +704,7 @@ func TestDeclaredConfigWithoutTraefikFilesSaysWhatToDo(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "README.txt"), []byte("notes\n"), 0o600); err != nil { t.Fatal(err) } - _, err := Stage(dir, t.TempDir(), "traefik:v3.7", "ob-ingress", nil, false) + _, err := Stage(dir, t.TempDir(), "traefik:v3.7", "onebox-ingress", nil, false) if err == nil { t.Fatal("a declared config directory without dynamic or static Traefik files must be refused") } diff --git a/internal/release/mounted_releases.go b/internal/release/mounted_releases.go index 8d7f05c3..7ab240ca 100644 --- a/internal/release/mounted_releases.go +++ b/internal/release/mounted_releases.go @@ -17,7 +17,7 @@ import ( // start, where a missing bind source is silently recreated as an empty // directory. That is why this reads `-a` rather than only what is running. // -// The mounts are the evidence, not the ob.release label. A retained container +// The mounts are the evidence, not the onebox.release label. A retained container // keeps the label of the release it was created in, and most retained workloads // mount nothing out of that release — trusting the label would pin a directory // nothing reads, for as long as that container lives, and quietly stop @@ -27,7 +27,7 @@ import ( // not a release id, contributes nothing: the store only ever offers valid ids as // deletion candidates, so such a mount can protect nothing. func MountedReleases(ctx context.Context, target transport.Transport, names app.Names) ([]string, error) { - command := "docker ps -a --no-trunc --filter label=ob.app=" + q(names.App) + " --format '{{.Mounts}}'" + command := "docker ps -a --no-trunc --filter label=onebox.app=" + q(names.App) + " --format '{{.Mounts}}'" result, err := target.Run(ctx, command) if err != nil { return nil, err diff --git a/internal/release/release.go b/internal/release/release.go index 97c2b960..f9fa0636 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -1,5 +1,5 @@ // Package release manages the versioned remote layout: -// /var/lib/ob//releases// + a `current` symlink. Nothing live is +// /app/releases// + a `current` symlink. Nothing live is // ever overwritten; rollback re-activates a previous directory. package release @@ -66,7 +66,7 @@ func Stage(dir string, composeYAML, snapshotYAML []byte) error { if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), composeYAML, 0o600); err != nil { return err } - return os.WriteFile(filepath.Join(dir, "ob.snapshot.yml"), snapshotYAML, 0o644) + return os.WriteFile(filepath.Join(dir, "onebox.snapshot.yml"), snapshotYAML, 0o644) } func Push(ctx context.Context, t transport.Transport, stagingDir string, n app.Names, id string) (string, error) { diff --git a/internal/release/release_test.go b/internal/release/release_test.go index fcdb9b11..8ca36c50 100644 --- a/internal/release/release_test.go +++ b/internal/release/release_test.go @@ -76,7 +76,7 @@ func TestPreviousAndPrune(t *testing.T) { t.Fatalf("removed=%v", removed) } joined := strings.Join(f.Commands, "\n") - if !strings.Contains(joined, "rm -rf '/var/lib/ob/sample/releases/20260701-010000-aaa'") { + if !strings.Contains(joined, "rm -rf '/var/lib/onebox/app/releases/20260701-010000-aaa'") { t.Fatalf("prune command missing:\n%s", joined) } } diff --git a/internal/release/retention_test.go b/internal/release/retention_test.go index 5f055c8a..5a1abb9f 100644 --- a/internal/release/retention_test.go +++ b/internal/release/retention_test.go @@ -120,7 +120,7 @@ func TestRetentionProtectsReleaseLeasedByScheduledJob(t *testing.T) { switch { case strings.Contains(command, "ls -1A"): return transport.Result{Stdout: leasedID + "\n" + currentID + "\n"}, true - case strings.Contains(command, ".ob-schedule.lease"): + case strings.Contains(command, ".onebox-schedule.lease"): return transport.Result{Stdout: leasedID + "\n"}, true case strings.Contains(command, "readlink"): return transport.Result{Stdout: "releases/" + currentID + "\n"}, true @@ -483,7 +483,7 @@ func TestRetentionDoesNotPinAReleaseAContainerOnlyLabels(t *testing.T) { // The container was created in the expired release and retained // ever since, so it still carries that label — while mounting // nothing out of the release store. - if strings.Contains(command, "ob.release") { + if strings.Contains(command, "onebox.release") { return transport.Result{Stdout: staleID + "\n"}, true } return transport.Result{Stdout: "/var/run/docker.sock,app-data\n"}, true diff --git a/internal/release/schedule_lease.go b/internal/release/schedule_lease.go index 631e42d7..cae486b5 100644 --- a/internal/release/schedule_lease.go +++ b/internal/release/schedule_lease.go @@ -10,7 +10,7 @@ import ( "github.com/labstack/onebox/internal/transport" ) -const scheduleLeaseFile = ".ob-schedule.lease" +const scheduleLeaseFile = ".onebox-schedule.lease" const scheduleLeaseConflictExitCode = 200 // ActiveScheduleLeases returns release ids held by pinned scheduled jobs. The diff --git a/internal/release/secrets.go b/internal/release/secrets.go index 19acaf9e..89f630f8 100644 --- a/internal/release/secrets.go +++ b/internal/release/secrets.go @@ -23,8 +23,7 @@ var ErrSecretCheckpointMissing = errors.New("secret checkpoint missing") type SecretPhase string const ( - LegacySecretCheckpointSchemaVersion = "onebox.run/secret-checkpoint/v1alpha1" - SecretCheckpointSchemaVersion = "onebox.run/secret-checkpoint/v1alpha2" + SecretCheckpointSchemaVersion = "onebox.run/secret-checkpoint/v1alpha2" SecretPrepared SecretPhase = "prepared" SecretReplacing SecretPhase = "replacing" @@ -119,7 +118,7 @@ func (checkpoint *SecretCheckpoint) MarkReplaced(workload string, at time.Time) } func (checkpoint SecretCheckpoint) Validate() error { - if checkpoint.SchemaVersion != SecretCheckpointSchemaVersion && checkpoint.SchemaVersion != LegacySecretCheckpointSchemaVersion { + if checkpoint.SchemaVersion != SecretCheckpointSchemaVersion { return fmt.Errorf("secret checkpoint schema %q is not supported", checkpoint.SchemaVersion) } if !IsID(checkpoint.ReleaseID) { @@ -137,10 +136,7 @@ func (checkpoint SecretCheckpoint) Validate() error { if !sortedUniqueExact(checkpoint.AffectedWorkloads) || !sortedUniqueExact(checkpoint.PayloadPaths) || !sortedUniqueExact(checkpoint.ChangedPaths) || !sortedUniqueExact(checkpoint.ReplacedWorkloads) { return errors.New("secret checkpoint lists must be sorted and unique") } - if checkpoint.SchemaVersion == LegacySecretCheckpointSchemaVersion && len(checkpoint.ChangedPaths) != 0 { - return errors.New("legacy secret checkpoint cannot contain changed paths") - } - if checkpoint.SchemaVersion == SecretCheckpointSchemaVersion && len(checkpoint.ChangedPaths) == 0 { + if len(checkpoint.ChangedPaths) == 0 { return errors.New("secret checkpoint must bind changed paths") } for _, payloadPath := range checkpoint.PayloadPaths { diff --git a/internal/release/secrets_test.go b/internal/release/secrets_test.go index ff8a3e41..a043c15f 100644 --- a/internal/release/secrets_test.go +++ b/internal/release/secrets_test.go @@ -122,26 +122,6 @@ func TestSecretCheckpointRejectsUnsafePayloadPaths(t *testing.T) { } } -func TestLegacySecretCheckpointRemainsReadable(t *testing.T) { - checkpoint, err := NewSecretCheckpoint("20260809-120000-current", "sg-111111111111111111111111", "sg-222222222222222222222222", []string{"web"}, []string{"web.env"}, time.Now()) - if err != nil { - t.Fatal(err) - } - checkpoint.SchemaVersion = LegacySecretCheckpointSchemaVersion - checkpoint.ChangedPaths = nil - body, err := EncodeSecretCheckpoint(checkpoint) - if err != nil { - t.Fatal(err) - } - decoded, err := DecodeSecretCheckpoint(body) - if err != nil { - t.Fatal(err) - } - if decoded.SchemaVersion != LegacySecretCheckpointSchemaVersion || len(decoded.ChangedPaths) != 0 { - t.Fatalf("legacy checkpoint = %#v", decoded) - } -} - func TestReadSecretCheckpointFailsClosed(t *testing.T) { names := app.Names{App: "shop", BasePath: "/srv/onebox"} tests := []struct { diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go index 58414d30..c7bc7e1d 100644 --- a/internal/secrets/secrets.go +++ b/internal/secrets/secrets.go @@ -54,7 +54,7 @@ func RenderContext(ctx context.Context, configDir, sopsFile string) ([]byte, err // callers can fingerprint exactly the ciphertext bytes that produced the // returned runtime payload without a second, racy read of the source path. func RenderBytesContext(ctx context.Context, sourceName string, encrypted []byte) ([]byte, error) { - directory, err := os.MkdirTemp("", "ob-sops-snapshot") + directory, err := os.MkdirTemp("", "onebox-sops-snapshot") if err != nil { return nil, err } diff --git a/internal/transport/fake.go b/internal/transport/fake.go index 438a5b51..4d602959 100644 --- a/internal/transport/fake.go +++ b/internal/transport/fake.go @@ -118,7 +118,7 @@ func (f *Fake) evalLocked(cmd string) Result { } // Engine epoch probes default to an absent file on a fresh fake host. Tests // can override this default through Dynamic or Script. - if strings.HasPrefix(cmd, ": ob-epoch-probe;") { + if strings.HasPrefix(cmd, ": onebox-epoch-probe;") { return Result{ExitCode: 3} } return Result{ExitCode: 0} diff --git a/internal/transport/ssh.go b/internal/transport/ssh.go index 134f47fd..433e9465 100644 --- a/internal/transport/ssh.go +++ b/internal/transport/ssh.go @@ -412,8 +412,8 @@ func knownHostKeyAlgos(cb ssh.HostKeyCallback, addr string) []string { // KeyError whose Want lists the pinned host keys. type probeKey struct{} -func (probeKey) Type() string { return "ob-probe" } -func (probeKey) Marshal() []byte { return []byte("ob-probe") } +func (probeKey) Type() string { return "onebox-probe" } +func (probeKey) Marshal() []byte { return []byte("onebox-probe") } func (probeKey) Verify([]byte, *ssh.Signature) error { return errors.New("probe") } func (s *SSH) Run(ctx context.Context, cmd string) (Result, error) { diff --git a/internal/transport/ssh_upload_test.go b/internal/transport/ssh_upload_test.go index 93fd68ea..e96cdb55 100644 --- a/internal/transport/ssh_upload_test.go +++ b/internal/transport/ssh_upload_test.go @@ -225,7 +225,7 @@ func TestAnAbortedUploadDoesNotWaitOnAWedgedRemoteForever(t *testing.T) { done := make(chan error, 1) go func() { - done <- uploadWithSession(context.Background(), sess, localDir, "/var/lib/ob/shop/releases/20260808-120000-abc") + done <- uploadWithSession(context.Background(), sess, localDir, "/var/lib/onebox/app/releases/20260808-120000-abc") }() select { case err := <-done: @@ -265,7 +265,7 @@ func TestAnAbortedUploadReportsAPossiblyPublishedDestination(t *testing.T) { waitDone <- nil sess := &wedgedSession{stdin: discardWriteCloser{Writer: io.Discard}, waitDone: waitDone} - const dest = "/var/lib/ob/shop/releases/20260808-120000-abc" + const dest = "/var/lib/onebox/app/releases/20260808-120000-abc" err := uploadWithSession(context.Background(), sess, localDir, dest) if err == nil { t.Fatal("upload reported success") diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 5534d576..1c1abbe7 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -163,7 +163,7 @@ func stagingPath(remoteDir string) string { // uploadSentinel is written as the final archive entry by transports that // stream, so the receiver can tell a complete payload from a truncated one. // See uploadScript. -const uploadSentinel = ".ob-upload-complete" +const uploadSentinel = ".onebox-upload-complete" // uploadScript wraps a transfer so an interrupted one cannot be mistaken for a // finished one. diff --git a/internal/transport/upload_staging_test.go b/internal/transport/upload_staging_test.go index bb68dddc..3c8caa45 100644 --- a/internal/transport/upload_staging_test.go +++ b/internal/transport/upload_staging_test.go @@ -16,7 +16,7 @@ import ( // read as a release is that the entry it adds there is hidden. This fails if // stagingRoot loses its leading dot. func TestStagingAddsOnlyAHiddenEntryToTheDestinationsDirectory(t *testing.T) { - dest := "/var/lib/ob/shop/releases/20260808-120000-abc" + dest := "/var/lib/onebox/app/releases/20260808-120000-abc" staging := stagingPath(dest) parent := filepath.Dir(dest) @@ -126,8 +126,8 @@ func TestAStreamedUploadDoesNotPublishItsSentinel(t *testing.T) { } func TestStagingIsDistinctPerDestination(t *testing.T) { - a := stagingPath("/var/lib/ob/shop/releases/r1") - b := stagingPath("/var/lib/ob/shop/releases/r2") + a := stagingPath("/var/lib/onebox/app/releases/r1") + b := stagingPath("/var/lib/onebox/app/releases/r2") if a == b { t.Fatal("two destinations share one staging path") } @@ -174,11 +174,11 @@ func TestUploadScriptRefusesDangerousDestinations(t *testing.T) { // A trailing slash would make staging a child of the target, so removing the // target would destroy the payload too. Cleaning the path first prevents it. func TestUploadScriptCleansItsDestination(t *testing.T) { - withSlash, err := uploadScript("/var/lib/ob/shop/releases/r1/", func(s string) string { return "true" }) + withSlash, err := uploadScript("/var/lib/onebox/app/releases/r1/", func(s string) string { return "true" }) if err != nil { t.Fatal(err) } - without, err := uploadScript("/var/lib/ob/shop/releases/r1", func(s string) string { return "true" }) + without, err := uploadScript("/var/lib/onebox/app/releases/r1", func(s string) string { return "true" }) if err != nil { t.Fatal(err) } diff --git a/internal/transport/upload_test.go b/internal/transport/upload_test.go index 6fe33534..43b32a9f 100644 --- a/internal/transport/upload_test.go +++ b/internal/transport/upload_test.go @@ -62,13 +62,13 @@ func TestUploadFailsWhenTheSourceCannotBeRead(t *testing.T) { func TestUploadSucceedsAndCopiesEverything(t *testing.T) { source := t.TempDir() writeFile(t, filepath.Join(source, "compose.yaml"), "services: {}\n") - writeFile(t, filepath.Join(source, "ob.snapshot.yml"), "app: shop\n") + writeFile(t, filepath.Join(source, "onebox.snapshot.yml"), "app: shop\n") dest := filepath.Join(t.TempDir(), "releases", "20260808-120000-abc") if err := NewLocal().Upload(context.Background(), source, dest); err != nil { t.Fatalf("upload: %v", err) } - for _, name := range []string{"compose.yaml", "ob.snapshot.yml"} { + for _, name := range []string{"compose.yaml", "onebox.snapshot.yml"} { if _, err := os.Stat(filepath.Join(dest, name)); err != nil { t.Errorf("%s did not arrive: %v", name, err) } diff --git a/site/astro.config.mjs b/site/astro.config.mjs index b35a1292..33d5d4a3 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -102,6 +102,7 @@ export default defineConfig({ { label: "Environment variables", slug: "guides/environment-variables" }, { label: "Adopt an existing Compose file", slug: "guides/adopt-compose" }, { label: "Eject", slug: "guides/eject" }, + { label: "Upgrade to onebox names", slug: "guides/upgrade-to-onebox-names" }, ], }, { diff --git a/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha1/application.schema.json index 14270a02..8581545d 100644 --- a/site/public/schemas/application/v1alpha1/application.schema.json +++ b/site/public/schemas/application/v1alpha1/application.schema.json @@ -26,23 +26,17 @@ "type": "object" }, "name": { - "description": "The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin \"ob-\" or be a name the host layout reserves. Stable application name used in generated runtime identities.", + "description": "The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin \"onebox-\" or be a name the host layout reserves. Stable application name used in generated runtime identities.", "examples": [ "shop" ], "not": { "anyOf": [ { - "pattern": "^ob-" + "pattern": "^onebox-" }, { - "const": "ob" - }, - { - "const": "onebox-proxy" - }, - { - "const": "_host" + "const": "onebox" } ] }, @@ -221,7 +215,7 @@ "type": "object" }, "basePath": { - "default": "/var/lib/ob", + "default": "/var/lib/onebox", "description": "Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter.", "examples": [ "/srv/ob" @@ -926,7 +920,7 @@ "type": "boolean" }, "network": { - "default": "ob-ingress", + "default": "onebox-ingress", "description": "External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved.", "type": "string" } @@ -1281,6 +1275,24 @@ "description": "Also accepts the version to run." }, "description": "Supporting services managed outside application releases, such as databases and caches.", + "propertyNames": { + "not": { + "anyOf": [ + { + "const": "proxy" + }, + { + "const": "discovery" + }, + { + "const": "ingress" + }, + { + "const": "services" + } + ] + } + }, "type": "object" }, "workloads": { @@ -2107,6 +2119,7 @@ "examples": [ 2 ], + "maximum": 100, "minimum": 1, "type": "integer" }, diff --git a/site/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro index 37b5ea8d..dcbbd4c0 100644 --- a/site/src/components/landing/Derivation.astro +++ b/site/src/components/landing/Derivation.astro @@ -262,11 +262,11 @@ derived.push(["onebox-proxy", "Traefik, its static configuration, a router and TLS"]); } if (on.postgres) { - derived.push(["shop-postgres-1", "image, health check, and a credential generated on the server"]); - derived.push(["ob_shop_postgres_data", "the durable volume — renaming it would need a migration"]); - derived.push(["ob_shop", "the external service network"]); + derived.push(["onebox-postgres", "image, health check, and a credential generated on the server"]); + derived.push(["onebox_postgres_data", "the durable volume — renaming it would need a migration"]); + derived.push(["onebox_services", "the external service network"]); } - derived.push(["/var/lib/ob/shop", "releases/ · current · journal · services"]); + derived.push(["/var/lib/onebox/app", "releases/ · current · journal · services"]); listOut.innerHTML = derived .map(([name, what]) => `
  • ${esc(name)}${esc(what)}
  • `) diff --git a/site/src/components/landing/InsideTheBox.astro b/site/src/components/landing/InsideTheBox.astro index 180f0819..8ba98399 100644 --- a/site/src/components/landing/InsideTheBox.astro +++ b/site/src/components/landing/InsideTheBox.astro @@ -17,7 +17,7 @@ ONE HOST · ONE APPLICATION - ob-ingress + onebox-ingress @@ -44,7 +44,7 @@ shop-worker-1 no route - ob_shop + onebox_services @@ -52,14 +52,14 @@ - shop-postgres-1 + onebox-postgres credential generated on the server backup → a repository you own - ob_shop_postgres_data + onebox_postgres_data ON DISK - /var/lib/ob/shop + /var/lib/onebox/app releases/ · current · journal · services shop_default — the application's own Compose network diff --git a/site/src/content/docs/explanation/generated-compose.mdx b/site/src/content/docs/explanation/generated-compose.mdx index df93dd86..d6cd99d3 100644 --- a/site/src/content/docs/explanation/generated-compose.mdx +++ b/site/src/content/docs/explanation/generated-compose.mdx @@ -33,10 +33,11 @@ becomes a question rather than a fact. ## What generation buys -- **Derived, stable names.** Application containers use the uniform - `--` grammar, such as `shop-web-1`; persistent and - provider resources include `shop_default`, `ob_shop_postgres_data`, `ob_shop`, - and `ob-ingress`. The application and service networks are declared external +- **Derived, stable names.** Workload containers use the uniform + `--` grammar, such as `shop-web-1`; managed services + are `onebox-`, such as `onebox-postgres`; persistent and + provider resources include `shop_default`, `onebox_postgres_data`, `onebox_services`, + and `onebox-ingress`. The application and service networks are declared external and created under Onebox's ownership fence, so Compose cannot remove a live shared network during release teardown. A full `ob destroy --volumes` removes them; if an unmanaged endpoint remains attached, destruction stops without @@ -90,7 +91,7 @@ workloads: ``` Refusals apply only where the referenced service contradicts something Onebox -owns — `extends`, a fixed `container_name`, `network_mode`, labels in the `ob.` +owns — `extends`, a fixed `container_name`, `network_mode`, labels in the `onebox.` or `traefik.` namespaces. See [Adopt an existing Compose file](/guides/adopt-compose). The result is a contract that generates by default and defers on request, rather diff --git a/site/src/content/docs/explanation/ownership-boundary.mdx b/site/src/content/docs/explanation/ownership-boundary.mdx index 5be740b3..e66a81b1 100644 --- a/site/src/content/docs/explanation/ownership-boundary.mdx +++ b/site/src/content/docs/explanation/ownership-boundary.mdx @@ -50,8 +50,8 @@ record before it does anything. A different application is refused with `host_owner_mismatch`. Runtime resources carry the same boundary. Containers, volumes, and networks -created by Onebox have an `ob.app` label, and preflight includes the application -default network (`_default`) and service network (`ob_`) in its +created by Onebox have an `onebox.app` label, and preflight includes the application +default network (`_default`) and service network (`onebox_services`) in its collision set. Both networks are external to a release's Compose lifecycle: a release can be removed without deleting a network still used by an unmanaged proxy or a supporting service. Networks created by older versions cannot be diff --git a/site/src/content/docs/guides/back-up-a-database.mdx b/site/src/content/docs/guides/back-up-a-database.mdx index 75746a80..82ededbd 100644 --- a/site/src/content/docs/guides/back-up-a-database.mdx +++ b/site/src/content/docs/guides/back-up-a-database.mdx @@ -51,8 +51,8 @@ nothing archives. $ ob backup enable database ✓ protected image ghcr.io/labstack/onebox-postgres:18 ✓ service database -→ backup schedule: ob-backup-shop-production-database-backup at 0 2 * * * -→ backup schedule: ob-backup-shop-production-database-verify at 0 4 * * * +→ backup schedule: onebox-backup-database-backup at 0 2 * * * +→ backup schedule: onebox-backup-database-verify at 0 4 * * * ✓ backup database ``` diff --git a/site/src/content/docs/guides/eject.mdx b/site/src/content/docs/guides/eject.mdx index 5f26a3df..7d5d3ae9 100644 --- a/site/src/content/docs/guides/eject.mdx +++ b/site/src/content/docs/guides/eject.mdx @@ -41,7 +41,7 @@ ob eject # write it out and hand it over - **Your data.** Service volumes and workload volumes stay exactly where they are. - **The host layout.** Releases, the journal, and supporting services under - `/var/lib/ob/` remain until you remove them. + `/var/lib/onebox/app` remain until you remove them. - **Foreign resources.** Anything Onebox does not own evidence for is left alone. ## Refusals diff --git a/site/src/content/docs/guides/roll-back.mdx b/site/src/content/docs/guides/roll-back.mdx index 373df66d..bc681b72 100644 --- a/site/src/content/docs/guides/roll-back.mdx +++ b/site/src/content/docs/guides/roll-back.mdx @@ -42,7 +42,7 @@ protecting current, predecessor-chain, and checkpoint-referenced releases. Rollback reconciles each workload against the predecessor's immutable runtime revision. A healthy fleet already at that exact revision stays running, even if -its `ob.release` label names an older release; a missing, unhealthy, incomplete +its `onebox.release` label names an older release; a missing, unhealthy, incomplete or different revision follows the predecessor's configured rolling or recreate strategy. Abort uses the same fail-closed rule. diff --git a/site/src/content/docs/guides/schedule-a-job.mdx b/site/src/content/docs/guides/schedule-a-job.mdx index aedfdba0..f20dcb8c 100644 --- a/site/src/content/docs/guides/schedule-a-job.mdx +++ b/site/src/content/docs/guides/schedule-a-job.mdx @@ -147,9 +147,9 @@ that no run record exists yet. ## Every run leaves a record When a run ends, for any reason, the unit's `ExecStopPost` writes one record -to the host journal with the syslog identifier `ob-run` and the job's unit in -its `ONEBOX_UNIT` field, so `journalctl SYSLOG_IDENTIFIER=ob-run -ONEBOX_UNIT=ob-- -o cat` on the host is the raw history: +to the host journal with the syslog identifier `onebox-run` and the job's unit in +its `ONEBOX_UNIT` field, so `journalctl SYSLOG_IDENTIFIER=onebox-run +ONEBOX_UNIT=onebox-job- -o cat` on the host is the raw history: ```json {"run":"a3f9…","job":"nightly-dump","trigger":"timer","operation":"","release":"20260905-140000-ab12cd","started_at":"2026-09-05T02:00:01Z","finished_at":"2026-09-05T02:04:37Z","duration_s":276,"attempts":2,"exit_status":0,"outcome":"success","reason":"","inputs":{}} diff --git a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx new file mode 100644 index 00000000..11f4a946 --- /dev/null +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -0,0 +1,167 @@ +--- +title: Upgrade to onebox names +description: Move an existing host to the onebox namespace. Onebox does not migrate anything for you. +summary: What the rename changes, what happens if you deploy without preparing the host, and the manual steps that carry the data and state across. +sidebar: + order: 10 +read_when: + - "Upgrading a host that was deployed before the onebox naming change" + - "A deploy is refused because a resource is held by something Onebox does not own" + - "A deploy came up with an empty database after an upgrade" +--- + +Everything Onebox puts on a host is now named `onebox`, and nothing it runs for +you carries the application's name. **There is no migration and no fallback.** +Onebox does not look for the old names, labels, paths or units, read from them, +or remove them. Prepare the host with the steps below **before** the first +command with the new version. + +## What changes + +| Resource | Before | After | +| --- | --- | --- | +| State directory | `/var/lib/ob/shop` | `/var/lib/onebox/app` | +| Host state | `/var/lib/ob/_host` | `/var/lib/onebox/_host` | +| Owner record | `shop` or `shop production` | `shop production` only | +| Ownership labels | `ob.app`, `ob.workload`, `ob.service`, … | `onebox.app`, `onebox.workload`, `onebox.service`, … | +| Managed service container | `shop-postgres-1` | `onebox-postgres` | +| Restore-drill container | `shop-postgres-restore-1` | `onebox-postgres-restore` | +| Volumes | `ob_shop_postgres_data` | `onebox_postgres_data` | +| Service Compose project | `ob_shop_postgres` | `onebox_postgres` | +| Service network | `ob_shop` | `onebox_services` | +| Ingress network | `ob-ingress` | `onebox-ingress` | +| Scheduled job units | `ob-shop-nightly` | `onebox-job-nightly` | +| Backup units | `ob-backup-shop-production-postgres-backup` | `onebox-backup-postgres-backup` | +| Proxy routers | `shop_web_r0` | `onebox_web_r0` | +| Job run journal identifier | `ob-run` | `onebox-run` | +| Files Onebox keeps beside its own | `.ob-schedule.lease`, `.ob-secret-generations/`, `.ob-tmp` | `.onebox-schedule.lease`, `.onebox-secret-generations/`, `.onebox-tmp` | +| Drain marker in containers | `/tmp/ob-drain` | `/tmp/onebox-drain` | +| Release snapshot | `ob.snapshot.yml` | `onebox.snapshot.yml` | +| Workload containers | `shop-web-1` | unchanged, but labelled `onebox.app` | +| Application network | `shop_default` | unchanged, but labelled `onebox.app` | + +Application names may no longer be `onebox` or begin with `onebox-`. Service +names may not be `proxy`, `discovery`, `ingress` or `services`. Custom Traefik objects may +not begin with `onebox_`. The default `basePath` is now `/var/lib/onebox`; if +you set your own, the application's state moves to `/app`. Host state +is always `/var/lib/onebox/_host` now, whatever `basePath` says. + +## What happens without these steps + +- **Every command reports an unowned host.** The owner record is read from + `/var/lib/onebox/_host/owner`, which does not exist yet. `ob bootstrap` would + claim the host again from scratch. +- **Deploys are refused.** `shop-web-1` and `shop_default` still carry the old + `ob.app` label, so preflight reports them as held by a resource Onebox does + not own. Docker cannot relabel a container or network. +- **Unprotected services start empty** if the deploy gets that far. A service + without `backup` starts on a new, empty `onebox_` volume. +- **Protected PostgreSQL refuses to apply**, because its recorded data volume is + missing. +- **Old timers keep firing.** The `ob-shop-*` and `ob-backup-shop-*` units stay + enabled and run jobs and backups against the old state directory, next to the + new units. + +## Move the host + +Run the host steps as root. Set `APP` to your application name and `ENV` to the +environment that owns the host. If you set `basePath`, use it in place of +`/var/lib/ob` and `/var/lib/onebox` in step 5 for the application's directory, +but not for `_host`: that moves from `/_host` to +`/var/lib/onebox/_host`. The application is down from step 2 until step 6. Copying the volumes temporarily doubles the disk space they use. + +1. Upgrade `ob` on your machine. Do not run any other `ob` command yet. + +2. Stop and remove the old scheduled job and backup units, so nothing runs + against the state while it moves: + + ```sh + APP=shop + ENV=production + # Older versions doubled a hyphen in the application's name: my-shop was + # written ob-my--shop-nightly. Match both spellings. + ESC=$(printf '%s' "$APP" | sed 's/-/--/g') + systemctl list-unit-files --no-legend "ob-$APP-*" "ob-$ESC-*" "ob-backup-$APP-*" "ob-backup-$ESC-*" \ + | awk '{print $1}' | xargs -r systemctl disable --now + rm -f /etc/systemd/system/ob-$APP-* /etc/systemd/system/ob-$ESC-* \ + /etc/systemd/system/ob-backup-$APP-* /etc/systemd/system/ob-backup-$ESC-* + systemctl daemon-reload + ``` + +3. Remove the application's containers and the proxy. Volumes are not removed: + + ```sh + docker ps -aq --filter label=ob.app=$APP | xargs -r docker rm -f + docker ps -aq --filter label=com.docker.compose.project=onebox-proxy | xargs -r docker rm -f + ``` + +4. Copy every volume into its new name, with the labels Onebox and Compose + give a volume they create: + + ```sh + for old in $(docker volume ls -q --filter label=ob.app=$APP | grep "^ob_${APP}_"); do + new="onebox_${old#ob_${APP}_}" + service=$(docker volume inspect -f '{{index .Labels "ob.service"}}' "$old") + if [ -n "$service" ]; then + set -- --label onebox.service="$service" --label com.docker.compose.project="onebox_$service" + else + set -- --label com.docker.compose.project="$APP" + fi + docker volume create --label onebox.app="$APP" "$@" \ + --label com.docker.compose.volume="$new" "$new" + docker run --rm -v "$old":/from:ro -v "$new":/to alpine cp -a /from/. /to/ + done + ``` + + A volume without the `onebox.app` label is refused by preflight as a + resource Onebox does not own. Without the Compose project label, + `ob destroy --volumes` would not find the volume, and would leave the data + behind. + +5. Move the state and rewrite the owner record: + + ```sh + mkdir -p /var/lib/onebox + mv /var/lib/ob/$APP /var/lib/onebox/app + mv /var/lib/ob/_host /var/lib/onebox/_host + printf '%s %s\n' "$APP" "$ENV" > /var/lib/onebox/_host/owner + printf '%s\n' "$APP" > /var/lib/onebox/app/.onebox-app + for network in "${APP}_default" "ob_$APP" ob-ingress; do + if docker network inspect "$network" >/dev/null 2>&1; then + docker network rm "$network" + fi + done + ``` + + `ob_$APP` exists only if the application declared a service. + + The state directory holds the managed services' credentials. Moving it, not + recreating it, is what lets the new containers open the copied data. The + `.onebox-app` marker says the directory is this application's: bootstrap + refuses to adopt a non-empty `app` directory without it, and destroy refuses + to delete one. + +6. From your machine, bootstrap and deploy: + + ```sh + ob bootstrap production + ob deploy production + ``` + + Bootstrap recreates the networks, the proxy, the managed services and the + units under their new names. A protected PostgreSQL service checks that the + copied volume holds the cluster it recorded, and stops here if the copy is + incomplete. + +7. Check that the application sees its data. Then remove the old volumes: + + ```sh + docker volume ls -q --filter label=ob.app=$APP | grep "^ob_${APP}_" | xargs -r docker volume rm + ``` + + Leave them until you are sure. They are the only copy of the data from + before the upgrade. + +Releases deployed before the upgrade stay in `releases/`, but their Compose +files name the old volumes and paths. Do not roll back to them; roll back only +to releases deployed after step 6. diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index 5bafd837..f2bb3839 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -115,7 +115,7 @@ Declare application intent instead of maintaining generated runtime. Turn fields - `onebox-proxy` — non-root, socketless Traefik, plus an isolated discovery controller, routing and TLS -- `/var/lib/ob/shop` — releases, current, journal and services +- `/var/lib/onebox/app` — releases, current, journal and services diff --git a/site/src/content/docs/reference/errors.mdx b/site/src/content/docs/reference/errors.mdx index ddce0185..57bef979 100644 --- a/site/src/content/docs/reference/errors.mdx +++ b/site/src/content/docs/reference/errors.mdx @@ -45,7 +45,7 @@ command. | `compose_file_unreadable` | a referenced Compose file could not be read | | `compose_ingress_attached` | a referenced service already attaches the ingress network | | `compose_network_mode` | a referenced service sets network_mode, which conflicts with the network it needs | -| `compose_ob_label` | a referenced service carries a label in a namespace Onebox generates into | +| `compose_onebox_label` | a referenced service carries a label in a namespace Onebox generates into | | `compose_ref_malformed` | a Compose reference is not of the form path#service | | `compose_service_missing` | a referenced Compose file has no such service | | `compose_traefik_label` | a referenced service carries routing labels while also declaring a route | diff --git a/site/src/content/docs/reference/fields/proxy.mdx b/site/src/content/docs/reference/fields/proxy.mdx index 3baa2377..6afb3bff 100644 --- a/site/src/content/docs/reference/fields/proxy.mdx +++ b/site/src/content/docs/reference/fields/proxy.mdx @@ -32,6 +32,6 @@ cannot drift from what `ob validate` accepts. | `image` | string | — | Container image used for the managed proxy. Expects a registry reference such as nginx:1.27 or ghcr.io/acme/app@sha256:…. | | `kind` | `TraefikDocker` · `None` | `TraefikDocker` | Proxy implementation, or none to disable routing. | | `managed` | boolean | — | Let Onebox converge the host-scoped proxy when routes are declared. | -| `network` | string | `ob-ingress` | External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved. | +| `network` | string | `onebox-ingress` | External container network shared with routed workloads; default and Onebox's derived application and service network names are reserved. | `*` marks a field that is required within its own object. diff --git a/site/src/content/docs/reference/fields/top-level.mdx b/site/src/content/docs/reference/fields/top-level.mdx index f4abe111..648a302d 100644 --- a/site/src/content/docs/reference/fields/top-level.mdx +++ b/site/src/content/docs/reference/fields/top-level.mdx @@ -25,10 +25,10 @@ cannot drift from what `ob validate` accepts. | Field | Type | Default | What it does | | --- | --- | --- | --- | | `apiVersion` `*` | string | — | Authored Application API identity. | -| `basePath` | string | `/var/lib/ob` | Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter. | +| `basePath` | string | `/var/lib/onebox` | Absolute host directory beneath which Onebox stores application state and releases. Expects an absolute path with no control character or shell metacharacter. | | `kind` `*` | string | — | Authored resource kind. | | `metadata` `*` | object | — | Application identity and opaque user metadata. | | `metadata.annotations` | map | — | Opaque user metadata that never affects plans or runtime behavior. | -| `metadata.name` `*` | — | — | The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin "ob-" or be a name the host layout reserves. Stable application name used in generated runtime identities. | +| `metadata.name` `*` | — | — | The application's name. Expects lower-case letters, digits and hyphens, starting with a letter, at most 40 characters, and may not begin "onebox-" or be a name the host layout reserves. Stable application name used in generated runtime identities. | `*` marks a field that is required within its own object. diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 56e3ff0c..1c00ed74 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -53,11 +53,71 @@ duration is an integer. ## Runtime resources -**Every application container is `--`.** Replica -ordinals are one-based and never omitted, including for a singleton. A +**`ob` is the command; `onebox` is everything it puts anywhere else.** Only +what an operator types or keeps next to the project uses `ob`: the command, the +project file `ob.yml`, and the artifacts passed between commands +(`ob-plan.json`, `ob-approval.json`, `ob-backup-report.json`). Every name Onebox +creates on a host, inside a container, or for its own use begins with +`onebox`, and the separator says what kind of name it is: + +| Form | For | Examples | +| --- | --- | --- | +| `onebox-` | named things on the host and in containers | `onebox-postgres`, `onebox-ingress`, `onebox-job-nightly`, `onebox-run`, `/tmp/onebox-drain` | +| `onebox__` | names joined from several identifiers, which must stay unambiguous | `onebox_postgres_data`, `onebox_services`, `onebox_web_r0` | +| `onebox.` | labels | `onebox.app`, `onebox.release` | +| `ONEBOX_` | environment variables | `ONEBOX_STEP_ID` | +| `/var/lib/onebox/…` | paths | `/var/lib/onebox/app` | +| `.onebox-` | Onebox's own files inside a directory it shares | `.onebox-schedule.lease`, `.onebox-tmp` | + +The application's name appears only in the author's own workload containers +and Compose project, and where the application has to be identified beyond the +host: the owner record, the backup repository path and the database name. A +test fails the build when code introduces any other `ob-`, `ob_` or `ob.` name. + +**Every workload container is `--`.** Replica +ordinals are one-based and never omitted, including for a singleton, because +replicas can change and a rollout moves containers between numbered slots. A three-replica `server` workload in the `monk` application is therefore -`monk-server-1`, `monk-server-2`, and `monk-server-3`; singleton components are -`monk-feed-1`, `monk-postgres-1`, and `monk-redis-1`. +`monk-server-1`, `monk-server-2`, and `monk-server-3`; a singleton workload is +`monk-feed-1`. + +**Every container Onebox runs from its own images is `onebox-`.** +There is no ordinal, because none of them has replicas. Managed services are +`onebox-postgres` and `onebox-redis`; a restore drill beside one is +`onebox-postgres-restore`; the host proxy is `onebox-proxy` and its discovery +controller `onebox-discovery`. The application is not in these names: a host +runs one application, and the `onebox.app` label records which. So `docker ps` +reads as two groups — your images under your application's name, and what +Onebox provides under `onebox-`. + +The same namespace covers the host's other shared resources: the ingress network +the proxy and routed workloads join is `onebox-ingress`. Because the namespace +is Onebox's, an application may not be called `onebox` or begin with `onebox-`, +and `proxy`, `discovery`, `ingress` and `services` are not valid service +names. + +**Everything else Onebox puts on the host is in the same namespace, without the +application.** + +| What | Name | +| --- | --- | +| State directory | `/var/lib/onebox/app` (releases, `current`, journal, services, backup) | +| Host state | `/var/lib/onebox/_host` (owner record, host lock, host journal, proxy), whatever `basePath` says | +| Scheduled job units | `onebox-job-.service` and `.timer` | +| Backup units | `onebox-backup--` | +| Proxy routers and services | `onebox__r`, `onebox_` | +| Ownership labels | `onebox.app`, `onebox.workload`, `onebox.service`, `onebox.release`, … | + +The `onebox.app` label, not any name, is what records which application owns a +resource. Names can drop the application because a host has exactly one: the +owner record is at a fixed path, so a different `basePath` does not give a +second application a second record. Custom Traefik objects in a proxy +configuration directory may not begin with `onebox_`. + +**Names do not carry the environment.** Staging and production of `shop` both +derive `shop-web-1` and `onebox-postgres`. What keeps them apart is the host: +its owner record names one application and one environment, and preflight +refuses a deploy of any other. A hyphen authored inside `app` or a component is doubled in the runtime name, so the separator remains unambiguous: app `help-desk` and component `web-api` @@ -65,11 +125,24 @@ produce `help--desk-web--api-1`. The temporary container joining a rollout is `--new`. It takes the stable numbered slot only after the previous occupant has drained and been -removed. The host-scoped managed proxy is `onebox-proxy`. - -Container names are the human-facing runtime grammar. Persistent volumes, -Compose projects, networks, and proxy-provider objects use their own derived -names because they have different collision and data-lifetime constraints. +removed. + +**Every other resource Onebox derives is `onebox__…`.** Volumes, +service Compose projects, the service network and restore-drill resources: +`onebox_postgres_data`, `onebox_web_uploads`, `onebox_postgres`, +`onebox_services`. They do not carry the application: a host has one, the +`onebox.app` label records which, and `ob destroy` releases the host only after +removing them. They join with underscores because a component or volume name +cannot contain one, so each name maps back to exactly one component and +volume — a hyphen, which names may contain, could not promise that. The one +exception is the application's own Compose network, `shop_default`, which is +the name Compose gives it. + +| Namespace | Separator | Holds | +| --- | --- | --- | +| `-` | hyphen | your workload containers | +| `onebox-` | hyphen | containers and the ingress network Onebox runs on the host | +| `onebox_` | underscore | volumes, service projects and networks | ## Commands diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 455deed3..f0fc1370 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -109,16 +109,18 @@ Full explanation, including why level four outranks the rest: ## What Onebox generates -**Names** — application containers are uniformly numbered: -`shop-web-1`, `shop-web-2`, and `shop-postgres-1`. The managed proxy is -`onebox-proxy`. Persistent and provider names include `ob_shop_postgres`, -`ob_shop_postgres_data`, `shop_default` (the external application network), -`ob_shop` (the external service network), and `ob-ingress`. These are contract: +**Names** — workload containers are uniformly numbered: `shop-web-1` and +`shop-web-2`. Containers Onebox runs from its own images are unnumbered: +`onebox-postgres` for a managed service and `onebox-proxy` for the proxy. Persistent and provider names include `onebox_postgres`, +`onebox_postgres_data`, `shop_default` (the external application network), +`onebox_services` (the external service network), and `onebox-ingress`. These are contract: once a persistent resource exists its name cannot change without migration. A foreign resource already holding a derived name is refused, not adopted. -**Layout** — `/var/lib/ob//releases/`, plus `current`, `journal`, and -`services`. Configurable per environment with `basePath`. +**Layout** — `/var/lib/onebox/app/releases/`, plus `current`, `journal`, and +`services`. `basePath` moves this per environment. Host state — the owner +record, host lock and proxy — stays in `/var/lib/onebox/_host` whatever +`basePath` says, so that one host has one owner. ### Bind-mount lifetimes @@ -154,7 +156,7 @@ not buffered. Declare `proxy.config` with dynamic YAML or TOML files to extend that managed configuration. Include `traefik.yml` or `traefik.yaml` in the same directory only when you need to own the static configuration too. The external `proxy.network` may be changed, but `default` is reserved for the application's -own Compose network. The derived `_default` and `ob_` names are +own Compose network. The derived `_default` and `onebox_services` names are reserved too; routed projects must use a distinct ingress network. Managed Traefik runs as a non-root user and never receives the Docker socket. diff --git a/site/src/content/docs/start/first-deploy.mdx b/site/src/content/docs/start/first-deploy.mdx index 0db1c073..8592a9d2 100644 --- a/site/src/content/docs/start/first-deploy.mdx +++ b/site/src/content/docs/start/first-deploy.mdx @@ -165,7 +165,7 @@ read-only. ## What you now have on the host -- /var/lib/ob/shop/ +- /var/lib/onebox/app/ - releases/ - 20260808T101500Z/ the release just deployed - 20260807T093000Z/ retained for rollback @@ -174,10 +174,10 @@ read-only. - services/ supporting services, outside every release -Container and volume names are derived and stable. Application containers are -`shop-web-1`, `shop-web-2`, and `shop-postgres-1`; the host proxy is -`onebox-proxy`. Persistent and provider resources include -`ob_shop_postgres_data`, `ob_shop` for the service network, and `ob-ingress` for +Container and volume names are derived and stable. Workload containers are +`shop-web-1` and `shop-web-2`; the managed database is `onebox-postgres` and the +host proxy is `onebox-proxy`. Persistent and provider resources include +`onebox_postgres_data`, `onebox_services` for the service network, and `onebox-ingress` for the host proxy network. Once a volume exists, its name cannot change without moving data.