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>
2 files changed,
+74
−0
cli.go+34 −0cli_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 := ®istry.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 := ®istry.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) |
| + } |
| +} |