carlos / platform Public

domains+config: a platform host's own sqid label outranks the ops fallback, and an absent manifest can't delete config the host disowns (#344)

Paul Campbell pushed by paul@keymail.dev 5d9b65864e2dbde818f5df92bec42c0067380ab2 parent d27e2f0
6 files changed, +216 −45
  • internal/appconfig/syncexec.go +15 −1
  • internal/appconfig/syncexec_test.go +106 −39
  • internal/domains/reconcile.go +17 −4
  • internal/domains/reconcile_test.go +48 −0
  • internal/host/agent.go +1 −1
  • internal/release/qualified.go +29 −0
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 1c897955b..2987f1f5e 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -61,7 +61,7 @@ const configEnvSuffix = ".config.env"
// (pipelines design §4a): two routes of ONE app bound to different
// environments resolve to different manifests, and keying on (acct, app)
// alone would render whichever route was seen first into both files.
-func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error) error {
+func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, appsDomain string) error {
routes, err := reg.List()
if err != nil {
return err
@@ -369,6 +369,20 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
if r.m == nil && skipRemovals {
continue
}
+ // The #344 guard: an absent manifest removes the config file ONLY
+ // when the account it was looked up under agrees with the account
+ // the host itself names. A platform host's <sqid> label is not a
+ // moving part; the row's account column is (the domains reconcile
+ // stamps it from bucket listings, and a mis-stamp deleted a live
+ // tenant's delivered secrets on 2026-08-28). Held, loudly, like an
+ // unresolved group — the fix is the row's account, not this file.
+ if r.m == nil {
+ if sq := release.HostAccountLabel(rt.Host, appsDomain); sq != "" && sq != k.acct {
+ fmt.Printf(" %s: no config manifest under account %q, but the host names account %q — config file left as-is; fix the route row's account column (#344)\n",
+ rt.Host, k.acct, sq)
+ continue
+ }
+ }
// Namespace-collision guard, checked before EITHER shape can reach
// syncExecHost below (the single call site both shapes share): if a
// route "<rt.Host>.config" also lives on this box, its hand-staged
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go
index aab960ce0..372e83818 100644
--- a/internal/appconfig/syncexec_test.go
+++ b/internal/appconfig/syncexec_test.go
@@ -94,7 +94,7 @@ func TestSyncExecWritesConfigFileAndBouncesOnChange(t *testing.T) {
}
var bounced []string
bounce := func(h string) error { bounced = append(bounced, h); return nil }
- if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "inst.example.com.config.env"))
@@ -112,7 +112,7 @@ func TestSyncExecWritesConfigFileAndBouncesOnChange(t *testing.T) {
}
// Second pass, nothing changed: no bounce.
bounced = nil
- if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec (second): %v", err)
}
if len(bounced) != 0 {
@@ -140,7 +140,7 @@ func TestSyncExecRemovesFileWhenManifestDeleted(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }); err != nil {
+ }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -185,7 +185,7 @@ func TestSyncExecMassRemovalGuardRefusesWhenEveryGroupResolvesAbsent(t *testing.
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }); err != nil {
+ }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(pathA); err != nil {
@@ -230,7 +230,7 @@ func TestSyncExecMassRemovalGuardDoesNotBlockNormalConvergence(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }); err != nil {
+ }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(pathA); err != nil {
@@ -282,7 +282,7 @@ func TestSyncExecRendersEachRoutesBoundEnvironment(t *testing.T) {
t.Fatalf("Upsert %s: %v", rt.Host, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
want := map[string]string{
@@ -329,7 +329,7 @@ func TestSyncExecLeavesFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T)
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }); err != nil {
+ }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(path)
@@ -392,7 +392,7 @@ func TestSyncExecOverlayOnlyAppDropsStaleKeys(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }); err != nil {
+ }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(path)
@@ -440,7 +440,7 @@ func TestSyncExecMassRemovalGuardIgnoresErroredGroups(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }); err != nil {
+ }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
@@ -476,7 +476,7 @@ func TestSyncExecNeverTouchesInstanceEnvOrOutOfScopeRoutes(t *testing.T) {
t.Fatalf("Upsert %s: %v", rt.Host, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if got, _ := os.ReadFile(own); string(got) != "CARLOS_ADMIN_TOKEN=tok\n" {
@@ -516,7 +516,7 @@ func TestSyncExecReapsOnlyItsOwnNamespace(t *testing.T) {
t.Fatalf("stage %s: %v", name, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
for name, keep := range files {
@@ -564,7 +564,7 @@ func TestSyncExecReapsOnlyItsOwnNamespaceWithAddrShapeInTheMix(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }); err != nil {
+ }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
for name, keep := range files {
@@ -592,7 +592,7 @@ func TestSyncExecEmptyRegistryRemovesNothing(t *testing.T) {
if err := os.WriteFile(path, []byte("X=1\n"), 0o600); err != nil {
t.Fatalf("stage: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(path); err != nil {
@@ -640,7 +640,7 @@ func TestSyncExecOptedInAddrRouteMaterializesFile(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -665,7 +665,7 @@ func TestSyncExecOptedInAddrRouteMaterializesFile(t *testing.T) {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond) // ensure a rewrite would move mtime
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec (second pass): %v", err)
}
after, err := os.Stat(path)
@@ -697,7 +697,7 @@ func TestSyncExecUnoptedAddrRouteIsSkipped(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(filepath.Join(dd, "demo.go.tito.io.config.env")); !os.IsNotExist(err) {
@@ -741,7 +741,7 @@ func TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal(t *testing.T) {
}
var syncErr error
out := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil })
+ syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "")
})
if syncErr != nil {
t.Fatalf("SyncExec: %v", syncErr)
@@ -790,7 +790,7 @@ func TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs(t *testing.T)
}
// First pass materializes normally — this is the file a transient
// blip must leave exactly as it is.
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec (initial): %v", err)
}
path := filepath.Join(dd, "demo.go.tito.io.config.env")
@@ -805,7 +805,7 @@ func TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs(t *testing.T)
bounce := func(h string) error { bounced = append(bounced, h); return nil }
var syncErr error
out := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, failing, pk, dd, bounce)
+ syncErr = SyncExec(ctx, reg, failing, pk, dd, bounce, "")
})
if syncErr != nil {
t.Fatalf("SyncExec (blip): %v", syncErr)
@@ -873,7 +873,7 @@ func TestSyncExecOptedInAddrRouteIsNeverBounced(t *testing.T) {
bounced = append(bounced, h)
return nil
}
- if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec (initial write): %v", err)
}
if len(bounced) != 0 {
@@ -884,7 +884,7 @@ func TestSyncExecOptedInAddrRouteIsNeverBounced(t *testing.T) {
if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"TITOGO_DEMO_BANNER": "2"}); err != nil {
t.Fatalf("SetEnv (change): %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec (changed content): %v", err)
}
if len(bounced) != 0 {
@@ -933,7 +933,7 @@ func TestSyncExecSocketRouteStillBouncesAlongsideOptedInAddrRoute(t *testing.T)
bounced = append(bounced, h)
return nil
}
- if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if !slices.Contains(bounced, "inst.example.com") {
@@ -979,7 +979,7 @@ func TestSyncExecOptedInAddrRouteHonorsBoundEnvironment(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -1018,7 +1018,7 @@ func TestSyncExecOptedInAddrAppWithNoVarsConvergesToEmptyFile(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -1073,7 +1073,7 @@ func TestSyncExecMassRemovalGuardUnaffectedByOptedInAddrAppWithContent(t *testin
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }); err != nil {
+ }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -1128,7 +1128,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec (opted in): %v", err)
}
path := filepath.Join(dd, "demo.go.tito.io.config.env")
@@ -1144,7 +1144,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) {
bounce := func(h string) error { bounced = append(bounced, h); return nil }
var syncErr error
out := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, s, pk, dd, bounce)
+ syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "")
})
if syncErr != nil {
t.Fatalf("SyncExec (opted out): %v", syncErr)
@@ -1170,7 +1170,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) {
// changed must repeat it, since nothing about the risk (stale
// plaintext secrets, forever) has gone away.
out2 := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, s, pk, dd, bounce)
+ syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "")
})
if syncErr != nil {
t.Fatalf("SyncExec (opted out, second pass): %v", syncErr)
@@ -1205,7 +1205,7 @@ func TestSyncExecNoOptOutWarningWhenNeverOptedIn(t *testing.T) {
}
var syncErr error
out := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil })
+ syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "")
})
if syncErr != nil {
t.Fatalf("SyncExec: %v", syncErr)
@@ -1245,7 +1245,7 @@ func TestSyncExecAddrFileCarriesHeaderSocketFileDoesNot(t *testing.T) {
t.Fatalf("Upsert %s: %v", rt.Host, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
addrGot, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -1299,7 +1299,7 @@ func TestSyncExecConvergesToEmptyFileWhenAllVarsUnset(t *testing.T) {
var bounced []string
bounce := func(h string) error { bounced = append(bounced, h); return nil }
- if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec (set): %v", err)
}
got, err := os.ReadFile(path)
@@ -1316,7 +1316,7 @@ func TestSyncExecConvergesToEmptyFileWhenAllVarsUnset(t *testing.T) {
t.Fatalf("Unset (all vars): %v", err)
}
bounced = nil
- if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec (unset): %v", err)
}
got, err = os.ReadFile(path)
@@ -1353,7 +1353,7 @@ func TestReapSparesLiveRoutesInstanceEnvWithConfigSuffix(t *testing.T) {
t.Fatalf("Upsert: %v", err)
}
out := captureStdout(t, func() {
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
})
@@ -1416,7 +1416,7 @@ func TestSyncExecRefusesConfigNamespaceCollision(t *testing.T) {
var bounced []string
bounce := func(h string) error { bounced = append(bounced, h); return nil }
out = captureStdout(t, func() {
- if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
})
@@ -1487,7 +1487,7 @@ func TestSyncExecFetchFailureLeavesOtherGroupsConverging(t *testing.T) {
failing := &failingKeyStore{Store: s, failKey: Key("bab", "appa"), err: blipErr}
var bounced []string
bounce := func(h string) error { bounced = append(bounced, h); return nil }
- if err := SyncExec(ctx, reg, failing, pk, dd, bounce); err != nil {
+ if err := SyncExec(ctx, reg, failing, pk, dd, bounce, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(stalePath)
@@ -1708,7 +1708,7 @@ func TestSyncExecOptInResolvesPerEnvironment(t *testing.T) {
t.Fatalf("Upsert %s: %v", rt.Host, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
assertConfigFile(t, dd, "connect.go.tito.io", tc.wantRelayFile)
@@ -1757,7 +1757,7 @@ func TestSyncExecEnvScopedOptInDeliversTheMergedBundle(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "connect.go.tito.io"+configEnvSuffix))
@@ -1802,7 +1802,7 @@ func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec: %v", err)
}
path := filepath.Join(dd, "connect.go.tito.io"+configEnvSuffix)
@@ -1814,7 +1814,7 @@ func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) {
blip := &failingKeyStore{Store: s, failKey: EnvironmentKey("bab", "titogo", "relay"),
err: errors.New("503 SlowDown")}
out := captureStdout(t, func() {
- if err := SyncExec(ctx, reg, blip, pk, dd, func(string) error { return nil }); err != nil {
+ if err := SyncExec(ctx, reg, blip, pk, dd, func(string) error { return nil }, ""); err != nil {
t.Fatalf("SyncExec (blip): %v", err)
}
})
@@ -1829,3 +1829,70 @@ func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) {
t.Fatalf("a fetch blip against the bound bundle must say so loudly and name the environment; got %q", out)
}
}
+
+// The #344 guard, from the 2026-08-28 incident: the domains reconcile
+// mis-stamped a route's account, the config sync keyed the manifest under
+// the stamped account, found nothing, and DELETED the tenant's delivered
+// config — secrets included — bouncing the instance into a crash loop. An
+// absent manifest may remove the file only when the lookup account agrees
+// with the account the host itself names; a platform host's <sqid> label
+// is not a moving part, the row's account column is.
+func TestSyncExecHoldsConfigWhenHostNamesADifferentAccount(t *testing.T) {
+ ctx, s, reg, dd, priv, _ := execKit(t)
+ pk, _ := ParsePrivateKey(priv, "test")
+ if err := reg.Upsert(registry.Route{
+ Host: "birthday-alarm.bde.oncarlos.test", Kind: "instance", App: "birthday-alarm",
+ Account: "bab", // the mis-stamp
+ Status: "ready", Socket: "/run/carlos/birthday.sock",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ // A second, healthy group so the mass-removal guard (which needs EVERY
+ // group absent) stays out of the way — this guard is per-route.
+ if err := reg.Upsert(registry.Route{
+ Host: "inst.example.com", Kind: "instance", App: "eleven", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/inst.sock",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"A": "1"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ path := filepath.Join(dd, "birthday-alarm.bde.oncarlos.test.config.env")
+ if err := os.WriteFile(path, []byte("BIRTHDAYALARM_INSTANCE_KEY=deadbeef\n"), 0o600); err != nil {
+ t.Fatalf("stage: %v", err)
+ }
+ var bounced []string
+ if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
+ bounced = append(bounced, h)
+ return nil
+ }, "oncarlos.test"); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("delivered config removed under a mis-stamped account: %v", err)
+ }
+ for _, h := range bounced {
+ if h == "birthday-alarm.bde.oncarlos.test" {
+ t.Fatalf("held route was bounced: %v", bounced)
+ }
+ }
+ // And the guard steps aside when the row and the host agree: the same
+ // absent manifest under the RIGHT account is a real deletion.
+ if err := reg.Upsert(registry.Route{
+ Host: "birthday-alarm.bab.oncarlos.test", Kind: "instance", App: "birthday-alarm",
+ Account: "bab", Status: "ready", Socket: "/run/carlos/birthday2.sock",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ agreed := filepath.Join(dd, "birthday-alarm.bab.oncarlos.test.config.env")
+ if err := os.WriteFile(agreed, []byte("OLD=1\n"), 0o600); err != nil {
+ t.Fatalf("stage: %v", err)
+ }
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "oncarlos.test"); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ if _, err := os.Stat(agreed); !os.IsNotExist(err) {
+ t.Fatalf("agreeing host's stale file survived, stat err = %v", err)
+ }
+}
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index 418ecb905..2cb29f138 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -186,7 +186,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
if routes[i].App == "" {
continue // an app-less route (status page, bare proxy) has no account
}
- acct := resolveRouteAccount(routes[i], owners[routes[i].App])
+ acct := resolveRouteAccount(routes[i], owners[routes[i].App], cfg.AppsDomain)
if acct == "" || routes[i].Account == acct {
continue
}
@@ -712,19 +712,32 @@ type appKey struct{ acct, app string }
// - several do, and the row names none of them: leave it alone. Picking one
// would re-home somebody's app onto another tenant's bytes, which is far
// worse than a row that reconciliation declines to touch.
-func resolveRouteAccount(rt registry.Route, owners []string) string {
+func resolveRouteAccount(rt registry.Route, owners []string, appsDomain string) string {
if len(owners) == 0 {
if rt.Account == "" {
return OpsSqid // nothing shipped yet: ops, like a console-less box
}
return "" // the row says something; the bucket says nothing. Believe the row.
}
- if slices.Contains(owners, release.RouteAccount(rt)) {
- return release.RouteAccount(rt)
+ // A row that names one of the owners is right; keep it.
+ if release.ValidAccount(rt.Account) && slices.Contains(owners, rt.Account) {
+ return rt.Account
+ }
+ // The row names nothing (or something the bucket doesn't hold). Before
+ // any fallback: a platform host literally names its owner in its own
+ // labels — <name>.<sqid>.<apps domain>. #344 was the ops fallback
+ // winning this tie (a stray pipeline.json under ops vs the tenant's
+ // whole app), and the config sync then deleting the tenant's delivered
+ // config for the mis-stamped account.
+ if sq := release.HostAccountLabel(rt.Host, appsDomain); sq != "" && slices.Contains(owners, sq) {
+ return sq
}
if len(owners) == 1 {
return owners[0]
}
+ // Several owners and nothing that decides between them. NOT ops-if-
+ // present: an unstamped row already reads as ops at runtime, so
+ // stamping buys nothing — and a wrong stamp is what #344 cost.
log.Printf("domains: %s (app %q) names account %q, but objects exist under %v — leaving it alone",
rt.Host, rt.App, rt.Account, owners)
return ""
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go
index 8e94b7167..7629174cf 100644
--- a/internal/domains/reconcile_test.go
+++ b/internal/domains/reconcile_test.go
@@ -1386,3 +1386,51 @@ func TestStampHealthIsIdempotent(t *testing.T) {
t.Error("a pass with no AppsDomain wrote a stamp")
}
}
+
+// The #344 shape, live on the flagship 2026-08-28: a route with an EMPTY
+// account column, for an app whose name holds objects under ops (a stray
+// pipeline.json from an abandoned attempt) AND under the tenant that
+// actually ships it. The ops fallback used to win the tie; the host's own
+// sqid label — birthday-alarm.<sqid>.oncarlos.com literally names its
+// owner — must win instead. The mis-stamp deleted the tenant's delivered
+// config (syncexec keyed apps/bab/...) and the app died on every wake.
+func TestReconcileStampsTheHostsOwnAccountNotOpsOnATie(t *testing.T) {
+ sqidNum := int64(793481920157)
+ s, reg := dirStore(t), regWith(t)
+ sqid := writeAccountManifest(t, s, sqidNum, "Paulca")
+ if err := reg.Upsert(registry.Route{Host: "birthday-alarm." + sqid + ".oncarlos.test",
+ Kind: "instance", App: "birthday-alarm", Status: "ready",
+ Socket: "/run/carlos/birthday-alarm.sock"}); err != nil {
+ t.Fatal(err)
+ }
+ putKey(t, s, "apps/bab/birthday-alarm/pipeline.json") // the stray
+ putKey(t, s, "apps/"+sqid+"/birthday-alarm/config.json")
+ if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
+ t.Fatal(err)
+ }
+ if rt := mustGet(t, reg, "birthday-alarm."+sqid+".oncarlos.test"); rt.Account != sqid {
+ t.Errorf("account = %q, want %s — the ops fallback won a tie the host itself decides", rt.Account, sqid)
+ }
+}
+
+// The same tie on a host that names NO account (a custom domain): the pass
+// must leave the row alone rather than defaulting it onto ops — an
+// unstamped row already reads as ops at runtime, so stamping buys nothing
+// and a wrong stamp deletes config (#344).
+func TestReconcileLeavesAnAmbiguousCustomDomainUnstamped(t *testing.T) {
+ s, reg := dirStore(t), regWith(t)
+ sqid := writeAccountManifest(t, s, 793481920157, "Paulca")
+ if err := reg.Upsert(registry.Route{Host: "birthdays.example.com",
+ Kind: "instance", App: "birthday-alarm", Status: "ready",
+ Socket: "/run/carlos/birthdays.sock"}); err != nil {
+ t.Fatal(err)
+ }
+ putKey(t, s, "apps/bab/birthday-alarm/pipeline.json")
+ putKey(t, s, "apps/"+sqid+"/birthday-alarm/config.json")
+ if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
+ t.Fatal(err)
+ }
+ if rt := mustGet(t, reg, "birthdays.example.com"); rt.Account != "" {
+ t.Errorf("account = %q, want unstamped — ambiguity is not ops's to win", rt.Account)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 294f0ff27..bd8f84dbb 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -652,7 +652,7 @@ var configSyncMu sync.Mutex
func syncExecSerialized(ctx context.Context, reg *registry.Registry, store release.Store, priv *ecdh.PrivateKey, dd string, svc *activator.Service) error {
configSyncMu.Lock()
defer configSyncMu.Unlock()
- return appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc))
+ return appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc), os.Getenv("PLATFORM_APPS_DOMAIN"))
}
// reconcileMu serializes the two provisioning loops' Reconcile passes
diff --git a/internal/release/qualified.go b/internal/release/qualified.go
index 9ab574b10..b3fb46b00 100644
--- a/internal/release/qualified.go
+++ b/internal/release/qualified.go
@@ -182,3 +182,32 @@ func GetMigrationSource(ctx context.Context, s Store, key, legacy string) (body
}
return body, etag, true, nil
}
+
+// HostAccountLabel extracts the account sqid a PLATFORM host names in its
+// own labels: <name>.<sqid>.<apps domain> (and the canary shape
+// <canary>.<app>.<sqid>.<apps domain>) both put the sqid immediately
+// before the apps domain. "" when the host is not under the apps domain,
+// has no label pair before it, or the label isn't a valid account name —
+// callers treat "" as "the host names nobody", never as an error. This is
+// the one detail #344 turned on: a route row's account column is a moving
+// part (the domains reconcile stamps it from bucket listings), but a
+// platform host's own name is not.
+func HostAccountLabel(host, appsDomain string) string {
+ appsDomain = strings.TrimSuffix(strings.TrimSpace(appsDomain), ".")
+ if appsDomain == "" {
+ return ""
+ }
+ rest, ok := strings.CutSuffix(strings.ToLower(host), "."+strings.ToLower(appsDomain))
+ if !ok {
+ return ""
+ }
+ labels := strings.Split(rest, ".")
+ if len(labels) < 2 {
+ return ""
+ }
+ sq := labels[len(labels)-1]
+ if !ValidAccount(sq) {
+ return ""
+ }
+ return sq
+}