From 5208d6c91171ab3ed62c9e4e02249a450b751c1a Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 07:47:44 -0700 Subject: [PATCH 01/15] feat(runtime)!: name managed containers onebox- Containers Onebox runs from its own images now share the onebox-* namespace with the host proxy and carry no replica ordinal, since none of them has replicas: managed services are onebox- and restore drills onebox--restore. Workload containers keep --. Volumes, Compose projects and networks keep the application because they hold its data. Reserve the application name onebox and the service names proxy and discovery, which would otherwise derive names inside that namespace or the host proxy's own container names. Replace comments that justified naming by two applications sharing a host, which Onebox does not allow. Closes #205 BREAKING CHANGE: managed service containers are renamed from --1 to onebox-, and restore drill containers from --restore-1 to onebox--restore. The next deploy recreates each managed service container; data volumes are unchanged. --- .../v1alpha1/application.schema.json | 15 +++++ docs/product.md | 9 +-- internal/app/backup_names_test.go | 2 +- internal/app/backup_walg.go | 3 +- internal/app/constraints.go | 23 ++++++- internal/app/jsonschema.go | 10 +++ internal/app/load.go | 3 + internal/app/names.go | 32 ++++++--- internal/app/names_test.go | 65 ++++++++++++++++++- internal/app/naming_scope_test.go | 17 ++++- internal/app/services.go | 9 ++- internal/app/testdata/contract-verdicts.json | 24 +++---- .../engine/backup_recovery_config_test.go | 8 +-- internal/engine/roll.go | 10 ++- .../v1alpha1/application.schema.json | 15 +++++ site/src/components/landing/Derivation.astro | 2 +- .../src/components/landing/InsideTheBox.astro | 2 +- .../docs/explanation/generated-compose.mdx | 5 +- site/src/content/docs/reference/naming.mdx | 25 +++++-- .../content/docs/reference/project-file.mdx | 6 +- site/src/content/docs/start/first-deploy.mdx | 6 +- 21 files changed, 227 insertions(+), 64 deletions(-) diff --git a/api/application/v1alpha1/application.schema.json b/api/application/v1alpha1/application.schema.json index 14270a02..b201fbb2 100644 --- a/api/application/v1alpha1/application.schema.json +++ b/api/application/v1alpha1/application.schema.json @@ -38,6 +38,9 @@ { "const": "ob" }, + { + "const": "onebox" + }, { "const": "onebox-proxy" }, @@ -1281,6 +1284,18 @@ "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" + } + ] + } + }, "type": "object" }, "workloads": { 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/internal/app/backup_names_test.go b/internal/app/backup_names_test.go index 4029c1ac..3a570cdb 100644 --- a/internal/app/backup_names_test.go +++ b/internal/app/backup_names_test.go @@ -15,7 +15,7 @@ func TestProtectedServiceReservesRestoreRuntimeNames(t *testing.T) { all := spec.All("production") for _, reserved := range []string{ "ob_example_database_restore", - "example-database-restore-1", + "onebox-database-restore", "ob_example_database_restore-net", "ob_example_database_restore-stage", } { diff --git a/internal/app/backup_walg.go b/internal/app/backup_walg.go index f10e365d..93373620 100644 --- a/internal/app/backup_walg.go +++ b/internal/app/backup_walg.go @@ -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/constraints.go b/internal/app/constraints.go index 3cfcf52f..3efd17c5 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -203,7 +203,28 @@ var ( // 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"} +// +// "onebox" is reserved because onebox-* names the containers Onebox runs from +// its own images; an application called onebox would derive workload names in +// that namespace and read as if Onebox ran them. +var reservedAppNames = []string{"ob", "onebox", "onebox-proxy", "_host"} + +// reservedServiceNames are the host proxy's components. A managed service's +// container is onebox-, so a service with one of these names would +// derive the host proxy's container name. +var reservedServiceNames = []string{"proxy", "discovery"} + +// 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: its container would be onebox-%s, which the host proxy uses", name, name) + } + } + return nil +} // checkAppName is the identifier grammar plus the reservations. func checkAppName(name string) error { diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index 5da8c081..f5a8048b 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()}, }}, @@ -736,6 +737,15 @@ func appNameConstraint() map[string]any { 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/load.go b/internal/app/load.go index 8065e4b3..5b35098a 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) diff --git a/internal/app/names.go b/internal/app/names.go index 078494f4..75682eb1 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -9,8 +9,12 @@ 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 and keep the application in every name, since they +// hold that application's data. // // Persistent and provider-internal identifiers are joined with underscores. // Hyphens would be ambiguous there: `ob--` maps both (a-b, c) and @@ -23,6 +27,10 @@ const ( ProxyProject = "onebox-proxy" IngressNetwork = "ob-ingress" + // ManagedContainerPrefix begins every container Onebox runs from its own + // images: the host proxy and a managed service alike. + ManagedContainerPrefix = "onebox" + // HostNamespace holds state shared by everything on the box. HostNamespace = "_host" @@ -67,10 +75,12 @@ func (n Names) ServiceProject(service string) string { return join("ob", n.App, 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 ob.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(ManagedContainerPrefix, service) } // ServiceNetwork joins the application to its services. It is one network per @@ -197,8 +207,10 @@ func (n Names) BackupRestoreProject(service string) string { return join("ob", n.App, 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(ManagedContainerPrefix, service, "restore") } func (n Names) BackupRestoreNetwork(service string) string { @@ -290,9 +302,11 @@ func (n Names) BackupUnitPrefixes() []string { // 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. +// 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) } diff --git a/internal/app/names_test.go b/internal/app/names_test.go index 839ed178..a44e38de 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", @@ -60,6 +59,7 @@ func TestDerivedNamesGolden(t *testing.T) { "ob_ledger_postgres_data", "ob_ledger_postgres_wal", "ob_ledger_web_uploads", + "onebox-postgres", } got := p.All("production") if len(got) != len(want) { @@ -211,7 +211,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 +223,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) { @@ -289,3 +305,46 @@ func TestNoDerivedNameCollidesWithHostScoped(t *testing.T) { } } } + +// 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 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/naming_scope_test.go b/internal/app/naming_scope_test.go index f1bb58a1..ed7cf432 100644 --- a/internal/app/naming_scope_test.go +++ b/internal/app/naming_scope_test.go @@ -5,13 +5,19 @@ import ( "testing" ) -// 8.5 — every name Onebox derives carries the application. +// 8.5 — every name derived from the author's components 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. // +// Managed service containers are the exception, by design: they are +// onebox-, the namespace of containers Onebox runs from its own +// images. Their volumes and projects still carry the application, because +// those hold its data. +// // The transient rollout name is included deliberately: it exists for seconds // during a handover, which is exactly when nobody is looking at it. func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { @@ -22,7 +28,6 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { "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(), @@ -38,6 +43,14 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { t.Errorf("%s = %q, which does not carry the application", label, got) } } + for label, got := range map[string]string{ + "service container": n.ServiceContainer("postgres"), + "restore container": n.BackupRestoreContainer("postgres"), + } { + if !strings.HasPrefix(got, ManagedContainerPrefix+"-") { + t.Errorf("%s = %q, which is not in the onebox-* namespace", label, got) + } + } // And two applications never derive the same name for the same thing. other := Names{App: "ledger", BasePath: DefaultBasePath} diff --git a/internal/app/services.go b/internal/app/services.go index fdcdf223..a6b7f47c 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 @@ -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": diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index f4cc610a..ecff6b39 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -392,17 +392,17 @@ { "case": "conformance/service backup policy", "loads": true, - "digest": "77d034b99d52d2ca044aae16988d35c67e0b24a504d092a5050887a0063abe06 postgres=c1475eb63145a73b" + "digest": "77d034b99d52d2ca044aae16988d35c67e0b24a504d092a5050887a0063abe06 postgres=9e85cbb9c8295288" }, { "case": "conformance/service scalar", "loads": true, - "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 postgres=16f6016ba043ba3b" + "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 postgres=39cc6d687e2f025a" }, { "case": "conformance/settings key that is a real driver flag", "loads": true, - "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 redis=f5e4171f39cd0dcb" + "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 redis=03e2b07b335d9d4d" }, { "case": "conformance/settings key with a shell metacharacter", @@ -532,12 +532,12 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "64ea7ff80a65ba1594bc9565675e7ff194b3acdb44540ebb2e84a46d2e4c4bf1 postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" + "digest": "64ea7ff80a65ba1594bc9565675e7ff194b3acdb44540ebb2e84a46d2e4c4bf1 postgres=fdfb04e3041d33f3 redis=aa4294d2c247915d" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "baf976b3c71b90c78e1bcab801a9765b6111abae7df5600a15233a9811c99a0e postgres=dc4f8448b8b82b4a redis=d2660eeb4faa49fe" + "digest": "baf976b3c71b90c78e1bcab801a9765b6111abae7df5600a15233a9811c99a0e postgres=fdfb04e3041d33f3 redis=aa4294d2c247915d" }, { "case": "corpus/ext-authentik.yml", @@ -552,7 +552,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "96f5d0556ae66e0c5052343fb6d763a16686510181a1406266864c5eed49be80 postgres=e70cc45c347098f9" + "digest": "96f5d0556ae66e0c5052343fb6d763a16686510181a1406266864c5eed49be80 postgres=386b5386322a6f60" }, { "case": "corpus/ext-immich-sourced.yml", @@ -567,7 +567,7 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "1b9c415f24ea342e79a3c14b5ad6bb485f29df88b7478371e99ee7f59006d86a postgres=809549d286e2dbdc redis=86933b446609e6d8" + "digest": "1b9c415f24ea342e79a3c14b5ad6bb485f29df88b7478371e99ee7f59006d86a postgres=4bd0ec307400e5a3 redis=3737afbfd7ea3993" }, { "case": "corpus/ext-paperless.yml", @@ -577,17 +577,17 @@ { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "9919231c9f5b2702f56fda1f56bae20a1d3912039e8013144af7ce204b986f2f events=1e798590e3a5dda4 postgres=b1fac70440c33545" + "digest": "9919231c9f5b2702f56fda1f56bae20a1d3912039e8013144af7ce204b986f2f events=5a78ea7c04e14c7b postgres=c68d89056320290b" }, { "case": "corpus/ext-umami.yml", "loads": true, - "digest": "4088b4fc05094ac274c76205c774fb6b99f03c00abc97f7e2574eadf4ca7c6f6 postgres=36c6c38ba304b445" + "digest": "4088b4fc05094ac274c76205c774fb6b99f03c00abc97f7e2574eadf4ca7c6f6 postgres=ed21f5c56a4992c0" }, { "case": "corpus/ghost.yml", "loads": true, - "digest": "ba2aaf586bcb73298f6175fb36813d8720a3f67d58c7bc023bf35a564b1e19d3 mysql=0f13a6374095d11b" + "digest": "ba2aaf586bcb73298f6175fb36813d8720a3f67d58c7bc023bf35a564b1e19d3 mysql=c56ab5644e9756b3" }, { "case": "corpus/gitea.yml", @@ -622,7 +622,7 @@ { "case": "corpus/penpot.yml", "loads": true, - "digest": "4d7d95dd74e291c481366cf606f5b2537fa5bf97cf126bea50df9fa927d26da3 postgres=fc584b1b50db23a6 redis=fcccb6a023ae5734" + "digest": "4d7d95dd74e291c481366cf606f5b2537fa5bf97cf126bea50df9fa927d26da3 postgres=06cc17f4fab42e94 redis=0888684827c826dd" }, { "case": "corpus/pursue.yml", @@ -637,7 +637,7 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "0b1a575f884bc3ae2aa87bde1df62dc2a00068ba17c56f2288fa7412eb918a96 mongodb=eaca06e5d1b88e4b" + "digest": "0b1a575f884bc3ae2aa87bde1df62dc2a00068ba17c56f2288fa7412eb918a96 mongodb=cde22b543b70d444" }, { "case": "corpus/umami.yml", diff --git a/internal/engine/backup_recovery_config_test.go b/internal/engine/backup_recovery_config_test.go index b79770c8..4dedcf26 100644 --- a/internal/engine/backup_recovery_config_test.go +++ b/internal/engine/backup_recovery_config_test.go @@ -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/roll.go b/internal/engine/roll.go index 3e58f093..4f9eb60a 100644 --- a/internal/engine/roll.go +++ b/internal/engine/roll.go @@ -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/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha1/application.schema.json index 14270a02..b201fbb2 100644 --- a/site/public/schemas/application/v1alpha1/application.schema.json +++ b/site/public/schemas/application/v1alpha1/application.schema.json @@ -38,6 +38,9 @@ { "const": "ob" }, + { + "const": "onebox" + }, { "const": "onebox-proxy" }, @@ -1281,6 +1284,18 @@ "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" + } + ] + } + }, "type": "object" }, "workloads": { diff --git a/site/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro index 37b5ea8d..22431455 100644 --- a/site/src/components/landing/Derivation.astro +++ b/site/src/components/landing/Derivation.astro @@ -262,7 +262,7 @@ 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(["onebox-postgres", "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"]); } diff --git a/site/src/components/landing/InsideTheBox.astro b/site/src/components/landing/InsideTheBox.astro index 180f0819..410a278b 100644 --- a/site/src/components/landing/InsideTheBox.astro +++ b/site/src/components/landing/InsideTheBox.astro @@ -52,7 +52,7 @@ - shop-postgres-1 + onebox-postgres credential generated on the server backup → a repository you own ob_shop_postgres_data diff --git a/site/src/content/docs/explanation/generated-compose.mdx b/site/src/content/docs/explanation/generated-compose.mdx index df93dd86..7c4413ef 100644 --- a/site/src/content/docs/explanation/generated-compose.mdx +++ b/site/src/content/docs/explanation/generated-compose.mdx @@ -33,8 +33,9 @@ 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 +- **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`, `ob_shop_postgres_data`, `ob_shop`, and `ob-ingress`. The application and service networks are declared external and created under Onebox's ownership fence, so Compose cannot remove a live diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 56e3ff0c..84982155 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -53,11 +53,24 @@ duration is an integer. ## Runtime resources -**Every application container is `--`.** Replica -ordinals are one-based and never omitted, including for a singleton. A +**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 `ob.app` label records which. So `docker ps` +reads as two groups — your images under your application's name, and what +Onebox provides under `onebox-`. + +Because of that namespace, `onebox` is not a valid application name, and +`proxy` and `discovery` are not valid service names. 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` @@ -69,7 +82,9 @@ 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. +names because they have different collision and data-lifetime constraints. They +carry the application even for a managed service — its data volume is +`ob_shop_postgres_data` — because they hold that application's data. ## Commands diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 455deed3..0f78e110 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -109,9 +109,9 @@ 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`, +**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 `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: once a persistent resource exists its name cannot change without migration. A diff --git a/site/src/content/docs/start/first-deploy.mdx b/site/src/content/docs/start/first-deploy.mdx index 0db1c073..8cc8d068 100644 --- a/site/src/content/docs/start/first-deploy.mdx +++ b/site/src/content/docs/start/first-deploy.mdx @@ -174,9 +174,9 @@ 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 +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 `ob_shop_postgres_data`, `ob_shop` for the service network, and `ob-ingress` for the host proxy network. Once a volume exists, its name cannot change without moving data. From 7dbe98226cdb3c2d00ae7e56cd399ab91147f15e Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 07:53:26 -0700 Subject: [PATCH 02/15] feat(runtime)!: move the ingress network into the onebox-* namespace Host-scoped resources Onebox runs now share one prefix: the ingress network is onebox-ingress, alongside onebox-proxy, onebox-discovery and managed service containers. Application names may no longer begin with onebox-, which replaces the single onebox-proxy reservation and keeps an application's derived names out of that namespace. Document that derived names do not carry the environment, and that the host owner record is what separates staging from production. Reword the proxy lock-order comment that implied several applications per host. BREAKING CHANGE: the default proxy network changes from ob-ingress to onebox-ingress, and application names beginning with onebox- are refused. The next proxy apply creates onebox-ingress and the next deploy moves routed workloads onto it; routing is interrupted in between. The old ob-ingress network is left in place and can be removed with `docker network rm ob-ingress` once nothing is attached. --- .../v1alpha1/application.schema.json | 10 +-- cmd/onebox-discovery/main.go | 2 +- internal/app/compose_test.go | 10 +-- internal/app/constraints.go | 21 +++--- internal/app/eject.go | 2 +- internal/app/eject_test.go | 2 +- internal/app/generate_test.go | 2 +- internal/app/jsonschema.go | 6 +- internal/app/load_test.go | 4 +- internal/app/names.go | 4 +- internal/app/preflight_test.go | 2 +- internal/app/testdata/compose.yaml | 2 +- internal/app/testdata/contract-verdicts.json | 66 +++++++++---------- internal/app/testdata/corpus/pursue.yml | 2 +- internal/app/testdata/corpus/recast.yml | 2 +- internal/app/types.go | 2 +- internal/discovery/discovery_test.go | 12 ++-- internal/discovery/docker_test.go | 8 +-- internal/engine/proxy.go | 5 +- internal/proxy/proxy_test.go | 10 +-- .../v1alpha1/application.schema.json | 10 +-- .../src/components/landing/InsideTheBox.astro | 2 +- .../docs/explanation/generated-compose.mdx | 2 +- .../content/docs/reference/fields/proxy.mdx | 2 +- .../docs/reference/fields/top-level.mdx | 2 +- site/src/content/docs/reference/naming.mdx | 11 +++- .../content/docs/reference/project-file.mdx | 2 +- site/src/content/docs/start/first-deploy.mdx | 2 +- 28 files changed, 109 insertions(+), 98 deletions(-) diff --git a/api/application/v1alpha1/application.schema.json b/api/application/v1alpha1/application.schema.json index b201fbb2..8d4bfe2b 100644 --- a/api/application/v1alpha1/application.schema.json +++ b/api/application/v1alpha1/application.schema.json @@ -26,7 +26,7 @@ "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 \"ob-\" or \"onebox-\" or be a name the host layout reserves. Stable application name used in generated runtime identities.", "examples": [ "shop" ], @@ -36,13 +36,13 @@ "pattern": "^ob-" }, { - "const": "ob" + "pattern": "^onebox-" }, { - "const": "onebox" + "const": "ob" }, { - "const": "onebox-proxy" + "const": "onebox" }, { "const": "_host" @@ -929,7 +929,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" } 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/internal/app/compose_test.go b/internal/app/compose_test.go index 151d7cf8..854eb3b5 100644 --- a/internal/app/compose_test.go +++ b/internal/app/compose_test.go @@ -51,13 +51,13 @@ func TestMergePreservesWhatTheUserWrote(t *testing.T) { // 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"}, + {"attached", overlay{Network: "onebox-ingress"}, "compose_ingress_attached"}, } for _, c := range cases { t.Run(c.code, func(t *testing.T) { diff --git a/internal/app/constraints.go b/internal/app/constraints.go index 3efd17c5..7ec178fa 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -201,13 +201,14 @@ 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. +// host's, and the collision would appear as a container that vanishes rather +// than as an error. // -// "onebox" is reserved because onebox-* names the containers Onebox runs from -// its own images; an application called onebox would derive workload names in -// that namespace and read as if Onebox ran them. -var reservedAppNames = []string{"ob", "onebox", "onebox-proxy", "_host"} +// 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{"ob", "onebox", "_host"} // reservedServiceNames are the host proxy's components. A managed service's // container is onebox-, so a service with one of these names would @@ -231,9 +232,11 @@ func checkAppName(name string) error { if err := gIdent.check("app", name); err != nil { return err } - if strings.HasPrefix(name, "ob-") { - return errf("project_invalid", "app", "", - "%q begins with \"ob-\", which names host-scoped resources Onebox owns", name) + for _, prefix := range []string{"ob-", "onebox-"} { + if strings.HasPrefix(name, prefix) { + return errf("project_invalid", "app", "", + "%q begins with %q, which names host-scoped resources Onebox owns", name, prefix) + } } for _, reserved := range reservedAppNames { if name == reserved { diff --git a/internal/app/eject.go b/internal/app/eject.go index 957218dd..832b7393 100644 --- a/internal/app/eject.go +++ b/internal/app/eject.go @@ -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, "ob-") || strings.HasPrefix(n.Value, "onebox-") { continue } kept = append(kept, n) diff --git a/internal/app/eject_test.go b/internal/app/eject_test.go index 37186297..891ec8e4 100644 --- a/internal/app/eject_test.go +++ b/internal/app/eject_test.go @@ -57,7 +57,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{"ob.app", "ob.release", "ob.workload", "traefik.", "onebox-ingress"} { if strings.Contains(out, forbidden) { t.Errorf("ejected runtime still carries %q\n%s", forbidden, out) } diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index 0face0c1..994bf73d 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -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") } } diff --git a/internal/app/jsonschema.go b/internal/app/jsonschema.go index f5a8048b..2b0f2c37 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -725,15 +725,15 @@ func anyRequired(fields []any) []any { // appNameConstraint is the identifier grammar plus the host layout's // 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 := make([]any, 0, len(reservedAppNames)+2) + forbidden = append(forbidden, map[string]any{"pattern": "^ob-"}, 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 \"ob-\" or \"onebox-\" or be a name the host layout reserves." return out } diff --git a/internal/app/load_test.go b/internal/app/load_test.go index 3c18c02f..96bd18f8 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -479,8 +479,8 @@ func TestDefaultsMaterialise(t *testing.T) { if p.BasePath != "/var/lib/ob" { t.Errorf("base_path = %q, want /var/lib/ob", 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) diff --git a/internal/app/names.go b/internal/app/names.go index 75682eb1..d6727a95 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -25,7 +25,7 @@ import ( const ( // ProxyProject and IngressNetwork are host-scoped. ProxyProject = "onebox-proxy" - IngressNetwork = "ob-ingress" + IngressNetwork = "onebox-ingress" // ManagedContainerPrefix begins every container Onebox runs from its own // images: the host proxy and a managed service alike. @@ -60,7 +60,7 @@ 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 begin `ob-` or `onebox-`. func (n Names) ComposeProject() string { return n.App } // ApplicationNetwork is the stable default network shared by every workload diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index f173f242..97796197 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -46,7 +46,7 @@ func healthyRunner() *fakeRunner { "/_host/owner": {Stdout: "ledger\n"}, "docker ps": {Stdout: ""}, "docker volume": {Stdout: ""}, - "docker network": {Stdout: "ob-ingress\t\n"}, + "docker network": {Stdout: "onebox-ingress\t\n"}, }} } diff --git a/internal/app/testdata/compose.yaml b/internal/app/testdata/compose.yaml index 504a30dd..23687007 100644 --- a/internal/app/testdata/compose.yaml +++ b/internal/app/testdata/compose.yaml @@ -46,7 +46,7 @@ services: 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 ecff6b39..573647a5 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -92,7 +92,7 @@ { "case": "conformance/base_path absolute", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" }, { "case": "conformance/bind source containing a compose separator", @@ -122,7 +122,7 @@ { "case": "conformance/encrypted env file entry", "loads": true, - "digest": "d87891ad760c2aca38053753731a3bf03ddab6f987d4cd9e87110af4715708d3" + "digest": "db841b109dcdf458f70da407aa50581e1c864fbab0bd21986b6144380594ff8c" }, { "case": "conformance/env file entry without a file", @@ -172,7 +172,7 @@ { "case": "conformance/hook with local", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" }, { "case": "conformance/host proxy name", @@ -242,12 +242,12 @@ { "case": "conformance/migration_policy expand-only", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" }, { "case": "conformance/minimum project", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" }, { "case": "conformance/missing api_version", @@ -282,7 +282,7 @@ { "case": "conformance/notification with no events", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" }, { "case": "conformance/one-char identifier", @@ -292,7 +292,7 @@ { "case": "conformance/operator proxy owns route middleware", "loads": true, - "digest": "bfb6fed92b7b1fee8a9170f5d5880e5eb70d36854803c8675f17fcb17dc002ba" + "digest": "880e1ce6682dad40a183ae3d9a2efb8f5b43a1ef0e45eaacb815723ad5e19092" }, { "case": "conformance/persistence block with no mode still refuses replicas", @@ -312,7 +312,7 @@ { "case": "conformance/provider-qualified route middlewares", "loads": true, - "digest": "68e64d82ad9afb845f41e9bfe57a920c0452649a74644877f6f69949714edbc4" + "digest": "df7af95b36d6abb23c8e1f47023bfc8e781a07d48f4f1403368e0094eb344978" }, { "case": "conformance/proxy kind none with a route", @@ -347,7 +347,7 @@ { "case": "conformance/relative env_file", "loads": true, - "digest": "9d40880f62c05790ff682afc8c185f0ebc454e77522e9e734ef8f256461dea91" + "digest": "adb10456ea95baaf80e2f783e9e84db51f972aa81f71649c82da57987d791761" }, { "case": "conformance/relative read-only bind mount", @@ -362,7 +362,7 @@ { "case": "conformance/repeated route middleware remains ordered", "loads": true, - "digest": "2422cebd0a6c76e245e576cfefa0bb2596966f7e47dda8c3fa42013633339b3e" + "digest": "0a6bfca2ad6f0229e81f60a6852da916b6d1a2574f3c5d12e48fb75e05f37a37" }, { "case": "conformance/rolling workload with published host port", @@ -372,7 +372,7 @@ { "case": "conformance/routes list", "loads": true, - "digest": "c44552dd7728f71b6232b172d17b60dff503ae9e576cc039db083dd76365a488" + "digest": "786555d998e67d069b77eef7703c44647f55abe916753bd4dd5f99705c64e2d6" }, { "case": "conformance/scheduled job", @@ -397,12 +397,12 @@ { "case": "conformance/service scalar", "loads": true, - "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 postgres=39cc6d687e2f025a" + "digest": "9509ff723ef0fc5a08762ae337f3079561d228468a05c59b4cb803310b6e1a43 postgres=39cc6d687e2f025a" }, { "case": "conformance/settings key that is a real driver flag", "loads": true, - "digest": "8ccece3e82ae24e63d91e2a5cc565adfd1b30cd185b644846c24ccce9e3b1742 redis=03e2b07b335d9d4d" + "digest": "9509ff723ef0fc5a08762ae337f3079561d228468a05c59b4cb803310b6e1a43 redis=03e2b07b335d9d4d" }, { "case": "conformance/settings key with a shell metacharacter", @@ -477,7 +477,7 @@ { "case": "conformance/unmanaged proxy keeps its routes", "loads": true, - "digest": "1427479dd14b45dd67d0ede585e8a7101f77d8e0b138fcc9bfd2f3b564b487c7" + "digest": "0dc9890f12ee12cb5b2b1ec6c3d95ec735364e90909f2d13405ec8d324b28861" }, { "case": "conformance/unqualified route middleware", @@ -492,7 +492,7 @@ { "case": "conformance/url check with contains and advisory", "loads": true, - "digest": "ba9ad62124d9e1ec28ae239b4e0f2f3a76ebca0076ea43b4c131d7491a4065b8" + "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" }, { "case": "conformance/volume scalar with a path", @@ -532,17 +532,17 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "64ea7ff80a65ba1594bc9565675e7ff194b3acdb44540ebb2e84a46d2e4c4bf1 postgres=fdfb04e3041d33f3 redis=aa4294d2c247915d" + "digest": "f396a8553e5fd21bc517a8f98991f1f23b9f0211e73b668f5d55acfe365f4c7f postgres=fdfb04e3041d33f3 redis=aa4294d2c247915d" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "baf976b3c71b90c78e1bcab801a9765b6111abae7df5600a15233a9811c99a0e postgres=fdfb04e3041d33f3 redis=aa4294d2c247915d" + "digest": "cd41c544b5bddfc67ea99d1e23343a4b3a060280e92b6aa253e0220771328957 postgres=fdfb04e3041d33f3 redis=aa4294d2c247915d" }, { "case": "corpus/ext-authentik.yml", "loads": true, - "digest": "e5aebd1b4f98af7beaa603ae44d981486150335461306d7cabf173476bdf27ee" + "digest": "22cba89f300edb95c59528a3b9f92657c45ea8170b18ad0f96a0c494e2953e31" }, { "case": "corpus/ext-frigate.yml", @@ -552,7 +552,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "96f5d0556ae66e0c5052343fb6d763a16686510181a1406266864c5eed49be80 postgres=386b5386322a6f60" + "digest": "490a7fab6a66a1f12bf04972d2d5341a1fa4b1fc9bbce44140718977f24190b4 postgres=386b5386322a6f60" }, { "case": "corpus/ext-immich-sourced.yml", @@ -567,32 +567,32 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "1b9c415f24ea342e79a3c14b5ad6bb485f29df88b7478371e99ee7f59006d86a postgres=4bd0ec307400e5a3 redis=3737afbfd7ea3993" + "digest": "dd00315223d8fed419d6055ec2cd8ec63b7fd05062e012508a7d262aed0f0146 postgres=4bd0ec307400e5a3 redis=3737afbfd7ea3993" }, { "case": "corpus/ext-paperless.yml", "loads": true, - "digest": "8ec597bfc4bc7d71d879e5f7dc12d94009c326fdba1720b65d7c5ea726e7a183" + "digest": "a9c1ed1a0d3e5b4fca6f3380ab509ec23b425b7078b4aabe4d1f273d76c6976d" }, { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "9919231c9f5b2702f56fda1f56bae20a1d3912039e8013144af7ce204b986f2f events=5a78ea7c04e14c7b postgres=c68d89056320290b" + "digest": "f92ebb3de2c4afabd40d9dcd1506c75e85f586c295f9e98676a8c5698d0b2611 events=5a78ea7c04e14c7b postgres=c68d89056320290b" }, { "case": "corpus/ext-umami.yml", "loads": true, - "digest": "4088b4fc05094ac274c76205c774fb6b99f03c00abc97f7e2574eadf4ca7c6f6 postgres=ed21f5c56a4992c0" + "digest": "23c82fe78d147be1d3ac296d9e68c071b6eb9120752381d3c23ea99cda0159ae postgres=ed21f5c56a4992c0" }, { "case": "corpus/ghost.yml", "loads": true, - "digest": "ba2aaf586bcb73298f6175fb36813d8720a3f67d58c7bc023bf35a564b1e19d3 mysql=c56ab5644e9756b3" + "digest": "d1ec8afc6bb9304e38d1ed79b55ec0de9f15dde5c1dfe2a8328acf277c3fd1a7 mysql=c56ab5644e9756b3" }, { "case": "corpus/gitea.yml", "loads": true, - "digest": "c6347add0af261bc2f87d29691f6de5b14401d63c7034ed5ba8c0798913d7d52" + "digest": "6420a7d55a997a328d0ba9e8ea56dfff13bd97ebfe8336a8b71ad7835f65a25a" }, { "case": "corpus/goal.yml", @@ -602,7 +602,7 @@ { "case": "corpus/immich.yml", "loads": true, - "digest": "d8e311adf9eb1ae4fc7e1362e53de2ba2e6ccefa2deefd0a8b1a976c7b5bfa06" + "digest": "bf9f8c2734487afc3974d6878018dc6425402bdfb554d192ed97eb8f97b50ad7" }, { "case": "corpus/monk.yml", @@ -612,17 +612,17 @@ { "case": "corpus/n8n.yml", "loads": true, - "digest": "45034dd978133e0aab7db59c3ee86a2157e306a608a8633feeb204b4958c5fef" + "digest": "f7f606a6bee885271e88ffb204f911c9be28af50c1b97c3d5f696b44b44850a8" }, { "case": "corpus/paperless.yml", "loads": true, - "digest": "80991cdd434564e0612397e3f64427088ce51d87f30e2502cdc9a496773ba81c" + "digest": "9ed4200e8494120111c786503327ccdbbbf69f88089e4a8b867ccbd018918ff5" }, { "case": "corpus/penpot.yml", "loads": true, - "digest": "4d7d95dd74e291c481366cf606f5b2537fa5bf97cf126bea50df9fa927d26da3 postgres=06cc17f4fab42e94 redis=0888684827c826dd" + "digest": "fdd3fa682e3fbe0221f566e67944f735d856915c545add134d66f054fcb35db2 postgres=06cc17f4fab42e94 redis=0888684827c826dd" }, { "case": "corpus/pursue.yml", @@ -637,21 +637,21 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "0b1a575f884bc3ae2aa87bde1df62dc2a00068ba17c56f2288fa7412eb918a96 mongodb=cde22b543b70d444" + "digest": "7bfda0263b19cdc21acaa254f8a742f8687e126df7af1893376f099280e84339 mongodb=cde22b543b70d444" }, { "case": "corpus/umami.yml", "loads": true, - "digest": "ea01ea117cbbae96ad9e6f3bc9dff6bf40839cdb21dcc540aca078e2d79d503b" + "digest": "71219adba7acff3f73a521dd805a0f5a47838a99745954b685b1e8b00b896bd1" }, { "case": "corpus/uptime-kuma.yml", "loads": true, - "digest": "e3f215d2cbbd19d05f6c4dd7b07f5b2e7cb28a79fc1877ec2ca25b06ff2feef4" + "digest": "2b3f8cbc10a34be3d17b51b8dcc7b5dd4897cf1d37f476899301090b444accfa" }, { "case": "corpus/vaultwarden.yml", "loads": true, - "digest": "d09dabcf3ed38023913407762fa2d2c6a25db15625ae90386ee23cd9628ab838" + "digest": "160fccdae522ddf42957c7f371a0e1c316af4643def09e5bce921aad20aeb283" } ] 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..00c973b8 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -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."` } diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go index eda755ec..b8e03167 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -17,7 +17,7 @@ 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}, } } @@ -39,7 +39,7 @@ 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) } @@ -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..771ba4c4 100644 --- a/internal/discovery/docker_test.go +++ b/internal/discovery/docker_test.go @@ -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/engine/proxy.go b/internal/engine/proxy.go index 0a62bf68..85fb68b0 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -51,8 +51,9 @@ 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 } diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 476f837c..6fc4840d 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -54,7 +54,7 @@ func TestPathsHostScoped(t *testing.T) { } 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 +68,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 +85,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 +650,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 +700,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/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha1/application.schema.json index b201fbb2..8d4bfe2b 100644 --- a/site/public/schemas/application/v1alpha1/application.schema.json +++ b/site/public/schemas/application/v1alpha1/application.schema.json @@ -26,7 +26,7 @@ "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 \"ob-\" or \"onebox-\" or be a name the host layout reserves. Stable application name used in generated runtime identities.", "examples": [ "shop" ], @@ -36,13 +36,13 @@ "pattern": "^ob-" }, { - "const": "ob" + "pattern": "^onebox-" }, { - "const": "onebox" + "const": "ob" }, { - "const": "onebox-proxy" + "const": "onebox" }, { "const": "_host" @@ -929,7 +929,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" } diff --git a/site/src/components/landing/InsideTheBox.astro b/site/src/components/landing/InsideTheBox.astro index 410a278b..2a5fefb9 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 diff --git a/site/src/content/docs/explanation/generated-compose.mdx b/site/src/content/docs/explanation/generated-compose.mdx index 7c4413ef..0445475f 100644 --- a/site/src/content/docs/explanation/generated-compose.mdx +++ b/site/src/content/docs/explanation/generated-compose.mdx @@ -37,7 +37,7 @@ becomes a question rather than a fact. `--` grammar, such as `shop-web-1`; managed services are `onebox-`, such as `onebox-postgres`; persistent and provider resources include `shop_default`, `ob_shop_postgres_data`, `ob_shop`, - and `ob-ingress`. The application and service networks are declared external + 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 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..9d73e919 100644 --- a/site/src/content/docs/reference/fields/top-level.mdx +++ b/site/src/content/docs/reference/fields/top-level.mdx @@ -29,6 +29,6 @@ cannot drift from what `ob validate` accepts. | `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 "ob-" or "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 84982155..92fd4318 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -69,8 +69,15 @@ runs one application, and the `ob.app` label records which. So `docker ps` reads as two groups — your images under your application's name, and what Onebox provides under `onebox-`. -Because of that namespace, `onebox` is not a valid application name, and -`proxy` and `discovery` are not valid service names. +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-` +(or `ob-`), and `proxy` and `discovery` are not valid service names. + +**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` diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 0f78e110..0cc0681c 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -113,7 +113,7 @@ Full explanation, including why level four outranks the rest: `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 `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: +`ob_shop` (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. diff --git a/site/src/content/docs/start/first-deploy.mdx b/site/src/content/docs/start/first-deploy.mdx index 8cc8d068..9dd27e47 100644 --- a/site/src/content/docs/start/first-deploy.mdx +++ b/site/src/content/docs/start/first-deploy.mdx @@ -177,7 +177,7 @@ read-only. 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 -`ob_shop_postgres_data`, `ob_shop` for the service network, and `ob-ingress` for +`ob_shop_postgres_data`, `ob_shop` for the service network, and `onebox-ingress` for the host proxy network. Once a volume exists, its name cannot change without moving data. From 67d279153fee5b2aa718431e52e3bdc719fe08a9 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 07:58:40 -0700 Subject: [PATCH 03/15] feat(runtime)!: derive application resources under onebox__ Volumes, service Compose projects, the service network and restore-drill resources move from ob__... to onebox__..., so every name Onebox derives outside the author's workload containers begins with onebox. The derived-name length check now measures the real derived names. There is no migration and no fallback. Add a guide that lists the renamed resources, what the next deploy does to a host that still holds ob_ volumes, and the manual steps to copy the data across before deploying. BREAKING CHANGE: volumes are renamed from ob__... to onebox__..., and Onebox does not migrate them. Deploying to an existing host without following guides/upgrade-to-onebox-names starts unprotected services on new, empty volumes, and refuses protected PostgreSQL services because their recorded data volume is missing. The old volumes stay on the host untouched. --- e2e/apps/one-app-one-host.sh | 2 +- internal/app/backup_names_test.go | 8 +- internal/app/generate_test.go | 4 +- internal/app/load.go | 7 +- internal/app/load_test.go | 2 +- internal/app/names.go | 29 ++--- internal/app/names_test.go | 10 +- internal/app/naming_scope_test.go | 2 +- internal/app/preflight_test.go | 18 +-- internal/app/services_test.go | 6 +- internal/app/testdata/contract-verdicts.json | 46 ++++---- internal/engine/backup_identity_test.go | 2 +- internal/engine/bootstrap_test.go | 4 +- internal/engine/networks.go | 2 +- internal/engine/networks_test.go | 10 +- internal/engine/ops_test.go | 16 +-- internal/engine/service_apply_test.go | 14 +-- site/astro.config.mjs | 1 + site/src/components/landing/Derivation.astro | 4 +- .../src/components/landing/InsideTheBox.astro | 4 +- .../docs/explanation/generated-compose.mdx | 2 +- .../docs/explanation/ownership-boundary.mdx | 2 +- .../docs/guides/upgrade-to-onebox-names.mdx | 108 ++++++++++++++++++ site/src/content/docs/reference/naming.mdx | 24 ++-- .../content/docs/reference/project-file.mdx | 8 +- site/src/content/docs/start/first-deploy.mdx | 2 +- 26 files changed, 229 insertions(+), 108 deletions(-) create mode 100644 site/src/content/docs/guides/upgrade-to-onebox-names.mdx diff --git a/e2e/apps/one-app-one-host.sh b/e2e/apps/one-app-one-host.sh index 60cab264..f0692797 100755 --- a/e2e/apps/one-app-one-host.sh +++ b/e2e/apps/one-app-one-host.sh @@ -103,7 +103,7 @@ for attempt in 1 2 3 4 5 6 7 8 9 10; do 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 ' ') +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/ob/$APP/current" 2>/dev/null) healthy=$(echo "$out" | grep '^healthy' | sed 's/^healthy *//') diff --git a/internal/app/backup_names_test.go b/internal/app/backup_names_test.go index 3a570cdb..9f4d4feb 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", + "onebox_example_database_restore", "onebox-database-restore", - "ob_example_database_restore-net", - "ob_example_database_restore-stage", + "onebox_example_database_restore-net", + "onebox_example_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_example_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/generate_test.go b/internal/app/generate_test.go index 994bf73d..e5202491 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -182,7 +182,7 @@ func TestRenderedRuntime(t *testing.T) { "condition: service_healthy", "stop_grace_period: 30s", "mem_limit: 1GB", - "ob_ledger_web_uploads", + "onebox_ledger_web_uploads", "/data/postgres:/var/lib/postgresql/data", "pg_isready -U 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_ledger_web_uploads") { t.Errorf("the derived volume name must be pinned\n%s", out) } } diff --git a/internal/app/load.go b/internal/app/load.go index 5b35098a..1d2cb074 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -676,6 +676,7 @@ func checkDerivedNames(p *Spec) error { "derived %s name %q is %d characters, over the %d-character limit; shorten the identifiers", kind, name, len(name), maxDerivedName) } + n := p.NamesFor("") for _, w := range sortedKeys(p.Workloads) { if err := check("container", p.Name+"_"+w); err != nil { return err @@ -684,17 +685,17 @@ func checkDerivedNames(p *Spec) error { if v.IsBind() { continue } - if err := check("volume", "ob_"+p.Name+"_"+w+"_"+v.Name); err != nil { + if err := check("volume", n.WorkloadVolume(w, v.Name)); err != nil { return err } } } for _, s := range sortedKeys(p.Services) { - if err := check("service project", "ob_"+p.Name+"_"+s); err != nil { + if err := check("service project", n.ServiceProject(s)); err != nil { return err } for _, v := range p.Services[s].Volumes { - if err := check("volume", "ob_"+p.Name+"_"+s+"_"+v); err != nil { + if err := check("volume", n.ServiceVolume(s, v)); err != nil { return err } } diff --git a/internal/app/load_test.go b/internal/app/load_test.go index 96bd18f8..52436c33 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_ledger"} { 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") { diff --git a/internal/app/names.go b/internal/app/names.go index d6727a95..17dbd5ce 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -17,8 +17,8 @@ import ( // hold that application's data. // // 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 +// 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. @@ -27,9 +27,10 @@ const ( ProxyProject = "onebox-proxy" IngressNetwork = "onebox-ingress" - // ManagedContainerPrefix begins every container Onebox runs from its own - // images: the host proxy and a managed service alike. - ManagedContainerPrefix = "onebox" + // Namespace begins every name Onebox derives outside the author's own + // containers: onebox- for what it runs from its own images, and + // onebox__... for the application's data and plumbing. + Namespace = "onebox" // HostNamespace holds state shared by everything on the box. HostNamespace = "_host" @@ -72,7 +73,7 @@ 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, n.App, service) } // ServiceContainer is a managed service's container. The name says who runs it, @@ -80,7 +81,7 @@ func (n Names) ServiceProject(service string) string { // 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 runtimeName(ManagedContainerPrefix, service) + return runtimeName(Namespace, service) } // ServiceNetwork joins the application to its services. It is one network per @@ -91,7 +92,7 @@ func (n Names) ServiceContainer(service string) string { // 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) } +func (n Names) ServiceNetwork() string { return join(Namespace, n.App) } // ServiceDir holds what services need and releases must not touch: their // generated Compose documents and their credentials. @@ -196,29 +197,29 @@ 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, n.App, workload, volume) } func (n Names) ServiceVolume(service, volume string) string { - return join("ob", n.App, service, volume) + return join(Namespace, n.App, service, volume) } func (n Names) BackupRestoreProject(service string) string { - return join("ob", n.App, service, "restore") + return join(Namespace, n.App, 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(ManagedContainerPrefix, service, "restore") + return runtimeName(Namespace, service, "restore") } func (n Names) BackupRestoreNetwork(service string) string { - return join("ob", n.App, service, "restore-net") + return join(Namespace, n.App, service, "restore-net") } func (n Names) BackupRestoreVolume(service string) string { - return join("ob", n.App, service, "restore-stage") + return join(Namespace, n.App, service, "restore-stage") } // ScheduledJobUnit is the systemd unit name without its suffix. diff --git a/internal/app/names_test.go b/internal/app/names_test.go index a44e38de..3e5a91b6 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -54,12 +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_ledger", + "onebox_ledger_postgres", + "onebox_ledger_postgres_data", + "onebox_ledger_postgres_wal", + "onebox_ledger_web_uploads", } got := p.All("production") if len(got) != len(want) { diff --git a/internal/app/naming_scope_test.go b/internal/app/naming_scope_test.go index ed7cf432..5015cdb4 100644 --- a/internal/app/naming_scope_test.go +++ b/internal/app/naming_scope_test.go @@ -47,7 +47,7 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { "service container": n.ServiceContainer("postgres"), "restore container": n.BackupRestoreContainer("postgres"), } { - if !strings.HasPrefix(got, ManagedContainerPrefix+"-") { + if !strings.HasPrefix(got, Namespace+"-") { t.Errorf("%s = %q, which is not in the onebox-* namespace", label, got) } } diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index 97796197..163dd246 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -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_ledger_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_ledger_web_uploads") { found = true } } @@ -270,27 +270,27 @@ func TestLegacyServiceNetworkRequiresOneboxState(t *testing.T) { 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["docker network ls"] = transport.Result{Stdout: "onebox_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") { + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "onebox_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["docker network ls"] = transport.Result{Stdout: "onebox_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") { + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "onebox_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["docker network ls"] = transport.Result{Stdout: "onebox_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() { @@ -302,10 +302,10 @@ func TestLegacyServiceNetworkRequiresOneboxState(t *testing.T) { 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["docker volume ls"] = transport.Result{Stdout: "onebox_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") { + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "onebox_ledger") { t.Fatalf("legacy service state masked a foreign volume: %+v", report.Failures()) } }) diff --git a/internal/app/services_test.go b/internal/app/services_test.go index fb3bfe15..262fbde2 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_shop_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_shop_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") { @@ -76,7 +76,7 @@ func TestNeedingAServiceJoinsItAndReadsItsURL(t *testing.T) { t.Fatal(err) } body := string(out.Bytes) - if !strings.Contains(body, "ob_shop") { + if !strings.Contains(body, "onebox_shop") { t.Fatalf("workload did not join the service network:\n%s", body) } if !strings.Contains(body, "/var/lib/ob/shop/services/store.client.env") { diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index 573647a5..ad8dbfef 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -207,7 +207,7 @@ { "case": "conformance/inferred durability does not refuse replicas", "loads": true, - "digest": "0c179c61a883e6dd0a59885d60ca765709015aec445fc4be770a6e021e2d7201" + "digest": "d13bbcd2e0335e7e22982e45897f8401f0020e3c2eb817f437a103ad124218c1" }, { "case": "conformance/job data_effect unknown", @@ -392,17 +392,17 @@ { "case": "conformance/service backup policy", "loads": true, - "digest": "77d034b99d52d2ca044aae16988d35c67e0b24a504d092a5050887a0063abe06 postgres=9e85cbb9c8295288" + "digest": "c77777d3237d81e886723e6c0261e16bfba9b7b7c16cdaea8f7ab72f882e4f05 postgres=b0a49cda2f10e076" }, { "case": "conformance/service scalar", "loads": true, - "digest": "9509ff723ef0fc5a08762ae337f3079561d228468a05c59b4cb803310b6e1a43 postgres=39cc6d687e2f025a" + "digest": "eefc3de6441d0b4b45d4b4259f2de211607540b321498fe65e3af90ad4b42ee0 postgres=c59aab9fd1501991" }, { "case": "conformance/settings key that is a real driver flag", "loads": true, - "digest": "9509ff723ef0fc5a08762ae337f3079561d228468a05c59b4cb803310b6e1a43 redis=03e2b07b335d9d4d" + "digest": "eefc3de6441d0b4b45d4b4259f2de211607540b321498fe65e3af90ad4b42ee0 redis=4aecae94a6d00a7f" }, { "case": "conformance/settings key with a shell metacharacter", @@ -497,7 +497,7 @@ { "case": "conformance/volume scalar with a path", "loads": true, - "digest": "70ab58ff576ee933006bae05e3634e08a4bbb45bde2422bc62d2a5ad41ad12e6" + "digest": "7379acefd6cf74a474ecdd9133f8c0e9f0ba910ac1404aab3e82b53138b0e11a" }, { "case": "conformance/volume scalar without a path", @@ -507,7 +507,7 @@ { "case": "conformance/volumes without persistence still load", "loads": true, - "digest": "70ab58ff576ee933006bae05e3634e08a4bbb45bde2422bc62d2a5ad41ad12e6" + "digest": "7379acefd6cf74a474ecdd9133f8c0e9f0ba910ac1404aab3e82b53138b0e11a" }, { "case": "conformance/worker with schedule", @@ -532,17 +532,17 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "f396a8553e5fd21bc517a8f98991f1f23b9f0211e73b668f5d55acfe365f4c7f postgres=fdfb04e3041d33f3 redis=aa4294d2c247915d" + "digest": "4892c3bbbcd176f7f2fbf83498e81ebbcfbf948e63451033c41ef119dcfd6420 postgres=b0ba235b1e704413 redis=4ef4f7f539752048" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "cd41c544b5bddfc67ea99d1e23343a4b3a060280e92b6aa253e0220771328957 postgres=fdfb04e3041d33f3 redis=aa4294d2c247915d" + "digest": "bee72ac827020e37936385f0b0d4a97c43e36497975e907a2e8b214a21b37b73 postgres=b0ba235b1e704413 redis=4ef4f7f539752048" }, { "case": "corpus/ext-authentik.yml", "loads": true, - "digest": "22cba89f300edb95c59528a3b9f92657c45ea8170b18ad0f96a0c494e2953e31" + "digest": "44555148878878035b190abf72f6aad5e89c6125483390dc71fa74321152c4e1" }, { "case": "corpus/ext-frigate.yml", @@ -552,7 +552,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "490a7fab6a66a1f12bf04972d2d5341a1fa4b1fc9bbce44140718977f24190b4 postgres=386b5386322a6f60" + "digest": "9890e66856b0dbd265a19c1611fee3cb7278f8a673ed8db7a54dcc9f690fbd23 postgres=977859980d7da3af" }, { "case": "corpus/ext-immich-sourced.yml", @@ -567,32 +567,32 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "dd00315223d8fed419d6055ec2cd8ec63b7fd05062e012508a7d262aed0f0146 postgres=4bd0ec307400e5a3 redis=3737afbfd7ea3993" + "digest": "791afef8d46ace5704c9a6d197ddf0056963d916d18882f32bb1b7aaaaf687bd postgres=3ab7645f27cecf79 redis=32b18c7b5b7754ab" }, { "case": "corpus/ext-paperless.yml", "loads": true, - "digest": "a9c1ed1a0d3e5b4fca6f3380ab509ec23b425b7078b4aabe4d1f273d76c6976d" + "digest": "fe8470a02daf8294ec22543cada04daf026fae6600c9e03bedaf8552f5e00dc8" }, { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "f92ebb3de2c4afabd40d9dcd1506c75e85f586c295f9e98676a8c5698d0b2611 events=5a78ea7c04e14c7b postgres=c68d89056320290b" + "digest": "38d84ccdbf9c71c3d5a5b793a74d5bf86852c7fefc887d98daeabe44e206da33 events=24c1d2c053cc0165 postgres=e47f5a3f0d281968" }, { "case": "corpus/ext-umami.yml", "loads": true, - "digest": "23c82fe78d147be1d3ac296d9e68c071b6eb9120752381d3c23ea99cda0159ae postgres=ed21f5c56a4992c0" + "digest": "094f74c54d96e608dcd805e778e876afa01e494ec2baa395ad87269b8ad7f6b5 postgres=228e41ff3d707075" }, { "case": "corpus/ghost.yml", "loads": true, - "digest": "d1ec8afc6bb9304e38d1ed79b55ec0de9f15dde5c1dfe2a8328acf277c3fd1a7 mysql=c56ab5644e9756b3" + "digest": "07c39c8bb59e7e46f07c987d69fadfc06f675db1c4685a546b5f6c042caa1ad5 mysql=33e6c87322ca558d" }, { "case": "corpus/gitea.yml", "loads": true, - "digest": "6420a7d55a997a328d0ba9e8ea56dfff13bd97ebfe8336a8b71ad7835f65a25a" + "digest": "777a3a2818843a49b43f270266d48ad98f7db1a1f2fec971eae30f09f1233c96" }, { "case": "corpus/goal.yml", @@ -602,7 +602,7 @@ { "case": "corpus/immich.yml", "loads": true, - "digest": "bf9f8c2734487afc3974d6878018dc6425402bdfb554d192ed97eb8f97b50ad7" + "digest": "1da904b1ae5b9a1eb95975a9da4f33cc2a24c5761dd328f1e8e75adb3485aea5" }, { "case": "corpus/monk.yml", @@ -612,17 +612,17 @@ { "case": "corpus/n8n.yml", "loads": true, - "digest": "f7f606a6bee885271e88ffb204f911c9be28af50c1b97c3d5f696b44b44850a8" + "digest": "4fb377f149fc9392317096ffee3b0d2981005942377b03ec8ce95a3dd030839f" }, { "case": "corpus/paperless.yml", "loads": true, - "digest": "9ed4200e8494120111c786503327ccdbbbf69f88089e4a8b867ccbd018918ff5" + "digest": "022818666ab351319737f7a6a68bcc8fd3ba441ac30200b594d8786e55cdd843" }, { "case": "corpus/penpot.yml", "loads": true, - "digest": "fdd3fa682e3fbe0221f566e67944f735d856915c545add134d66f054fcb35db2 postgres=06cc17f4fab42e94 redis=0888684827c826dd" + "digest": "4acc932a773548a637cf2342af978634a5b89999e048243f453a49356efc4a41 postgres=b92ba9f4e9b4375a redis=5a183a9ec1c0221c" }, { "case": "corpus/pursue.yml", @@ -637,12 +637,12 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "7bfda0263b19cdc21acaa254f8a742f8687e126df7af1893376f099280e84339 mongodb=cde22b543b70d444" + "digest": "442a640151b50f969beac7f6659fba2e9077273d4c0dc06351fadc1aa646c4f7 mongodb=eb676981eccc96b6" }, { "case": "corpus/umami.yml", "loads": true, - "digest": "71219adba7acff3f73a521dd805a0f5a47838a99745954b685b1e8b00b896bd1" + "digest": "25527dbae71bdc67468e983f0676fa4333063bba5f0aaad53c0eacfafe30880b" }, { "case": "corpus/uptime-kuma.yml", @@ -652,6 +652,6 @@ { "case": "corpus/vaultwarden.yml", "loads": true, - "digest": "160fccdae522ddf42957c7f371a0e1c316af4643def09e5bce921aad20aeb283" + "digest": "5c2d33df2effb118879256b3ea6b870ee10500b0961c1e40767f6838c3d59ab5" } ] diff --git a/internal/engine/backup_identity_test.go b/internal/engine/backup_identity_test.go index d3e2128d..83e0f20b 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_shop_postgres_data is missing"}, {name: "replaced", actual: "7513211627332151224", want: "belongs to PostgreSQL cluster 7513211627332151224"}, {name: "same", actual: recorded}, } { diff --git a/internal/engine/bootstrap_test.go b/internal/engine/bootstrap_test.go index 4aaddf62..3df0bb90 100644 --- a/internal/engine/bootstrap_test.go +++ b/internal/engine/bootstrap_test.go @@ -46,7 +46,7 @@ func TestBootstrapSequence(t *testing.T) { "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_sample_postgres'", // services } last := -1 for _, want := range ordered { @@ -299,7 +299,7 @@ func TestBootstrapEnsuresManagedProxyBeforeServices(t *testing.T) { 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_sample_postgres'", } last := -1 for _, want := range ordered { diff --git a/internal/engine/networks.go b/internal/engine/networks.go index e827e4e2..f0a8212f 100644 --- a/internal/engine/networks.go +++ b/internal/engine/networks.go @@ -57,7 +57,7 @@ func (e *Engine) removeOwnedNetworks(ctx context.Context) error { }{ {n.ApplicationNetwork(), n.ComposeProject(), ""}, } - // `ob_` is reserved only when the app has services. A project that + // `onebox_` 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 diff --git a/internal/engine/networks_test.go b/internal/engine/networks_test.go index e3ed307d..82e1d16a 100644 --- a/internal/engine/networks_test.go +++ b/internal/engine/networks_test.go @@ -98,7 +98,7 @@ func TestLegacyServiceNetworkRequiresServiceStateBeforeAcceptance(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") { + if strings.Contains(command, "network inspect") && strings.Contains(command, "onebox_sample") { return transport.Result{Stdout: "def456||\n"}, true } if strings.Contains(command, "test -d '/var/lib/ob/sample/services'") { @@ -139,7 +139,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_sample'") { t.Fatalf("teardown continued after the application network could not be removed:\n%s", commands) } } @@ -153,7 +153,7 @@ func TestRemoveOwnedNetworksIgnoresServiceNameWithoutServiceState(t *testing.T) if strings.Contains(command, "test -d '/var/lib/ob/sample/services'") { return transport.Result{ExitCode: 1}, true } - if strings.Contains(command, "network inspect") && strings.Contains(command, "ob_sample") { + if strings.Contains(command, "network inspect") && strings.Contains(command, "onebox_sample") { return transport.Result{Stdout: "def456||\n"}, true } return base(command) @@ -163,10 +163,10 @@ 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_sample") { 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_sample'") { t.Fatalf("destroy removed an undeclared service-network name:\n%s", commands) } } diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index e045b8d0..27894b64 100644 --- a/internal/engine/ops_test.go +++ b/internal/engine/ops_test.go @@ -98,7 +98,7 @@ func TestDestroyWithVolumesRemovesEverything(t *testing.T) { if !strings.Contains(seq, "rm -f '/var/lib/ob/_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_sample"} { if !strings.Contains(seq, "docker network rm '"+network+"'") { t.Fatalf("complete teardown retained network %s:\n%s", network, seq) } @@ -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_sample_postgres -f '/var/lib/ob/sample/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 { @@ -450,12 +450,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_sample_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_sample_postgres_data-before-restore-20260822T160242Z", + "onebox_sample_postgres_data-before-restore-not-a-timestamp", + "onebox_other_postgres_data-before-restore-20260822T160242Z", }, "\n")}, true } return transport.Result{}, false @@ -466,10 +466,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_sample_postgres_data onebox_sample_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_sample_postgres_data-before-restore-not") { t.Fatalf("destroy removed a volume whose ownership was not proved:\n%s", seq) } } diff --git a/internal/engine/service_apply_test.go b/internal/engine/service_apply_test.go index 54850cdd..a79efc6f 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_sample_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_sample_postgres' -f") && !strings.Contains(c, "ob-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_sample_postgres'") { t.Fatalf("service apply mutated after journal failure:\n%s", strings.Join(f.Commands, "\n")) } } @@ -121,7 +121,7 @@ func TestServiceApplyRefusesDestructiveMounts(t *testing.T) { 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_sample_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_sample_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_sample_postgres_data") { + return transport.Result{Stdout: "onebox_sample_postgres_data\n"}, true // data is there } return base(cmd) } 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/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro index 22431455..a446b3ce 100644 --- a/site/src/components/landing/Derivation.astro +++ b/site/src/components/landing/Derivation.astro @@ -263,8 +263,8 @@ } if (on.postgres) { derived.push(["onebox-postgres", "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_shop_postgres_data", "the durable volume — renaming it would need a migration"]); + derived.push(["onebox_shop", "the external service network"]); } derived.push(["/var/lib/ob/shop", "releases/ · current · journal · services"]); diff --git a/site/src/components/landing/InsideTheBox.astro b/site/src/components/landing/InsideTheBox.astro index 2a5fefb9..9854ab62 100644 --- a/site/src/components/landing/InsideTheBox.astro +++ b/site/src/components/landing/InsideTheBox.astro @@ -44,7 +44,7 @@ shop-worker-1 no route - ob_shop + onebox_shop @@ -55,7 +55,7 @@ onebox-postgres credential generated on the server backup → a repository you own - ob_shop_postgres_data + onebox_shop_postgres_data ON DISK diff --git a/site/src/content/docs/explanation/generated-compose.mdx b/site/src/content/docs/explanation/generated-compose.mdx index 0445475f..abfedfba 100644 --- a/site/src/content/docs/explanation/generated-compose.mdx +++ b/site/src/content/docs/explanation/generated-compose.mdx @@ -36,7 +36,7 @@ becomes a question rather than a fact. - **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`, `ob_shop_postgres_data`, `ob_shop`, + provider resources include `shop_default`, `onebox_shop_postgres_data`, `onebox_shop`, 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 diff --git a/site/src/content/docs/explanation/ownership-boundary.mdx b/site/src/content/docs/explanation/ownership-boundary.mdx index 5be740b3..58b9d32c 100644 --- a/site/src/content/docs/explanation/ownership-boundary.mdx +++ b/site/src/content/docs/explanation/ownership-boundary.mdx @@ -51,7 +51,7 @@ record before it does anything. A different application is refused with 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 +default network (`_default`) and service network (`onebox_`) 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/upgrade-to-onebox-names.mdx b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx new file mode 100644 index 00000000..4359a2d9 --- /dev/null +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -0,0 +1,108 @@ +--- +title: Upgrade to onebox names +description: Move an existing host from ob_ and ob-ingress to the onebox namespace. Onebox does not migrate anything for you. +summary: What the rename changes, what the next deploy does to a host that still has ob_ volumes, and the manual steps that carry the data across. +sidebar: + order: 10 +read_when: + - "Upgrading a host that was deployed before the onebox naming change" + - "A deploy came up with an empty database after an upgrade" + - "Old ob_ volumes, ob-ingress, or --1 containers are still on a host" +--- + +Every name Onebox derives outside your own workload containers now begins with +`onebox`. **There is no migration and no fallback.** Onebox does not look for +the old names, read from them, or remove them. If you deploy an upgraded +project to a host that still holds `ob_` volumes, **it starts the service on a +new, empty volume**. The old data is still on the host, but nothing uses it. + +Do the steps below **before** the first deploy with the new version. + +## What changes + +| Resource | Before | After | +| --- | --- | --- | +| Managed service container | `shop-postgres-1` | `onebox-postgres` | +| Restore-drill container | `shop-postgres-restore-1` | `onebox-postgres-restore` | +| Service and workload volumes | `ob_shop_postgres_data` | `onebox_shop_postgres_data` | +| Service Compose project | `ob_shop_postgres` | `onebox_shop_postgres` | +| Service network | `ob_shop` | `onebox_shop` | +| Ingress network | `ob-ingress` | `onebox-ingress` | +| Workload containers | `shop-web-1` | unchanged | +| Application network | `shop_default` | unchanged | + +Application names may no longer be `onebox` or begin with `onebox-`, and a +service may not be called `proxy` or `discovery`. + +## What the next deploy does without these steps + +- **Unprotected services start empty.** A service without `backup` starts on a + new, empty volume, uses the existing credential, and reports healthy. Your + application connects to an empty database. +- **Protected PostgreSQL refuses to apply.** A service with `backup` records its + cluster identity, so the deploy stops with `data volume + onebox___data is missing`. Nothing is started. +- **The old service containers keep running.** They belong to the old Compose + project, so the deploy does not replace them. They keep the old volumes open. +- **Routing stops until the workloads are redeployed.** The proxy moves to + `onebox-ingress`, and each workload moves when it is next rolled. + +## Move the data + +Run this on the host as root. Set `APP` to your application name. The commands +stop the application, so plan for downtime. Copying the volumes temporarily +doubles the disk space they use. + +1. Upgrade `ob` on your machine, but do not deploy yet. + +2. On the host, remove the old service containers and stop the workloads: + + ```sh + APP=shop + docker ps -aq --filter label=ob.app=$APP --filter label=ob.service | xargs -r docker rm -f + docker ps -q --filter label=ob.app=$APP | xargs -r docker stop + ``` + + Removing the service containers does not remove their volumes. + +3. Copy every `ob_` volume into its `onebox_` name, keeping the labels Onebox + uses to recognise it as its own: + + ```sh + for old in $(docker volume ls -q --filter label=ob.app=$APP | grep '^ob_'); do + new="onebox_${old#ob_}" + labels=$(docker volume inspect -f '{{range $k, $v := .Labels}}{{if or (eq $k "ob.app") (eq $k "ob.service")}}--label {{$k}}={{$v}} {{end}}{{end}}' "$old") + docker volume create $labels "$new" + docker run --rm -v "$old":/from:ro -v "$new":/to alpine cp -a /from/. /to/ + done + ``` + + A volume without the `ob.app` label is refused by preflight as a resource + Onebox does not own. The loop copies the label, so the new volume passes. + +4. Deploy from your machine: + + ```sh + ob deploy production + ``` + + A protected PostgreSQL service checks that the copied volume holds the same + cluster it recorded. If the copy is incomplete, the deploy stops here. + +5. Check that the application sees its data. Then remove what the old names + left behind: + + ```sh + docker volume ls -q --filter label=ob.app=$APP | grep '^ob_' | xargs -r docker volume rm + docker network rm ob_$APP ob-ingress + ``` + + `docker network rm` fails while anything is still attached to the network. + Leave the old volumes in place until you are sure. They are the only copy + of the data from before the upgrade. + +## If you already deployed + +If the deploy already ran, the old volumes are untouched. Stop the application +as in step 2, and remove the new empty volumes with `docker volume rm` after +checking that they hold nothing you need. Then continue from step 3. diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 92fd4318..08348365 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -85,13 +85,23 @@ 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. They -carry the application even for a managed service — its data volume is -`ob_shop_postgres_data` — because they hold that application's data. +removed. + +**Every other resource Onebox derives is `onebox__…`.** Volumes, service +Compose projects, the service network and restore-drill resources: +`onebox_shop_postgres_data`, `onebox_shop_postgres`, `onebox_shop`. They carry +the application, even for a managed service, because they hold that +application's data. They join with underscores because an application or +component name cannot contain one, so each name maps back to exactly one +application, 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 | the application's 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 0cc0681c..26fd71ed 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -111,9 +111,9 @@ Full explanation, including why level four outranks the rest: **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 `ob_shop_postgres`, -`ob_shop_postgres_data`, `shop_default` (the external application network), -`ob_shop` (the external service network), and `onebox-ingress`. These are contract: +`onebox-postgres` for a managed service and `onebox-proxy` for the proxy. Persistent and provider names include `onebox_shop_postgres`, +`onebox_shop_postgres_data`, `shop_default` (the external application network), +`onebox_shop` (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. @@ -154,7 +154,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_` 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 9dd27e47..f99ca099 100644 --- a/site/src/content/docs/start/first-deploy.mdx +++ b/site/src/content/docs/start/first-deploy.mdx @@ -177,7 +177,7 @@ read-only. 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 -`ob_shop_postgres_data`, `ob_shop` for the service network, and `onebox-ingress` for +`onebox_shop_postgres_data`, `onebox_shop` for the service network, and `onebox-ingress` for the host proxy network. Once a volume exists, its name cannot change without moving data. From 2116f63bded552b22f1974d25dbbe8ab4d68ef5b Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 08:05:47 -0700 Subject: [PATCH 04/15] feat(runtime)!: drop the application from onebox_ resource names Volumes, service Compose projects, the service network and restore-drill resources no longer carry the application: onebox_postgres_data, onebox_web_uploads, onebox_postgres, onebox_services. A host has one application, the ob.app label records which, and ob destroy releases the host only after removing these resources, so the application in the name distinguished nothing. Reserve the service name services, whose Compose project would derive the service network's name. BREAKING CHANGE: volumes are renamed from ob___ to onebox__, the service network from ob_ to onebox_services, and service projects from ob__ to onebox_. There is no migration; guides/upgrade-to-onebox-names gives the manual steps. --- .../v1alpha1/application.schema.json | 3 ++ internal/app/backup_names_test.go | 8 +-- internal/app/constraints.go | 11 ++-- internal/app/generate_test.go | 4 +- internal/app/load_test.go | 2 +- internal/app/names.go | 35 +++++++------ internal/app/names_test.go | 42 +++++++++------ internal/app/naming_scope_test.go | 52 ++++++++----------- internal/app/preflight_test.go | 18 +++---- internal/app/services_test.go | 6 +-- internal/app/testdata/contract-verdicts.json | 46 ++++++++-------- internal/engine/backup_identity_test.go | 2 +- internal/engine/bootstrap_test.go | 4 +- internal/engine/networks.go | 2 +- internal/engine/networks_test.go | 10 ++-- internal/engine/ops_test.go | 16 +++--- internal/engine/service_apply_test.go | 14 ++--- .../v1alpha1/application.schema.json | 3 ++ site/src/components/landing/Derivation.astro | 4 +- .../src/components/landing/InsideTheBox.astro | 4 +- .../docs/explanation/generated-compose.mdx | 2 +- .../docs/explanation/ownership-boundary.mdx | 2 +- .../docs/guides/upgrade-to-onebox-names.mdx | 19 +++---- site/src/content/docs/reference/naming.mdx | 24 +++++---- .../content/docs/reference/project-file.mdx | 8 +-- site/src/content/docs/start/first-deploy.mdx | 2 +- 26 files changed, 180 insertions(+), 163 deletions(-) diff --git a/api/application/v1alpha1/application.schema.json b/api/application/v1alpha1/application.schema.json index 8d4bfe2b..ffa51988 100644 --- a/api/application/v1alpha1/application.schema.json +++ b/api/application/v1alpha1/application.schema.json @@ -1292,6 +1292,9 @@ }, { "const": "discovery" + }, + { + "const": "services" } ] } diff --git a/internal/app/backup_names_test.go b/internal/app/backup_names_test.go index 9f4d4feb..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{ - "onebox_example_database_restore", + "onebox_database_restore", "onebox-database-restore", - "onebox_example_database_restore-net", - "onebox_example_database_restore-stage", + "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{"onebox_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/constraints.go b/internal/app/constraints.go index 7ec178fa..e151321f 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -210,10 +210,11 @@ var ( // derive its own names inside it. var reservedAppNames = []string{"ob", "onebox", "_host"} -// reservedServiceNames are the host proxy's components. A managed service's -// container is onebox-, so a service with one of these names would -// derive the host proxy's container name. -var reservedServiceNames = []string{"proxy", "discovery"} +// reservedServiceNames are names a service would share with something else +// Onebox runs. A managed service's container is onebox-, so proxy and +// discovery would derive the host proxy's containers; its Compose project is +// onebox_, so services would derive the service network. +var reservedServiceNames = []string{"proxy", "discovery", ServiceNetworkName} // checkServiceName refuses a service name whose container would be the host // proxy's. @@ -221,7 +222,7 @@ func checkServiceName(name string) error { for _, reserved := range reservedServiceNames { if name == reserved { return errf("project_invalid", "services."+name, "", - "%q is reserved: its container would be onebox-%s, which the host proxy uses", name, name) + "%q is reserved: it would derive onebox-%s or onebox_%s, which Onebox already uses", name, name, name) } } return nil diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index e5202491..c7f02629 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -182,7 +182,7 @@ func TestRenderedRuntime(t *testing.T) { "condition: service_healthy", "stop_grace_period: 30s", "mem_limit: 1GB", - "onebox_ledger_web_uploads", + "onebox_web_uploads", "/data/postgres:/var/lib/postgresql/data", "pg_isready -U 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: onebox_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/load_test.go b/internal/app/load_test.go index 52436c33..fa64f11a 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", "onebox_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") { diff --git a/internal/app/names.go b/internal/app/names.go index 17dbd5ce..ab18c712 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -13,12 +13,11 @@ import ( // 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 and keep the application in every name, since they -// hold that application's data. +// the injective join below under onebox_. // // Persistent and provider-internal identifiers are joined with underscores. -// 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 +// 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. @@ -29,9 +28,15 @@ const ( // Namespace begins every name Onebox derives outside the author's own // containers: onebox- for what it runs from its own images, and - // onebox__... for the application's data and plumbing. + // onebox__... for data and plumbing. Neither carries the + // application: a host has one, the ob.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 holds state shared by everything on the box. HostNamespace = "_host" @@ -73,7 +78,7 @@ 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(Namespace, n.App, service) + return join(Namespace, service) } // ServiceContainer is a managed service's container. The name says who runs it, @@ -89,10 +94,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(Namespace, 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. @@ -197,15 +202,15 @@ 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(Namespace, n.App, workload, volume) + return join(Namespace, workload, volume) } func (n Names) ServiceVolume(service, volume string) string { - return join(Namespace, n.App, service, volume) + return join(Namespace, service, volume) } func (n Names) BackupRestoreProject(service string) string { - return join(Namespace, n.App, service, "restore") + return join(Namespace, service, "restore") } // BackupRestoreContainer is the transient restore-drill container beside a @@ -215,11 +220,11 @@ func (n Names) BackupRestoreContainer(service string) string { } func (n Names) BackupRestoreNetwork(service string) string { - return join(Namespace, n.App, service, "restore-net") + return join(Namespace, service, "restore-net") } func (n Names) BackupRestoreVolume(service string) string { - return join(Namespace, n.App, service, "restore-stage") + return join(Namespace, service, "restore-stage") } // ScheduledJobUnit is the systemd unit name without its suffix. diff --git a/internal/app/names_test.go b/internal/app/names_test.go index 3e5a91b6..5ad0f376 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -55,11 +55,11 @@ func TestDerivedNamesGolden(t *testing.T) { "ledger-worker-new", "ledger_default", "onebox-postgres", - "onebox_ledger", - "onebox_ledger_postgres", - "onebox_ledger_postgres_data", - "onebox_ledger_postgres_wal", - "onebox_ledger_web_uploads", + "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 } } } @@ -330,6 +329,17 @@ spec: web: {image: nginx} services: proxy: {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 diff --git a/internal/app/naming_scope_test.go b/internal/app/naming_scope_test.go index 5015cdb4..39c8172b 100644 --- a/internal/app/naming_scope_test.go +++ b/internal/app/naming_scope_test.go @@ -5,21 +5,18 @@ import ( "testing" ) -// 8.5 — every name derived from the author's components 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. -// -// Managed service containers are the exception, by design: they are -// onebox-, the namespace of containers Onebox runs from its own -// images. Their volumes and projects still carry the application, because -// those hold its data. +// 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 and networks do not carry the +// application. A host has one application, the ob.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} @@ -27,10 +24,6 @@ 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 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"), @@ -51,21 +44,20 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { t.Errorf("%s = %q, which is not in the onebox-* namespace", 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{ + "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"), } { - if pair[0] == pair[1] { - t.Errorf("%s: two applications derive the same name %q", label, pair[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) } } } diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index 163dd246..6061ed78 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -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: "onebox_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, "onebox_ledger_web_uploads") { + if c.Name == "name collisions" && strings.Contains(c.Detail, "onebox_web_uploads") { found = true } } @@ -270,27 +270,27 @@ func TestLegacyServiceNetworkRequiresOneboxState(t *testing.T) { t.Run("fresh host refuses an unlabelled network", func(t *testing.T) { run := healthyRunner() - run.answers["docker network ls"] = transport.Result{Stdout: "onebox_ledger\t\t\n"} + run.answers["docker network ls"] = transport.Result{Stdout: "onebox_services\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, "onebox_ledger") { + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "onebox_services") { 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: "onebox_ledger\t\tledger\n"} + run.answers["docker network ls"] = transport.Result{Stdout: "onebox_services\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, "onebox_ledger") { + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "onebox_services") { 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: "onebox_ledger\t\t\n"} + run.answers["docker network ls"] = transport.Result{Stdout: "onebox_services\t\t\n"} run.answers["test -d '/var/lib/ob/ledger/services'"] = transport.Result{} report := preflight(t, run, project) for _, failure := range report.Failures() { @@ -302,10 +302,10 @@ func TestLegacyServiceNetworkRequiresOneboxState(t *testing.T) { t.Run("service state does not bless another resource kind", func(t *testing.T) { run := healthyRunner() - run.answers["docker volume ls"] = transport.Result{Stdout: "onebox_ledger\t\n"} + run.answers["docker volume ls"] = transport.Result{Stdout: "onebox_services\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, "onebox_ledger") { + if report.OK() || !strings.Contains(report.Failures()[0].Detail, "onebox_services") { t.Fatalf("legacy service state masked a foreign volume: %+v", report.Failures()) } }) diff --git a/internal/app/services_test.go b/internal/app/services_test.go index 262fbde2..9be46e25 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: onebox_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, "onebox_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") { @@ -76,7 +76,7 @@ func TestNeedingAServiceJoinsItAndReadsItsURL(t *testing.T) { t.Fatal(err) } body := string(out.Bytes) - if !strings.Contains(body, "onebox_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") { diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index ad8dbfef..5d9d6214 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -207,7 +207,7 @@ { "case": "conformance/inferred durability does not refuse replicas", "loads": true, - "digest": "d13bbcd2e0335e7e22982e45897f8401f0020e3c2eb817f437a103ad124218c1" + "digest": "fc4d0a2aff6f4b3713ad28ac0a996ba28799aca4e51c561864f0bd1f1800165a" }, { "case": "conformance/job data_effect unknown", @@ -392,17 +392,17 @@ { "case": "conformance/service backup policy", "loads": true, - "digest": "c77777d3237d81e886723e6c0261e16bfba9b7b7c16cdaea8f7ab72f882e4f05 postgres=b0a49cda2f10e076" + "digest": "7716e66e5cfd6b0c2a4ba040692c8fe6494a5d15fa54bbbcc3b81d736fc7dfcb postgres=818046b94f7dfb29" }, { "case": "conformance/service scalar", "loads": true, - "digest": "eefc3de6441d0b4b45d4b4259f2de211607540b321498fe65e3af90ad4b42ee0 postgres=c59aab9fd1501991" + "digest": "58655dd0bcba7f5fdfdd4bff8a13763401ae1d40a15f487ecf23e33267f83e7c postgres=14970cbe488cb31f" }, { "case": "conformance/settings key that is a real driver flag", "loads": true, - "digest": "eefc3de6441d0b4b45d4b4259f2de211607540b321498fe65e3af90ad4b42ee0 redis=4aecae94a6d00a7f" + "digest": "58655dd0bcba7f5fdfdd4bff8a13763401ae1d40a15f487ecf23e33267f83e7c redis=ee0801c9bacecc53" }, { "case": "conformance/settings key with a shell metacharacter", @@ -497,7 +497,7 @@ { "case": "conformance/volume scalar with a path", "loads": true, - "digest": "7379acefd6cf74a474ecdd9133f8c0e9f0ba910ac1404aab3e82b53138b0e11a" + "digest": "d01a9f018c0e78b13e356eb8055fdd8a2087c9427da603757f255feaeb293d34" }, { "case": "conformance/volume scalar without a path", @@ -507,7 +507,7 @@ { "case": "conformance/volumes without persistence still load", "loads": true, - "digest": "7379acefd6cf74a474ecdd9133f8c0e9f0ba910ac1404aab3e82b53138b0e11a" + "digest": "d01a9f018c0e78b13e356eb8055fdd8a2087c9427da603757f255feaeb293d34" }, { "case": "conformance/worker with schedule", @@ -532,17 +532,17 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "4892c3bbbcd176f7f2fbf83498e81ebbcfbf948e63451033c41ef119dcfd6420 postgres=b0ba235b1e704413 redis=4ef4f7f539752048" + "digest": "2202fcb86cd2cd04d7b693d8d7930192440238d310f4949bbc6877a720bc5627 postgres=9d597f5da1926a42 redis=8d4cfce9447047aa" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "bee72ac827020e37936385f0b0d4a97c43e36497975e907a2e8b214a21b37b73 postgres=b0ba235b1e704413 redis=4ef4f7f539752048" + "digest": "3068425213c316ac96b029c62005bc6483a13d905a35b95e0c31bcd324a63c51 postgres=9d597f5da1926a42 redis=8d4cfce9447047aa" }, { "case": "corpus/ext-authentik.yml", "loads": true, - "digest": "44555148878878035b190abf72f6aad5e89c6125483390dc71fa74321152c4e1" + "digest": "b9eb700cc86f53209f22f5886b01f509b0c9c5b134729f77d435a95f73808a79" }, { "case": "corpus/ext-frigate.yml", @@ -552,7 +552,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "9890e66856b0dbd265a19c1611fee3cb7278f8a673ed8db7a54dcc9f690fbd23 postgres=977859980d7da3af" + "digest": "eae6e33eb4abf65aecd4c7d930ca880178b0e78c92ff6f5d46fe68bfcfe50442 postgres=603c07798b9cf982" }, { "case": "corpus/ext-immich-sourced.yml", @@ -567,32 +567,32 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "791afef8d46ace5704c9a6d197ddf0056963d916d18882f32bb1b7aaaaf687bd postgres=3ab7645f27cecf79 redis=32b18c7b5b7754ab" + "digest": "2b4f82be3fa842b8b95363edd03b3a5eaf715df6699c334d76a3cfdbd940ed78 postgres=b7c083be80e3ca7d redis=25418a76a70caba2" }, { "case": "corpus/ext-paperless.yml", "loads": true, - "digest": "fe8470a02daf8294ec22543cada04daf026fae6600c9e03bedaf8552f5e00dc8" + "digest": "0337c7874581b5884c67ba0eb78587259af8d0273d8d3fb423fc3f8069e5c3bf" }, { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "38d84ccdbf9c71c3d5a5b793a74d5bf86852c7fefc887d98daeabe44e206da33 events=24c1d2c053cc0165 postgres=e47f5a3f0d281968" + "digest": "20af7df837e1d22a421033082adde67e1484cd7b4f5d02598b895d58016ebe70 events=dbea2ef4d0c835a2 postgres=a9d1af1f5dc1ee11" }, { "case": "corpus/ext-umami.yml", "loads": true, - "digest": "094f74c54d96e608dcd805e778e876afa01e494ec2baa395ad87269b8ad7f6b5 postgres=228e41ff3d707075" + "digest": "f7fd92b303f5cd1c56a1395d24324d22d42ad14507fd28466b1fa06c19abbfdb postgres=fa306c6e4104bd3e" }, { "case": "corpus/ghost.yml", "loads": true, - "digest": "07c39c8bb59e7e46f07c987d69fadfc06f675db1c4685a546b5f6c042caa1ad5 mysql=33e6c87322ca558d" + "digest": "e6f9bb5839535bf428588d9176f3f5c921a8adac91ed5456207d4280951e771c mysql=f9c6ef95af207a8d" }, { "case": "corpus/gitea.yml", "loads": true, - "digest": "777a3a2818843a49b43f270266d48ad98f7db1a1f2fec971eae30f09f1233c96" + "digest": "9b3770ed716daf6e1aa4844b212773ce4b3448f0ddf0e347f6839104184e3648" }, { "case": "corpus/goal.yml", @@ -602,7 +602,7 @@ { "case": "corpus/immich.yml", "loads": true, - "digest": "1da904b1ae5b9a1eb95975a9da4f33cc2a24c5761dd328f1e8e75adb3485aea5" + "digest": "0da2b001800e2d4bd727e5ef62e077a572d3005b7addd760fa7ed773e18041b1" }, { "case": "corpus/monk.yml", @@ -612,17 +612,17 @@ { "case": "corpus/n8n.yml", "loads": true, - "digest": "4fb377f149fc9392317096ffee3b0d2981005942377b03ec8ce95a3dd030839f" + "digest": "8392e4f9f5674595042ed6c63858776ef300a00335e8e5016397df6286f2aff3" }, { "case": "corpus/paperless.yml", "loads": true, - "digest": "022818666ab351319737f7a6a68bcc8fd3ba441ac30200b594d8786e55cdd843" + "digest": "9a40f70d296d8f132804bb94920809effb6b8227f65cd5d3bff77f81c0787a5c" }, { "case": "corpus/penpot.yml", "loads": true, - "digest": "4acc932a773548a637cf2342af978634a5b89999e048243f453a49356efc4a41 postgres=b92ba9f4e9b4375a redis=5a183a9ec1c0221c" + "digest": "d3385f03a6d23d7a556662d85710050d85318ddadffe5f4e77bfb1698a144a36 postgres=cd0c1feae31470d5 redis=a44cbdbccdf3fdb7" }, { "case": "corpus/pursue.yml", @@ -637,12 +637,12 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "442a640151b50f969beac7f6659fba2e9077273d4c0dc06351fadc1aa646c4f7 mongodb=eb676981eccc96b6" + "digest": "91b3e8b029f49595f82c88643719c609484d1b0fcbf375ef9c957d16fbcc744f mongodb=ba797e9006dfd6b5" }, { "case": "corpus/umami.yml", "loads": true, - "digest": "25527dbae71bdc67468e983f0676fa4333063bba5f0aaad53c0eacfafe30880b" + "digest": "3248bffd82b0882fba905063365e2eda228bc0cbd3252f1789ab7edd41433245" }, { "case": "corpus/uptime-kuma.yml", @@ -652,6 +652,6 @@ { "case": "corpus/vaultwarden.yml", "loads": true, - "digest": "5c2d33df2effb118879256b3ea6b870ee10500b0961c1e40767f6838c3d59ab5" + "digest": "40992c777230d019d54eda82d61f2f2438aad334f291cde457089071eb936336" } ] diff --git a/internal/engine/backup_identity_test.go b/internal/engine/backup_identity_test.go index 83e0f20b..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 onebox_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/bootstrap_test.go b/internal/engine/bootstrap_test.go index 3df0bb90..da7ef789 100644 --- a/internal/engine/bootstrap_test.go +++ b/internal/engine/bootstrap_test.go @@ -46,7 +46,7 @@ func TestBootstrapSequence(t *testing.T) { "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 'onebox_sample_postgres'", // services + "docker compose -p 'onebox_postgres'", // services } last := -1 for _, want := range ordered { @@ -299,7 +299,7 @@ func TestBootstrapEnsuresManagedProxyBeforeServices(t *testing.T) { ordered := []string{ "docker login 'ghcr.io'", "docker compose -p onebox-proxy -f '/var/lib/ob/_host/proxy/compose.yaml' up -d", - "docker compose -p 'onebox_sample_postgres'", + "docker compose -p 'onebox_postgres'", } last := -1 for _, want := range ordered { diff --git a/internal/engine/networks.go b/internal/engine/networks.go index f0a8212f..91611d84 100644 --- a/internal/engine/networks.go +++ b/internal/engine/networks.go @@ -57,7 +57,7 @@ func (e *Engine) removeOwnedNetworks(ctx context.Context) error { }{ {n.ApplicationNetwork(), n.ComposeProject(), ""}, } - // `onebox_` is reserved only when the app has services. A project that + // `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 diff --git a/internal/engine/networks_test.go b/internal/engine/networks_test.go index 82e1d16a..32fa4033 100644 --- a/internal/engine/networks_test.go +++ b/internal/engine/networks_test.go @@ -98,7 +98,7 @@ func TestLegacyServiceNetworkRequiresServiceStateBeforeAcceptance(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, "onebox_sample") { + if strings.Contains(command, "network inspect") && strings.Contains(command, "onebox_services") { return transport.Result{Stdout: "def456||\n"}, true } if strings.Contains(command, "test -d '/var/lib/ob/sample/services'") { @@ -139,7 +139,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 'onebox_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) } } @@ -153,7 +153,7 @@ func TestRemoveOwnedNetworksIgnoresServiceNameWithoutServiceState(t *testing.T) if strings.Contains(command, "test -d '/var/lib/ob/sample/services'") { return transport.Result{ExitCode: 1}, true } - if strings.Contains(command, "network inspect") && strings.Contains(command, "onebox_sample") { + if strings.Contains(command, "network inspect") && strings.Contains(command, "onebox_services") { return transport.Result{Stdout: "def456||\n"}, true } return base(command) @@ -163,10 +163,10 @@ func TestRemoveOwnedNetworksIgnoresServiceNameWithoutServiceState(t *testing.T) t.Fatal(err) } commands := strings.Join(f.Commands, "\n") - if strings.Contains(commands, "network inspect") && strings.Contains(commands, "onebox_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 'onebox_sample'") { + if strings.Contains(commands, "network rm 'onebox_services'") { t.Fatalf("destroy removed an undeclared service-network name:\n%s", commands) } } diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index 27894b64..6ead005d 100644 --- a/internal/engine/ops_test.go +++ b/internal/engine/ops_test.go @@ -98,7 +98,7 @@ func TestDestroyWithVolumesRemovesEverything(t *testing.T) { if !strings.Contains(seq, "rm -f '/var/lib/ob/_host/owner'") { t.Fatalf("complete teardown without a managed proxy retained host ownership:\n%s", seq) } - for _, network := range []string{"sample_default", "onebox_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) } @@ -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 onebox_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/ob/sample/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 { @@ -450,12 +450,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: "onebox_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{ - "onebox_sample_postgres_data-before-restore-20260822T160242Z", - "onebox_sample_postgres_data-before-restore-not-a-timestamp", - "onebox_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 +466,10 @@ func TestRemoveServicesRemovesPreservedRestoreVolumes(t *testing.T) { t.Fatal(err) } seq := strings.Join(f.Commands, "\n") - if !strings.Contains(seq, "docker volume rm onebox_sample_postgres_data onebox_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 onebox_other") || strings.Contains(seq, "docker volume rm onebox_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) } } diff --git a/internal/engine/service_apply_test.go b/internal/engine/service_apply_test.go index a79efc6f..da41354d 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 'onebox_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, "onebox_sample_postgres' -f") && !strings.Contains(c, "ob-fenced") { + if strings.Contains(c, "onebox_postgres' -f") && !strings.Contains(c, "ob-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 'onebox_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")) } } @@ -121,7 +121,7 @@ func TestServiceApplyRefusesDestructiveMounts(t *testing.T) { 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"), "onebox_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"), "onebox_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, "onebox_sample_postgres_data") { - return transport.Result{Stdout: "onebox_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/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha1/application.schema.json index 8d4bfe2b..ffa51988 100644 --- a/site/public/schemas/application/v1alpha1/application.schema.json +++ b/site/public/schemas/application/v1alpha1/application.schema.json @@ -1292,6 +1292,9 @@ }, { "const": "discovery" + }, + { + "const": "services" } ] } diff --git a/site/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro index a446b3ce..c19d6529 100644 --- a/site/src/components/landing/Derivation.astro +++ b/site/src/components/landing/Derivation.astro @@ -263,8 +263,8 @@ } if (on.postgres) { derived.push(["onebox-postgres", "image, health check, and a credential generated on the server"]); - derived.push(["onebox_shop_postgres_data", "the durable volume — renaming it would need a migration"]); - derived.push(["onebox_shop", "the external service network"]); + 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"]); diff --git a/site/src/components/landing/InsideTheBox.astro b/site/src/components/landing/InsideTheBox.astro index 9854ab62..7c5c65ad 100644 --- a/site/src/components/landing/InsideTheBox.astro +++ b/site/src/components/landing/InsideTheBox.astro @@ -44,7 +44,7 @@ shop-worker-1 no route - onebox_shop + onebox_services @@ -55,7 +55,7 @@ onebox-postgres credential generated on the server backup → a repository you own - onebox_shop_postgres_data + onebox_postgres_data ON DISK diff --git a/site/src/content/docs/explanation/generated-compose.mdx b/site/src/content/docs/explanation/generated-compose.mdx index abfedfba..cb2692f5 100644 --- a/site/src/content/docs/explanation/generated-compose.mdx +++ b/site/src/content/docs/explanation/generated-compose.mdx @@ -36,7 +36,7 @@ becomes a question rather than a fact. - **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_shop_postgres_data`, `onebox_shop`, + 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 diff --git a/site/src/content/docs/explanation/ownership-boundary.mdx b/site/src/content/docs/explanation/ownership-boundary.mdx index 58b9d32c..c4f5c907 100644 --- a/site/src/content/docs/explanation/ownership-boundary.mdx +++ b/site/src/content/docs/explanation/ownership-boundary.mdx @@ -51,7 +51,7 @@ record before it does anything. A different application is refused with 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 (`onebox_`) in its +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/upgrade-to-onebox-names.mdx b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx index 4359a2d9..52856376 100644 --- a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -24,15 +24,16 @@ Do the steps below **before** the first deploy with the new version. | --- | --- | --- | | Managed service container | `shop-postgres-1` | `onebox-postgres` | | Restore-drill container | `shop-postgres-restore-1` | `onebox-postgres-restore` | -| Service and workload volumes | `ob_shop_postgres_data` | `onebox_shop_postgres_data` | -| Service Compose project | `ob_shop_postgres` | `onebox_shop_postgres` | -| Service network | `ob_shop` | `onebox_shop` | +| Service and workload 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` | | Workload containers | `shop-web-1` | unchanged | | Application network | `shop_default` | unchanged | -Application names may no longer be `onebox` or begin with `onebox-`, and a -service may not be called `proxy` or `discovery`. +Names in the `onebox` namespace no longer carry the application. Application +names may no longer be `onebox` or begin with `onebox-`, and a service may not +be called `proxy`, `discovery` or `services`. ## What the next deploy does without these steps @@ -41,7 +42,7 @@ service may not be called `proxy` or `discovery`. application connects to an empty database. - **Protected PostgreSQL refuses to apply.** A service with `backup` records its cluster identity, so the deploy stops with `data volume - onebox___data is missing`. Nothing is started. + onebox__data is missing`. Nothing is started. - **The old service containers keep running.** They belong to the old Compose project, so the deploy does not replace them. They keep the old volumes open. - **Routing stops until the workloads are redeployed.** The proxy moves to @@ -65,12 +66,12 @@ doubles the disk space they use. Removing the service containers does not remove their volumes. -3. Copy every `ob_` volume into its `onebox_` name, keeping the labels Onebox - uses to recognise it as its own: +3. Copy every `ob__` volume into its `onebox_` name, keeping the labels + Onebox uses to recognise it as its own: ```sh for old in $(docker volume ls -q --filter label=ob.app=$APP | grep '^ob_'); do - new="onebox_${old#ob_}" + new="onebox_${old#ob_${APP}_}" labels=$(docker volume inspect -f '{{range $k, $v := .Labels}}{{if or (eq $k "ob.app") (eq $k "ob.service")}}--label {{$k}}={{$v}} {{end}}{{end}}' "$old") docker volume create $labels "$new" docker run --rm -v "$old":/from:ro -v "$new":/to alpine cp -a /from/. /to/ diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 08348365..0c22e414 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -72,7 +72,8 @@ 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-` -(or `ob-`), and `proxy` and `discovery` are not valid service names. +(or `ob-`), and `proxy`, `discovery` and `services` are not valid service +names. **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: @@ -87,21 +88,22 @@ The temporary container joining a rollout is `--new`. It takes the stable numbered slot only after the previous occupant has drained and been removed. -**Every other resource Onebox derives is `onebox__…`.** Volumes, service -Compose projects, the service network and restore-drill resources: -`onebox_shop_postgres_data`, `onebox_shop_postgres`, `onebox_shop`. They carry -the application, even for a managed service, because they hold that -application's data. They join with underscores because an application or -component name cannot contain one, so each name maps back to exactly one -application, 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. +**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 +`ob.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 | the application's volumes, service projects and networks | +| `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 26fd71ed..978d2719 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -111,9 +111,9 @@ Full explanation, including why level four outranks the rest: **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_shop_postgres`, -`onebox_shop_postgres_data`, `shop_default` (the external application network), -`onebox_shop` (the external service network), and `onebox-ingress`. These are contract: +`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. @@ -154,7 +154,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 `onebox_` 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 f99ca099..7b7c6125 100644 --- a/site/src/content/docs/start/first-deploy.mdx +++ b/site/src/content/docs/start/first-deploy.mdx @@ -177,7 +177,7 @@ read-only. 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_shop_postgres_data`, `onebox_shop` for the service network, and `onebox-ingress` for +`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. From 2a12e91193e7fb0f3592f0637497a7109d382a0e Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 08:25:20 -0700 Subject: [PATCH 05/15] feat(runtime)!: move host state, units, routes and labels to onebox Finish putting everything Onebox places on a host in the onebox namespace, without the application's name: - State lives in /app, and the default basePath is /var/lib/onebox. The host journal keeps its own directory. - Scheduled job units are onebox-job-; backup units are onebox-backup---. - Proxy routers and services are onebox_[_r]; custom Traefik objects may not begin with onebox_. - Ownership and runtime labels move from ob.* to onebox.*, and the compose_ob_label error code becomes compose_onebox_label. - The ob- application-name rule and the ob reservation are gone; nothing derives an ob- name any more. Remove every compatibility path: the pre-2026.8.6 unit and credential spellings and their migration, unit ownership recovered from unit bodies, unlabelled networks adopted from Compose labels or service state, host owner records without an environment, the v1alpha1 secret checkpoint, the short-option schedule lock rendezvous, the old discovery container name, and unlabelled compose-run job containers. An engine built without an environment now takes the one its resolved project was resolved for. The legacy backup repository layout stays readable: removing it would make existing backups unrestorable. BREAKING CHANGE: state moves from /var/lib/ob/ to /var/lib/onebox/app, labels from ob.* to onebox.*, units from ob--* and ob-backup--* to onebox-job-* and onebox-backup-*, and owner records must name an environment. There is no migration; guides/upgrade-to-onebox-names gives the manual steps. --- README.md | 2 +- .../v1alpha1/application.schema.json | 10 +- cmd/ob/ops_contract_test.go | 2 +- cmd/ob/preview_test.go | 2 +- e2e/apps/one-app-one-host.sh | 6 +- e2e/network_ownership_test.go | 2 +- e2e/ops_test.go | 2 +- e2e/server_execution_test.go | 5 +- e2e/server_probe_test.go | 8 +- e2e/server_test.go | 55 ++-- internal/app/backup_walg_test.go | 2 +- internal/app/compose.go | 6 +- internal/app/compose_test.go | 8 +- internal/app/constraints.go | 10 +- internal/app/eject.go | 2 +- internal/app/eject_test.go | 2 +- internal/app/errors.go | 2 +- internal/app/generate.go | 20 +- internal/app/generate_test.go | 12 +- internal/app/host_owner_probe.go | 24 +- internal/app/jsonschema.go | 6 +- internal/app/jsonschema_test.go | 2 +- internal/app/load_test.go | 10 +- internal/app/names.go | 99 +++---- internal/app/names_test.go | 75 +++--- internal/app/naming_scope_test.go | 60 +++-- internal/app/preflight.go | 40 +-- internal/app/preflight_test.go | 70 +---- internal/app/secret_generation.go | 14 +- internal/app/secret_generation_test.go | 16 +- internal/app/service_image_state_test.go | 2 +- internal/app/services.go | 8 +- internal/app/services_test.go | 4 +- internal/app/testdata/compose.yaml | 2 +- internal/app/testdata/contract-verdicts.json | 118 ++++----- internal/app/types.go | 2 +- internal/app/validate.go | 2 +- internal/app/workload_contract.go | 8 +- internal/app/workload_contract_test.go | 2 +- internal/discovery/discovery_test.go | 22 +- internal/durable/runner.py | 16 +- internal/durable/runner_test.py | 67 +---- internal/engine/backup_base_selection_test.go | 2 +- internal/engine/backup_credentials.go | 55 ---- internal/engine/backup_credentials_test.go | 66 +---- internal/engine/backup_image_test.go | 4 +- internal/engine/backup_lock_test.go | 2 +- internal/engine/backup_postgres.go | 10 +- .../engine/backup_recovery_config_test.go | 2 +- internal/engine/backup_restore.go | 2 +- internal/engine/backup_schedule.go | 13 +- internal/engine/bootstrap_test.go | 10 +- internal/engine/deploy_test.go | 24 +- internal/engine/drain_budget_test.go | 6 +- internal/engine/engine.go | 4 + internal/engine/epoch_test.go | 12 +- internal/engine/finalize_test.go | 22 +- internal/engine/gate.go | 6 +- internal/engine/gate_test.go | 14 +- internal/engine/hooks_test.go | 2 +- internal/engine/host_environment_test.go | 35 +-- internal/engine/host_owner.go | 33 +-- internal/engine/host_owner_test.go | 4 +- internal/engine/job_containers_test.go | 4 +- internal/engine/job_test.go | 12 +- internal/engine/lock.go | 11 - internal/engine/lock_test.go | 83 +++--- internal/engine/networks.go | 72 ++--- internal/engine/networks_test.go | 62 +---- internal/engine/ops.go | 10 +- internal/engine/ops_test.go | 39 ++- internal/engine/plan.go | 6 +- internal/engine/plan_drain_test.go | 6 +- internal/engine/plan_test.go | 8 +- internal/engine/preflight.go | 8 +- internal/engine/preflight_containers_test.go | 2 +- internal/engine/preflight_test.go | 10 +- internal/engine/proxy_test.go | 34 +-- internal/engine/proxylock_test.go | 14 +- internal/engine/proxystatus.go | 2 +- internal/engine/proxystatus_test.go | 14 +- internal/engine/recovery.go | 2 +- internal/engine/recovery_test.go | 12 +- internal/engine/recreate_test.go | 2 +- internal/engine/release_state_test.go | 8 +- internal/engine/resume.go | 2 +- internal/engine/resume_test.go | 34 +-- internal/engine/roll.go | 8 +- internal/engine/roll_test.go | 22 +- internal/engine/schedule.go | 97 +------ internal/engine/schedule_execution.go | 2 +- internal/engine/schedule_execution_test.go | 6 +- internal/engine/schedule_history_test.go | 4 +- internal/engine/schedule_pause_test.go | 24 +- internal/engine/schedule_run_test.go | 20 +- internal/engine/schedule_test.go | 250 ++++++------------ .../engine/secret_generation_rolling_test.go | 10 +- internal/engine/secret_generation_test.go | 12 +- internal/engine/secretspush.go | 5 +- internal/engine/secretspush_test.go | 2 +- internal/engine/service_apply_test.go | 6 +- internal/engine/services.go | 3 - internal/engine/status.go | 2 +- internal/engine/status_snapshot_test.go | 10 +- internal/engine/status_test.go | 20 +- internal/engine/workload_plan_test.go | 2 +- internal/journal/journal_test.go | 6 +- internal/journal/journals_test.go | 2 +- internal/onebox/bootstrap_test.go | 2 +- internal/onebox/exec_test.go | 4 +- internal/onebox/job_plan_test.go | 2 +- internal/onebox/secrets_push_test.go | 8 +- internal/onebox/service_test.go | 12 +- internal/onebox/staging_secrets_test.go | 2 +- internal/proxy/proxy.go | 15 +- internal/release/mounted_releases.go | 4 +- internal/release/release_test.go | 2 +- internal/release/retention_test.go | 2 +- internal/release/secrets.go | 10 +- internal/release/secrets_test.go | 20 -- internal/transport/ssh_upload_test.go | 4 +- internal/transport/upload_staging_test.go | 10 +- .../v1alpha1/application.schema.json | 10 +- site/src/components/landing/Derivation.astro | 2 +- .../src/components/landing/InsideTheBox.astro | 2 +- .../docs/explanation/generated-compose.mdx | 2 +- .../docs/explanation/ownership-boundary.mdx | 2 +- .../docs/guides/back-up-a-database.mdx | 4 +- site/src/content/docs/guides/eject.mdx | 2 +- site/src/content/docs/guides/roll-back.mdx | 2 +- .../content/docs/guides/schedule-a-job.mdx | 2 +- .../docs/guides/upgrade-to-onebox-names.mdx | 159 ++++++----- site/src/content/docs/index.mdx | 2 +- site/src/content/docs/reference/errors.mdx | 2 +- .../docs/reference/fields/top-level.mdx | 4 +- site/src/content/docs/reference/naming.mdx | 25 +- .../content/docs/reference/project-file.mdx | 2 +- site/src/content/docs/start/first-deploy.mdx | 2 +- 138 files changed, 915 insertions(+), 1531 deletions(-) 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 ffa51988..b9c8f831 100644 --- a/api/application/v1alpha1/application.schema.json +++ b/api/application/v1alpha1/application.schema.json @@ -26,21 +26,15 @@ "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 \"onebox-\" 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" }, @@ -224,7 +218,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" diff --git a/cmd/ob/ops_contract_test.go b/cmd/ob/ops_contract_test.go index ffe1e564..0d10f964 100644 --- a/cmd/ob/ops_contract_test.go +++ b/cmd/ob/ops_contract_test.go @@ -193,7 +193,7 @@ spec: case strings.HasPrefix(command, ": ob-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/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/e2e/apps/one-app-one-host.sh b/e2e/apps/one-app-one-host.sh index f0692797..b5f50941 100755 --- a/e2e/apps/one-app-one-host.sh +++ b/e2e/apps/one-app-one-host.sh @@ -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 ' ') +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/ob/$APP/current" 2>/dev/null) +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/network_ownership_test.go b/e2e/network_ownership_test.go index 109b479c..07e423b2 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) } diff --git a/e2e/ops_test.go b/e2e/ops_test.go index fc4c62f4..ffff6840 100644 --- a/e2e/ops_test.go +++ b/e2e/ops_test.go @@ -43,7 +43,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)) diff --git a/e2e/server_execution_test.go b/e2e/server_execution_test.go index 587c8e29..9d0bef15 100644 --- a/e2e/server_execution_test.go +++ b/e2e/server_execution_test.go @@ -18,7 +18,7 @@ func TestServerDurableExecutions(t *testing.T) { s.requireDocker(t) name := fmt.Sprintf("durable%d", time.Now().UnixNano()) base := "/tmp/onebox-" + name - root := base + "/" + name + root := base + "/app" unit := "ob-" + name + "-refresh" dir := t.TempDir() s.run(t, "mkdir -p "+base+"/data") @@ -70,9 +70,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_probe_test.go b/e2e/server_probe_test.go index 511b5a06..39dedfb4 100644 --- a/e2e/server_probe_test.go +++ b/e2e/server_probe_test.go @@ -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,11 +202,11 @@ 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"}, + {"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 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"}, + {"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 | grep -c observer || true", "0"}, } { if got := strings.TrimSpace(s.run(t, probe.command)); got != probe.want { diff --git a/e2e/server_test.go b/e2e/server_test.go index f7db594c..490c6069 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) { @@ -307,11 +306,11 @@ HTTPServer(("127.0.0.1", 18080), Handler).handle_request() }, "\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/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..359a8b08 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,8 +201,8 @@ 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, "", + if strings.HasPrefix(k, "onebox.") { + return errf("compose_onebox_label", ref, "", "referenced service in %q declares %q; the ob. namespace is Onebox's", ref, k) } if ov.HasRoute && strings.HasPrefix(k, "traefik.") { diff --git a/internal/app/compose_test.go b/internal/app/compose_test.go index 854eb3b5..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,7 +44,7 @@ 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) } } @@ -72,7 +72,7 @@ func TestMergeRefusesConflicts(t *testing.T) { {"named", overlay{}, "compose_container_name"}, {"hostnet", overlay{Network: "onebox-ingress"}, "compose_network_mode"}, {"labelled", overlay{HasRoute: true}, "compose_traefik_label"}, - {"owned", overlay{}, "compose_ob_label"}, + {"owned", overlay{}, "compose_onebox_label"}, {"attached", overlay{Network: "onebox-ingress"}, "compose_ingress_attached"}, } for _, c := range cases { @@ -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 e151321f..85529d78 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -208,7 +208,7 @@ var ( // 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{"ob", "onebox", "_host"} +var reservedAppNames = []string{"onebox", "_host"} // reservedServiceNames are names a service would share with something else // Onebox runs. A managed service's container is onebox-, so proxy and @@ -233,11 +233,9 @@ func checkAppName(name string) error { if err := gIdent.check("app", name); err != nil { return err } - for _, prefix := range []string{"ob-", "onebox-"} { - if strings.HasPrefix(name, prefix) { - return errf("project_invalid", "app", "", - "%q begins with %q, which names host-scoped resources Onebox owns", name, prefix) - } + if strings.HasPrefix(name, Namespace+"-") { + return errf("project_invalid", "app", "", + "%q begins with %q, which names host-scoped resources Onebox owns", name, Namespace+"-") } for _, reserved := range reservedAppNames { if name == reserved { diff --git a/internal/app/eject.go b/internal/app/eject.go index 832b7393..c84be900 100644 --- a/internal/app/eject.go +++ b/internal/app/eject.go @@ -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]) diff --git a/internal/app/eject_test.go b/internal/app/eject_test.go index 891ec8e4..d20a6e49 100644 --- a/internal/app/eject_test.go +++ b/internal/app/eject_test.go @@ -57,7 +57,7 @@ func TestEjectedRuntimeIsOrdinaryCompose(t *testing.T) { t.Fatal(err) } out := string(body) - for _, forbidden := range []string{"ob.app", "ob.release", "ob.workload", "traefik.", "onebox-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) } 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..e2813570 100644 --- a/internal/app/generate.go +++ b/internal/app/generate.go @@ -38,7 +38,7 @@ const UnresolvedImage = "ob-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 } diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index c7f02629..f4f687e2 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -173,12 +173,12 @@ 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", @@ -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} 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 2b0f2c37..bc9513bf 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -725,15 +725,15 @@ func anyRequired(fields []any) []any { // appNameConstraint is the identifier grammar plus the host layout's // reservations, which a schema can hold as well as the loader can. func appNameConstraint() map[string]any { - forbidden := make([]any, 0, len(reservedAppNames)+2) - forbidden = append(forbidden, map[string]any{"pattern": "^ob-"}, map[string]any{"pattern": "^onebox-"}) + forbidden := make([]any, 0, len(reservedAppNames)+1) + 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 \"onebox-\" or be a name the host layout reserves." + ", and may not begin \"onebox-\" or be a name the host layout reserves." return out } 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_test.go b/internal/app/load_test.go index fa64f11a..599bd9f1 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -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,8 +476,8 @@ 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 != "onebox-ingress" { t.Errorf("proxy.network = %q, want onebox-ingress", p.Proxy.Network) @@ -847,7 +847,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 ab18c712..d28cb6b4 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -17,8 +17,9 @@ import ( // // Persistent and provider-internal identifiers are joined with underscores. // 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. +// (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 ( @@ -29,7 +30,7 @@ const ( // 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 ob.app label records which, and the host + // application: a host has one, the onebox.app label records which, and the host // is released only after these resources are removed. Namespace = "onebox" @@ -37,12 +38,19 @@ const ( // to managed services. ServiceNetworkName = "services" - // HostNamespace holds state shared by everything on the box. + // HostNamespace holds state shared by everything on the box. It begins with + // an underscore, which no identifier can, so nothing derived from the + // project can name the same directory. 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" + // 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" ) // Names derives every generated name for one project and environment. @@ -66,7 +74,7 @@ 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-` or `onebox-`. +// identifiers contain no underscore and may not be or begin `onebox`. func (n Names) ComposeProject() string { return n.App } // ApplicationNetwork is the stable default network shared by every workload @@ -82,7 +90,7 @@ func (n Names) ServiceProject(service string) string { } // ServiceContainer is a managed service's container. The name says who runs it, -// not who owns it: the ob.app label carries ownership, and a host has one +// 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 { @@ -156,18 +164,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. @@ -229,7 +225,7 @@ func (n Names) BackupRestoreVolume(service string) string { // 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 @@ -262,20 +258,7 @@ 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+"-") -} - // 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" } @@ -283,30 +266,24 @@ func (n Names) BackupTimerForEnvironment(environment, service, operation string) // 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) + return BackupUnitPrefix + runtimeName(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+"-", - ) +// BackupUnitPrefixForEnvironment is the part of every backup unit name that +// belongs to one environment. +func (n Names) BackupUnitPrefixForEnvironment(environment string) string { + return BackupUnitPrefix + runtimeName(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-" +// 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, @@ -333,11 +310,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. @@ -352,11 +329,19 @@ 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) } +// +// The host's own journal is written through Names{App: HostNamespace}, so that +// one application name resolves to the host directory. +func (n Names) AppDir() string { + if n.App == HostNamespace { + return n.HostDir() + } + return path.Join(n.BasePath, AppNamespace) +} func (n Names) ReleasesDir() string { return path.Join(n.AppDir(), "releases") } func (n Names) ReleaseDir(id string) string { return path.Join(n.ReleasesDir(), id) diff --git a/internal/app/names_test.go b/internal/app/names_test.go index 5ad0f376..1d0e02a6 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -97,56 +97,47 @@ 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 _, environment := range idents { + for _, service := range idents { + for _, target := range idents { + unit := n.BackupUnitForEnvironment(environment, service, target) + source := environment + "|" + service + "|" + target + 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.BackupUnitForEnvironment("pre-prod", "data-base", "back-up"); got != "onebox-backup-pre--prod-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.BackupUnitPrefixForEnvironment("pre-prod"); got != "onebox-backup-pre--prod-" { + t.Fatalf("unit reconciliation prefix = %q", 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) } } @@ -169,13 +160,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) } } @@ -268,8 +259,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) } } diff --git a/internal/app/naming_scope_test.go b/internal/app/naming_scope_test.go index 39c8172b..fef9bfb3 100644 --- a/internal/app/naming_scope_test.go +++ b/internal/app/naming_scope_test.go @@ -14,8 +14,8 @@ import ( // 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 and networks do not carry the -// application. A host has one application, the ob.app label records which, and +// 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} @@ -26,11 +26,6 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { "transient rollout": n.TransientContainer("web"), "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) @@ -45,13 +40,16 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { } } 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"), + "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) @@ -60,6 +58,14 @@ func TestEveryDerivedNameCarriesTheApplication(t *testing.T) { 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 !strings.HasPrefix(got, "/var/lib/onebox/app") { + t.Errorf("%s = %q, which is not under /var/lib/onebox/app", label, got) + } + } } // 6.3 — a multi-route workload and a non-HTTP route survive the whole path: @@ -106,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") } } @@ -153,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..f3c5945c 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" @@ -203,11 +202,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{ @@ -288,23 +284,12 @@ func basePathCheck(ctx context.Context, run Runner, base string) Check { func ownedNames(ctx context.Context, run Runner, project *Spec, environment 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 - } - 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"}}'`, "network"}, } { res, err := run.Run(ctx, q.cmd) if err != nil { @@ -319,7 +304,7 @@ func ownedNames(ctx context.Context, run Runner, project *Spec, environment stri if strings.TrimSpace(line) == "" { continue } - fields := strings.SplitN(line, "\t", 3) + fields := strings.SplitN(line, "\t", 2) name := strings.TrimSpace(fields[0]) if name == "" { continue @@ -328,19 +313,6 @@ 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 { - owner = application - } // Docker permits the same name in different resource kinds. Every // holder must belong to this application: one foreign or unlabelled // holder is a collision even if another kind is app-owned. diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index 6061ed78..34f8c7e2 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -43,7 +43,7 @@ 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: "onebox-ingress\t\n"}, @@ -52,7 +52,7 @@ func healthyRunner() *fakeRunner { 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()) @@ -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,14 @@ func TestOwnedNetworkDoesNotMaskForeignHolderOfTheSameName(t *testing.T) { } } -func TestLegacyServiceNetworkRequiresOneboxState(t *testing.T) { +func TestUnlabelledServiceNetworkIsForeign(t *testing.T) { project := preflightProject + "services: {postgres: {version: 17}}\n" - - t.Run("fresh host refuses an unlabelled network", func(t *testing.T) { - run := healthyRunner() - run.answers["docker network ls"] = transport.Result{Stdout: "onebox_services\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, "onebox_services") { - 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: "onebox_services\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, "onebox_services") { - 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: "onebox_services\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: "onebox_services\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, "onebox_services") { - t.Fatalf("legacy service state masked a foreign volume: %+v", report.Failures()) - } - }) + 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 +519,7 @@ func TestHostOwnerRecordParsesTheSameForPreflightAndEngine(t *testing.T) { record string ok bool }{ - {"sample", true}, + {"sample", false}, {"sample production", true}, {" sample production ", true}, {"sample production extra", false}, diff --git a/internal/app/secret_generation.go b/internal/app/secret_generation.go index df269c80..c6c17dc6 100644 --- a/internal/app/secret_generation.go +++ b/internal/app/secret_generation.go @@ -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..89022d38 100644 --- a/internal/app/secret_generation_test.go +++ b/internal/app/secret_generation_test.go @@ -30,10 +30,10 @@ 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} + labels: {onebox.app: shop} worker: env_file: [.ob-decrypted-sops-worker.env] - labels: {ob.app: shop} + labels: {onebox.app: shop} `) graph := []SecretDeclaration{ {OutputPath: ".ob-decrypted-sops-api.env", AffectedWorkloads: []string{"web"}}, @@ -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) { @@ -86,7 +86,7 @@ func TestApplySecretGenerationReplacesListFormLabel(t *testing.T) { input := []byte(`services: web: env_file: [.ob-decrypted-sops-api.env] - labels: [ob.app=shop, ob.secret-generation=` + oldGeneration + `] + labels: [onebox.app=shop, onebox.secret-generation=` + oldGeneration + `] `) graph := []SecretDeclaration{{OutputPath: ".ob-decrypted-sops-api.env", AffectedWorkloads: []string{"web"}}} output, err := ApplySecretGeneration(input, graph, newGeneration) @@ -94,7 +94,7 @@ func TestApplySecretGenerationReplacesListFormLabel(t *testing.T) { 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/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 a6b7f47c..d043c910 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -367,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 @@ -463,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 { diff --git a/internal/app/services_test.go b/internal/app/services_test.go index 9be46e25..f5d384e1 100644 --- a/internal/app/services_test.go +++ b/internal/app/services_test.go @@ -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) } } @@ -79,7 +79,7 @@ func TestNeedingAServiceJoinsItAndReadsItsURL(t *testing.T) { 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 23687007..fa9d1b03 100644 --- a/internal/app/testdata/compose.yaml +++ b/internal/app/testdata/compose.yaml @@ -42,7 +42,7 @@ services: owned: image: nginx labels: - ob.app: someone-else + onebox.app: someone-else attached: image: nginx diff --git a/internal/app/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index 5d9d6214..8057e3c2 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": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" + "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": "db841b109dcdf458f70da407aa50581e1c864fbab0bd21986b6144380594ff8c" + "digest": "ee857837f209fe0d1117fda8120dab3e2fa45a4d36a549a0f340f6dd7fb32c10" }, { "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": "36e28ac892b8231ed5bec3495f7d0273f22e51d91e02e635a1f34dda94550f9e" }, { "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": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" + "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": "fc4d0a2aff6f4b3713ad28ac0a996ba28799aca4e51c561864f0bd1f1800165a" + "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": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/minimum project", "loads": true, - "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/missing api_version", @@ -282,17 +282,17 @@ { "case": "conformance/notification with no events", "loads": true, - "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/one-char identifier", "loads": true, - "digest": "087192685a9b863e38bc6b96e8436075b279b2d7222ad66b7ff1a9d782a127d2" + "digest": "dac5c9ed4decb409eb9fb2ed8c90191ffd524d92a17dfc3ce442600f9b78d60d" }, { "case": "conformance/operator proxy owns route middleware", "loads": true, - "digest": "880e1ce6682dad40a183ae3d9a2efb8f5b43a1ef0e45eaacb815723ad5e19092" + "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": "df7af95b36d6abb23c8e1f47023bfc8e781a07d48f4f1403368e0094eb344978" + "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": "adb10456ea95baaf80e2f783e9e84db51f972aa81f71649c82da57987d791761" + "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": "0a6bfca2ad6f0229e81f60a6852da916b6d1a2574f3c5d12e48fb75e05f37a37" + "digest": "5fb1a46d08a46a8d9b481a560c06893857d6451cd50ed96d4b53d571772ee245" }, { "case": "conformance/rolling workload with published host port", @@ -372,12 +372,12 @@ { "case": "conformance/routes list", "loads": true, - "digest": "786555d998e67d069b77eef7703c44647f55abe916753bd4dd5f99705c64e2d6" + "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": "7716e66e5cfd6b0c2a4ba040692c8fe6494a5d15fa54bbbcc3b81d736fc7dfcb postgres=818046b94f7dfb29" + "digest": "73135190e9f9fae42347e107f496211e4d2f75bfe5ff934ead0096f4f3571a19 postgres=ee9972ab51c56731" }, { "case": "conformance/service scalar", "loads": true, - "digest": "58655dd0bcba7f5fdfdd4bff8a13763401ae1d40a15f487ecf23e33267f83e7c postgres=14970cbe488cb31f" + "digest": "f6ddf429bf783de806d4b1475df352d4b3ff85210dfe379804248dd3fdd0b80a postgres=f566c9221cb196ab" }, { "case": "conformance/settings key that is a real driver flag", "loads": true, - "digest": "58655dd0bcba7f5fdfdd4bff8a13763401ae1d40a15f487ecf23e33267f83e7c redis=ee0801c9bacecc53" + "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": "0dc9890f12ee12cb5b2b1ec6c3d95ec735364e90909f2d13405ec8d324b28861" + "digest": "11d9655e57e8814a2fad47821e7e691d7d401f01d2595d4610fe260c1a2a443c" }, { "case": "conformance/unqualified route middleware", @@ -492,12 +492,12 @@ { "case": "conformance/url check with contains and advisory", "loads": true, - "digest": "fc966ee4b52865c22b76ad779fa79095161b4900b01c6d570780921ea827aaa5" + "digest": "4722d2d66d41aa91e17ee4e3d9e56074ff6edbae82a46d6202abad3282521801" }, { "case": "conformance/volume scalar with a path", "loads": true, - "digest": "d01a9f018c0e78b13e356eb8055fdd8a2087c9427da603757f255feaeb293d34" + "digest": "615d6b5e05cc3cda30185684fe9773c6eb24abe8be3ab8733e98056279b6275f" }, { "case": "conformance/volume scalar without a path", @@ -507,7 +507,7 @@ { "case": "conformance/volumes without persistence still load", "loads": true, - "digest": "d01a9f018c0e78b13e356eb8055fdd8a2087c9427da603757f255feaeb293d34" + "digest": "615d6b5e05cc3cda30185684fe9773c6eb24abe8be3ab8733e98056279b6275f" }, { "case": "conformance/worker with schedule", @@ -532,17 +532,17 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "2202fcb86cd2cd04d7b693d8d7930192440238d310f4949bbc6877a720bc5627 postgres=9d597f5da1926a42 redis=8d4cfce9447047aa" + "digest": "9ebfd098386e0ba17df924e39dfb559e9a048195b35fc3ce4f936e195789ccf0 postgres=d88fd3d620b30ea1 redis=53bad39e8f359a02" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "3068425213c316ac96b029c62005bc6483a13d905a35b95e0c31bcd324a63c51 postgres=9d597f5da1926a42 redis=8d4cfce9447047aa" + "digest": "695e57ce7479e93105765d7425026586bd3c9111ee4834018b131b773c4073dd postgres=d88fd3d620b30ea1 redis=53bad39e8f359a02" }, { "case": "corpus/ext-authentik.yml", "loads": true, - "digest": "b9eb700cc86f53209f22f5886b01f509b0c9c5b134729f77d435a95f73808a79" + "digest": "d047ba583e16538ace0f57403c5d25023c0a86c65df975f16236a9deb3a1f37d" }, { "case": "corpus/ext-frigate.yml", @@ -552,7 +552,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "eae6e33eb4abf65aecd4c7d930ca880178b0e78c92ff6f5d46fe68bfcfe50442 postgres=603c07798b9cf982" + "digest": "1f899cb760e6e261d8391959ab41a447b66db79d1b71cf63f59c83e31dd2d917 postgres=446e25618a3d2e4e" }, { "case": "corpus/ext-immich-sourced.yml", @@ -567,32 +567,32 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "2b4f82be3fa842b8b95363edd03b3a5eaf715df6699c334d76a3cfdbd940ed78 postgres=b7c083be80e3ca7d redis=25418a76a70caba2" + "digest": "8c9e2e6bae6a1434fec4fd16a7470999743dc58877ba0b945b09fa5e8fc7dc88 postgres=c9e06c2c21513656 redis=83f7ec43501bb1f4" }, { "case": "corpus/ext-paperless.yml", "loads": true, - "digest": "0337c7874581b5884c67ba0eb78587259af8d0273d8d3fb423fc3f8069e5c3bf" + "digest": "b0ef183132b8159b180a29eb5bdb28de719dc97e7e5c151bc7896b96cbdb04ad" }, { "case": "corpus/ext-plausible.yml", "loads": true, - "digest": "20af7df837e1d22a421033082adde67e1484cd7b4f5d02598b895d58016ebe70 events=dbea2ef4d0c835a2 postgres=a9d1af1f5dc1ee11" + "digest": "4574810017062b8d857ad2c43597b484fe88e6b4198f75ec74c2d705cbcf17d1 events=79a3b4327069a1dc postgres=128c7ea3ed2b4f69" }, { "case": "corpus/ext-umami.yml", "loads": true, - "digest": "f7fd92b303f5cd1c56a1395d24324d22d42ad14507fd28466b1fa06c19abbfdb postgres=fa306c6e4104bd3e" + "digest": "fd193a8c2f51cbe206e2e038ccd277c3e6785c84912b5967a2afd58c1320ac13 postgres=a0f143f02b9f5845" }, { "case": "corpus/ghost.yml", "loads": true, - "digest": "e6f9bb5839535bf428588d9176f3f5c921a8adac91ed5456207d4280951e771c mysql=f9c6ef95af207a8d" + "digest": "aba3c31a08bb6fe625c52c3ce330e6e1d0e9af08fb1aa51f520c9b906b3fc024 mysql=6dc2ab74dac73160" }, { "case": "corpus/gitea.yml", "loads": true, - "digest": "9b3770ed716daf6e1aa4844b212773ce4b3448f0ddf0e347f6839104184e3648" + "digest": "0d0f9d727619f0067c830853351fa1861c36de508259c181af38a239ab2d2256" }, { "case": "corpus/goal.yml", @@ -602,7 +602,7 @@ { "case": "corpus/immich.yml", "loads": true, - "digest": "0da2b001800e2d4bd727e5ef62e077a572d3005b7addd760fa7ed773e18041b1" + "digest": "378aa6674b9040640023b55a1ad48d8c338ab60fad7957db23f467264707ca42" }, { "case": "corpus/monk.yml", @@ -612,17 +612,17 @@ { "case": "corpus/n8n.yml", "loads": true, - "digest": "8392e4f9f5674595042ed6c63858776ef300a00335e8e5016397df6286f2aff3" + "digest": "bb31040f4c0b7a657c68f21ec13d070626ce0b6ca2cb0bbd3fd556b6bc062521" }, { "case": "corpus/paperless.yml", "loads": true, - "digest": "9a40f70d296d8f132804bb94920809effb6b8227f65cd5d3bff77f81c0787a5c" + "digest": "898956cb196cc1ed8381c8be43592177886085bf255ad676c00d549c73845fc1" }, { "case": "corpus/penpot.yml", "loads": true, - "digest": "d3385f03a6d23d7a556662d85710050d85318ddadffe5f4e77bfb1698a144a36 postgres=cd0c1feae31470d5 redis=a44cbdbccdf3fdb7" + "digest": "ec20c704e16fd00dcead5fc3931ddf9e03f3add10eacd731620c27af59340612 postgres=fce3c20f4ad2e0e4 redis=e6f4fb5264fe75ae" }, { "case": "corpus/pursue.yml", @@ -637,21 +637,21 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "91b3e8b029f49595f82c88643719c609484d1b0fcbf375ef9c957d16fbcc744f mongodb=ba797e9006dfd6b5" + "digest": "f065d84ec4900a79f1f3c60c6da9958e976ba424e1f3490ba7082cafcdc738ca mongodb=09562fa5123be0a1" }, { "case": "corpus/umami.yml", "loads": true, - "digest": "3248bffd82b0882fba905063365e2eda228bc0cbd3252f1789ab7edd41433245" + "digest": "086a6a692883531cd9db9485f1b78a84edc0e40392b104378f258c0654a161d4" }, { "case": "corpus/uptime-kuma.yml", "loads": true, - "digest": "2b3f8cbc10a34be3d17b51b8dcc7b5dd4897cf1d37f476899301090b444accfa" + "digest": "19c85a8a0102688af4808ca1790c84492215fd2061cf9f9384a77f301bf101ff" }, { "case": "corpus/vaultwarden.yml", "loads": true, - "digest": "40992c777230d019d54eda82d61f2f2438aad334f291cde457089071eb936336" + "digest": "5779468825f6562666a9d32ad1e7e1390e35de0ffaef97e8a377ccf81047f99b" } ] diff --git a/internal/app/types.go b/internal/app/types.go index 00c973b8..637215e9 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."` diff --git a/internal/app/validate.go b/internal/app/validate.go index ee185353..99f53989 100644 --- a/internal/app/validate.go +++ b/internal/app/validate.go @@ -463,7 +463,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..2965df9f 100644 --- a/internal/app/workload_contract_test.go +++ b/internal/app/workload_contract_test.go @@ -16,7 +16,7 @@ func TestWorkloadRevisionIgnoresSecretStorageGeneration(t *testing.T) { 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 + `} + 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 b8e03167..9af130b5 100644 --- a/internal/discovery/discovery_test.go +++ b/internal/discovery/discovery_test.go @@ -24,14 +24,14 @@ func routedContainer(id string, created time.Time, health, ip string, labels map 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), @@ -43,14 +43,14 @@ func TestBuildPreservesHealthAwareHTTPRouting(t *testing.T) { 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) } 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/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..9841d47d 100644 --- a/internal/engine/backup_credentials.go +++ b/internal/engine/backup_credentials.go @@ -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_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_test.go b/internal/engine/backup_lock_test.go index b12d7671..e647da9d 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 { 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 4dedcf26..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, diff --git a/internal/engine/backup_restore.go b/internal/engine/backup_restore.go index 475b1336..9d55b2b6 100644 --- a/internal/engine/backup_restore.go +++ b/internal/engine/backup_restore.go @@ -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_schedule.go b/internal/engine/backup_schedule.go index 8116afcd..a9d47157 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 := n.BackupUnitPrefixForEnvironment(e.Opts.Environment) // 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 @@ -68,16 +67,6 @@ func (e *Engine) SyncBackupSchedules(ctx context.Context) error { bare := strings.TrimSuffix(unit, ".timer") if matchesRuntimePrefix(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 - } } } diff --git a/internal/engine/bootstrap_test.go b/internal/engine/bootstrap_test.go index da7ef789..8b8298e1 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,8 +40,8 @@ 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 @@ -212,7 +212,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,7 +298,7 @@ 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 onebox-proxy -f '/var/lib/onebox/_host/proxy/compose.yaml' up -d", "docker compose -p 'onebox_postgres'", } last := -1 diff --git a/internal/engine/deploy_test.go b/internal/engine/deploy_test.go index d8e5e437..ffe52e4f 100644 --- a/internal/engine/deploy_test.go +++ b/internal/engine/deploy_test.go @@ -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=") { @@ -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") @@ -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) @@ -402,7 +402,7 @@ func TestDeployJournalsAndFencesLifecycle(t *testing.T) { } } // 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) } } @@ -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_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..31075ba9 100644 --- a/internal/engine/finalize_test.go +++ b/internal/engine/finalize_test.go @@ -93,7 +93,7 @@ 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 @@ -101,7 +101,7 @@ func buildActivatedFake(t *testing.T, activationResult bool, tail ...journal.Rec return transport.Result{Stdout: "releases/" + engineTestDeployReleaseID + "\n"}, true case strings.Contains(cmd, "ob.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/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..84262a15 100644 --- a/internal/engine/gate_test.go +++ b/internal/engine/gate_test.go @@ -69,7 +69,7 @@ func TestAutoRollbackUsesPreviousReleaseSnapshot(t *testing.T) { return transport.Result{Stdout: "releases/" + engineTestPreviousReleaseID + "\n"}, true case strings.Contains(cmd, "/releases/"+engineTestPreviousReleaseID+"/ob.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 @@ -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..f404d301 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) { @@ -89,7 +76,7 @@ func TestHostOwnerRecordRoundTrips(t *testing.T) { ok bool }{ {"sample production", hostOwner{App: "sample", Environment: "production"}, true}, - {"sample", hostOwner{App: "sample"}, true}, + {"sample", hostOwner{}, false}, {" sample production ", hostOwner{App: "sample", Environment: "production"}, true}, {"", hostOwner{}, false}, {"sample production extra", hostOwner{}, false}, @@ -106,19 +93,17 @@ func TestHostOwnerRecordRoundTrips(t *testing.T) { } } -// 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..0fb3bbe3 100644 --- a/internal/engine/host_owner.go +++ b/internal/engine/host_owner.go @@ -49,18 +49,11 @@ func (e *HostEnvironmentMismatchError) Code() string { return "host_environment_ // 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 @@ -73,9 +66,6 @@ func parseHostOwner(record string) (hostOwner, bool) { } func (o hostOwner) record() string { - if o.legacy() { - return o.App - } return o.App + " " + o.Environment } @@ -138,13 +128,6 @@ func (e *Engine) RequireHostOwner(ctx context.Context) error { 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 - } if owner.Environment != e.Opts.Environment { return &HostEnvironmentMismatchError{ Application: e.Spec.Name, @@ -166,7 +149,7 @@ func (e *Engine) claimHostOwner(ctx context.Context) error { 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.App == e.Spec.Name { if owner.Environment != e.Opts.Environment { return &HostEnvironmentMismatchError{ Application: e.Spec.Name, @@ -176,9 +159,8 @@ func (e *Engine) claimHostOwner(ctx context.Context) error { } 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 } @@ -190,7 +172,7 @@ func (e *Engine) claimHostOwner(ctx context.Context) error { 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.App == e.Spec.Name { if owner.Environment != e.Opts.Environment { return &HostEnvironmentMismatchError{ Application: e.Spec.Name, @@ -203,13 +185,8 @@ func (e *Engine) claimHostOwner(ctx context.Context) error { claim := hostOwner{App: 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. + // nobody wins twice. 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) - } 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..36fa03dd 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 }} @@ -139,7 +139,7 @@ func TestClaimHostOwnerRechecksUnderLock(t *testing.T) { 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 }} 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_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..1f44b856 100644 --- a/internal/engine/lock.go +++ b/internal/engine/lock.go @@ -95,14 +95,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 +107,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) diff --git a/internal/engine/lock_test.go b/internal/engine/lock_test.go index 8288726d..00491897 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") { @@ -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,11 +608,11 @@ 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) @@ -645,7 +622,7 @@ func TestStatePathsFollowTheDeclaredBasePath(t *testing.T) { 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 91611d84..afc079ba 100644 --- a/internal/engine/networks.go +++ b/internal/engine/networks.go @@ -13,31 +13,28 @@ 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 that already +// carries this application's label. 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,11 +49,7 @@ 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(), ""}, - } + 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 @@ -71,24 +64,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 +88,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\"}}' " + q(name) res, err := e.T.Run(ctx, inspect) if err != nil { return false, err @@ -117,7 +108,7 @@ func (e *Engine) ownedNetworkExists(ctx context.Context, name, legacyComposeProj return false, fmt.Errorf("network %s: cannot inspect ownership (exit %d): %s", name, res.ExitCode, strings.TrimSpace(res.Stderr)) } - fields := strings.SplitN(strings.TrimSpace(res.Stdout), "|", 3) + fields := strings.SplitN(strings.TrimSpace(res.Stdout), "|", 2) if len(fields) == 0 || !validID.MatchString(strings.TrimSpace(fields[0])) { return false, fmt.Errorf("network %s: inspect returned no valid identity", name) } @@ -125,29 +116,14 @@ 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 !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 32fa4033..bfeafd18 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, "onebox_services") { - 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) } }) } @@ -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, "onebox_services") { - return transport.Result{Stdout: "def456||\n"}, true + return transport.Result{Stdout: "def456|\n"}, true } return base(command) } diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 23ac0fc4..36c6c56c 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -128,9 +128,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 @@ -219,10 +219,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 { diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index 6ead005d..b2ba8358 100644 --- a/internal/engine/ops_test.go +++ b/internal/engine/ops_test.go @@ -48,7 +48,7 @@ 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-") && @@ -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,10 +92,10 @@ 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", "onebox_services"} { @@ -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) } } @@ -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) } @@ -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 onebox_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) } } @@ -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) } } diff --git a/internal/engine/plan.go b/internal/engine/plan.go index 0e8fcd19..e556099c 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 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..02a3e62f 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") @@ -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_test.go b/internal/engine/proxy_test.go index 19d84fd4..4d43449f 100644 --- a/internal/engine/proxy_test.go +++ b/internal/engine/proxy_test.go @@ -133,19 +133,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 +178,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 +204,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 +225,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 +252,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 +278,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 +296,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 +311,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 +321,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 +341,7 @@ 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")) } } 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..4910b0c9 100644 --- a/internal/engine/recovery.go +++ b/internal/engine/recovery.go @@ -193,7 +193,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..46b33171 100644 --- a/internal/engine/recovery_test.go +++ b/internal/engine/recovery_test.go @@ -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") } } @@ -278,7 +278,7 @@ func TestRecoveryEngineUsesSnapshotChoreography(t *testing.T) { 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..73a03bdf 100644 --- a/internal/engine/resume.go +++ b/internal/engine/resume.go @@ -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 diff --git a/internal/engine/resume_test.go b/internal/engine/resume_test.go index 3e657336..f3ab92a6 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,7 +137,7 @@ 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"): return transport.Result{Stdout: oldSnapshot}, true @@ -203,7 +203,7 @@ 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"): return transport.Result{Stdout: strings.Replace(engineProject, "dataEffect: Unknown", "dataEffect: Migration", 1)}, 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,7 +415,7 @@ 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"): return transport.Result{Stdout: interruptedWebSnapshot}, true @@ -423,16 +423,16 @@ func TestAbortUsesBothReleaseSnapshotsAfterConfigEdit(t *testing.T) { 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) @@ -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 4f9eb60a..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 { diff --git a/internal/engine/roll_test.go b/internal/engine/roll_test.go index ed155edd..33db2e1c 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,7 +174,7 @@ 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") @@ -189,12 +189,12 @@ 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", @@ -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") @@ -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/schedule.go b/internal/engine/schedule.go index 145e66f6..7cddafda 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) { 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 - } } } @@ -936,15 +917,10 @@ 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 + // 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. - n := e.names() - jobPrefixes := n.ScheduledJobUnitPrefixes() - backupPrefixes := n.BackupUnitPrefixes() 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 +932,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 matchesRuntimePrefix(unit, app.JobUnitPrefix) || matchesRuntimePrefix(unit, app.BackupUnitPrefix) { units = append(units, unit) } } @@ -1020,48 +977,10 @@ 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. +// of an escaped hyphen. For example, onebox-backup-prod- owns +// onebox-backup-prod-db-base but not onebox-backup-prod--eu-db-base, whose +// environment is prod-eu. 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_execution.go b/internal/engine/schedule_execution.go index 1cf1440a..417bbf52 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 { 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..fc184527 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=ob-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) 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_test.go b/internal/engine/schedule_run_test.go index 3df4c36b..78304811 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,7 +197,7 @@ 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"): // Newest first: a stale record from an earlier run precedes ours, @@ -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..0b963142 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,7 +113,7 @@ func TestScheduleApplyUpgradesLegacyUnitsUnderRegime(t *testing.T) { } } 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") && strings.Contains(command, ".ob-tmp") && !strings.Contains(command, "ob-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,23 +182,23 @@ 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'`, "docker rm -f '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,24 +292,24 @@ 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')", + "release_dir=$(readlink -f '/var/lib/onebox/app/current')", "exec 7>>\"$release_dir/.ob-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'", @@ -743,7 +743,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 +847,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,17 +855,17 @@ 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) } } @@ -877,12 +877,10 @@ func TestRuntimePrefixStopsAtEscapedComponentBoundary(t *testing.T) { 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}, + {"job owned", "onebox-job-nightly", "onebox-job-", true}, + {"backup environment owned", "onebox-backup-prod-postgres-backup", "onebox-backup-prod-", true}, + {"hyphenated backup environment owned", "onebox-backup-prod--eu-postgres-backup", "onebox-backup-prod--eu-", true}, + {"backup belongs to hyphen extension", "onebox-backup-prod--eu-postgres-backup", "onebox-backup-prod-", false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -893,43 +891,10 @@ func TestRuntimePrefixStopsAtEscapedComponentBoundary(t *testing.T) { } } -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{Stdout: "onebox-backup-prod--eu-postgres-backup.timer\n"}, true } return transport.Result{}, false }} @@ -950,8 +915,8 @@ func TestScheduleSyncIgnoresInvalidHostListedUnitNames(t *testing.T) { 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 +938,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"}. + backupTimer := app.Names{App: "example", BasePath: "/var/lib/onebox"}. BackupTimerForEnvironment("production", "database", "backup") - if strings.HasPrefix(backupTimer, "ob-example-") { + 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,15 +955,15 @@ 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", + "onebox-job-nightly.timer", + "onebox-backup-production-postgres-backup.timer", + "onebox-backup-production-postgres-verify.timer", // Another application's, and a stranger's. Neither is ours. "ob-backup-other-production-postgres-backup.timer", "logrotate.timer", @@ -1016,9 +978,9 @@ 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-production-postgres-backup", + "onebox-backup-production-postgres-verify", } { if !strings.Contains(seq, "rm -f /etc/systemd/system/"+want+".timer") { t.Errorf("teardown left %s installed:\n%s", want, seq) @@ -1031,42 +993,6 @@ func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { } } -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 +1000,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 +1010,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 +1034,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 +1049,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}`, @@ -1154,7 +1062,7 @@ func TestScheduledJobNotifierWritesOneRunRecordToTheJournal(t *testing.T) { `"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`, + `"$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 +1083,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 +1109,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) } @@ -1213,7 +1121,7 @@ func runNotifierIn(t *testing.T, base string, job app.ScheduledJob, notification 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 '^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 +1177,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 +1299,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 +1312,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,10 +1424,10 @@ 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=} ;;`, @@ -1544,7 +1452,7 @@ 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) + 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 "$@" --name`) { t.Fatalf("exclusive runner does not consume inputs:\n%s", exclusive) } @@ -1604,7 +1512,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 +1654,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 +1684,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 +1697,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 +1764,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 +1800,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..cd202596 100644 --- a/internal/engine/secret_generation_rolling_test.go +++ b/internal/engine/secret_generation_rolling_test.go @@ -120,7 +120,7 @@ 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"): @@ -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 @@ -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) diff --git a/internal/engine/secret_generation_test.go b/internal/engine/secret_generation_test.go index 3ccfc153..fe063679 100644 --- a/internal/engine/secret_generation_test.go +++ b/internal/engine/secret_generation_test.go @@ -56,7 +56,7 @@ 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"): @@ -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 @@ -126,7 +126,7 @@ func generationFromEngineSecretCommand(command string) string { func requireGenerationProjectDirectory(t *testing.T, commands []string, generation string) { t.Helper() - releaseDir := "/srv/onebox/shop/releases/20260809-120000-current" + releaseDir := "/srv/onebox/app/releases/20260809-120000-current" composePath := releaseDir + "/.ob-secret-generations/" + generation + "/compose.yaml" projectArg := "--project-directory '" + releaseDir + "'" found := false @@ -148,10 +148,10 @@ 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} + 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} + labels: {onebox.app: shop, onebox.release: 20260809-120000-current, onebox.secret-generation: %[1]s} `, generation) } @@ -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/.ob-secret-generations/"+newSecretGeneration+"'") if installed < 0 || removed <= installed { t.Fatalf("installed plaintext candidate survived checkpoint failure:\n%s", commands) } diff --git a/internal/engine/secretspush.go b/internal/engine/secretspush.go index 09f2647f..6600639a 100644 --- a/internal/engine/secretspush.go +++ b/internal/engine/secretspush.go @@ -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 @@ -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..62c0fb1b 100644 --- a/internal/engine/secretspush_test.go +++ b/internal/engine/secretspush_test.go @@ -72,7 +72,7 @@ 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"): diff --git a/internal/engine/service_apply_test.go b/internal/engine/service_apply_test.go index da41354d..852a3167 100644 --- a/internal/engine/service_apply_test.go +++ b/internal/engine/service_apply_test.go @@ -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,7 +115,7 @@ 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") { diff --git a/internal/engine/services.go b/internal/engine/services.go index 316fc80a..ab34864d 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. diff --git a/internal/engine/status.go b/internal/engine/status.go index ecdf8ea4..545d1e83 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 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/journal_test.go b/internal/journal/journal_test.go index 30799534..5b20d9cc 100644 --- a/internal/journal/journal_test.go +++ b/internal/journal/journal_test.go @@ -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"`, diff --git a/internal/journal/journals_test.go b/internal/journal/journals_test.go index 5196042a..ab8c195c 100644 --- a/internal/journal/journals_test.go +++ b/internal/journal/journals_test.go @@ -49,7 +49,7 @@ func TestJournalsOneRoundTrip(t *testing.T) { 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" { 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/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/secrets_push_test.go b/internal/onebox/secrets_push_test.go index 644307a0..9767c747 100644 --- a/internal/onebox/secrets_push_test.go +++ b/internal/onebox/secrets_push_test.go @@ -44,7 +44,7 @@ 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"): @@ -53,10 +53,10 @@ func pushFake() *transport.Fake { 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} + 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} + 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 diff --git a/internal/onebox/service_test.go b/internal/onebox/service_test.go index be4052fe..4d6f8d0c 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) } } @@ -264,10 +264,10 @@ spec: 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 + `} + 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 @@ -277,7 +277,7 @@ spec: 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..55e99ecf 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) } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index dbc3bee0..2a41716a 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]+$`) @@ -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/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_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..438af938 100644 --- a/internal/release/retention_test.go +++ b/internal/release/retention_test.go @@ -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/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/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/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/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha1/application.schema.json index ffa51988..b9c8f831 100644 --- a/site/public/schemas/application/v1alpha1/application.schema.json +++ b/site/public/schemas/application/v1alpha1/application.schema.json @@ -26,21 +26,15 @@ "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 \"onebox-\" 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" }, @@ -224,7 +218,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" diff --git a/site/src/components/landing/Derivation.astro b/site/src/components/landing/Derivation.astro index c19d6529..dcbbd4c0 100644 --- a/site/src/components/landing/Derivation.astro +++ b/site/src/components/landing/Derivation.astro @@ -266,7 +266,7 @@ 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 7c5c65ad..8ba98399 100644 --- a/site/src/components/landing/InsideTheBox.astro +++ b/site/src/components/landing/InsideTheBox.astro @@ -59,7 +59,7 @@ 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 cb2692f5..d6cd99d3 100644 --- a/site/src/content/docs/explanation/generated-compose.mdx +++ b/site/src/content/docs/explanation/generated-compose.mdx @@ -91,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 c4f5c907..e66a81b1 100644 --- a/site/src/content/docs/explanation/ownership-boundary.mdx +++ b/site/src/content/docs/explanation/ownership-boundary.mdx @@ -50,7 +50,7 @@ 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 +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 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..839a89fa 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-production-database-backup at 0 2 * * * +→ backup schedule: onebox-backup-production-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..7fedb4a0 100644 --- a/site/src/content/docs/guides/schedule-a-job.mdx +++ b/site/src/content/docs/guides/schedule-a-job.mdx @@ -149,7 +149,7 @@ that no run record exists yet. 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: +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 index 52856376..fd420bf8 100644 --- a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -1,109 +1,140 @@ --- title: Upgrade to onebox names -description: Move an existing host from ob_ and ob-ingress to the onebox namespace. Onebox does not migrate anything for you. -summary: What the rename changes, what the next deploy does to a host that still has ob_ volumes, and the manual steps that carry the data across. +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" - - "Old ob_ volumes, ob-ingress, or --1 containers are still on a host" --- -Every name Onebox derives outside your own workload containers now begins with -`onebox`. **There is no migration and no fallback.** Onebox does not look for -the old names, read from them, or remove them. If you deploy an upgraded -project to a host that still holds `ob_` volumes, **it starts the service on a -new, empty volume**. The old data is still on the host, but nothing uses it. - -Do the steps below **before** the first deploy with the new version. +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` | -| Service and workload volumes | `ob_shop_postgres_data` | `onebox_postgres_data` | +| 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` | -| Workload containers | `shop-web-1` | unchanged | -| Application network | `shop_default` | unchanged | - -Names in the `onebox` namespace no longer carry the application. Application -names may no longer be `onebox` or begin with `onebox-`, and a service may not -be called `proxy`, `discovery` or `services`. - -## What the next deploy does without these steps - -- **Unprotected services start empty.** A service without `backup` starts on a - new, empty volume, uses the existing credential, and reports healthy. Your - application connects to an empty database. -- **Protected PostgreSQL refuses to apply.** A service with `backup` records its - cluster identity, so the deploy stops with `data volume - onebox__data is missing`. Nothing is started. -- **The old service containers keep running.** They belong to the old Compose - project, so the deploy does not replace them. They keep the old volumes open. -- **Routing stops until the workloads are redeployed.** The proxy moves to - `onebox-ingress`, and each workload moves when it is next rolled. - -## Move the data - -Run this on the host as root. Set `APP` to your application name. The commands -stop the application, so plan for downtime. Copying the volumes temporarily -doubles the disk space they use. - -1. Upgrade `ob` on your machine, but do not deploy yet. - -2. On the host, remove the old service containers and stop the workloads: +| Scheduled job units | `ob-shop-nightly` | `onebox-job-nightly` | +| Backup units | `ob-backup-shop-production-postgres-backup` | `onebox-backup-production-postgres-backup` | +| Proxy routers | `shop_web_r0` | `onebox_web_r0` | +| 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` 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`. + +## 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`. 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 - docker ps -aq --filter label=ob.app=$APP --filter label=ob.service | xargs -r docker rm -f - docker ps -q --filter label=ob.app=$APP | xargs -r docker stop + ENV=production + systemctl list-unit-files --no-legend "ob-$APP-*" "ob-backup-$APP-*" | awk '{print $1}' \ + | xargs -r systemctl disable --now + rm -f /etc/systemd/system/ob-$APP-* /etc/systemd/system/ob-backup-$APP-* + systemctl daemon-reload ``` - Removing the service containers does not remove their volumes. +3. Remove the application's containers and the proxy. Volumes are not removed: -3. Copy every `ob__` volume into its `onebox_` name, keeping the labels - Onebox uses to recognise it as its own: + ```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 new labels: ```sh - for old in $(docker volume ls -q --filter label=ob.app=$APP | grep '^ob_'); do + for old in $(docker volume ls -q --filter label=ob.app=$APP | grep "^ob_${APP}_"); do new="onebox_${old#ob_${APP}_}" - labels=$(docker volume inspect -f '{{range $k, $v := .Labels}}{{if or (eq $k "ob.app") (eq $k "ob.service")}}--label {{$k}}={{$v}} {{end}}{{end}}' "$old") + service=$(docker volume inspect -f '{{index .Labels "ob.service"}}' "$old") + labels="--label onebox.app=$APP" + [ -n "$service" ] && labels="$labels --label onebox.service=$service" docker volume create $labels "$new" docker run --rm -v "$old":/from:ro -v "$new":/to alpine cp -a /from/. /to/ done ``` - A volume without the `ob.app` label is refused by preflight as a resource - Onebox does not own. The loop copies the label, so the new volume passes. + A volume without the `onebox.app` label is refused by preflight as a + resource Onebox does not own. -4. Deploy from your machine: +5. Move the state and rewrite the owner record: ```sh - ob deploy production + 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 + docker network rm ${APP}_default ob_$APP ob-ingress ``` - A protected PostgreSQL service checks that the copied volume holds the same - cluster it recorded. If the copy is incomplete, the deploy stops here. + The state directory holds the managed services' credentials. Moving it, not + recreating it, is what lets the new containers open the copied data. -5. Check that the application sees its data. Then remove what the old names - left behind: +6. From your machine, bootstrap and deploy: ```sh - docker volume ls -q --filter label=ob.app=$APP | grep '^ob_' | xargs -r docker volume rm - docker network rm ob_$APP ob-ingress + ob bootstrap production + ob deploy production ``` - `docker network rm` fails while anything is still attached to the network. - Leave the old volumes in place until you are sure. They are the only copy - of the data from before the upgrade. + 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 + ``` -## If you already deployed + Leave them until you are sure. They are the only copy of the data from + before the upgrade. -If the deploy already ran, the old volumes are untouched. Stop the application -as in step 2, and remove the new empty volumes with `docker volume rm` after -checking that they hold nothing you need. Then continue from step 3. +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/top-level.mdx b/site/src/content/docs/reference/fields/top-level.mdx index 9d73e919..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 "onebox-" 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 0c22e414..03cdb17b 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -65,15 +65,30 @@ 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 `ob.app` label records which. So `docker ps` +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-` -(or `ob-`), and `proxy`, `discovery` and `services` are not valid service -names. +is Onebox's, an application may not be called `onebox` or begin with `onebox-`, +and `proxy`, `discovery` 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, proxy) | +| 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. 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: @@ -92,7 +107,7 @@ removed. 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 -`ob.app` label records which, and `ob destroy` releases the host only after +`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 diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 978d2719..706e380f 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -117,7 +117,7 @@ Full explanation, including why level four outranks the rest: 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 +**Layout** — `/var/lib/onebox/app/releases/`, plus `current`, `journal`, and `services`. Configurable per environment with `basePath`. ### Bind-mount lifetimes diff --git a/site/src/content/docs/start/first-deploy.mdx b/site/src/content/docs/start/first-deploy.mdx index 7b7c6125..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 From f01a536f31f9e99dd57dafa73bddde85d703107f Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 09:49:50 -0700 Subject: [PATCH 06/15] fix(runtime): close gaps found in review of the onebox rename - Reserve the service name ingress, whose container would take the ingress network's onebox-ingress name. - Refuse to claim a host without an environment, which would write an owner record no command can read. - Strip only onebox- networks on eject; ob- is an author's name again. - Give the host journal its own directory instead of treating the application name _host as a sentinel in AppDir. - Name the onebox. label namespace in the compose_onebox_label message. - Remove distinctNames and ownedNames' unused parameters. In the upgrade guide, remove old units under both spellings of a hyphenated application name, give copied volumes the Compose project and volume labels so ob destroy --volumes still finds them, and remove only the old networks that exist. --- .../v1alpha1/application.schema.json | 3 ++ internal/app/compose.go | 2 +- internal/app/constraints.go | 9 +++-- internal/app/eject.go | 2 +- internal/app/eject_test.go | 20 ++++++++++ internal/app/names.go | 22 +---------- internal/app/names_test.go | 11 ++++++ internal/app/preflight.go | 5 +-- internal/engine/host_owner.go | 7 ++++ internal/engine/host_owner_test.go | 15 ++++++++ internal/engine/proxy.go | 4 +- internal/journal/journal.go | 13 +++++-- .../v1alpha1/application.schema.json | 3 ++ .../docs/guides/upgrade-to-onebox-names.mdx | 37 ++++++++++++++----- site/src/content/docs/reference/naming.mdx | 3 +- 15 files changed, 109 insertions(+), 47 deletions(-) diff --git a/api/application/v1alpha1/application.schema.json b/api/application/v1alpha1/application.schema.json index b9c8f831..5025317a 100644 --- a/api/application/v1alpha1/application.schema.json +++ b/api/application/v1alpha1/application.schema.json @@ -1287,6 +1287,9 @@ { "const": "discovery" }, + { + "const": "ingress" + }, { "const": "services" } diff --git a/internal/app/compose.go b/internal/app/compose.go index 359a8b08..9405e473 100644 --- a/internal/app/compose.go +++ b/internal/app/compose.go @@ -203,7 +203,7 @@ func refuseConflicts(ref string, svc map[string]any, ov overlay) error { for _, k := range sortedKeys(labelMap(svc["labels"])) { if strings.HasPrefix(k, "onebox.") { return errf("compose_onebox_label", ref, "", - "referenced service in %q declares %q; the ob. namespace is Onebox's", ref, k) + "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/constraints.go b/internal/app/constraints.go index 85529d78..c3406b79 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -211,10 +211,11 @@ var ( var reservedAppNames = []string{"onebox", "_host"} // reservedServiceNames are names a service would share with something else -// Onebox runs. A managed service's container is onebox-, so proxy and -// discovery would derive the host proxy's containers; its Compose project is -// onebox_, so services would derive the service network. -var reservedServiceNames = []string{"proxy", "discovery", ServiceNetworkName} +// 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. diff --git a/internal/app/eject.go b/internal/app/eject.go index c84be900..24198962 100644 --- a/internal/app/eject.go +++ b/internal/app/eject.go @@ -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-") || strings.HasPrefix(n.Value, "onebox-") { + if n.Value == IngressNetwork || strings.HasPrefix(n.Value, Namespace+"-") { continue } kept = append(kept, n) diff --git a/internal/app/eject_test.go b/internal/app/eject_test.go index d20a6e49..9ed0b341 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 @@ -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/names.go b/internal/app/names.go index d28cb6b4..e2954610 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -333,15 +333,7 @@ func (n Names) ProxyServiceFor(workload string, route int) string { } // AppDir, ReleasesDir, ReleaseDir, CurrentLink and HostDir are the remote layout. -// -// The host's own journal is written through Names{App: HostNamespace}, so that -// one application name resolves to the host directory. -func (n Names) AppDir() string { - if n.App == HostNamespace { - return n.HostDir() - } - return path.Join(n.BasePath, AppNamespace) -} +func (n Names) AppDir() string { return path.Join(n.BasePath, AppNamespace) } func (n Names) ReleasesDir() string { return path.Join(n.AppDir(), "releases") } func (n Names) ReleaseDir(id string) string { return path.Join(n.ReleasesDir(), id) @@ -478,18 +470,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 1d0e02a6..3b152f14 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -320,6 +320,17 @@ spec: 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 diff --git a/internal/app/preflight.go b/internal/app/preflight.go index f3c5945c..30f21eeb 100644 --- a/internal/app/preflight.go +++ b/internal/app/preflight.go @@ -104,7 +104,7 @@ func (r *Resolved) Preflight(ctx context.Context, run Runner) (*Report, error) { // 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 } @@ -281,9 +281,8 @@ 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 for _, q := range []struct { cmd, kind string }{ diff --git a/internal/engine/host_owner.go b/internal/engine/host_owner.go index 0fb3bbe3..c7470843 100644 --- a/internal/engine/host_owner.go +++ b/internal/engine/host_owner.go @@ -2,6 +2,7 @@ package engine import ( "context" + "errors" "fmt" "strings" @@ -142,6 +143,12 @@ func (e *Engine) RequireHostOwner(ctx context.Context) error { // 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 { + // 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") + } owner, err := e.readHostOwner(ctx) if err != nil { return err diff --git a/internal/engine/host_owner_test.go b/internal/engine/host_owner_test.go index 36fa03dd..9047db81 100644 --- a/internal/engine/host_owner_test.go +++ b/internal/engine/host_owner_test.go @@ -204,3 +204,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()) + 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/proxy.go b/internal/engine/proxy.go index 85fb68b0..3b46ffed 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -13,8 +13,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 @@ -58,7 +56,7 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo 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, Names: e.names(), Dir: e.names().HostDir() + "/journal", 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) } diff --git a/internal/journal/journal.go b/internal/journal/journal.go index 9f6d818f..9d8fe7d9 100644 --- a/internal/journal/journal.go +++ b/internal/journal/journal.go @@ -99,7 +99,10 @@ 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 + Names app.Names + // Dir replaces the application's journal directory. The host journal, + // which belongs to no application, is written through it. + Dir string DeployID string Epoch int Operator string @@ -182,8 +185,12 @@ 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) + d := w.Dir + if d == "" { + d = dir(w.Names) + } + f := d + "/" + w.DeployID + ".jsonl" + cmd := "mkdir -p " + q(d) + " && printf '%s\\n' " + q(string(b)) + " >> " + q(f) + " && sync " + q(f) res, err := w.T.Run(ctx, cmd) if err != nil { return err diff --git a/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha1/application.schema.json index b9c8f831..5025317a 100644 --- a/site/public/schemas/application/v1alpha1/application.schema.json +++ b/site/public/schemas/application/v1alpha1/application.schema.json @@ -1287,6 +1287,9 @@ { "const": "discovery" }, + { + "const": "ingress" + }, { "const": "services" } diff --git a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx index fd420bf8..27f678c7 100644 --- a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -37,7 +37,7 @@ command with the new version. | 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` or `services`. Custom Traefik objects may +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`. @@ -72,9 +72,13 @@ step 6. Copying the volumes temporarily doubles the disk space they use. ```sh APP=shop ENV=production - systemctl list-unit-files --no-legend "ob-$APP-*" "ob-backup-$APP-*" | awk '{print $1}' \ - | xargs -r systemctl disable --now - rm -f /etc/systemd/system/ob-$APP-* /etc/systemd/system/ob-backup-$APP-* + # 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 ``` @@ -85,21 +89,28 @@ step 6. Copying the volumes temporarily doubles the disk space they use. 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 new labels: +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") - labels="--label onebox.app=$APP" - [ -n "$service" ] && labels="$labels --label onebox.service=$service" - docker volume create $labels "$new" + 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. + 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: @@ -108,9 +119,15 @@ step 6. Copying the volumes temporarily doubles the disk space they use. 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 - docker network rm ${APP}_default ob_$APP ob-ingress + 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. diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 03cdb17b..44126058 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -72,7 +72,8 @@ 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` and `services` are not valid service names. +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.** From b323b1f7bea744be5ee4916b5cdc2fdf4e87b9c2 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 10:09:52 -0700 Subject: [PATCH 07/15] fix(runtime): accept the application network Compose creates for a proxy A Compose file that runs its own proxy beside the workloads makes Compose create _default before Onebox does, labelled with the application's project but not with onebox.app, and Docker cannot add a label to it afterwards. Removing that evidence as legacy made bootstrap refuse every such host, including fresh ones: the Docker end-to-end suite failed with "network obe2e_default exists without Onebox ownership". Accept the Compose project label again, for the application network only, in both the engine and preflight. It proves nothing for the service network or any other name. --- e2e/network_ownership_test.go | 4 +-- internal/app/preflight.go | 12 +++++++-- internal/app/preflight_test.go | 19 +++++++++++++++ internal/engine/networks.go | 35 +++++++++++++++----------- internal/engine/networks_test.go | 42 +++++++++++++++++++++++++++++++- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/e2e/network_ownership_test.go b/e2e/network_ownership_test.go index 07e423b2..efd6eb74 100644 --- a/e2e/network_ownership_test.go +++ b/e2e/network_ownership_test.go @@ -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/internal/app/preflight.go b/internal/app/preflight.go index 30f21eeb..97f2bda9 100644 --- a/internal/app/preflight.go +++ b/internal/app/preflight.go @@ -288,7 +288,7 @@ func ownedNames(ctx context.Context, run Runner, application string) (map[string }{ {`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"}}'`, "network"}, + {`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 { @@ -303,7 +303,7 @@ func ownedNames(ctx context.Context, run Runner, application string) (map[string if strings.TrimSpace(line) == "" { continue } - fields := strings.SplitN(line, "\t", 2) + fields := strings.SplitN(line, "\t", 3) name := strings.TrimSpace(fields[0]) if name == "" { continue @@ -312,6 +312,14 @@ func ownedNames(ctx context.Context, run Runner, application string) (map[string if len(fields) > 1 { owner = strings.TrimSpace(fields[1]) } + // A Compose file that runs its own proxy beside the workloads makes + // Compose create the application network, labelled with the + // application's project but not with onebox.app. The engine accepts + // it; preflight must predict that. Only this exact name: a project + // label on anything else proves nothing. + if owner == "" && q.kind == "network" && name == (Names{App: application}).ApplicationNetwork() && len(fields) > 2 && strings.TrimSpace(fields[2]) == application { + owner = application + } // Docker permits the same name in different resource kinds. Every // holder must belong to this application: one foreign or unlabelled // holder is a collision even if another kind is app-owned. diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index 34f8c7e2..df692747 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -253,6 +253,25 @@ func TestOwnedNetworkDoesNotMaskForeignHolderOfTheSameName(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()) + } +} + func TestUnlabelledServiceNetworkIsForeign(t *testing.T) { project := preflightProject + "services: {postgres: {version: 17}}\n" run := healthyRunner() diff --git a/internal/engine/networks.go b/internal/engine/networks.go index afc079ba..5e9ed878 100644 --- a/internal/engine/networks.go +++ b/internal/engine/networks.go @@ -13,21 +13,25 @@ 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()) + return e.ensureOwnedNetwork(ctx, n.ApplicationNetwork(), n.ComposeProject()) } // ensureServiceNetwork establishes the long-lived network shared by workloads // and supporting services. func (e *Engine) ensureServiceNetwork(ctx context.Context, n app.Names) error { - return e.ensureOwnedNetwork(ctx, n.ServiceNetwork()) + return e.ensureOwnedNetwork(ctx, n.ServiceNetwork(), "") } -// ensureOwnedNetwork creates a labelled network or accepts one that already -// carries this application's label. A derived name alone is never evidence: +// ensureOwnedNetwork creates a labelled network or accepts one this +// application provably owns: it carries the application's label, or — for the +// application network only — Compose created it for the application's own +// project. The second case is a Compose file that runs its own proxy beside the +// workloads: Compose creates _default before Onebox does, and Docker cannot +// add a label to it afterwards. 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) +func (e *Engine) ensureOwnedNetwork(ctx context.Context, name, composeProject string) error { + exists, err := e.ownedNetworkExists(ctx, name, composeProject) if err != nil { return err } @@ -49,7 +53,7 @@ func (e *Engine) ensureOwnedNetwork(ctx context.Context, name string) error { // either remove them or stop before deleting state and releasing host ownership. func (e *Engine) removeOwnedNetworks(ctx context.Context) error { n := e.names() - networks := []string{n.ApplicationNetwork()} + networks := []struct{ name, composeProject string }{{n.ApplicationNetwork(), n.ComposeProject()}} // `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 @@ -64,22 +68,22 @@ func (e *Engine) removeOwnedNetworks(ctx context.Context) error { includeServiceNetwork = state.ExitCode == 0 } if includeServiceNetwork { - networks = append(networks, n.ServiceNetwork()) + networks = append(networks, struct{ name, composeProject string }{n.ServiceNetwork(), ""}) } for _, network := range networks { - exists, err := e.ownedNetworkExists(ctx, network) + exists, err := e.ownedNetworkExists(ctx, network.name, network.composeProject) if err != nil { return err } if !exists { continue } - removed, removeErr := e.mutate(ctx, "docker network rm "+q(network)) + removed, removeErr := e.mutate(ctx, "docker network rm "+q(network.name)) 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, strings.TrimSpace(removed.Stderr)) + return fmt.Errorf("network %s: cannot remove owned network: %s; detach its remaining endpoints, then retry destroy", network.name, strings.TrimSpace(removed.Stderr)) } } return nil @@ -88,12 +92,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 string) (bool, error) { +func (e *Engine) ownedNetworkExists(ctx context.Context, name, composeProject 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 \"onebox.app\"}}' " + 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 @@ -108,7 +112,7 @@ func (e *Engine) ownedNetworkExists(ctx context.Context, name string) (bool, err return false, fmt.Errorf("network %s: cannot inspect ownership (exit %d): %s", name, res.ExitCode, strings.TrimSpace(res.Stderr)) } - fields := strings.SplitN(strings.TrimSpace(res.Stdout), "|", 2) + fields := strings.SplitN(strings.TrimSpace(res.Stdout), "|", 3) if len(fields) == 0 || !validID.MatchString(strings.TrimSpace(fields[0])) { return false, fmt.Errorf("network %s: inspect returned no valid identity", name) } @@ -116,6 +120,9 @@ func (e *Engine) ownedNetworkExists(ctx context.Context, name string) (bool, err if len(fields) > 1 { owner = networkLabel(fields[1]) } + if owner == "" && composeProject != "" && len(fields) > 2 && networkLabel(fields[2]) == composeProject { + owner = e.Spec.Name + } switch owner { case "": return false, fmt.Errorf("network %s exists without Onebox ownership; refusing to adopt it", name) diff --git a/internal/engine/networks_test.go b/internal/engine/networks_test.go index bfeafd18..0d970d5e 100644 --- a/internal/engine/networks_test.go +++ b/internal/engine/networks_test.go @@ -78,7 +78,7 @@ func TestUnlabelledNetworkIsRefused(t *testing.T) { return base(command) } e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - _, err := e.ownedNetworkExists(context.Background(), name) + _, err := e.ownedNetworkExists(context.Background(), name, "sample") if err == nil || !strings.Contains(err.Error(), "refusing to adopt") { t.Fatalf("unlabelled network error = %v", err) } @@ -132,3 +132,43 @@ func TestRemoveOwnedNetworksIgnoresServiceNameWithoutServiceState(t *testing.T) 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")) + } + }) + } +} From 78f527c80d8683f87f71b8866f6d0777ee2b5710 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 10:44:30 -0700 Subject: [PATCH 08/15] feat(runtime)!: keep ob for the CLI and onebox for everything else Only the command, the project file and the artifacts an operator passes between commands keep the ob prefix. Every other name Onebox creates now begins with onebox: the onebox-run journal identifier, the onebox-wal-g wrapper, .onebox-schedule.lease, .onebox-secret-generations, .onebox-decrypted-* and .onebox-external-* files, /tmp/onebox-drain, onebox.snapshot.yml, .onebox-tmp and .onebox-bak, fence and lock-loss markers, and local temporary directories. Document the rule and its separators in the naming reference, and add a test that fails when a string literal introduces any other ob-, ob_ or ob. name. BREAKING CHANGE: host-side files, markers and the journal identifier are renamed without migration; guides/upgrade-to-onebox-names lists them. Workload containers from an earlier release watch /tmp/ob-drain, so they must be replaced as the guide describes. --- cmd/ob/ops_contract_test.go | 2 +- cmd/ob/output.go | 6 +- cmd/ob/staged_artifact_test.go | 16 +-- e2e/apps/one-app-one-host.sh | 2 +- e2e/destroy_test.go | 2 +- e2e/server_harness_test.go | 2 +- e2e/server_test.go | 6 +- internal/app/backup_walg.go | 2 +- internal/app/eject.go | 4 +- internal/app/eject_test.go | 2 +- internal/app/generate.go | 6 +- internal/app/names.go | 2 +- internal/app/names_test.go | 5 +- internal/app/namespace_test.go | 113 ++++++++++++++++++ internal/app/secret_generation.go | 2 +- internal/app/secret_generation_test.go | 16 +-- internal/app/secrets_graph_test.go | 14 +-- internal/app/services.go | 2 +- internal/app/testdata/contract-verdicts.json | 32 ++--- internal/app/types.go | 2 +- internal/app/workload_contract_test.go | 2 +- internal/discovery/docker_test.go | 2 +- internal/engine/backup_credentials.go | 2 +- internal/engine/backup_lock.go | 8 +- internal/engine/backup_lock_test.go | 2 +- internal/engine/backup_restore.go | 8 +- internal/engine/backup_restore_client_test.go | 2 +- internal/engine/deploy_test.go | 18 +-- internal/engine/epoch.go | 2 +- internal/engine/finalize_test.go | 2 +- internal/engine/findincomplete_test.go | 2 +- internal/engine/gate_test.go | 2 +- internal/engine/job_history_test.go | 6 +- internal/engine/lock.go | 14 +-- internal/engine/lock_test.go | 4 +- internal/engine/ops_test.go | 10 +- internal/engine/payload_digest_shell_test.go | 8 +- internal/engine/plan.go | 4 +- internal/engine/plan_test.go | 12 +- internal/engine/proxy.go | 2 +- internal/engine/proxylock.go | 4 +- internal/engine/recovery.go | 2 +- internal/engine/recovery_test.go | 4 +- internal/engine/resume_test.go | 14 +-- internal/engine/roll_test.go | 8 +- internal/engine/rollback_test.go | 8 +- internal/engine/schedule.go | 6 +- internal/engine/schedule_execution.go | 2 +- internal/engine/schedule_history_test.go | 4 +- internal/engine/schedule_run_test.go | 2 +- internal/engine/schedule_test.go | 18 +-- .../engine/secret_generation_rolling_test.go | 8 +- internal/engine/secret_generation_test.go | 26 ++-- internal/engine/secretspush.go | 2 +- internal/engine/secretspush_test.go | 12 +- internal/engine/service_apply_test.go | 2 +- internal/engine/services.go | 6 +- internal/engine/status.go | 4 +- internal/journal/journal.go | 2 +- internal/onebox/backup_read.go | 2 +- internal/onebox/execution_boundary_test.go | 2 +- internal/onebox/plan_deploy.go | 2 +- internal/onebox/secrets_push_test.go | 8 +- internal/onebox/service_test.go | 4 +- internal/onebox/staging_secrets_test.go | 6 +- internal/release/release.go | 2 +- internal/release/retention_test.go | 2 +- internal/release/schedule_lease.go | 2 +- internal/secrets/secrets.go | 2 +- internal/transport/fake.go | 2 +- internal/transport/ssh.go | 4 +- internal/transport/transport.go | 2 +- internal/transport/upload_test.go | 4 +- .../content/docs/guides/schedule-a-job.mdx | 4 +- .../docs/guides/upgrade-to-onebox-names.mdx | 4 + site/src/content/docs/reference/naming.mdx | 21 ++++ 76 files changed, 346 insertions(+), 211 deletions(-) create mode 100644 internal/app/namespace_test.go diff --git a/cmd/ob/ops_contract_test.go b/cmd/ob/ops_contract_test.go index 0d10f964..5ae95689 100644 --- a/cmd/ob/ops_contract_test.go +++ b/cmd/ob/ops_contract_test.go @@ -190,7 +190,7 @@ 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 production\n"}, true 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/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/e2e/apps/one-app-one-host.sh b/e2e/apps/one-app-one-host.sh index b5f50941..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)" diff --git a/e2e/destroy_test.go b/e2e/destroy_test.go index b1842936..c3840a3a 100644 --- a/e2e/destroy_test.go +++ b/e2e/destroy_test.go @@ -68,7 +68,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/server_harness_test.go b/e2e/server_harness_test.go index 6c42def6..c7291a9b 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 diff --git a/e2e/server_test.go b/e2e/server_test.go index 490c6069..b96c7192 100644 --- a/e2e/server_test.go +++ b/e2e/server_test.go @@ -296,11 +296,11 @@ 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")) diff --git a/internal/app/backup_walg.go b/internal/app/backup_walg.go index 93373620..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 diff --git a/internal/app/eject.go b/internal/app/eject.go index 24198962..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) } @@ -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 9ed0b341..2cda4c84 100644 --- a/internal/app/eject_test.go +++ b/internal/app/eject_test.go @@ -304,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. diff --git a/internal/app/generate.go b/internal/app/generate.go index e2813570..f7f56790 100644 --- a/internal/app/generate.go +++ b/internal/app/generate.go @@ -34,7 +34,7 @@ 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. @@ -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/names.go b/internal/app/names.go index e2954610..426e95b2 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -137,7 +137,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 diff --git a/internal/app/names_test.go b/internal/app/names_test.go index 3b152f14..9cce7f6a 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -144,7 +144,7 @@ func TestBackupNamesEscapeHyphenatedSegments(t *testing.T) { // 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") } @@ -290,9 +290,6 @@ 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) - } } } diff --git a/internal/app/namespace_test.go b/internal/app/namespace_test.go new file mode 100644 index 00000000..2e52a1b3 --- /dev/null +++ b/internal/app/namespace_test.go @@ -0,0 +1,113 @@ +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, +} + +var obName = regexp.MustCompile(`(?:^|[^A-Za-z0-9_])(\.?ob[-_.][A-Za-z0-9][A-Za-z0-9_.-]*)`) + +// TestNoNewNamesOutsideTheOneboxNamespace fails when a string literal in the +// product introduces an ob-, ob_ or ob. name that is not one of cliNames. +// Comments are not checked; names are. +func TestNoNewNamesOutsideTheOneboxNamespace(t *testing.T) { + root := filepath.Join("..", "..") + for _, dir := range []string{"internal", "cmd"} { + 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 + } + if 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 := strings.TrimRight(match[1], ".") + 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/secret_generation.go b/internal/app/secret_generation.go index c6c17dc6..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}$`) diff --git a/internal/app/secret_generation_test.go b/internal/app/secret_generation_test.go index 89022d38..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] + 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] + 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) } @@ -85,10 +85,10 @@ func TestApplySecretGenerationReplacesListFormLabel(t *testing.T) { const newGeneration = "sg-222222222222222222222222" input := []byte(`services: web: - env_file: [.ob-decrypted-sops-api.env] + 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) 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/services.go b/internal/app/services.go index d043c910..a9743925 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -846,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/testdata/contract-verdicts.json b/internal/app/testdata/contract-verdicts.json index 8057e3c2..d6c95be0 100644 --- a/internal/app/testdata/contract-verdicts.json +++ b/internal/app/testdata/contract-verdicts.json @@ -122,7 +122,7 @@ { "case": "conformance/encrypted env file entry", "loads": true, - "digest": "ee857837f209fe0d1117fda8120dab3e2fa45a4d36a549a0f340f6dd7fb32c10" + "digest": "c300b69e70991a95bc2625fe246ca4a41557913ec412009253dcc7365b03b3c3" }, { "case": "conformance/env file entry without a file", @@ -152,7 +152,7 @@ { "case": "conformance/external service connection", "loads": true, - "digest": "36e28ac892b8231ed5bec3495f7d0273f22e51d91e02e635a1f34dda94550f9e" + "digest": "9b15e15fef71409b167288343b8e318b300a7f595ee4393cb83e4cd0cf52ea19" }, { "case": "conformance/hook naming a declared job", @@ -532,17 +532,17 @@ { "case": "corpus/authentik.yml", "loads": true, - "digest": "9ebfd098386e0ba17df924e39dfb559e9a048195b35fc3ce4f936e195789ccf0 postgres=d88fd3d620b30ea1 redis=53bad39e8f359a02" + "digest": "e82f2e9eac87e3494cf0aa86b80f91a60f363f0af7aac28bb45e1ec6ed2e40fb postgres=d88fd3d620b30ea1 redis=53bad39e8f359a02" }, { "case": "corpus/ext-authentik-managed.yml", "loads": true, - "digest": "695e57ce7479e93105765d7425026586bd3c9111ee4834018b131b773c4073dd postgres=d88fd3d620b30ea1 redis=53bad39e8f359a02" + "digest": "10748708444a383dc2e587286c13f06a4f3ea218835e2eeab1af83ed312ec741 postgres=d88fd3d620b30ea1 redis=53bad39e8f359a02" }, { "case": "corpus/ext-authentik.yml", "loads": true, - "digest": "d047ba583e16538ace0f57403c5d25023c0a86c65df975f16236a9deb3a1f37d" + "digest": "d7fdef6fcc8d19296aa43c09e11a3925f1e31ba035c8e90bd6cb9095d3a59cac" }, { "case": "corpus/ext-frigate.yml", @@ -552,7 +552,7 @@ { "case": "corpus/ext-gitea.yml", "loads": true, - "digest": "1f899cb760e6e261d8391959ab41a447b66db79d1b71cf63f59c83e31dd2d917 postgres=446e25618a3d2e4e" + "digest": "f6028df90e48b43f68c3375115ef402255ebd0aadf0ddde9eb9e2d538598cb65 postgres=446e25618a3d2e4e" }, { "case": "corpus/ext-immich-sourced.yml", @@ -567,7 +567,7 @@ { "case": "corpus/ext-n8n.yml", "loads": true, - "digest": "8c9e2e6bae6a1434fec4fd16a7470999743dc58877ba0b945b09fa5e8fc7dc88 postgres=c9e06c2c21513656 redis=83f7ec43501bb1f4" + "digest": "3831bd9a52d01deada4936e218fc8ba33c96a0b1d939e6bf503f3fbda8ba0895 postgres=c9e06c2c21513656 redis=83f7ec43501bb1f4" }, { "case": "corpus/ext-paperless.yml", @@ -587,12 +587,12 @@ { "case": "corpus/ghost.yml", "loads": true, - "digest": "aba3c31a08bb6fe625c52c3ce330e6e1d0e9af08fb1aa51f520c9b906b3fc024 mysql=6dc2ab74dac73160" + "digest": "7ab89c09054e821edfb0edd5c014b488ca06785d052f4178a688d4008961e1ad mysql=6dc2ab74dac73160" }, { "case": "corpus/gitea.yml", "loads": true, - "digest": "0d0f9d727619f0067c830853351fa1861c36de508259c181af38a239ab2d2256" + "digest": "0bf67b194d6c01daec00313f1e6f2cd9be3613a5dffd8ad0097a728360d7a269" }, { "case": "corpus/goal.yml", @@ -602,7 +602,7 @@ { "case": "corpus/immich.yml", "loads": true, - "digest": "378aa6674b9040640023b55a1ad48d8c338ab60fad7957db23f467264707ca42" + "digest": "923534f84d1c3d4c4d0fd59c6c20b9d2d197acfc045046531b7235cfc73e0d3e" }, { "case": "corpus/monk.yml", @@ -612,17 +612,17 @@ { "case": "corpus/n8n.yml", "loads": true, - "digest": "bb31040f4c0b7a657c68f21ec13d070626ce0b6ca2cb0bbd3fd556b6bc062521" + "digest": "ecc2235d37c777399acbda26f8c149515e3960fbfbdd286873459fc5c8b0164d" }, { "case": "corpus/paperless.yml", "loads": true, - "digest": "898956cb196cc1ed8381c8be43592177886085bf255ad676c00d549c73845fc1" + "digest": "c5eea938217dd6488e684078199dc338bc4915d8be3de3e0f728e6ced3b2b01d" }, { "case": "corpus/penpot.yml", "loads": true, - "digest": "ec20c704e16fd00dcead5fc3931ddf9e03f3add10eacd731620c27af59340612 postgres=fce3c20f4ad2e0e4 redis=e6f4fb5264fe75ae" + "digest": "1cd500aae4deac5cf744dec3f1222b13aec493457de4b6069a3449126ad70210 postgres=fce3c20f4ad2e0e4 redis=e6f4fb5264fe75ae" }, { "case": "corpus/pursue.yml", @@ -637,17 +637,17 @@ { "case": "corpus/rocketchat.yml", "loads": true, - "digest": "f065d84ec4900a79f1f3c60c6da9958e976ba424e1f3490ba7082cafcdc738ca mongodb=09562fa5123be0a1" + "digest": "4a091cd29c4a572eb57fcc9cd68edb76fbd56eb88ce16aab29efb30cb16af69f mongodb=09562fa5123be0a1" }, { "case": "corpus/umami.yml", "loads": true, - "digest": "086a6a692883531cd9db9485f1b78a84edc0e40392b104378f258c0654a161d4" + "digest": "93e16cdd46259289535d57fa3fe762f65717a8ab054daa365e14f1ca8f5b815d" }, { "case": "corpus/uptime-kuma.yml", "loads": true, - "digest": "19c85a8a0102688af4808ca1790c84492215fd2061cf9f9384a77f301bf101ff" + "digest": "0814a4130438e336934f9265f6d4f482bac99ba4198df1c7ccc0f2668655003e" }, { "case": "corpus/vaultwarden.yml", diff --git a/internal/app/types.go b/internal/app/types.go index 637215e9..fbec2531 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -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/workload_contract_test.go b/internal/app/workload_contract_test.go index 2965df9f..ff672126 100644 --- a/internal/app/workload_contract_test.go +++ b/internal/app/workload_contract_test.go @@ -15,7 +15,7 @@ func TestWorkloadRevisionIgnoresSecretStorageGeneration(t *testing.T) { compose := []byte(`services: worker: image: example/worker@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - env_file: [.ob-secret-generations/` + first + `/app.env] + env_file: [.onebox-secret-generations/` + first + `/app.env] labels: {onebox.app: sample, onebox.release: R1, onebox.secret-generation: ` + first + `} `) contract := map[string]WorkloadContract{"worker": { diff --git a/internal/discovery/docker_test.go b/internal/discovery/docker_test.go index 771ba4c4..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) } diff --git a/internal/engine/backup_credentials.go b/internal/engine/backup_credentials.go index 9841d47d..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") } 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 e647da9d..3f5d9f1c 100644 --- a/internal/engine/backup_lock_test.go +++ b/internal/engine/backup_lock_test.go @@ -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_restore.go b/internal/engine/backup_restore.go index 9d55b2b6..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 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/deploy_test.go b/internal/engine/deploy_test.go index ffe52e4f..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 @@ -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 { @@ -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) @@ -394,9 +394,9 @@ 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) } } @@ -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) 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/finalize_test.go b/internal/engine/finalize_test.go index 31075ba9..97e4ee8f 100644 --- a/internal/engine/finalize_test.go +++ b/internal/engine/finalize_test.go @@ -99,7 +99,7 @@ func buildActivatedFake(t *testing.T, activationResult bool, tail ...journal.Rec 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=onebox.app="): return transport.Result{Stdout: "NEW1|web|" + engineTestDeployReleaseID + "|Up 2 minutes (healthy)\n" + 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_test.go b/internal/engine/gate_test.go index 84262a15..e28afb11 100644 --- a/internal/engine/gate_test.go +++ b/internal/engine/gate_test.go @@ -67,7 +67,7 @@ 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, "onebox.release='"+engineTestPreviousReleaseID+"'"): return transport.Result{}, true 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/lock.go b/internal/engine/lock.go index 1f44b856..281a9812 100644 --- a/internal/engine/lock.go +++ b/internal/engine/lock.go @@ -327,12 +327,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 { @@ -348,12 +348,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 @@ -366,12 +366,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 @@ -384,7 +384,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 00491897..135ac968 100644 --- a/internal/engine/lock_test.go +++ b/internal/engine/lock_test.go @@ -524,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) { @@ -558,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()) } } diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index b2ba8358..09f991d2 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") { @@ -51,7 +51,7 @@ func TestDestroySequence(t *testing.T) { 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) @@ -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) @@ -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) 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 e556099c..41d75d2f 100644 --- a/internal/engine/plan.go +++ b/internal/engine/plan.go @@ -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_test.go b/internal/engine/plan_test.go index 02a3e62f..0f3d123e 100644 --- a/internal/engine/plan_test.go +++ b/internal/engine/plan_test.go @@ -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", diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index 3b46ffed..c572abb9 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -250,7 +250,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/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/recovery.go b/internal/engine/recovery.go index 4910b0c9..118eb1bb 100644 --- a/internal/engine/recovery.go +++ b/internal/engine/recovery.go @@ -28,7 +28,7 @@ func (e *Engine) engineFromReleaseSnapshotFor(ctx context.Context, releaseID, op 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) diff --git a/internal/engine/recovery_test.go b/internal/engine/recovery_test.go index 46b33171..37e86d3b 100644 --- a/internal/engine/recovery_test.go +++ b/internal/engine/recovery_test.go @@ -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,7 +272,7 @@ 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") { diff --git a/internal/engine/resume_test.go b/internal/engine/resume_test.go index f3ab92a6..c58ffe67 100644 --- a/internal/engine/resume_test.go +++ b/internal/engine/resume_test.go @@ -139,7 +139,7 @@ func TestResumeUsesInterruptedReleaseSnapshotAfterConfigEdit(t *testing.T) { switch { 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")) } } @@ -205,7 +205,7 @@ func interruptedBeforeMigrationFake(allowUnknown bool) *transport.Fake { switch { 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 @@ -417,9 +417,9 @@ func TestAbortUsesBothReleaseSnapshotsAfterConfigEdit(t *testing.T) { switch { 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 @@ -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) diff --git a/internal/engine/roll_test.go b/internal/engine/roll_test.go index 33db2e1c..56c6ee72 100644 --- a/internal/engine/roll_test.go +++ b/internal/engine/roll_test.go @@ -181,7 +181,7 @@ func TestRollRoleResumeAdoptsExistingNewcomer(t *testing.T) { 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) } } @@ -197,7 +197,7 @@ func TestRollRoleCommandSequence(t *testing.T) { "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") } } @@ -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) { 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 7cddafda..37d936eb 100644 --- a/internal/engine/schedule.go +++ b/internal/engine/schedule.go @@ -277,8 +277,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 @@ -648,7 +648,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 diff --git a/internal/engine/schedule_execution.go b/internal/engine/schedule_execution.go index 417bbf52..a97ca9d3 100644 --- a/internal/engine/schedule_execution.go +++ b/internal/engine/schedule_execution.go @@ -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", diff --git a/internal/engine/schedule_history_test.go b/internal/engine/schedule_history_test.go index fc184527..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='onebox-job-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) } @@ -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_run_test.go b/internal/engine/schedule_run_test.go index 78304811..da34a42b 100644 --- a/internal/engine/schedule_run_test.go +++ b/internal/engine/schedule_run_test.go @@ -199,7 +199,7 @@ func TestScheduleRunWaitReportsTheRecordAndFailsOnAnyOtherOutcome(t *testing.T) return transport.Result{Stdout: "inactive\n"}, true 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" + diff --git a/internal/engine/schedule_test.go b/internal/engine/schedule_test.go index 0b963142..b0de0e76 100644 --- a/internal/engine/schedule_test.go +++ b/internal/engine/schedule_test.go @@ -114,7 +114,7 @@ func TestScheduleApplyUpgradesLegacyUnitsUnderRegime(t *testing.T) { } for _, command := range f.Commands { if strings.Contains(command, "/etc/systemd/system/onebox-job-nightly") && - strings.Contains(command, ".ob-tmp") && !strings.Contains(command, "ob-fenced") { + strings.Contains(command, ".onebox-tmp") && !strings.Contains(command, "onebox-fenced") { t.Errorf("schedule artifact write escaped the fence: %s", command) } } @@ -304,7 +304,7 @@ func TestPinnedScheduledJobRunnerLeasesImmutableRelease(t *testing.T) { "exec 8>'/var/lib/onebox/app/schedule.lock'", "flock --shared --timeout 10 --conflict-exit-code 200 8", "release_dir=$(readlink -f '/var/lib/onebox/app/current')", - "exec 7>>\"$release_dir/.ob-schedule.lease\"", + "exec 7>>\"$release_dir/.onebox-schedule.lease\"", "flock --shared 7", "flock --unlock 8", "trap cleanup 0", @@ -672,7 +672,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 +711,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) @@ -964,8 +964,8 @@ func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { "onebox-job-nightly.timer", "onebox-backup-production-postgres-backup.timer", "onebox-backup-production-postgres-verify.timer", - // Another application's, and a stranger's. Neither is ours. - "ob-backup-other-production-postgres-backup.timer", + // Outside Onebox's namespaces, and a stranger's. Neither is ours. + "backup-other-production-postgres-backup.timer", "logrotate.timer", "", }, "\n")}, true @@ -986,7 +986,7 @@ func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { 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) } @@ -1061,7 +1061,7 @@ 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`, + `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) { @@ -1120,7 +1120,7 @@ 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 '^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" diff --git a/internal/engine/secret_generation_rolling_test.go b/internal/engine/secret_generation_rolling_test.go index cd202596..aff9d90f 100644 --- a/internal/engine/secret_generation_rolling_test.go +++ b/internal/engine/secret_generation_rolling_test.go @@ -123,7 +123,7 @@ func newRollingGenerationFakeWith(t *testing.T, project string, generations map[ 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 @@ -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 { @@ -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 fe063679..62d50355 100644 --- a/internal/engine/secret_generation_test.go +++ b/internal/engine/secret_generation_test.go @@ -59,7 +59,7 @@ func newGenerationFake(t *testing.T, unchanged bool) (*transport.Fake, *generati 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 @@ -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 "" @@ -127,7 +127,7 @@ func generationFromEngineSecretCommand(command string) string { func requireGenerationProjectDirectory(t *testing.T, commands []string, generation string) { t.Helper() releaseDir := "/srv/onebox/app/releases/20260809-120000-current" - composePath := releaseDir + "/.ob-secret-generations/" + generation + "/compose.yaml" + composePath := releaseDir + "/.onebox-secret-generations/" + generation + "/compose.yaml" projectArg := "--project-directory '" + releaseDir + "'" found := false for _, command := range commands { @@ -147,10 +147,10 @@ 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] + 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] + 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/app/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 6600639a..a083b0a7 100644 --- a/internal/engine/secretspush.go +++ b/internal/engine/secretspush.go @@ -474,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 } diff --git a/internal/engine/secretspush_test.go b/internal/engine/secretspush_test.go index 62c0fb1b..3401f51b 100644 --- a/internal/engine/secretspush_test.go +++ b/internal/engine/secretspush_test.go @@ -75,7 +75,7 @@ func TestSecretsPushRefusesExactDeclarationGraphDriftBeforeMutation(t *testing.T 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_test.go b/internal/engine/service_apply_test.go index 852a3167..ff3c9da1 100644 --- a/internal/engine/service_apply_test.go +++ b/internal/engine/service_apply_test.go @@ -58,7 +58,7 @@ func TestServiceApplyConvergesUnderRegime(t *testing.T) { t.Fatalf("service converged inside the application's project:\n%s", seq) } for _, c := range f.Commands { - if strings.Contains(c, "onebox_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) } } diff --git a/internal/engine/services.go b/internal/engine/services.go index ab34864d..b7e5cf3c 100644 --- a/internal/engine/services.go +++ b/internal/engine/services.go @@ -436,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 545d1e83..274ae779 100644 --- a/internal/engine/status.go +++ b/internal/engine/status.go @@ -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/journal/journal.go b/internal/journal/journal.go index 9d8fe7d9..a07d730c 100644 --- a/internal/journal/journal.go +++ b/internal/journal/journal.go @@ -242,7 +242,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 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/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/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 9767c747..4b03600c 100644 --- a/internal/onebox/secrets_push_test.go +++ b/internal/onebox/secrets_push_test.go @@ -47,15 +47,15 @@ func pushFake() *transport.Fake { 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] + 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] + 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"): @@ -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 4d6f8d0c..d256e73a 100644 --- a/internal/onebox/service_test.go +++ b/internal/onebox/service_test.go @@ -263,7 +263,7 @@ spec: liveCompose := `services: web: image: ` + imageWeb + ` - env_file: [.ob-secret-generations/` + oldGeneration + `/.ob-decrypted-sops-web.enc.env] + 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 + ` @@ -273,7 +273,7 @@ spec: 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 diff --git a/internal/onebox/staging_secrets_test.go b/internal/onebox/staging_secrets_test.go index 55e99ecf..11472a28 100644 --- a/internal/onebox/staging_secrets_test.go +++ b/internal/onebox/staging_secrets_test.go @@ -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/release/release.go b/internal/release/release.go index 97c2b960..a0313c16 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -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/retention_test.go b/internal/release/retention_test.go index 438af938..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 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/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/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_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/src/content/docs/guides/schedule-a-job.mdx b/site/src/content/docs/guides/schedule-a-job.mdx index 7fedb4a0..f20dcb8c 100644 --- a/site/src/content/docs/guides/schedule-a-job.mdx +++ b/site/src/content/docs/guides/schedule-a-job.mdx @@ -147,8 +147,8 @@ 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 +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 diff --git a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx index 27f678c7..139b8975 100644 --- a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -33,6 +33,10 @@ command with the new version. | Scheduled job units | `ob-shop-nightly` | `onebox-job-nightly` | | Backup units | `ob-backup-shop-production-postgres-backup` | `onebox-backup-production-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` | diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 44126058..b49f4a81 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -53,6 +53,27 @@ duration is an integer. ## Runtime resources +**`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 From 9aadaf7e1369644bbc630d6178a495f2b8adbf19 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 11:22:28 -0700 Subject: [PATCH 09/15] fix(runtime): prove unit ownership, and close review gaps - Schedule and backup reconciliation read each unit's owner from its Description and never remove or overwrite another application's unit. One owner record per basePath means a second application can share a host, and a name alone was trusted to be ours. - Backup units drop the environment: onebox-backup--. - Scheduled runs label their container onebox.execution.job, so the durable runner can reclaim one a crash left behind. - Journal functions take a directory. The host journal is read, written and pruned in the same place, and deploy prunes it. - One rule decides that Compose created the application network, shared by the engine and preflight. - The namespace guard catches bare ob- prefixes and scans e2e, whose destroy probes and durable fixture now use the current names. --- e2e/e2e_test.go | 2 +- e2e/ops_test.go | 2 +- e2e/server_execution_test.go | 2 +- e2e/server_probe_test.go | 8 +- .../worker/{ob-broken.yml => broken.yml} | 0 internal/app/names.go | 33 ++++--- internal/app/names_test.go | 21 ++--- internal/app/namespace_test.go | 21 +++-- internal/app/preflight.go | 9 +- internal/engine/audit.go | 4 +- internal/engine/backup_schedule.go | 15 +++- internal/engine/bootstrap.go | 2 +- internal/engine/deploy.go | 24 ++++-- internal/engine/gate_test.go | 4 +- internal/engine/job.go | 2 +- internal/engine/job_history.go | 2 +- internal/engine/networks.go | 33 ++++--- internal/engine/networks_test.go | 2 +- internal/engine/ops.go | 2 +- internal/engine/proxy.go | 2 +- internal/engine/recovery_test.go | 2 +- internal/engine/resume.go | 4 +- internal/engine/schedule.go | 85 +++++++++++++++++-- internal/engine/schedule_apply.go | 2 +- internal/engine/schedule_execution.go | 2 +- internal/engine/schedule_pause.go | 2 +- internal/engine/schedule_run.go | 2 +- internal/engine/schedule_test.go | 81 +++++++++++++++--- internal/engine/secretspush.go | 2 +- internal/engine/service_apply.go | 2 +- internal/journal/dangling_journal_dir_test.go | 28 +++--- internal/journal/journal.go | 53 ++++++------ internal/journal/journal_test.go | 10 +-- internal/journal/journals_test.go | 8 +- .../docs/guides/back-up-a-database.mdx | 4 +- .../docs/guides/upgrade-to-onebox-names.mdx | 2 +- site/src/content/docs/reference/naming.mdx | 5 +- 37 files changed, 323 insertions(+), 161 deletions(-) rename e2e/testdata/worker/{ob-broken.yml => broken.yml} (100%) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 07a9a675..45ca65cb 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 diff --git a/e2e/ops_test.go b/e2e/ops_test.go index ffff6840..ac67b941 100644 --- a/e2e/ops_test.go +++ b/e2e/ops_test.go @@ -228,7 +228,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 9d0bef15..df27b7a8 100644 --- a/e2e/server_execution_test.go +++ b/e2e/server_execution_test.go @@ -19,7 +19,7 @@ func TestServerDurableExecutions(t *testing.T) { name := fmt.Sprintf("durable%d", time.Now().UnixNano()) base := "/tmp/onebox-" + name root := base + "/app" - unit := "ob-" + name + "-refresh" + unit := "onebox-job-refresh" dir := t.TempDir() s.run(t, "mkdir -p "+base+"/data") t.Cleanup(func() { diff --git a/e2e/server_probe_test.go b/e2e/server_probe_test.go index 39dedfb4..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) } @@ -203,11 +203,11 @@ func TestServerProbes(t *testing.T) { for _, probe := range []struct{ name, command, want string }{ {"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 ob-backup || true", "0"}, - {"unit files", "ls /etc/systemd/system/ob-backup-* 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 | grep -c observer || true", "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/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/names.go b/internal/app/names.go index 426e95b2..cff42bcc 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -77,6 +77,17 @@ func (p *Spec) NamesFor(env string) Names { // 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 @@ -258,21 +269,17 @@ func (n Names) ScheduledJobPause(job string) string { return path.Join(n.AppDir(), "schedule", job+".paused") } -// BackupTimerForEnvironment names a backup timer. -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(environment, service, operation) +// BackupTimer names a backup timer. +func (n Names) BackupTimer(service, operation string) string { + return n.BackupUnit(service, operation) + ".timer" } -// BackupUnitPrefixForEnvironment is the part of every backup unit name that -// belongs to one environment. -func (n Names) BackupUnitPrefixForEnvironment(environment string) string { - return BackupUnitPrefix + runtimeName(environment) + "-" +// 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, and +// the unit's Description records the owner for reconciliation. +func (n Names) BackupUnit(service, operation string) string { + return BackupUnitPrefix + runtimeName(service, operation) } // JobUnitPrefix and BackupUnitPrefix are the systemd namespaces Onebox owns diff --git a/internal/app/names_test.go b/internal/app/names_test.go index 9cce7f6a..08492038 100644 --- a/internal/app/names_test.go +++ b/internal/app/names_test.go @@ -113,16 +113,14 @@ func TestBackupNamesEscapeHyphenatedSegments(t *testing.T) { credentialNames[credential] = source } } - for _, environment := range idents { - for _, service := range idents { - for _, target := range idents { - unit := n.BackupUnitForEnvironment(environment, service, target) - source := environment + "|" + service + "|" + target - if previous, exists := unitNames[unit]; exists { - t.Fatalf("backup unit collision: %q derives from both %s and %s", unit, previous, source) - } - unitNames[unit] = source + 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 } } @@ -130,12 +128,9 @@ func TestBackupNamesEscapeHyphenatedSegments(t *testing.T) { 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 != "onebox-backup-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.BackupUnitPrefixForEnvironment("pre-prod"); got != "onebox-backup-pre--prod-" { - t.Fatalf("unit reconciliation prefix = %q", got) - } if got := n.ScheduledJobUnit("data-base"); got != "onebox-job-data-base" { t.Fatalf("scheduled job unit = %q", got) } diff --git a/internal/app/namespace_test.go b/internal/app/namespace_test.go index 2e52a1b3..5f906405 100644 --- a/internal/app/namespace_test.go +++ b/internal/app/namespace_test.go @@ -30,14 +30,17 @@ var cliNames = map[string]bool{ "ob-backup-report.json": true, } -var obName = regexp.MustCompile(`(?:^|[^A-Za-z0-9_])(\.?ob[-_.][A-Za-z0-9][A-Za-z0-9_.-]*)`) +// 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 in the -// product introduces an ob-, ob_ or ob. name that is not one of cliNames. -// Comments are not checked; names are. +// 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"} { + 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 @@ -48,7 +51,8 @@ func TestNoNewNamesOutsideTheOneboxNamespace(t *testing.T) { } return nil } - if strings.HasSuffix(path, "_test.go") || strings.HasSuffix(path, "_test.py") { + 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 @@ -62,7 +66,10 @@ func TestNoNewNamesOutsideTheOneboxNamespace(t *testing.T) { } for _, literal := range literals { for _, match := range obName.FindAllStringSubmatch(literal, -1) { - name := strings.TrimRight(match[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) } diff --git a/internal/app/preflight.go b/internal/app/preflight.go index 97f2bda9..a916a767 100644 --- a/internal/app/preflight.go +++ b/internal/app/preflight.go @@ -283,6 +283,7 @@ func basePathCheck(ctx context.Context, run Runner, base string) Check { // normal case — a previous release — and only a foreign holder is a collision. func ownedNames(ctx context.Context, run Runner, application string) (map[string]string, error) { owned := map[string]string{} + n := Names{App: application} for _, q := range []struct { cmd, kind string }{ @@ -312,12 +313,8 @@ func ownedNames(ctx context.Context, run Runner, application string) (map[string if len(fields) > 1 { owner = strings.TrimSpace(fields[1]) } - // A Compose file that runs its own proxy beside the workloads makes - // Compose create the application network, labelled with the - // application's project but not with onebox.app. The engine accepts - // it; preflight must predict that. Only this exact name: a project - // label on anything else proves nothing. - if owner == "" && q.kind == "network" && name == (Names{App: application}).ApplicationNetwork() && len(fields) > 2 && strings.TrimSpace(fields[2]) == application { + // 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/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_schedule.go b/internal/engine/backup_schedule.go index a9d47157..8482b36d 100644 --- a/internal/engine/backup_schedule.go +++ b/internal/engine/backup_schedule.go @@ -46,7 +46,11 @@ import ( // project no longer describes, and nothing in the project would explain why. func (e *Engine) SyncBackupSchedules(ctx context.Context) error { n := e.names() - prefix := n.BackupUnitPrefixForEnvironment(e.Opts.Environment) + prefix := app.BackupUnitPrefix + owners, err := e.scheduleUnitOwners(ctx) + if err != nil { + return err + } // 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 @@ -65,7 +69,7 @@ func (e *Engine) SyncBackupSchedules(ctx context.Context) error { continue } bare := strings.TrimSuffix(unit, ".timer") - if matchesRuntimePrefix(bare, prefix) { + if matchesRuntimePrefix(bare, prefix) && owners[bare] == e.Spec.Name { installed[bare] = true } } @@ -127,7 +131,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), @@ -147,6 +151,11 @@ func (e *Engine) SyncBackupSchedules(ctx context.Context) error { } } + for _, unit := range wanted { + if err := e.requireUnitOwnership(owners, unit.name); err != nil { + return err + } + } wantedNames := map[string]bool{} for _, unit := range wanted { wantedNames[unit.name] = true diff --git a/internal/engine/bootstrap.go b/internal/engine/bootstrap.go index ae3e44a4..9a7b68cf 100644 --- a/internal/engine/bootstrap.go +++ b/internal/engine/bootstrap.go @@ -58,7 +58,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) } diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go index 8636a148..6f7e59b6 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,24 @@ 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 + } + } + // The host journal records operations on the host itself, such as proxy + // apply. It keeps the same window, or it would grow for the host's life. + hostJournal := journal.HostDir(e.names()) + hvictims, err := journal.PruneCandidates(ctx, e.T, hostJournal, e.Spec.Deployment.RetainReleases*2) + if err != nil { + return "", err + } + for _, id := range hvictims { + if err := e.mutateChecked(ctx, "prune host journal "+id, "rm -f "+q(hostJournal+"/"+id+".jsonl")); err != nil { return "", err } } @@ -662,7 +674,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/gate_test.go b/internal/engine/gate_test.go index e28afb11..8635a78a 100644 --- a/internal/engine/gate_test.go +++ b/internal/engine/gate_test.go @@ -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) 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_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/networks.go b/internal/engine/networks.go index 5e9ed878..7a76e415 100644 --- a/internal/engine/networks.go +++ b/internal/engine/networks.go @@ -13,25 +13,22 @@ 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. func (e *Engine) ensureServiceNetwork(ctx context.Context, n app.Names) error { - return e.ensureOwnedNetwork(ctx, n.ServiceNetwork(), "") + return e.ensureOwnedNetwork(ctx, n.ServiceNetwork()) } // ensureOwnedNetwork creates a labelled network or accepts one this -// application provably owns: it carries the application's label, or — for the -// application network only — Compose created it for the application's own -// project. The second case is a Compose file that runs its own proxy beside the -// workloads: Compose creates _default before Onebox does, and Docker cannot -// add a label to it afterwards. 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, composeProject string) error { - exists, err := e.ownedNetworkExists(ctx, name, composeProject) +// 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 } @@ -53,7 +50,7 @@ func (e *Engine) ensureOwnedNetwork(ctx context.Context, name, composeProject st // 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, composeProject string }{{n.ApplicationNetwork(), n.ComposeProject()}} + 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 @@ -68,22 +65,22 @@ func (e *Engine) removeOwnedNetworks(ctx context.Context) error { includeServiceNetwork = state.ExitCode == 0 } if includeServiceNetwork { - networks = append(networks, struct{ name, composeProject string }{n.ServiceNetwork(), ""}) + networks = append(networks, n.ServiceNetwork()) } for _, network := range networks { - exists, err := e.ownedNetworkExists(ctx, network.name, network.composeProject) + 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 @@ -92,7 +89,7 @@ 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, composeProject 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 @@ -120,7 +117,7 @@ func (e *Engine) ownedNetworkExists(ctx context.Context, name, composeProject st if len(fields) > 1 { owner = networkLabel(fields[1]) } - if owner == "" && composeProject != "" && len(fields) > 2 && networkLabel(fields[2]) == composeProject { + if owner == "" && len(fields) > 2 && e.names().ComposeCreatedApplicationNetwork(name, networkLabel(fields[2])) { owner = e.Spec.Name } switch owner { diff --git a/internal/engine/networks_test.go b/internal/engine/networks_test.go index 0d970d5e..1e9e2e19 100644 --- a/internal/engine/networks_test.go +++ b/internal/engine/networks_test.go @@ -78,7 +78,7 @@ func TestUnlabelledNetworkIsRefused(t *testing.T) { return base(command) } e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - _, err := e.ownedNetworkExists(context.Background(), name, "sample") + _, err := e.ownedNetworkExists(context.Background(), name) if err == nil || !strings.Contains(err.Error(), "refusing to adopt") { t.Fatalf("unlabelled network error = %v", err) } diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 36c6c56c..3aab6c6d 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -399,7 +399,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/proxy.go b/internal/engine/proxy.go index c572abb9..ab3fd01e 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -56,7 +56,7 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo return err } defer e.releaseHostLock(ctx) - jw := &journal.Writer{T: e.T, Names: e.names(), Dir: e.names().HostDir() + "/journal", DeployID: deployID, Operator: journal.DefaultOperator(), GitSHA: e.Opts.GitSHA, ConfigHash: e.Opts.ConfigHash, Runner: &e.Opts.Runner} + jw := &journal.Writer{T: e.T, Dir: journal.HostDir(e.names()), 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) } diff --git a/internal/engine/recovery_test.go b/internal/engine/recovery_test.go index 37e86d3b..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) { diff --git a/internal/engine/resume.go b/internal/engine/resume.go index 73a03bdf..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 } @@ -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/schedule.go b/internal/engine/schedule.go index 37d936eb..144a63f3 100644 --- a/internal/engine/schedule.go +++ b/internal/engine/schedule.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "path" "regexp" "strconv" "strings" @@ -45,6 +46,10 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { } n := e.names() prefix := app.JobUnitPrefix + owners, err := e.scheduleUnitOwners(ctx) + if err != nil { + return err + } // 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}'") @@ -58,7 +63,7 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { continue } bare := strings.TrimSuffix(unit, ".timer") - if matchesRuntimePrefix(bare, prefix) { + if matchesRuntimePrefix(bare, prefix) && owners[bare] == e.Spec.Name { installed[bare] = true } } @@ -67,6 +72,11 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { if err := e.requireScheduleHost(ctx, jobs); err != nil { return err } + for _, job := range jobs { + if err := e.requireUnitOwnership(owners, n.ScheduledJobUnit(job.Name)); err != nil { + return err + } + } for _, job := range jobs { if job.Execution != nil { res, err := e.mutate(ctx, "install -d -m 700 "+q(n.AppDir()+"/schedule")) @@ -224,7 +234,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.", @@ -255,13 +265,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.", @@ -920,7 +935,12 @@ func (e *Engine) RemoveSchedules(ctx context.Context) error { // 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. + // deleted. They belong to this application and they go with it — and only + // those whose Description names it. + owners, err := e.scheduleUnitOwners(ctx) + if err != nil { + return err + } 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 @@ -932,7 +952,8 @@ func (e *Engine) RemoveSchedules(ctx context.Context) error { continue } unit = strings.TrimSuffix(unit, ".timer") - if matchesRuntimePrefix(unit, app.JobUnitPrefix) || matchesRuntimePrefix(unit, app.BackupUnitPrefix) { + if (matchesRuntimePrefix(unit, app.JobUnitPrefix) || matchesRuntimePrefix(unit, app.BackupUnitPrefix)) && + owners[unit] == e.Spec.Name { units = append(units, unit) } } @@ -977,6 +998,60 @@ func (e *Engine) removeScheduleUnit(ctx context.Context, unit string) error { return errors.Join(errs...) } +// scheduleUnitOwners reads which application owns each installed Onebox job and +// backup unit, from the Description Onebox writes into every .service. +// +// The name alone is not proof. A host has one owner per basePath, so a second +// application with its own basePath can install units with the same names; a +// deploy or destroy that trusted the name would delete or overwrite them. +// Units whose owner cannot be read are nobody's: they are left alone. +func (e *Engine) scheduleUnitOwners(ctx context.Context) (map[string]string, error) { + res, err := e.T.Run(ctx, "grep -H '^Description=Onebox ' /etc/systemd/system/"+app.JobUnitPrefix+"*.service /etc/systemd/system/"+app.BackupUnitPrefix+"*.service 2>/dev/null || true") + if err != nil { + return nil, fmt.Errorf("read schedule unit owners: %w", err) + } + owners := map[string]string{} + for _, line := range strings.Split(res.Stdout, "\n") { + file, description, ok := strings.Cut(strings.TrimSpace(line), ":Description=") + if !ok { + continue + } + unit := strings.TrimSuffix(path.Base(file), ".service") + if owner := unitDescriptionOwner(description); owner != "" && unitName.MatchString(unit+".service") { + owners[unit] = owner + } + } + return owners, nil +} + +// unitDescriptionOwner extracts the application from the two Description +// forms Onebox writes: "Onebox scheduled job for " and +// "Onebox backup for (/)". +func unitDescriptionOwner(description string) string { + if rest, ok := strings.CutPrefix(description, "Onebox backup "); ok { + open := strings.LastIndex(rest, "(") + if open < 0 || !strings.HasSuffix(rest, ")") { + return "" + } + owner, _, _ := strings.Cut(rest[open+1:len(rest)-1], "/") + return owner + } + if rest, ok := strings.CutPrefix(description, "Onebox scheduled job "); ok { + if at := strings.LastIndex(rest, " for "); at >= 0 { + return rest[at+len(" for "):] + } + } + return "" +} + +// requireUnitOwnership refuses to write over a unit another application owns. +func (e *Engine) requireUnitOwnership(owners map[string]string, unit string) error { + if owner, ok := owners[unit]; ok && owner != e.Spec.Name { + return fmt.Errorf("systemd unit %s belongs to application %s; one host runs one application — remove it or use another host", unit, owner) + } + return nil +} + // matchesRuntimePrefix distinguishes a component boundary from the first half // of an escaped hyphen. For example, onebox-backup-prod- owns // onebox-backup-prod-db-base but not onebox-backup-prod--eu-db-base, whose 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 a97ca9d3..95c13d71 100644 --- a/internal/engine/schedule_execution.go +++ b/internal/engine/schedule_execution.go @@ -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_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_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_test.go b/internal/engine/schedule_test.go index b0de0e76..483ee8e3 100644 --- a/internal/engine/schedule_test.go +++ b/internal/engine/schedule_test.go @@ -200,7 +200,7 @@ func TestScheduledJobUnitContract(t *testing.T) { "--project-directory", "/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'", @@ -313,7 +313,7 @@ func TestPinnedScheduledJobRunnerLeasesImmutableRelease(t *testing.T) { "--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) { @@ -848,6 +848,8 @@ func TestRemoveSchedulesRemovesFilesAndReloadsAfterFailedDisable(t *testing.T) { switch { case strings.Contains(cmd, "list-unit-files"): return transport.Result{Stdout: "onebox-job-nightly.timer\n"}, true + case strings.Contains(cmd, "grep -H '^Description=Onebox '"): + return transport.Result{Stdout: unitOwnerListing("onebox-job-nightly", "sample")}, true case strings.Contains(cmd, "systemctl disable --now"): return transport.Result{ExitCode: 5, Stderr: "unit is busy"}, true } @@ -944,7 +946,7 @@ func TestScheduleSyncIgnoresInvalidHostListedUnitNames(t *testing.T) { // trace being a line saying the schedule was "no longer declared". func TestSyncSchedulesLeavesBackupTimersAlone(t *testing.T) { backupTimer := app.Names{App: "example", BasePath: "/var/lib/onebox"}. - BackupTimerForEnvironment("production", "database", "backup") + BackupTimer("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) } @@ -962,14 +964,22 @@ func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { if strings.Contains(cmd, "list-unit-files") { return transport.Result{Stdout: strings.Join([]string{ "onebox-job-nightly.timer", - "onebox-backup-production-postgres-backup.timer", - "onebox-backup-production-postgres-verify.timer", - // Outside Onebox's namespaces, and a stranger's. Neither is ours. + "onebox-backup-postgres-backup.timer", + "onebox-backup-postgres-verify.timer", + // Another application's, outside Onebox's namespaces, and a + // stranger's. None is ours. + "onebox-backup-other-backup.timer", "backup-other-production-postgres-backup.timer", "logrotate.timer", "", }, "\n")}, true } + if strings.Contains(cmd, "grep -H '^Description=Onebox '") { + return transport.Result{Stdout: unitOwnerListing("onebox-job-nightly", "sample") + + backupOwnerListing("onebox-backup-postgres-backup", "sample") + + backupOwnerListing("onebox-backup-postgres-verify", "sample") + + backupOwnerListing("onebox-backup-other-backup", "other")}, true + } return transport.Result{}, false }} e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) @@ -979,14 +989,14 @@ func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { seq := strings.Join(f.Commands, "\n") for _, want := range []string{ "onebox-job-nightly", - "onebox-backup-production-postgres-backup", - "onebox-backup-production-postgres-verify", + "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{"backup-other-production", "logrotate"} { + for _, never := range []string{"onebox-backup-other-backup", "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) } @@ -1433,7 +1443,7 @@ func TestScheduledJobRunnerConsumesManualInputsWithoutShellInterpolation(t *test `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) @@ -1453,7 +1463,7 @@ func TestScheduledJobRunnerConsumesManualInputsWithoutShellInterpolation(t *test 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/onebox/app/lock", nil, 10*time.Minute, true) - if !strings.Contains(exclusive, "inputs_file=") || !strings.Contains(exclusive, `run --rm --no-deps "$@" --name`) { + 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) } } @@ -1895,3 +1905,52 @@ ActiveState=active t.Fatalf("a stale systemd result outvoted the record: %#v", got) } } + +func unitOwnerListing(unit, application string) string { + return "/etc/systemd/system/" + unit + ".service:Description=Onebox scheduled job nightly for " + application + "\n" +} + +func backupOwnerListing(unit, application string) string { + return "/etc/systemd/system/" + unit + ".service:Description=Onebox backup backup for postgres (" + application + "/production)\n" +} + +// A unit name is not proof of ownership: a second application with its own +// basePath has its own owner record, so its units can share names with ours. +func TestScheduleSyncNeverTouchesAnotherApplicationsUnits(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: "onebox-job-cleanup.timer\n"}, true + case strings.Contains(cmd, "grep -H '^Description=Onebox '"): + return transport.Result{Stdout: unitOwnerListing("onebox-job-cleanup", "other")}, true + } + return transport.Result{}, false + }} + cfg := testConfig() + e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + if err := e.SyncSchedules(context.Background()); err != nil { + t.Fatal(err) + } + if seq := strings.Join(f.Commands, "\n"); strings.Contains(seq, "onebox-job-cleanup.timer /etc") || strings.Contains(seq, "disable --now onebox-job-cleanup") { + t.Fatalf("sync removed another application's unit:\n%s", seq) + } +} + +func TestUnitDescriptionOwner(t *testing.T) { + for description, want := range map[string]string{ + "Onebox scheduled job nightly for shop": "shop", + "Onebox scheduled job nightly-for-x for shop": "shop", + "Onebox backup backup for postgres (shop/production)": "shop", + "Onebox backup verify for data-base (help-desk/pre-prod)": "help-desk", + "Onebox schedule for nightly (shop)": "", + "Something else": "", + } { + if got := unitDescriptionOwner(description); got != want { + t.Errorf("unitDescriptionOwner(%q) = %q, want %q", description, got, want) + } + } + e := &Engine{Spec: testConfig()} + if err := e.requireUnitOwnership(map[string]string{"onebox-job-nightly": "other"}, "onebox-job-nightly"); err == nil || !strings.Contains(err.Error(), "belongs to application other") { + t.Fatalf("another application's unit was not refused: %v", err) + } +} diff --git a/internal/engine/secretspush.go b/internal/engine/secretspush.go index a083b0a7..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 { 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/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 a07d730c..81ce6fda 100644 --- a/internal/journal/journal.go +++ b/internal/journal/journal.go @@ -97,11 +97,9 @@ 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 replaces the application's journal directory. The host journal, - // which belongs to no application, is written through it. + // Dir is the journal directory: Dir(names) for an application's journal, + // HostDir(names) 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 @@ -118,8 +116,14 @@ 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" } + +// HostDir is the host's journal directory, for operations on the host itself +// such as applying the proxy. +func HostDir(n app.Names) string { return n.HostDir() + "/journal" } + +func file(dir, id string) string { return dir + "/" + id + ".jsonl" } func DefaultOperator() string { user := os.Getenv("USER") @@ -185,12 +189,11 @@ func (w *Writer) Append(ctx context.Context, r Record) error { if err != nil { return err } - d := w.Dir - if d == "" { - d = dir(w.Names) + if w.Dir == "" { + return errors.New("journal writer has no directory") } - f := d + "/" + w.DeployID + ".jsonl" - cmd := "mkdir -p " + q(d) + " && printf '%s\\n' " + q(string(b)) + " >> " + q(f) + " && sync " + q(f) + 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 @@ -203,8 +206,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 } @@ -223,8 +226,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 } @@ -250,7 +253,7 @@ const journalMarker = "@@onebox-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 @@ -259,7 +262,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. @@ -271,11 +274,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 @@ -284,11 +287,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)) @@ -321,11 +324,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 5b20d9cc..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) } @@ -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 ab8c195c..6e1d08e7 100644 --- a/internal/journal/journals_test.go +++ b/internal/journal/journals_test.go @@ -42,7 +42,7 @@ 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) } @@ -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/site/src/content/docs/guides/back-up-a-database.mdx b/site/src/content/docs/guides/back-up-a-database.mdx index 839a89fa..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: onebox-backup-production-database-backup at 0 2 * * * -→ backup schedule: onebox-backup-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/upgrade-to-onebox-names.mdx b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx index 139b8975..39454bf5 100644 --- a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -31,7 +31,7 @@ command with the new version. | 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-production-postgres-backup` | +| 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` | diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index b49f4a81..5391df64 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -104,12 +104,13 @@ application.** | State directory | `/var/lib/onebox/app` (releases, `current`, journal, services, backup) | | Host state | `/var/lib/onebox/_host` (owner record, proxy) | | Scheduled job units | `onebox-job-.service` and `.timer` | -| Backup units | `onebox-backup---` | +| 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. Custom Traefik objects in a proxy configuration directory may not +resource; for a systemd unit, its `Description` does. Onebox never removes or +overwrites a unit whose `Description` names another application. 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 From 33e3ad2568a647ccf155c46e350346cf51f94df5 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 11:40:54 -0700 Subject: [PATCH 10/15] fix(runtime): one host state directory, so one host has one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host owner record lived under basePath, so an application with a different basePath got its own record and could claim a host another application already owned. Every name this branch stopped qualifying with the application relied on one application per host, and each shared namespace needed its own patch. Host state — the owner record, host lock, host journal and proxy — now lives at /var/lib/onebox/_host whatever basePath says. That restores the invariant everywhere, so reconciliation no longer parses unit Descriptions for ownership; that check also failed open when a unit file could not be read. ONEBOX_TEST_HOST_STATE_DIR relocates host state for test suites that cannot write /var/lib, or that run several fixture applications against one machine. It is not a supported setting. Also: - The host journal is pruned after proxy apply, the only place that writes it, rather than on every deploy. Names.HostJournalDir is its one spelling. - The derived-name length check measures every name All derives, with escaped hyphens, ordinals and restore suffixes. - matchesRuntimePrefix and Names.BackupTimer are gone. --- e2e/e2e_test.go | 9 +- e2e/ops_test.go | 3 + e2e/server_execution_test.go | 5 + internal/app/load.go | 38 ++----- internal/app/load_test.go | 17 +++ internal/app/names.go | 34 ++++-- internal/engine/backup_schedule.go | 11 +- internal/engine/bootstrap_test.go | 1 + internal/engine/deploy.go | 12 -- internal/engine/lock_test.go | 4 +- internal/engine/proxy.go | 18 ++- internal/engine/schedule.go | 85 +------------- internal/engine/schedule_test.go | 104 +----------------- internal/journal/journal.go | 6 +- internal/proxy/proxy.go | 30 ++--- internal/proxy/proxy_test.go | 14 +-- .../docs/guides/upgrade-to-onebox-names.mdx | 8 +- site/src/content/docs/reference/naming.mdx | 9 +- .../content/docs/reference/project-file.mdx | 4 +- 19 files changed, 124 insertions(+), 288 deletions(-) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 45ca65cb..c6066f2a 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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/ops_test.go b/e2e/ops_test.go index ac67b941..e455a41f 100644 --- a/e2e/ops_test.go +++ b/e2e/ops_test.go @@ -35,6 +35,9 @@ 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. + t.Setenv(app.TestHostStateDirEnv, filepath.Join(t.TempDir(), "host")) } // buildDeploy loads config+compose fresh (env-sensitive) and returns an diff --git a/e2e/server_execution_test.go b/e2e/server_execution_test.go index df27b7a8..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, @@ -19,6 +21,9 @@ func TestServerDurableExecutions(t *testing.T) { name := fmt.Sprintf("durable%d", time.Now().UnixNano()) base := "/tmp/onebox-" + name 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") diff --git a/internal/app/load.go b/internal/app/load.go index 1d2cb074..4e241119 100644 --- a/internal/app/load.go +++ b/internal/app/load.go @@ -667,37 +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) - } - n := p.NamesFor("") - 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", n.WorkloadVolume(w, v.Name)); err != nil { - return err - } - } - } - for _, s := range sortedKeys(p.Services) { - if err := check("service project", n.ServiceProject(s)); err != nil { - return err - } - for _, v := range p.Services[s].Volumes { - if err := check("volume", n.ServiceVolume(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 599bd9f1..be02df38 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -573,6 +573,23 @@ 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)) + } +} + // 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) { diff --git a/internal/app/names.go b/internal/app/names.go index cff42bcc..f063a2e2 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "os" "path" "sort" "strings" @@ -51,6 +52,20 @@ const ( // DefaultBasePath follows the platform convention for variable state owned // by a program that installs nothing of its own. 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 relocates HostStateDir for test suites that cannot + // write to /var/lib or that run several fixture applications against one + // machine. It is not a supported setting: with it, one host can hold + // several owner records again. + TestHostStateDirEnv = "ONEBOX_TEST_HOST_STATE_DIR" ) // Names derives every generated name for one project and environment. @@ -269,15 +284,9 @@ func (n Names) ScheduledJobPause(job string) string { return path.Join(n.AppDir(), "schedule", job+".paused") } -// BackupTimer names a backup timer. -func (n Names) BackupTimer(service, operation string) string { - return n.BackupUnit(service, operation) + ".timer" -} - // 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, and -// the unit's Description records the owner for reconciliation. +// the application nor the environment: the host owner record names both. func (n Names) BackupUnit(service, operation string) string { return BackupUnitPrefix + runtimeName(service, operation) } @@ -346,7 +355,16 @@ 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 dir := os.Getenv(TestHostStateDirEnv); path.IsAbs(dir) { + return path.Clean(dir) + } + return HostStateDir +} + +// 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 diff --git a/internal/engine/backup_schedule.go b/internal/engine/backup_schedule.go index 8482b36d..8e2b7cbd 100644 --- a/internal/engine/backup_schedule.go +++ b/internal/engine/backup_schedule.go @@ -47,10 +47,6 @@ import ( func (e *Engine) SyncBackupSchedules(ctx context.Context) error { n := e.names() prefix := app.BackupUnitPrefix - owners, err := e.scheduleUnitOwners(ctx) - if err != nil { - return err - } // 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 @@ -69,7 +65,7 @@ func (e *Engine) SyncBackupSchedules(ctx context.Context) error { continue } bare := strings.TrimSuffix(unit, ".timer") - if matchesRuntimePrefix(bare, prefix) && owners[bare] == e.Spec.Name { + if strings.HasPrefix(bare, prefix) { installed[bare] = true } } @@ -151,11 +147,6 @@ func (e *Engine) SyncBackupSchedules(ctx context.Context) error { } } - for _, unit := range wanted { - if err := e.requireUnitOwnership(owners, unit.name); err != nil { - return err - } - } wantedNames := map[string]bool{} for _, unit := range wanted { wantedNames[unit.name] = true diff --git a/internal/engine/bootstrap_test.go b/internal/engine/bootstrap_test.go index 8b8298e1..ba1f6a2d 100644 --- a/internal/engine/bootstrap_test.go +++ b/internal/engine/bootstrap_test.go @@ -130,6 +130,7 @@ func TestConcurrentBootstrapDoesNotRunSecondHook(t *testing.T) { t.Fatal(err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(app.TestHostStateDirEnv, filepath.Join(dir, "host")) entered := filepath.Join(dir, "hook-entered") release := filepath.Join(dir, "release-hook") diff --git a/internal/engine/deploy.go b/internal/engine/deploy.go index 6f7e59b6..32ec52b3 100644 --- a/internal/engine/deploy.go +++ b/internal/engine/deploy.go @@ -608,18 +608,6 @@ func (e *Engine) pruneRetention(ctx context.Context) (string, error) { return "", err } } - // The host journal records operations on the host itself, such as proxy - // apply. It keeps the same window, or it would grow for the host's life. - hostJournal := journal.HostDir(e.names()) - hvictims, err := journal.PruneCandidates(ctx, e.T, hostJournal, e.Spec.Deployment.RetainReleases*2) - if err != nil { - return "", err - } - for _, id := range hvictims { - if err := e.mutateChecked(ctx, "prune host journal "+id, "rm -f "+q(hostJournal+"/"+id+".jsonl")); err != nil { - return "", err - } - } return "", nil } diff --git a/internal/engine/lock_test.go b/internal/engine/lock_test.go index 135ac968..0ea92803 100644 --- a/internal/engine/lock_test.go +++ b/internal/engine/lock_test.go @@ -614,8 +614,8 @@ func TestStatePathsFollowTheDeclaredBasePath(t *testing.T) { 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"] diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index ab3fd01e..5ca779b3 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -56,7 +56,7 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo return err } defer e.releaseHostLock(ctx) - jw := &journal.Writer{T: e.T, Dir: journal.HostDir(e.names()), 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) } @@ -226,6 +226,22 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo return fmt.Errorf("write proxy hash: %s", strings.TrimSpace(res.Stderr)) } e.logf("proxy: healthy at config %.8s", hash) + // The host journal is written only here, so it is pruned only here, with + // the application journal's window; otherwise it grows for the host's life. + hostJournal := e.names().HostJournalDir() + victims, err := journal.PruneCandidates(ctx, e.T, hostJournal, e.Spec.Deployment.RetainReleases*2) + if err != nil { + return fmt.Errorf("prune host journal: %w", err) + } + for _, id := range victims { + res, err := e.hostMutate(ctx, "rm -f "+q(hostJournal+"/"+id+".jsonl")) + if err != nil { + return err + } + if res.ExitCode != 0 { + return fmt.Errorf("prune host journal %s: %s", id, strings.TrimSpace(res.Stderr)) + } + } return nil } diff --git a/internal/engine/schedule.go b/internal/engine/schedule.go index 144a63f3..d11f5deb 100644 --- a/internal/engine/schedule.go +++ b/internal/engine/schedule.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "math" - "path" "regexp" "strconv" "strings" @@ -46,10 +45,6 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { } n := e.names() prefix := app.JobUnitPrefix - owners, err := e.scheduleUnitOwners(ctx) - if err != nil { - return err - } // 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}'") @@ -63,7 +58,7 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { continue } bare := strings.TrimSuffix(unit, ".timer") - if matchesRuntimePrefix(bare, prefix) && owners[bare] == e.Spec.Name { + if strings.HasPrefix(bare, prefix) { installed[bare] = true } } @@ -72,11 +67,6 @@ func (e *Engine) SyncSchedules(ctx context.Context) error { if err := e.requireScheduleHost(ctx, jobs); err != nil { return err } - for _, job := range jobs { - if err := e.requireUnitOwnership(owners, n.ScheduledJobUnit(job.Name)); err != nil { - return err - } - } for _, job := range jobs { if job.Execution != nil { res, err := e.mutate(ctx, "install -d -m 700 "+q(n.AppDir()+"/schedule")) @@ -935,12 +925,8 @@ func (e *Engine) RemoveSchedules(ctx context.Context) error { // 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 — and only - // those whose Description names it. - owners, err := e.scheduleUnitOwners(ctx) - if err != nil { - return err - } + // 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 @@ -952,8 +938,7 @@ func (e *Engine) RemoveSchedules(ctx context.Context) error { continue } unit = strings.TrimSuffix(unit, ".timer") - if (matchesRuntimePrefix(unit, app.JobUnitPrefix) || matchesRuntimePrefix(unit, app.BackupUnitPrefix)) && - owners[unit] == e.Spec.Name { + if strings.HasPrefix(unit, app.JobUnitPrefix) || strings.HasPrefix(unit, app.BackupUnitPrefix) { units = append(units, unit) } } @@ -997,65 +982,3 @@ func (e *Engine) removeScheduleUnit(ctx context.Context, unit string) error { } return errors.Join(errs...) } - -// scheduleUnitOwners reads which application owns each installed Onebox job and -// backup unit, from the Description Onebox writes into every .service. -// -// The name alone is not proof. A host has one owner per basePath, so a second -// application with its own basePath can install units with the same names; a -// deploy or destroy that trusted the name would delete or overwrite them. -// Units whose owner cannot be read are nobody's: they are left alone. -func (e *Engine) scheduleUnitOwners(ctx context.Context) (map[string]string, error) { - res, err := e.T.Run(ctx, "grep -H '^Description=Onebox ' /etc/systemd/system/"+app.JobUnitPrefix+"*.service /etc/systemd/system/"+app.BackupUnitPrefix+"*.service 2>/dev/null || true") - if err != nil { - return nil, fmt.Errorf("read schedule unit owners: %w", err) - } - owners := map[string]string{} - for _, line := range strings.Split(res.Stdout, "\n") { - file, description, ok := strings.Cut(strings.TrimSpace(line), ":Description=") - if !ok { - continue - } - unit := strings.TrimSuffix(path.Base(file), ".service") - if owner := unitDescriptionOwner(description); owner != "" && unitName.MatchString(unit+".service") { - owners[unit] = owner - } - } - return owners, nil -} - -// unitDescriptionOwner extracts the application from the two Description -// forms Onebox writes: "Onebox scheduled job for " and -// "Onebox backup for (/)". -func unitDescriptionOwner(description string) string { - if rest, ok := strings.CutPrefix(description, "Onebox backup "); ok { - open := strings.LastIndex(rest, "(") - if open < 0 || !strings.HasSuffix(rest, ")") { - return "" - } - owner, _, _ := strings.Cut(rest[open+1:len(rest)-1], "/") - return owner - } - if rest, ok := strings.CutPrefix(description, "Onebox scheduled job "); ok { - if at := strings.LastIndex(rest, " for "); at >= 0 { - return rest[at+len(" for "):] - } - } - return "" -} - -// requireUnitOwnership refuses to write over a unit another application owns. -func (e *Engine) requireUnitOwnership(owners map[string]string, unit string) error { - if owner, ok := owners[unit]; ok && owner != e.Spec.Name { - return fmt.Errorf("systemd unit %s belongs to application %s; one host runs one application — remove it or use another host", unit, owner) - } - return nil -} - -// matchesRuntimePrefix distinguishes a component boundary from the first half -// of an escaped hyphen. For example, onebox-backup-prod- owns -// onebox-backup-prod-db-base but not onebox-backup-prod--eu-db-base, whose -// environment is prod-eu. -func matchesRuntimePrefix(name, prefix string) bool { - return strings.HasPrefix(name, prefix) && len(name) > len(prefix) && name[len(prefix)] != '-' -} diff --git a/internal/engine/schedule_test.go b/internal/engine/schedule_test.go index 483ee8e3..05fe6e59 100644 --- a/internal/engine/schedule_test.go +++ b/internal/engine/schedule_test.go @@ -848,8 +848,6 @@ func TestRemoveSchedulesRemovesFilesAndReloadsAfterFailedDisable(t *testing.T) { switch { case strings.Contains(cmd, "list-unit-files"): return transport.Result{Stdout: "onebox-job-nightly.timer\n"}, true - case strings.Contains(cmd, "grep -H '^Description=Onebox '"): - return transport.Result{Stdout: unitOwnerListing("onebox-job-nightly", "sample")}, true case strings.Contains(cmd, "systemctl disable --now"): return transport.Result{ExitCode: 5, Stderr: "unit is busy"}, true } @@ -872,45 +870,6 @@ func TestRemoveSchedulesRemovesFilesAndReloadsAfterFailedDisable(t *testing.T) { } } -func TestRuntimePrefixStopsAtEscapedComponentBoundary(t *testing.T) { - tests := []struct { - name string - unit string - prefix string - want bool - }{ - {"job owned", "onebox-job-nightly", "onebox-job-", true}, - {"backup environment owned", "onebox-backup-prod-postgres-backup", "onebox-backup-prod-", true}, - {"hyphenated backup environment owned", "onebox-backup-prod--eu-postgres-backup", "onebox-backup-prod--eu-", true}, - {"backup belongs to hyphen extension", "onebox-backup-prod--eu-postgres-backup", "onebox-backup-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 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: "onebox-backup-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 @@ -946,7 +905,7 @@ func TestScheduleSyncIgnoresInvalidHostListedUnitNames(t *testing.T) { // trace being a line saying the schedule was "no longer declared". func TestSyncSchedulesLeavesBackupTimersAlone(t *testing.T) { backupTimer := app.Names{App: "example", BasePath: "/var/lib/onebox"}. - BackupTimer("database", "backup") + 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) } @@ -966,20 +925,12 @@ func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { "onebox-job-nightly.timer", "onebox-backup-postgres-backup.timer", "onebox-backup-postgres-verify.timer", - // Another application's, outside Onebox's namespaces, and a - // stranger's. None is ours. - "onebox-backup-other-backup.timer", + // Outside Onebox's namespaces, and a stranger's. Neither is ours. "backup-other-production-postgres-backup.timer", "logrotate.timer", "", }, "\n")}, true } - if strings.Contains(cmd, "grep -H '^Description=Onebox '") { - return transport.Result{Stdout: unitOwnerListing("onebox-job-nightly", "sample") + - backupOwnerListing("onebox-backup-postgres-backup", "sample") + - backupOwnerListing("onebox-backup-postgres-verify", "sample") + - backupOwnerListing("onebox-backup-other-backup", "other")}, true - } return transport.Result{}, false }} e := New(testConfig(), testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) @@ -996,7 +947,7 @@ func TestRemoveSchedulesTakesBackupTimersToo(t *testing.T) { t.Errorf("teardown left %s installed:\n%s", want, seq) } } - for _, never := range []string{"onebox-backup-other-backup", "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) } @@ -1905,52 +1856,3 @@ ActiveState=active t.Fatalf("a stale systemd result outvoted the record: %#v", got) } } - -func unitOwnerListing(unit, application string) string { - return "/etc/systemd/system/" + unit + ".service:Description=Onebox scheduled job nightly for " + application + "\n" -} - -func backupOwnerListing(unit, application string) string { - return "/etc/systemd/system/" + unit + ".service:Description=Onebox backup backup for postgres (" + application + "/production)\n" -} - -// A unit name is not proof of ownership: a second application with its own -// basePath has its own owner record, so its units can share names with ours. -func TestScheduleSyncNeverTouchesAnotherApplicationsUnits(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: "onebox-job-cleanup.timer\n"}, true - case strings.Contains(cmd, "grep -H '^Description=Onebox '"): - return transport.Result{Stdout: unitOwnerListing("onebox-job-cleanup", "other")}, true - } - return transport.Result{}, false - }} - cfg := testConfig() - e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - if err := e.SyncSchedules(context.Background()); err != nil { - t.Fatal(err) - } - if seq := strings.Join(f.Commands, "\n"); strings.Contains(seq, "onebox-job-cleanup.timer /etc") || strings.Contains(seq, "disable --now onebox-job-cleanup") { - t.Fatalf("sync removed another application's unit:\n%s", seq) - } -} - -func TestUnitDescriptionOwner(t *testing.T) { - for description, want := range map[string]string{ - "Onebox scheduled job nightly for shop": "shop", - "Onebox scheduled job nightly-for-x for shop": "shop", - "Onebox backup backup for postgres (shop/production)": "shop", - "Onebox backup verify for data-base (help-desk/pre-prod)": "help-desk", - "Onebox schedule for nightly (shop)": "", - "Something else": "", - } { - if got := unitDescriptionOwner(description); got != want { - t.Errorf("unitDescriptionOwner(%q) = %q, want %q", description, got, want) - } - } - e := &Engine{Spec: testConfig()} - if err := e.requireUnitOwnership(map[string]string{"onebox-job-nightly": "other"}, "onebox-job-nightly"); err == nil || !strings.Contains(err.Error(), "belongs to application other") { - t.Fatalf("another application's unit was not refused: %v", err) - } -} diff --git a/internal/journal/journal.go b/internal/journal/journal.go index 81ce6fda..634a825d 100644 --- a/internal/journal/journal.go +++ b/internal/journal/journal.go @@ -98,7 +98,7 @@ type WorkloadPlanEvidence struct { type Writer struct { T transport.Transport // Dir is the journal directory: Dir(names) for an application's journal, - // HostDir(names) for the host's. Every reader takes the same directory, so + // 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 @@ -119,10 +119,6 @@ type Writer struct { // Dir is an application's journal directory, beside the releases it describes. func Dir(n app.Names) string { return release.PathsFor(n).Base + "/journal" } -// HostDir is the host's journal directory, for operations on the host itself -// such as applying the proxy. -func HostDir(n app.Names) string { return n.HostDir() + "/journal" } - func file(dir, id string) string { return dir + "/" + id + ".jsonl" } func DefaultOperator() string { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 2a41716a..f557e435 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -58,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", diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 6fc4840d..85af0b88 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -38,18 +38,18 @@ 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) + t.Setenv(app.TestHostStateDirEnv, "/tmp/fixture-host") + 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) } } diff --git a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx index 39454bf5..d35e84ee 100644 --- a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -43,7 +43,8 @@ command with the new version. 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`. +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 @@ -65,8 +66,9 @@ you set your own, the application's state moves to `/app`. 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`. The application is down from step 2 until -step 6. Copying the volumes temporarily doubles the disk space they use. +`/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. diff --git a/site/src/content/docs/reference/naming.mdx b/site/src/content/docs/reference/naming.mdx index 5391df64..1c00ed74 100644 --- a/site/src/content/docs/reference/naming.mdx +++ b/site/src/content/docs/reference/naming.mdx @@ -102,16 +102,17 @@ application.** | What | Name | | --- | --- | | State directory | `/var/lib/onebox/app` (releases, `current`, journal, services, backup) | -| Host state | `/var/lib/onebox/_host` (owner record, proxy) | +| 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; for a systemd unit, its `Description` does. Onebox never removes or -overwrites a unit whose `Description` names another application. Custom Traefik objects in a proxy configuration directory may not -begin with `onebox_`. +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: diff --git a/site/src/content/docs/reference/project-file.mdx b/site/src/content/docs/reference/project-file.mdx index 706e380f..f0fc1370 100644 --- a/site/src/content/docs/reference/project-file.mdx +++ b/site/src/content/docs/reference/project-file.mdx @@ -118,7 +118,9 @@ 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/onebox/app/releases/`, plus `current`, `journal`, and -`services`. Configurable per environment with `basePath`. +`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 From 4901c5f6ae07def67b194ed0dca3d02d53e713bb Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 12:01:28 -0700 Subject: [PATCH 11/15] fix(runtime): guard the fixed state paths - The state directory is /app, a generic name under a path the operator chose. Bootstrap marks it with .onebox-app and refuses to adopt a non-empty one without the marker; destroy deletes nothing without it, so an unrelated /srv/app survives basePath: /srv. - Preflight checks that the fixed host state directory is writable, not just basePath. - The host journal is pruned after every proxy check, which is what writes it, and pruning is best-effort: it never fails an applied proxy. - ob warns whenever ONEBOX_TEST_HOST_STATE_DIR is set, and says when a relative value is ignored. - Drop environment fallbacks that engine.New now makes dead. --- cmd/ob/main.go | 20 ++++++ cmd/ob/main_test.go | 17 +++++ e2e/destroy_test.go | 4 ++ e2e/server_harness_test.go | 17 ++++- internal/app/names.go | 17 ++++- internal/app/preflight.go | 33 +++++++--- internal/app/preflight_test.go | 20 ++++++ internal/engine/app_dir_claim_test.go | 65 +++++++++++++++++++ internal/engine/bootstrap.go | 27 +++++++- internal/engine/ops.go | 10 ++- internal/engine/ops_test.go | 21 ++++++ internal/engine/proxy.go | 31 +++++---- internal/engine/recovery.go | 3 - internal/engine/schedule.go | 3 - .../docs/guides/upgrade-to-onebox-names.mdx | 6 +- 15 files changed, 258 insertions(+), 36 deletions(-) create mode 100644 internal/engine/app_dir_claim_test.go diff --git a/cmd/ob/main.go b/cmd/ob/main.go index d1bea48d..c39b25b6 100644 --- a/cmd/ob/main.go +++ b/cmd/ob/main.go @@ -3,8 +3,11 @@ package main import ( "context" "errors" + "fmt" + "io" "os" "os/signal" + "path" "syscall" "github.com/spf13/cobra" @@ -105,6 +108,7 @@ func main() { ui.RestoreCursor(os.Stderr) stopSignals() }() + warnTestHostStateOverride(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 +125,19 @@ func main() { os.Exit(2) } } + +// warnTestHostStateOverride says so whenever the test-only host state override +// 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 warnTestHostStateOverride(w io.Writer) { + value, set := os.LookupEnv(app.TestHostStateDirEnv) + if !set { + return + } + if !path.IsAbs(value) { + fmt.Fprintf(w, "warning: %s=%q is ignored: it is not an absolute path\n", app.TestHostStateDirEnv, value) + return + } + fmt.Fprintf(w, "warning: %s=%s moves host state for a test suite; unset it on real hosts\n", app.TestHostStateDirEnv, value) +} diff --git a/cmd/ob/main_test.go b/cmd/ob/main_test.go index 14cd4cbe..c823451b 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,18 @@ 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") + warnTestHostStateOverride(&out) + if !strings.Contains(out.String(), "unset it on real hosts") { + t.Fatalf("override warning = %q", out.String()) + } + out.Reset() + t.Setenv(app.TestHostStateDirEnv, "relative") + warnTestHostStateOverride(&out) + if !strings.Contains(out.String(), "is ignored") { + t.Fatalf("relative override warning = %q", out.String()) + } +} diff --git a/e2e/destroy_test.go b/e2e/destroy_test.go index c3840a3a..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: diff --git a/e2e/server_harness_test.go b/e2e/server_harness_test.go index c7291a9b..931593d1 100644 --- a/e2e/server_harness_test.go +++ b/e2e/server_harness_test.go @@ -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/internal/app/names.go b/internal/app/names.go index f063a2e2..dbf26098 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -49,6 +49,9 @@ const ( // 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/onebox" @@ -62,9 +65,11 @@ const ( HostStateDir = DefaultBasePath + "/" + HostNamespace // TestHostStateDirEnv relocates HostStateDir for test suites that cannot - // write to /var/lib or that run several fixture applications against one - // machine. It is not a supported setting: with it, one host can hold - // several owner records again. + // 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" ) @@ -351,6 +356,12 @@ func (n Names) ProxyServiceFor(workload string, route int) string { // AppDir, ReleasesDir, ReleaseDir, CurrentLink and HostDir are the remote layout. 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) } diff --git a/internal/app/preflight.go b/internal/app/preflight.go index a916a767..bbbc0a39 100644 --- a/internal/app/preflight.go +++ b/internal/app/preflight.go @@ -99,6 +99,7 @@ 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 @@ -216,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 @@ -234,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), } 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 @@ -272,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", } } diff --git a/internal/app/preflight_test.go b/internal/app/preflight_test.go index df692747..f36fdcdd 100644 --- a/internal/app/preflight_test.go +++ b/internal/app/preflight_test.go @@ -577,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/engine/app_dir_claim_test.go b/internal/engine/app_dir_claim_test.go new file mode 100644 index 00000000..751d600d --- /dev/null +++ b/internal/engine/app_dir_claim_test.go @@ -0,0 +1,65 @@ +package engine + +import ( + "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.Command("sh", "-c", claimAppDirCommand(app.Names{App: "shop", BasePath: base}, "shop")).Output() + if exit, ok := err.(*exec.ExitError); ok { + 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/bootstrap.go b/internal/engine/bootstrap.go index 9a7b68cf..c7af9763 100644 --- a/internal/engine/bootstrap.go +++ b/internal/engine/bootstrap.go @@ -40,11 +40,17 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) { e.logf("bootstrap: base dirs") p := release.PathsFor(e.names()) - res, err := e.T.Run(ctx, "mkdir -p "+q(p.Releases)) + res, err := e.T.Run(ctx, claimAppDirCommand(e.names(), e.Spec.Name)) if err != nil { return fmt.Errorf("mkdir %s: %w", p.Releases, err) } - if res.ExitCode != 0 { + switch res.ExitCode { + case 0: + case appDirForeign: + return fmt.Errorf("%s holds another application's state (%s says %q); choose another basePath", e.names().AppDir(), app.AppMarkerFile, strings.TrimSpace(res.Stdout)) + case appDirUnmarked: + return fmt.Errorf("%s already exists and was not created by Onebox; move it aside or choose another basePath — Onebox will not adopt a directory it may later delete", e.names().AppDir()) + default: return fmt.Errorf("mkdir %s: %s", p.Releases, strings.TrimSpace(res.Stderr)) } @@ -162,3 +168,20 @@ 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 +) + +// claimAppDirCommand creates the application's state directory, or accepts +// one that carries this application's marker. An existing, non-empty directory +// without the marker is refused: AppDir is a generic name under a basePath the +// operator chose, and destroy removes it whole. +func claimAppDirCommand(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 [ -d " + dir + " ] && [ -n \"$(ls -A " + dir + ")\" ]; then exit " + fmt.Sprint(appDirUnmarked) + "; fi; " + + "mkdir -p " + q(n.ReleasesDir()) + " && printf '%s\\n' " + q(application) + " > " + marker +} diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 3aab6c6d..bc828035 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" @@ -139,6 +140,7 @@ 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. sweep := "rm -rf " + q(base) keepingCredentials := !removeVolumes && len(e.Spec.Services) > 0 if keepingCredentials { @@ -151,10 +153,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) } diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index 09f991d2..ce62b282 100644 --- a/internal/engine/ops_test.go +++ b/internal/engine/ops_test.go @@ -564,3 +564,24 @@ 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") + } +} diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index 5ca779b3..bc66da08 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -68,7 +68,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 { @@ -226,23 +228,30 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo return fmt.Errorf("write proxy hash: %s", strings.TrimSpace(res.Stderr)) } e.logf("proxy: healthy at config %.8s", hash) - // The host journal is written only here, so it is pruned only here, with - // the application journal's window; otherwise it grows for the host's life. - hostJournal := e.names().HostJournalDir() - victims, err := journal.PruneCandidates(ctx, e.T, hostJournal, e.Spec.Deployment.RetainReleases*2) + 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. It is housekeeping: a failure is reported +// and never turns an applied proxy into a failed one. +func (e *Engine) pruneHostJournal(ctx context.Context) { + dir := e.names().HostJournalDir() + victims, err := journal.PruneCandidates(ctx, e.T, dir, e.Spec.Deployment.RetainReleases*2) if err != nil { - return fmt.Errorf("prune host journal: %w", err) + e.logf("proxy: host journal not pruned: %v", err) + return } for _, id := range victims { - res, err := e.hostMutate(ctx, "rm -f "+q(hostJournal+"/"+id+".jsonl")) - if err != nil { - return err + res, err := e.hostMutate(ctx, "rm -f "+q(dir+"/"+id+".jsonl")) + if err == nil && res.ExitCode != 0 { + err = errors.New(strings.TrimSpace(res.Stderr)) } - if res.ExitCode != 0 { - return fmt.Errorf("prune host journal %s: %s", id, strings.TrimSpace(res.Stderr)) + if err != nil { + e.logf("proxy: host journal entry %s not pruned: %v", id, err) + return } } - return nil } func (e *Engine) proxyContainerIDs(ctx context.Context) ([]string, error) { diff --git a/internal/engine/recovery.go b/internal/engine/recovery.go index 118eb1bb..3b01686c 100644 --- a/internal/engine/recovery.go +++ b/internal/engine/recovery.go @@ -25,9 +25,6 @@ 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 + "/onebox.snapshot.yml" res, err := e.T.Run(ctx, "cat "+q(path)) if err != nil { diff --git a/internal/engine/schedule.go b/internal/engine/schedule.go index d11f5deb..f093a300 100644 --- a/internal/engine/schedule.go +++ b/internal/engine/schedule.go @@ -744,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.", diff --git a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx index d35e84ee..11f4a946 100644 --- a/site/src/content/docs/guides/upgrade-to-onebox-names.mdx +++ b/site/src/content/docs/guides/upgrade-to-onebox-names.mdx @@ -125,6 +125,7 @@ but not for `_host`: that moves from `/_host` to 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" @@ -135,7 +136,10 @@ but not for `_host`: that moves from `/_host` to `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. + 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: From 8af06eebba28694506bdec5e137a29cda15796ae Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 12:15:26 -0700 Subject: [PATCH 12/15] fix(runtime): claim the state directory where it is created - Every lock acquisition claims the state directory and its marker, not only bootstrap, so no command writes lock, fence, journal or release files into a directory Onebox has not marked. - Bootstrap refuses a foreign owner before touching anything, and claims the state directory before the host, so refusing a directory Onebox did not create leaves the host unclaimed. - A destroy that keeps anything keeps the marker, so the later `ob destroy --volumes` can still prove the directory and finish. - An unreadable existing directory counts as not empty. - The host journal ages out by name, in one rm, whatever its files hold, so a torn record no longer stops pruning. - The engine uses app.HostOwnerRecord rather than a second copy of it. - The broken-link remedy no longer points at base_path. --- internal/app/preflight.go | 2 +- internal/engine/bootstrap.go | 55 +++++++++++++------- internal/engine/host_environment_test.go | 12 ++--- internal/engine/host_owner.go | 65 ++++++++++++------------ internal/engine/lock.go | 4 +- internal/engine/ops.go | 7 ++- internal/engine/ops_test.go | 41 +++++++++++++++ internal/engine/proxy.go | 32 +++++++----- internal/engine/proxy_test.go | 33 ++++++++++++ 9 files changed, 177 insertions(+), 74 deletions(-) diff --git a/internal/app/preflight.go b/internal/app/preflight.go index bbbc0a39..2b662bee 100644 --- a/internal/app/preflight.go +++ b/internal/app/preflight.go @@ -257,7 +257,7 @@ func writablePathCheck(ctx context.Context, run Runner, label, base, setting str return Check{ 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{ diff --git a/internal/engine/bootstrap.go b/internal/engine/bootstrap.go index c7af9763..af4b633e 100644 --- a/internal/engine/bootstrap.go +++ b/internal/engine/bootstrap.go @@ -34,24 +34,19 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) { } passwords[name] = password } - if err := e.claimHostOwner(ctx); err != nil { + // Refuse a foreign host before touching anything, then claim the state + // directory before the host: refusing a directory Onebox did not create + // must not leave the host claimed. + if err := e.refuseForeignHostOwner(ctx); err != nil { return err } - e.logf("bootstrap: base dirs") p := release.PathsFor(e.names()) - res, err := e.T.Run(ctx, claimAppDirCommand(e.names(), e.Spec.Name)) - if err != nil { - return fmt.Errorf("mkdir %s: %w", p.Releases, err) + if err := e.claimAppDir(ctx); err != nil { + return err } - switch res.ExitCode { - case 0: - case appDirForeign: - return fmt.Errorf("%s holds another application's state (%s says %q); choose another basePath", e.names().AppDir(), app.AppMarkerFile, strings.TrimSpace(res.Stdout)) - case appDirUnmarked: - return fmt.Errorf("%s already exists and was not created by Onebox; move it aside or choose another basePath — Onebox will not adopt a directory it may later delete", e.names().AppDir()) - default: - return fmt.Errorf("mkdir %s: %s", p.Releases, strings.TrimSpace(res.Stderr)) + if err := e.claimHostOwner(ctx); err != nil { + return err } // one regime for every mutation: bootstrap locks, fences, @@ -174,14 +169,38 @@ const ( appDirUnmarked = 4 ) -// claimAppDirCommand creates the application's state directory, or accepts -// one that carries this application's marker. An existing, non-empty directory -// without the marker is refused: AppDir is a generic name under a basePath the -// operator chose, and destroy removes it whole. +// 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 { + n := e.names() + res, err := e.T.Run(ctx, claimAppDirCommand(n, e.Spec.Name)) + 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)) + } +} + +// claimAppDirCommand creates the directory and its marker, or accepts this +// application's marker. An existing directory without the marker is refused +// unless it is provably empty: unreadable counts as not empty. func claimAppDirCommand(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 [ -d " + dir + " ] && [ -n \"$(ls -A " + dir + ")\" ]; then exit " + fmt.Sprint(appDirUnmarked) + "; fi; " + + "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; " + "mkdir -p " + q(n.ReleasesDir()) + " && printf '%s\\n' " + q(application) + " > " + marker } diff --git a/internal/engine/host_environment_test.go b/internal/engine/host_environment_test.go index f404d301..e71c63ad 100644 --- a/internal/engine/host_environment_test.go +++ b/internal/engine/host_environment_test.go @@ -75,20 +75,20 @@ func TestHostOwnerRecordRoundTrips(t *testing.T) { want hostOwner ok bool }{ - {"sample production", hostOwner{App: "sample", Environment: "production"}, true}, + {"sample production", hostOwner{Application: "sample", Environment: "production"}, true}, {"sample", hostOwner{}, false}, - {" sample production ", hostOwner{App: "sample", Environment: "production"}, true}, + {" 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) } } } diff --git a/internal/engine/host_owner.go b/internal/engine/host_owner.go index c7470843..ea7bb910 100644 --- a/internal/engine/host_owner.go +++ b/internal/engine/host_owner.go @@ -48,27 +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. -type hostOwner struct { - App string - Environment string -} - -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 { - 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 @@ -100,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 @@ -123,11 +105,11 @@ 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} + if owner.Application != e.Spec.Name { + return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.Application} } if owner.Environment != e.Opts.Environment { return &HostEnvironmentMismatchError{ @@ -139,6 +121,23 @@ func (e *Engine) RequireHostOwner(ctx context.Context) error { return nil } +// refuseForeignHostOwner reads the owner record and refuses a host claimed by +// another application or environment. It changes nothing; claimHostOwner +// repeats the check under the host lock before it writes. +func (e *Engine) refuseForeignHostOwner(ctx context.Context) error { + owner, err := e.readHostOwner(ctx) + if err != nil { + return err + } + if owner.Application != "" && owner.Application != e.Spec.Name { + return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.Application} + } + 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 +} + // 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. @@ -153,10 +152,10 @@ func (e *Engine) claimHostOwner(ctx context.Context) error { if err != nil { return err } - if owner.App != "" && owner.App != e.Spec.Name { - return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.App} + if owner.Application != "" && owner.Application != e.Spec.Name { + return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.Application} } - if owner.App == e.Spec.Name { + if owner.Application == e.Spec.Name { if owner.Environment != e.Opts.Environment { return &HostEnvironmentMismatchError{ Application: e.Spec.Name, @@ -176,10 +175,10 @@ func (e *Engine) claimHostOwner(ctx context.Context) error { if err != nil { return err } - if owner.App != "" && owner.App != e.Spec.Name { - return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.App} + if owner.Application != "" && owner.Application != e.Spec.Name { + return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.Application} } - if owner.App == e.Spec.Name { + if owner.Application == e.Spec.Name { if owner.Environment != e.Opts.Environment { return &HostEnvironmentMismatchError{ Application: e.Spec.Name, @@ -189,11 +188,11 @@ func (e *Engine) claimHostOwner(ctx context.Context) error { } 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. - write := "umask 077 && set -C && printf '%s\\n' " + q(claim.record()) + " > " + q(path) + 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/lock.go b/internal/engine/lock.go index 281a9812..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 { diff --git a/internal/engine/ops.go b/internal/engine/ops.go index bc828035..d0a42ff9 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -140,8 +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. + // 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 diff --git a/internal/engine/ops_test.go b/internal/engine/ops_test.go index ce62b282..699b9df8 100644 --- a/internal/engine/ops_test.go +++ b/internal/engine/ops_test.go @@ -585,3 +585,44 @@ func TestDestroyRefusesAnUnmarkedStateDirectory(t *testing.T) { 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/proxy.go b/internal/engine/proxy.go index bc66da08..d2306243 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -233,24 +233,32 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo // 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. It is housekeeping: a failure is reported -// and never turns an applied proxy into a failed one. +// 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) { dir := e.names().HostJournalDir() - victims, err := journal.PruneCandidates(ctx, e.T, dir, e.Spec.Deployment.RetainReleases*2) + ids, err := journal.List(ctx, e.T, dir) if err != nil { e.logf("proxy: host journal not pruned: %v", err) return } - for _, id := range victims { - res, err := e.hostMutate(ctx, "rm -f "+q(dir+"/"+id+".jsonl")) - if err == nil && res.ExitCode != 0 { - err = errors.New(strings.TrimSpace(res.Stderr)) - } - if err != nil { - e.logf("proxy: host journal entry %s not pruned: %v", id, err) - return - } + keep := e.Spec.Deployment.RetainReleases * 2 + if keep < 1 || len(ids) <= keep { + return + } + paths := make([]string, 0, len(ids)-keep) + for _, id := range ids[:len(ids)-keep] { + paths = append(paths, q(dir+"/"+id+".jsonl")) + } + res, err := e.hostMutate(ctx, "rm -f "+strings.Join(paths, " ")) + 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) } } diff --git a/internal/engine/proxy_test.go b/internal/engine/proxy_test.go index 4d43449f..f2eb54cd 100644 --- a/internal/engine/proxy_test.go +++ b/internal/engine/proxy_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "os" "path/filepath" "strings" @@ -345,3 +346,35 @@ func TestEnsureProxyReleasesHostLock(t *testing.T) { 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 TestHostJournalPrunesOldestFilesInOneCommand(t *testing.T) { + var listed []string + for i := 0; i < 25; i++ { + listed = append(listed, fmt.Sprintf("20260901-1200%02d-nogit-proxy.jsonl", i)) + } + f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { + if strings.HasPrefix(cmd, "ls -1 ") { + return transport.Result{Stdout: strings.Join(listed, "\n") + "\n"}, true + } + return transport.Result{}, false + }} + cfg := testConfig() + cfg.Deployment.RetainReleases = 10 + e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) + e.hostLockVal = "test-host-lock" + e.pruneHostJournal(context.Background()) + var removals []string + for _, cmd := range f.Commands { + if strings.Contains(cmd, "rm -f") { + removals = append(removals, cmd) + } + } + if len(removals) != 1 { + t.Fatalf("pruning took %d removal commands, want 1: %v", len(removals), removals) + } + if !strings.Contains(removals[0], "20260901-120004-nogit-proxy.jsonl") || strings.Contains(removals[0], "20260901-120005-nogit-proxy.jsonl") { + t.Fatalf("pruned the wrong files: %s", removals[0]) + } +} From 7115fdbe7302adefc2da9875d26ea761a3197a60 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 12:31:53 -0700 Subject: [PATCH 13/15] fix(runtime): write the state marker once, and bound replicas - The .onebox-app marker is written only when missing, through a temporary file and a rename, so an interrupted write never leaves the ownership proof empty and the host locked out. - Bootstrap reads the owner once, refuses a conflict, checks the state directory without changing it, claims the host, and only then lets the lock create the directory: a refused bootstrap leaves nothing behind. - One ownerConflict comparison serves bootstrap, the locked claim and RequireHostOwner. - replicas is capped at 100, in the loader and the schema, so a typo can no longer make loading a project build millions of names. - The test host state override is set explicitly with app.SetTestHostStateDir; only the ob command reads its environment variable, and warns when it does. - Host journal pruning is one remote command. - Stale /var/lib/ob/ comments are corrected, and the unreachable _host application-name reservation is removed. --- .../v1alpha1/application.schema.json | 4 +- cmd/ob/commands.go | 2 +- cmd/ob/main.go | 24 +++++---- cmd/ob/main_test.go | 7 ++- e2e/ops_test.go | 6 ++- internal/app/constraints.go | 8 ++- internal/app/jsonschema.go | 2 +- internal/app/load_test.go | 12 +++++ internal/app/names.go | 31 ++++++++--- internal/app/validate.go | 4 ++ internal/engine/bootstrap.go | 54 +++++++++++++------ internal/engine/bootstrap_test.go | 6 ++- internal/engine/host_owner.go | 51 ++++-------------- internal/engine/host_owner_test.go | 11 ++-- internal/engine/proxy.go | 23 ++++---- internal/engine/proxy_test.go | 48 +++++++++-------- internal/journal/journal.go | 2 +- internal/proxy/proxy_test.go | 6 ++- internal/release/release.go | 2 +- .../v1alpha1/application.schema.json | 4 +- 20 files changed, 175 insertions(+), 132 deletions(-) diff --git a/api/application/v1alpha1/application.schema.json b/api/application/v1alpha1/application.schema.json index 5025317a..8581545d 100644 --- a/api/application/v1alpha1/application.schema.json +++ b/api/application/v1alpha1/application.schema.json @@ -37,9 +37,6 @@ }, { "const": "onebox" - }, - { - "const": "_host" } ] }, @@ -2122,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 c39b25b6..c65b766f 100644 --- a/cmd/ob/main.go +++ b/cmd/ob/main.go @@ -7,7 +7,6 @@ import ( "io" "os" "os/signal" - "path" "syscall" "github.com/spf13/cobra" @@ -108,7 +107,7 @@ func main() { ui.RestoreCursor(os.Stderr) stopSignals() }() - warnTestHostStateOverride(os.Stderr) + 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) @@ -126,18 +125,21 @@ func main() { } } -// warnTestHostStateOverride says so whenever the test-only host state override -// 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 warnTestHostStateOverride(w io.Writer) { +// 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 + return restore } - if !path.IsAbs(value) { - fmt.Fprintf(w, "warning: %s=%q is ignored: it is not an absolute path\n", app.TestHostStateDirEnv, value) - return + 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 c823451b..6960bd1e 100644 --- a/cmd/ob/main_test.go +++ b/cmd/ob/main_test.go @@ -140,13 +140,16 @@ func TestExplicitProjectPathDoesNotFallback(t *testing.T) { func TestTestHostStateOverrideIsNeverSilent(t *testing.T) { var out bytes.Buffer t.Setenv(app.TestHostStateDirEnv, "/tmp/fixture-host") - warnTestHostStateOverride(&out) + 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") - warnTestHostStateOverride(&out) + applyTestHostStateOverride(&out) if !strings.Contains(out.String(), "is ignored") { t.Fatalf("relative override warning = %q", out.String()) } diff --git a/e2e/ops_test.go b/e2e/ops_test.go index e455a41f..a8d48277 100644 --- a/e2e/ops_test.go +++ b/e2e/ops_test.go @@ -37,7 +37,11 @@ func gate(t *testing.T) { } // 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. - t.Setenv(app.TestHostStateDirEnv, filepath.Join(t.TempDir(), "host")) + 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 diff --git a/internal/app/constraints.go b/internal/app/constraints.go index c3406b79..3760d893 100644 --- a/internal/app/constraints.go +++ b/internal/app/constraints.go @@ -208,7 +208,7 @@ var ( // 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", "_host"} +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, @@ -294,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/jsonschema.go b/internal/app/jsonschema.go index bc9513bf..bad6b24d 100644 --- a/internal/app/jsonschema.go +++ b/internal/app/jsonschema.go @@ -415,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)}, diff --git a/internal/app/load_test.go b/internal/app/load_test.go index be02df38..4c7bb054 100644 --- a/internal/app/load_test.go +++ b/internal/app/load_test.go @@ -590,6 +590,18 @@ func TestOverLongEscapedContainerNameRefused(t *testing.T) { } } +// 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) { diff --git a/internal/app/names.go b/internal/app/names.go index dbf26098..07d22d6d 100644 --- a/internal/app/names.go +++ b/internal/app/names.go @@ -2,7 +2,6 @@ package app import ( "fmt" - "os" "path" "sort" "strings" @@ -39,9 +38,8 @@ const ( // to managed services. ServiceNetworkName = "services" - // HostNamespace holds state shared by everything on the box. It begins with - // an underscore, which no identifier can, so nothing derived from the - // project can name the same directory. + // 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 @@ -64,8 +62,9 @@ const ( // without the application relies on one application per host. HostStateDir = DefaultBasePath + "/" + HostNamespace - // TestHostStateDirEnv relocates HostStateDir for test suites that cannot - // write to /var/lib, or that keep a fixture's host state inside the + // 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, @@ -367,12 +366,28 @@ func (n Names) ReleaseDir(id string) string { } func (n Names) CurrentLink() string { return path.Join(n.AppDir(), "current") } func (n Names) HostDir() string { - if dir := os.Getenv(TestHostStateDirEnv); path.IsAbs(dir) { - return path.Clean(dir) + 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") } diff --git a/internal/app/validate.go b/internal/app/validate.go index 99f53989..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 } diff --git a/internal/engine/bootstrap.go b/internal/engine/bootstrap.go index af4b633e..fa0b71c7 100644 --- a/internal/engine/bootstrap.go +++ b/internal/engine/bootstrap.go @@ -34,20 +34,25 @@ func (e *Engine) Bootstrap(ctx context.Context, releaseID string) (err error) { } passwords[name] = password } - // Refuse a foreign host before touching anything, then claim the state - // directory before the host: refusing a directory Onebox did not create - // must not leave the host claimed. - if err := e.refuseForeignHostOwner(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()) - if err := e.claimAppDir(ctx); err != nil { + if err := e.ownerConflict(owner); err != nil { + return err + } + if err := e.checkAppDir(ctx); err != nil { return err } - if err := e.claimHostOwner(ctx); err != nil { + 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 @@ -174,8 +179,17 @@ const ( // 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, claimAppDirCommand(n, e.Spec.Name)) + res, err := e.T.Run(ctx, command) if err != nil { return fmt.Errorf("claim %s: %w", n.AppDir(), err) } @@ -191,16 +205,26 @@ func (e *Engine) claimAppDir(ctx context.Context) error { } } -// claimAppDirCommand creates the directory and its marker, or accepts this -// application's marker. An existing directory without the marker is refused -// unless it is provably empty: unreadable counts as not empty. -func claimAppDirCommand(n app.Names, application string) string { +// 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; " + - "mkdir -p " + q(n.ReleasesDir()) + " && printf '%s\\n' " + q(application) + " > " + marker + "[ -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") + return checkAppDirCommand(n, application) + "; mkdir -p " + q(n.ReleasesDir()) + " || exit 1; " + + "[ -e " + marker + " ] || { printf '%s\\n' " + q(application) + " > " + staged + " && mv -f " + staged + " " + marker + "; }" } diff --git a/internal/engine/bootstrap_test.go b/internal/engine/bootstrap_test.go index ba1f6a2d..9433b63e 100644 --- a/internal/engine/bootstrap_test.go +++ b/internal/engine/bootstrap_test.go @@ -130,7 +130,11 @@ func TestConcurrentBootstrapDoesNotRunSecondHook(t *testing.T) { t.Fatal(err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv(app.TestHostStateDirEnv, filepath.Join(dir, "host")) + 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") diff --git a/internal/engine/host_owner.go b/internal/engine/host_owner.go index ea7bb910..dfd6ed53 100644 --- a/internal/engine/host_owner.go +++ b/internal/engine/host_owner.go @@ -108,27 +108,12 @@ func (e *Engine) RequireHostOwner(ctx context.Context) error { if owner.Application == "" { return fmt.Errorf("host has no Onebox application owner; run `ob bootstrap` for %q first", e.Spec.Name) } - if 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, - } - } - return nil + return e.ownerConflict(owner) } -// refuseForeignHostOwner reads the owner record and refuses a host claimed by -// another application or environment. It changes nothing; claimHostOwner -// repeats the check under the host lock before it writes. -func (e *Engine) refuseForeignHostOwner(ctx context.Context) error { - owner, err := e.readHostOwner(ctx) - if err != nil { - return err - } +// 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} } @@ -141,28 +126,17 @@ func (e *Engine) refuseForeignHostOwner(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 { +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") } - owner, err := e.readHostOwner(ctx) - if err != nil { + if err := e.ownerConflict(owner); err != nil { return err } - if owner.Application != "" && owner.Application != e.Spec.Name { - return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.Application} - } if owner.Application == e.Spec.Name { - if owner.Environment != e.Opts.Environment { - return &HostEnvironmentMismatchError{ - Application: e.Spec.Name, - Requesting: e.Opts.Environment, - Owner: owner.Environment, - } - } return nil } // Unclaimed: take the lock and recheck, so two first-contact attempts @@ -171,21 +145,14 @@ func (e *Engine) claimHostOwner(ctx context.Context) error { return err } defer e.releaseHostLock(ctx) - owner, err = e.readHostOwner(ctx) + owner, err := e.readHostOwner(ctx) if err != nil { return err } - if owner.Application != "" && owner.Application != e.Spec.Name { - return &HostOwnerMismatchError{Requesting: e.Spec.Name, Owner: owner.Application} + if err := e.ownerConflict(owner); err != nil { + return err } if owner.Application == e.Spec.Name { - if owner.Environment != e.Opts.Environment { - return &HostEnvironmentMismatchError{ - Application: e.Spec.Name, - Requesting: e.Opts.Environment, - Owner: owner.Environment, - } - } return nil } claim := hostOwner{Application: e.Spec.Name, Environment: e.Opts.Environment} diff --git a/internal/engine/host_owner_test.go b/internal/engine/host_owner_test.go index 9047db81..8a44f67d 100644 --- a/internal/engine/host_owner_test.go +++ b/internal/engine/host_owner_test.go @@ -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 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) } } @@ -209,7 +208,7 @@ 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()) + err := engine.claimHostOwner(context.Background(), hostOwner{}) if err == nil || !strings.Contains(err.Error(), "without an environment") { t.Fatalf("claim without an environment = %v", err) } diff --git a/internal/engine/proxy.go b/internal/engine/proxy.go index d2306243..4125246d 100644 --- a/internal/engine/proxy.go +++ b/internal/engine/proxy.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -239,21 +240,11 @@ func (e *Engine) EnsureProxy(ctx context.Context, deployID string, breakLock boo // 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) { - dir := e.names().HostJournalDir() - ids, err := journal.List(ctx, e.T, dir) - if err != nil { - e.logf("proxy: host journal not pruned: %v", err) - return - } keep := e.Spec.Deployment.RetainReleases * 2 - if keep < 1 || len(ids) <= keep { + if keep < 1 { return } - paths := make([]string, 0, len(ids)-keep) - for _, id := range ids[:len(ids)-keep] { - paths = append(paths, q(dir+"/"+id+".jsonl")) - } - res, err := e.hostMutate(ctx, "rm -f "+strings.Join(paths, " ")) + res, err := e.hostMutate(ctx, pruneHostJournalCommand(e.names().HostJournalDir(), keep)) if err == nil && res.ExitCode != 0 { err = errors.New(strings.TrimSpace(res.Stderr)) } @@ -262,6 +253,14 @@ func (e *Engine) pruneHostJournal(ctx context.Context) { } } +// 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") diff --git a/internal/engine/proxy_test.go b/internal/engine/proxy_test.go index f2eb54cd..38bd7c15 100644 --- a/internal/engine/proxy_test.go +++ b/internal/engine/proxy_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -349,32 +350,35 @@ func TestEnsureProxyReleasesHostLock(t *testing.T) { // 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 TestHostJournalPrunesOldestFilesInOneCommand(t *testing.T) { - var listed []string +func TestHostJournalPrunesOldestFiles(t *testing.T) { + dir := t.TempDir() for i := 0; i < 25; i++ { - listed = append(listed, fmt.Sprintf("20260901-1200%02d-nogit-proxy.jsonl", i)) - } - f := &transport.Fake{Dynamic: func(cmd string) (transport.Result, bool) { - if strings.HasPrefix(cmd, "ls -1 ") { - return transport.Result{Stdout: strings.Join(listed, "\n") + "\n"}, true + body := `{"phase":"proxy-apply"}` + "\n" + if i == 3 { + body = `{"phase":"pro` // torn } - return transport.Result{}, false - }} - cfg := testConfig() - cfg.Deployment.RetainReleases = 10 - e := New(cfg, testProject(t), f, Options{Out: &bytes.Buffer{}, Sleep: noSleep}) - e.hostLockVal = "test-host-lock" - e.pruneHostJournal(context.Background()) - var removals []string - for _, cmd := range f.Commands { - if strings.Contains(cmd, "rm -f") { - removals = append(removals, cmd) + 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 len(removals) != 1 { - t.Fatalf("pruning took %d removal commands, want 1: %v", len(removals), removals) + if err := os.WriteFile(filepath.Join(dir, "unrelated.txt"), nil, 0o600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("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 !strings.Contains(removals[0], "20260901-120004-nogit-proxy.jsonl") || strings.Contains(removals[0], "20260901-120005-nogit-proxy.jsonl") { - t.Fatalf("pruned the wrong files: %s", removals[0]) + if out, err := exec.Command("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/journal/journal.go b/internal/journal/journal.go index 634a825d..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 diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 85af0b88..5bd0f88b 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -47,7 +47,11 @@ func TestPathsHostScoped(t *testing.T) { if p.Compose != app.HostStateDir+"/proxy/compose.yaml" || p.Owner != app.HostStateDir+"/owner" || p.Journal != app.HostStateDir+"/journal" { t.Fatalf("paths: %+v", p) } - t.Setenv(app.TestHostStateDirEnv, "/tmp/fixture-host") + 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) } diff --git a/internal/release/release.go b/internal/release/release.go index a0313c16..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 diff --git a/site/public/schemas/application/v1alpha1/application.schema.json b/site/public/schemas/application/v1alpha1/application.schema.json index 5025317a..8581545d 100644 --- a/site/public/schemas/application/v1alpha1/application.schema.json +++ b/site/public/schemas/application/v1alpha1/application.schema.json @@ -37,9 +37,6 @@ }, { "const": "onebox" - }, - { - "const": "_host" } ] }, @@ -2122,6 +2119,7 @@ "examples": [ 2 ], + "maximum": 100, "minimum": 1, "type": "integer" }, From 3a55308095a49fc3912bbaf170a40b2c1b964c7b Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 12:39:28 -0700 Subject: [PATCH 14/15] fix(runtime): mark the state directory before filling it Write .onebox-app before creating releases/, through a per-claim staging file, so an interrupted first claim never strands a non-empty unmarked directory and two concurrent claims cannot race on one staging path. Also write the marker in the Linux-only pinned-schedule lock test, which builds its state directory by hand. --- internal/engine/bootstrap.go | 9 ++++++--- internal/engine/schedule_test.go | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/engine/bootstrap.go b/internal/engine/bootstrap.go index fa0b71c7..b18a1b68 100644 --- a/internal/engine/bootstrap.go +++ b/internal/engine/bootstrap.go @@ -224,7 +224,10 @@ func checkAppDirCommand(n app.Names, application string) string { // write may ever leave it empty. func claimAppDirCommand(n app.Names, application string) string { marker := q(n.AppMarker()) - staged := q(n.AppMarker() + ".tmp") - return checkAppDirCommand(n, application) + "; mkdir -p " + q(n.ReleasesDir()) + " || exit 1; " + - "[ -e " + marker + " ] || { printf '%s\\n' " + q(application) + " > " + staged + " && mv -f " + staged + " " + marker + "; }" + 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/schedule_test.go b/internal/engine/schedule_test.go index 05fe6e59..98c43be0 100644 --- a/internal/engine/schedule_test.go +++ b/internal/engine/schedule_test.go @@ -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) } From 938f17b93a16f5d79c67a1bccfb778bc7a982c93 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Wed, 23 Sep 2026 13:01:26 -0700 Subject: [PATCH 15/15] test(runtime): satisfy lint in new tests --- internal/engine/app_dir_claim_test.go | 6 ++++-- internal/engine/proxy_test.go | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/engine/app_dir_claim_test.go b/internal/engine/app_dir_claim_test.go index 751d600d..cff1e009 100644 --- a/internal/engine/app_dir_claim_test.go +++ b/internal/engine/app_dir_claim_test.go @@ -1,6 +1,7 @@ package engine import ( + "errors" "os" "os/exec" "path/filepath" @@ -16,8 +17,9 @@ import ( func TestClaimAppDirOnlyTakesWhatIsOurs(t *testing.T) { run := func(t *testing.T, base string) (int, string) { t.Helper() - out, err := exec.Command("sh", "-c", claimAppDirCommand(app.Names{App: "shop", BasePath: base}, "shop")).Output() - if exit, ok := err.(*exec.ExitError); ok { + 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) diff --git a/internal/engine/proxy_test.go b/internal/engine/proxy_test.go index 38bd7c15..0f556002 100644 --- a/internal/engine/proxy_test.go +++ b/internal/engine/proxy_test.go @@ -352,7 +352,7 @@ func TestEnsureProxyReleasesHostLock(t *testing.T) { // a torn record must not stop pruning for the life of the host. func TestHostJournalPrunesOldestFiles(t *testing.T) { dir := t.TempDir() - for i := 0; i < 25; i++ { + for i := range 25 { body := `{"phase":"proxy-apply"}` + "\n" if i == 3 { body = `{"phase":"pro` // torn @@ -364,7 +364,7 @@ func TestHostJournalPrunesOldestFiles(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "unrelated.txt"), nil, 0o600); err != nil { t.Fatal(err) } - if out, err := exec.Command("sh", "-c", pruneHostJournalCommand(dir, 20)).CombinedOutput(); err != nil { + 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) @@ -378,7 +378,7 @@ func TestHostJournalPrunesOldestFiles(t *testing.T) { 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.Command("sh", "-c", pruneHostJournalCommand(filepath.Join(dir, "absent"), 20)).CombinedOutput(); err != nil { + 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) } }