carlos / platform Public

cli, provision: the unflag and re-add guards also refuse a host with a layout journal (plan 4 final review)

A row whose paths are still flat can be mid-move, and the journal is what
says so. carlos route --hibernate=false, carlos add and the reconcile
flag-clear now refuse such a host too, with the approved texts unchanged.
The Lstat predicate moves to provision.LayoutJournalExists so provision
can use it; layout.JournalPresent delegates to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6dd81eb60bff734034bd94e9b7b07684ad6604ed parent f0668be
6 files changed, +120 −8
  • cli.go +7 −4
  • cli_test.go +52 −0
  • internal/layout/journal.go +1 −2
  • internal/provision/provision.go +9 −0
  • internal/provision/reconcile.go +5 −2
  • internal/provision/reconcile_test.go +46 −0
diff --git a/cli.go b/cli.go
index c995a392f..3775df883 100644
--- a/cli.go
+++ b/cli.go
@@ -70,9 +70,11 @@ func reservedSlugs() map[string]bool {
// with this verb's flat defaults, which would leave the row pointing at
// the shared directory over files that live under the instance tree, and
// the next wake would restore a second database there (design §8.2,
-// downgrade rules). A host with no row, or a flat one, goes ahead.
+// downgrade rules). A host with a layout journal is mid-move, row or no
+// row, and a re-add would rewrite what recovery is about to check, so it
+// is refused the same way. A host with no row, or a flat one, goes ahead.
func refuseAddNested(host string, existing *registry.Route, dataDir string) error {
- if existing != nil && provision.NestedPaths(dataDir, *existing) {
+ if (existing != nil && provision.NestedPaths(dataDir, *existing)) || provision.LayoutJournalExists(dataDir, host) {
return fmt.Errorf("add %s: its paths are nested. revert the layout first; re-adding it would point it back at the shared directory", host)
}
return nil
@@ -1393,9 +1395,10 @@ func portGrantConflict(routes []registry.Route, host, tok string) error {
// management, and the layout revert only quiesces and moves rows the
// activator manages, so the row's files would be stranded under the
// instance tree (design §8.2, downgrade rules). The test is by path shape,
-// whatever else the row says.
+// whatever else the row says, or by a layout journal, which means the row
+// is mid-move and recovery needs it managed.
func refuseUnflagNested(rt registry.Route, dataDir string) error {
- if provision.NestedPaths(dataDir, rt) {
+ if provision.NestedPaths(dataDir, rt) || provision.LayoutJournalExists(dataDir, rt.Host) {
return fmt.Errorf("route: %s: its paths are nested. revert the layout first; a nested instance must stay managed so the revert can stop it", rt.Host)
}
return nil
diff --git a/cli_test.go b/cli_test.go
index ebea590c4..a2a499472 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -1327,6 +1327,58 @@ func TestRefuseUnflagNested(t *testing.T) {
}
}
+// writeLayoutJournal puts a journal for host under dataDir/layout. The
+// guards only ask whether one is there, so its contents do not matter.
+func writeLayoutJournal(t *testing.T, dataDir, host string) {
+ t.Helper()
+ path := provision.LayoutJournalPath(dataDir, host)
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// A row whose paths are still flat can be mid-move: the journal is what
+// says so. Unflagging it would take it out of the management recovery
+// needs, so the guard refuses it with the same approved text.
+func TestRefuseUnflagNestedRefusesAFlatRowWithAJournal(t *testing.T) {
+ host := "acme.example.com"
+ dd := t.TempDir()
+ flat := registry.Route{Host: host, Kind: "instance", Hibernate: true,
+ DBPath: provision.DefaultDBPath(dd, host), Socket: provision.DefaultSocket(host)}
+ writeLayoutJournal(t, dd, host)
+ err := refuseUnflagNested(flat, dd)
+ want := "route: acme.example.com: its paths are nested. revert the layout first; a nested instance must stay managed so the revert can stop it"
+ if err == nil || err.Error() != want {
+ t.Fatalf("refuseUnflagNested(flat, journal) = %v, want %q", err, want)
+ }
+}
+
+// A re-add over a host with a journal would rewrite the row a recovery is
+// about to check and flip, row or no row, so it refuses too.
+func TestRefuseAddNestedRefusesAHostWithAJournal(t *testing.T) {
+ host := "acme.example.com"
+ dd := t.TempDir()
+ flat := &registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(dd, host), Socket: provision.DefaultSocket(host)}
+ writeLayoutJournal(t, dd, host)
+ want := "add acme.example.com: its paths are nested. revert the layout first; re-adding it would point it back at the shared directory"
+ for name, existing := range map[string]*registry.Route{"flat row": flat, "no row": nil} {
+ if err := refuseAddNested(host, existing, dd); err == nil || err.Error() != want {
+ t.Errorf("%s: refuseAddNested = %v, want %q", name, err, want)
+ }
+ }
+ // A journal this process cannot even read the target of still counts.
+ other := "other.example.com"
+ if err := os.Symlink("/nonexistent", provision.LayoutJournalPath(dd, other)); err != nil {
+ t.Fatal(err)
+ }
+ if err := refuseAddNested(other, nil, dd); err == nil {
+ t.Error("a dangling journal symlink was not taken as a journal")
+ }
+}
+
// `carlos add` over an existing nested host would rewrite its paths to the
// flat defaults and strand the files under the instance tree, so it
// refuses. A new host, and a re-add of a flat one, go ahead.
diff --git a/internal/layout/journal.go b/internal/layout/journal.go
index 2dee2e1d9..2c43980af 100644
--- a/internal/layout/journal.go
+++ b/internal/layout/journal.go
@@ -270,8 +270,7 @@ func RemoveJournal(dataDir, host string) error {
// permission problem, say) is treated as present: silently proceeding
// past a journal this process cannot even stat is worse than refusing.
func JournalPresent(dataDir, host string) bool {
- _, err := os.Lstat(provision.LayoutJournalPath(dataDir, host))
- return !errors.Is(err, os.ErrNotExist)
+ return provision.LayoutJournalExists(dataDir, host)
}
// Validate is the recovery check of design §8.2: the journal's endpoints
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 4709ef0fa..38bd3c09e 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -1320,6 +1320,15 @@ func LayoutJournalPath(dataDir, host string) string {
return filepath.Join(dataDir, "layout", host+".json")
}
+// LayoutJournalExists reports whether a journal is at LayoutJournalPath.
+// It Lstats, so a dangling symlink there counts, and any error other than
+// "not exist" counts too: a journal this process cannot even stat is not
+// proof there isn't one.
+func LayoutJournalExists(dataDir, host string) bool {
+ _, err := os.Lstat(LayoutJournalPath(dataDir, host))
+ return !errors.Is(err, os.ErrNotExist)
+}
+
// IsControlEndpoint reports whether path is the control channel or
// anything else under its directory. The edge and the agent refuse to
// dial such a path as an upstream (design §5.4): a registry row pointing
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 4bbb3b978..549e5bbda 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -968,8 +968,11 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// 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) {
+ // under the instance tree (design §8.2, downgrade rules). A row
+ // with a layout journal is mid-move whatever its paths say, and
+ // recovery needs it managed just the same.
+ if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate && !*rec.Hibernate &&
+ (NestedPaths(cfg.DataDir, existing) || LayoutJournalExists(cfg.DataDir, rec.Host)) {
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 := ""
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index afbe61aaf..4067c63ec 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -784,6 +784,52 @@ func TestReconcileKeepsANestedRowFlagged(t *testing.T) {
}
}
+// A flat row with a layout journal is mid-move, and clearing its flag
+// would strand it the same way, so the record is not applied and the same
+// approved line is logged.
+func TestReconcileKeepsAFlatRowFlaggedWhileAJournalExists(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: t.TempDir()}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ journal := LayoutJournalPath(cfg.DataDir, rec.Host)
+ if err := os.MkdirAll(filepath.Dir(journal), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(journal, []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ 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 NestedPaths(cfg.DataDir, *mustGet(t, reg, rec.Host)) {
+ t.Fatal("setup: the row is nested; this test is about a flat row")
+ }
+ if !mustGet(t, reg, rec.Host).Hibernate {
+ t.Fatal("the record cleared hibernate on a row with a layout journal")
+ }
+ 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())
+ }
+}
+
// 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`.