carlos / platform Public

cli: route --hibernate=false and add refuse a host whose paths are nested (plan 4, task 6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 53ee105e5bdeffe3d6b5092a630d17209d02c2bb parent d2686c2
2 files changed, +74 −0
  • cli.go +34 −0
  • cli_test.go +40 −0
diff --git a/cli.go b/cli.go
index 4c500a4bb..c995a392f 100644
--- a/cli.go
+++ b/cli.go
@@ -65,6 +65,19 @@ func reservedSlugs() map[string]bool {
return out
}
+// refuseAddNested stops a re-add of a host whose row is on the nested
+// layout. Upsert overwrites the socket, database path and hibernate flag
+// 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.
+func refuseAddNested(host string, existing *registry.Route, dataDir string) error {
+ if existing != nil && provision.NestedPaths(dataDir, *existing) {
+ 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
+}
+
func runAdd(args []string) {
fs := flag.NewFlagSet("add", flag.ExitOnError)
reg := fs.String("registry", defaultRegistry(), "registry database")
@@ -195,6 +208,9 @@ func runAdd(args []string) {
if err != nil {
log.Fatal(err)
}
+ if err := refuseAddNested(host, existing, dataDir()); err != nil {
+ log.Fatalf("%v", err)
+ }
route := registry.Route{
Host: host, Slug: *slug, Kind: *kind, Socket: *socket, Addr: *addr,
App: *app, Account: account,
@@ -1372,6 +1388,19 @@ func portGrantConflict(routes []registry.Route, host, tok string) error {
return nil
}
+// refuseUnflagNested stops `carlos route --hibernate=false` on a row whose
+// paths are nested. Clearing the flag takes the row out of the activator's
+// 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.
+func refuseUnflagNested(rt registry.Route, dataDir string) error {
+ if provision.NestedPaths(dataDir, rt) {
+ 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
+}
+
// runRouteHibernate flips the per-route hibernation opt-in — the first CLI
// caller registry.SetHibernate has ever had — behind the one rail that makes
// the capability model coherent: a route whose release declares `daemon` may
@@ -1431,6 +1460,11 @@ func runRouteHibernate(r *registry.Registry, rt registry.Route, on, dryRun bool)
log.Fatalf("route: %s: %s (%s)", rt.Host, daemonHibernateRefusal, note)
}
}
+ if !on {
+ if err := refuseUnflagNested(rt, dataDir()); err != nil {
+ log.Fatalf("%v", err)
+ }
+ }
prefix := ""
if dryRun {
prefix = colorFor(os.Stdout).warn("(dry run)") + " "
diff --git a/cli_test.go b/cli_test.go
index a98f75b85..ebea590c4 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -1307,3 +1307,43 @@ func TestRouteRefusesReservedSuffixWiredIn(t *testing.T) {
t.Fatalf("the suffix row must be named in a skip line, not silently dropped:\n%s", out)
}
}
+
+// A nested row must stay activator-managed so the layout revert can stop
+// it: `carlos route --hibernate=false` refuses one, by path shape and
+// whatever else the row says.
+func TestRefuseUnflagNested(t *testing.T) {
+ host := "acme.example.com"
+ nested := registry.Route{Host: host, Kind: "instance", Hibernate: true,
+ DBPath: provision.NestedDBPath("/data", host), Socket: provision.NestedSocket(host)}
+ err := refuseUnflagNested(nested, "/data")
+ 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(nested) = %v, want %q", err, want)
+ }
+ flat := nested
+ flat.DBPath, flat.Socket = provision.DefaultDBPath("/data", host), provision.DefaultSocket(host)
+ if err := refuseUnflagNested(flat, "/data"); err != nil {
+ t.Fatalf("refuseUnflagNested(flat) = %v, want nil", err)
+ }
+}
+
+// `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.
+func TestRefuseAddNested(t *testing.T) {
+ host := "acme.example.com"
+ nested := &registry.Route{Host: host, Kind: "instance", Hibernate: true,
+ DBPath: provision.NestedDBPath("/data", host), Socket: provision.NestedSocket(host)}
+ err := refuseAddNested(host, nested, "/data")
+ want := "add acme.example.com: its paths are nested. revert the layout first; re-adding it would point it back at the shared directory"
+ if err == nil || err.Error() != want {
+ t.Fatalf("refuseAddNested(nested) = %v, want %q", err, want)
+ }
+ if err := refuseAddNested(host, nil, "/data"); err != nil {
+ t.Fatalf("refuseAddNested(new host) = %v, want nil", err)
+ }
+ flat := &registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath("/data", host), Socket: provision.DefaultSocket(host)}
+ if err := refuseAddNested(host, flat, "/data"); err != nil {
+ t.Fatalf("refuseAddNested(flat) = %v, want nil", err)
+ }
+}