provision: NestedPaths, and the reconciler keeps a nested row flagged and calls its paths layout, not drift (plan 4, task 6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+174
−9
internal/provision/provision.go+16 −0internal/provision/provision_test.go+27 −0internal/provision/reconcile.go+27 −9internal/provision/reconcile_test.go+104 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 2b399c424..4709ef0fa 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1289,6 +1289,22 @@ func NestedSocket(host string) string { |
| return filepath.Join(InstanceRunDir(host), "app.sock") |
| } |
| +// NestedPaths reports whether a row's database or socket sits anywhere |
| +// under the nested layout's trees: <dataDir>/instances/ or |
| +// <SocketDir>/instances/. It reads the path shape only, whatever the row's |
| +// flags, backing or exact file names say. |
| +// |
| +// That is deliberately looser than activator.Classify, which calls a row |
| +// nested only when both paths equal the nested defaults. The downgrade |
| +// guards (design §8.2, "That order is enforced") use this test instead: |
| +// a row can leave management or be hand-edited and still have its files |
| +// under the instance tree, and a release before the layout, an unflag or |
| +// a re-add would mishandle it all the same. Do not merge the two. |
| +func NestedPaths(dataDir string, rt registry.Route) bool { |
| + return strings.HasPrefix(rt.DBPath, filepath.Join(dataDir, "instances")+"/") || |
| + strings.HasPrefix(rt.Socket, filepath.Join(SocketDir, "instances")+"/") |
| +} |
| + |
| // LogSocket is the agent's per-role log listener for a host (design |
| // §6.4): outside every tenant mount, keyed by the identifier. |
| func LogSocket(host, role string) string { |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index a97933440..5503c959e 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -1099,6 +1100,32 @@ func TestNestedHelpersAreKeyedByInstanceID(t *testing.T) { |
| } |
| } |
| +// NestedPaths is a path-shape test and nothing more: it must answer true |
| +// for a row that left management (unflagged, unit-backed) and for a row |
| +// whose paths are nested but not the exact defaults, because the downgrade |
| +// guards have to see every row a pre-layout release would mishandle. |
| +func TestNestedPathsIsShapeOnly(t *testing.T) { |
| + host := "acme.example.com" |
| + for _, tc := range []struct { |
| + name string |
| + rt registry.Route |
| + want bool |
| + }{ |
| + {"flat defaults", registry.Route{Host: host, Hibernate: true, DBPath: DefaultDBPath("/data", host), Socket: DefaultSocket(host)}, false}, |
| + {"nested defaults", registry.Route{Host: host, Hibernate: true, DBPath: NestedDBPath("/data", host), Socket: NestedSocket(host)}, true}, |
| + {"nested and unflagged", registry.Route{Host: host, DBPath: NestedDBPath("/data", host), Socket: NestedSocket(host)}, true}, |
| + {"nested db only, off the default name", registry.Route{Host: host, Backing: registry.BackingUnit, DBPath: "/data/instances/other/x.db"}, true}, |
| + {"nested socket only", registry.Route{Host: host, Socket: "/run/carlos/instances/zz/app.sock"}, true}, |
| + {"the instances directory itself", registry.Route{Host: host, DBPath: "/data/instances", Socket: "/run/carlos/instances"}, false}, |
| + {"a sibling that shares the prefix", registry.Route{Host: host, DBPath: "/data/instances-old/x.db", Socket: "/run/carlos/instancesx/app.sock"}, false}, |
| + {"another data dir", registry.Route{Host: host, DBPath: "/srv/instances/a/a.db"}, false}, |
| + } { |
| + if got := NestedPaths("/data/", tc.rt); got != tc.want { |
| + t.Errorf("%s: NestedPaths = %v, want %v", tc.name, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| func TestFlatDefaultsUnchangedThisRelease(t *testing.T) { |
| // Spec §8.1: nothing mints nested in this release. The flat helpers |
| // are what routeFor and `carlos add` still use. |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 8b3d2d9e8..4bbb3b978 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -964,7 +964,14 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // the flip rather than left for an operator to notice, because |
| // it is the one operator-typed value a converge tick can |
| // silently spend. |
| - if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate { |
| + // |
| + // A nested row keeps its flag whatever the record says: only a |
| + // row the activator manages is one the layout revert can stop |
| + // and move back, so clearing the flag would strand its files |
| + // under the instance tree (design §8.2, downgrade rules). |
| + if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate && !*rec.Hibernate && NestedPaths(cfg.DataDir, existing) { |
| + log.Printf("provision: %s: record asks to clear hibernate but the row's paths are nested; revert the layout first", rec.Host) |
| + } else if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate { |
| dropped := "" |
| if !*rec.Hibernate && existing.Replication != "" { |
| dropped = fmt.Sprintf(" (replication %q cleared: only a hibernating route's rung is read)", existing.Replication) |
| @@ -1009,15 +1016,26 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // widened to prevent. What remains are the columns nothing |
| // converges in place: the route CLASS and the two filesystem paths |
| // derived from it. |
| - if want := routeFor(rec, cfg.DataDir, seed); want.Kind != existing.Kind || |
| - (rec.Unit != "" && want.Unit != existing.Unit) || |
| + // |
| + // A nested row's paths differ from the record's flat derivation |
| + // by design (the layout pass moved them), so when that is the |
| + // only difference the line says so, rather than pointing an |
| + // operator at `carlos remove`. |
| + want := routeFor(rec, cfg.DataDir, seed) |
| + unitDrift := rec.Unit != "" && want.Unit != existing.Unit |
| + if want.Kind != existing.Kind || unitDrift || |
| want.Socket != existing.Socket || want.DBPath != existing.DBPath { |
| - log.Printf("provision: %s: record describes a different route SHAPE than the row "+ |
| - "(kind %q/%q unit %q/%q socket %q/%q db %q/%q) — not converged in place; "+ |
| - "`carlos remove %s` purges this route's capability grants (registry.Delete's contract, "+ |
| - "not a bug) before the next pass re-mints it, so treat that as a last resort, not a routine fix", |
| - rec.Host, want.Kind, existing.Kind, want.Unit, existing.Unit, |
| - want.Socket, existing.Socket, want.DBPath, existing.DBPath, rec.Host) |
| + if want.Kind == existing.Kind && !unitDrift && NestedPaths(cfg.DataDir, existing) { |
| + log.Printf("provision: %s: row is on the nested layout (db %q socket %q); the record's shared paths are not drift", |
| + rec.Host, existing.DBPath, existing.Socket) |
| + } else { |
| + log.Printf("provision: %s: record describes a different route SHAPE than the row "+ |
| + "(kind %q/%q unit %q/%q socket %q/%q db %q/%q) — not converged in place; "+ |
| + "`carlos remove %s` purges this route's capability grants (registry.Delete's contract, "+ |
| + "not a bug) before the next pass re-mints it, so treat that as a last resort, not a routine fix", |
| + rec.Host, want.Kind, existing.Kind, want.Unit, existing.Unit, |
| + want.Socket, existing.Socket, want.DBPath, existing.DBPath, rec.Host) |
| + } |
| } |
| default: |
| // A route exists that this pass did not mint. If it is the same |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index e877c33dc..afbe61aaf 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -714,6 +714,110 @@ func TestReconcileConvergingHibernateOffClearsTheReplicationRung(t *testing.T) { |
| } |
| } |
| +// nestRow moves a minted flat row onto the nested layout the way the layout |
| +// pass's compare-and-swap does, so a test can meet a nested row without the |
| +// pass itself. |
| +func nestRow(t *testing.T, reg *registry.Registry, dataDir, host string) { |
| + t.Helper() |
| + if err := reg.SetInstancePaths(host, DefaultDBPath(dataDir, host), DefaultSocket(host), |
| + NestedDBPath(dataDir, host), NestedSocket(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// A record that stops a nested row hibernating is not applied: the row would |
| +// leave the activator's management with its files under the instance tree, |
| +// and the revert pass only quiesces rows the activator manages. |
| +func TestReconcileKeepsANestedRowFlagged(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + on := true |
| + rec.Hibernate = &on |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + nestRow(t, reg, cfg.DataDir, rec.Host) |
| + |
| + var logged bytes.Buffer |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + off := false |
| + rec.Hibernate = &off |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !mustGet(t, reg, rec.Host).Hibernate { |
| + t.Fatal("the record cleared hibernate on a nested row") |
| + } |
| + want := "provision: acme.go.tito.io: record asks to clear hibernate but the row's paths are nested; revert the layout first" |
| + if !strings.Contains(logged.String(), want) { |
| + t.Errorf("no refusal logged; want %q in:\n%s", want, logged.String()) |
| + } |
| + if strings.Contains(logged.String(), "converged acme.go.tito.io hibernate") { |
| + t.Errorf("a converge was logged over the refusal:\n%s", logged.String()) |
| + } |
| + |
| + // A flat row still converges off: the refusal is by path shape. |
| + flat := acmeRecord(sqid) |
| + flat.Host, flat.Slug = "flat.go.tito.io", "flat" |
| + flat.Hibernate = &on |
| + if err := CreateRecord(ctx, s, flat); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + flat.Hibernate = &off |
| + putReplace(t, s, RecordKey(sqid, "tito", flat.Host), mustJSON(t, flat)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mustGet(t, reg, flat.Host).Hibernate { |
| + t.Error("a flat row's hibernate did not converge off") |
| + } |
| +} |
| + |
| +// A nested row's paths differ from the record's flat derivation by design, |
| +// so the drift log names the layout rather than a shape mismatch that |
| +// points the operator at `carlos remove`. |
| +func TestReconcileNestedRowIsNotShapeDrift(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + on := true |
| + rec.Hibernate = &on |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + nestRow(t, reg, cfg.DataDir, rec.Host) |
| + |
| + var logged bytes.Buffer |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := fmt.Sprintf("provision: acme.go.tito.io: row is on the nested layout (db %q socket %q); the record's shared paths are not drift", |
| + NestedDBPath(cfg.DataDir, rec.Host), NestedSocket(rec.Host)) |
| + if !strings.Contains(logged.String(), want) { |
| + t.Errorf("want %q in:\n%s", want, logged.String()) |
| + } |
| + if strings.Contains(logged.String(), "different route SHAPE") { |
| + t.Errorf("a nested row was reported as shape drift:\n%s", logged.String()) |
| + } |
| +} |
| + |
| // StatusNamed is converged, so it carries Hibernate's tri-state for |
| // Hibernate's reason: explicit true reaches the row, explicit false CLEARS |
| // it, and absent is not an answer at all. |