carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 70d7fd18279e23db27bd0142808014dc9392ef55 parent 80e1c69
4 files changed, +174 −9
  • internal/provision/provision.go +16 −0
  • internal/provision/provision_test.go +27 −0
  • internal/provision/reconcile.go +27 −9
  • internal/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.