carlos / platform Public

cli: route --unit, --hibernate and add validate the row that would result

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev db5d6704174ac091cf8cf5d7b38936ca5f7a1a04 parent 72eb63a
2 files changed, +70 −15
  • cli.go +41 −13
  • cli_test.go +29 −2
diff --git a/cli.go b/cli.go
index fe12cda75..5efe2e338 100644
--- a/cli.go
+++ b/cli.go
@@ -112,6 +112,15 @@ func runAdd(args []string) {
log.Fatalf("add %s: --backing unit with --unit %s: a unit-backed route's process is the carlos-app@%s unit the platform derives and already restarts, "+
"so a second unit name here would be two answers to one question — drop one of the two flags", host, *unit, host)
}
+ // The root pass delivers config to a stamped route only in one shape
+ // (appconfig.StampShape); this door must not mint a row it would refuse
+ // every pass. --socket is refused even though the default below would
+ // add one for a bare instance: a stamped route needs --addr.
+ if *unit != "" {
+ if *socket != "" || *addr == "" || *hibernate {
+ log.Fatalf("add %s: --unit %s needs --addr and neither --socket nor --hibernate: the root pass delivers config to a stamped route only when a unit owns an addr-backed, always-on process", host, *unit)
+ }
+ }
if *socket == "" && *addr == "" && *kind == "instance" {
if *backing == registry.BackingUnit {
// The carlos-app@<host>.socket unit's path — enable that unit
@@ -1324,6 +1333,9 @@ func runRouteHibernate(r *registry.Registry, rt registry.Route, on, dryRun bool)
"and a hibernation flip here is reverted on the next reconcile pass. This verb does not own this row", rt.Host)
}
if on {
+ if err := hibernateRail(rt); err != nil {
+ log.Fatalf("route: %s: %v", rt.Host, err)
+ }
store, pub := deploymentReadSeam()
daemon, note, proven := routeDaemonCheck(context.Background(), store, pub, release.RouteAccount(rt), rt)
if !proven {
@@ -1484,7 +1496,7 @@ func backingRail(rt registry.Route, backing string) error {
// most needed. What it does instead is print what the next adopt pass will do
// with the value, at the moment of typing.
func runRouteUnit(r *registry.Registry, rt registry.Route, unit string, dryRun bool) {
- if err := unitRail(rt); err != nil {
+ if err := unitRail(rt, unit); err != nil {
log.Fatalf("route: %s: %v", rt.Host, err)
}
prefix := ""
@@ -1509,23 +1521,39 @@ func runRouteUnit(r *registry.Registry, rt registry.Route, unit string, dryRun b
}
// unitRail is the row-shape half of runRouteUnit's refusals, split out as a
-// pure function for the same reason backingRail is: a rail nobody can
-// exercise without running the process is a rail nobody tests.
+// pure function for the same reason backingRail is. It validates the ROW
+// THAT WOULD RESULT (spec 2026-09-07-named-unit-config-delivery-design.md
+// §3.1, codex R3-5): clearing a stamp (unit == "") is always allowed, so
+// that no row can be stranded; stamping refuses a unit-backed row (two
+// answers to one question) and a row outside the supported stamped shape
+// (appconfig.StampShape: an addr upstream, no socket, no hibernation),
+// which the root pass would refuse every pass anyway.
//
-// Deliberately NARROWER than backingRail. It does not refuse Platform rows:
-// this column is not derived by the domains reconciler (mintedRoute never
-// sets it) and registry.Upsert preserves a non-empty value, so a stamp there
-// survives a re-mint rather than being silently reverted — and the
-// deployment's own console can legitimately be a platform-minted row. It does
-// not refuse non-instance kinds either: a `service` route is precisely a
-// sibling process the platform only proxies, and naming the unit that owns it
-// is a true statement, even though adoption has no version to move for one
-// today.
-func unitRail(rt registry.Route) error {
+// Deliberately NOT refused: Platform rows and service rows — see the
+// previous version's reasoning, which still holds.
+func unitRail(rt registry.Route, unit string) error {
+ if unit == "" {
+ return nil
+ }
if rt.Backing == registry.BackingUnit {
return fmt.Errorf("unit-backed route — its process is the carlos-app@%s unit the platform derives and already restarts, "+
"so a second unit name here would be two answers to one question (drop the backing first with `carlos route --backing \"\"` if this route really is owned by a bespoke service)", rt.Host)
}
+ proposed := rt
+ proposed.Unit = unit
+ if err := appconfig.StampShape(proposed); err != nil {
+ return fmt.Errorf("%v — the root pass delivers config only to an addr-backed, non-hibernating route", err)
+ }
+ return nil
+}
+
+// hibernateRail refuses hibernation on a stamped row: the root pass
+// restarts a stamped unit on config change, and a hibernating route is the
+// activator's to wake, so the two would fight over one process.
+func hibernateRail(rt registry.Route) error {
+ if rt.Unit != "" {
+ return fmt.Errorf("route names unit %s — a stamped route is restarted by the root config pass and cannot also hibernate; clear the stamp first (`carlos route --host %s --unit -`)", rt.Unit, rt.Host)
+ }
return nil
}
diff --git a/cli_test.go b/cli_test.go
index bbf55cb54..74bc19b09 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -481,7 +481,7 @@ func TestUnitRailRefusals(t *testing.T) {
}
unitBacked := console
unitBacked.Backing = registry.BackingUnit
- err := unitRail(unitBacked)
+ err := unitRail(unitBacked, "console.service")
if err == nil {
t.Fatal("unitRail(unit-backed route) accepted; want a refusal")
}
@@ -503,13 +503,40 @@ func TestUnitRailRefusals(t *testing.T) {
{"service route", func() registry.Route { rt := console; rt.Kind = "service"; return rt }()},
} {
t.Run(c.name, func(t *testing.T) {
- if err := unitRail(c.rt); err != nil {
+ if err := unitRail(c.rt, "console.service"); err != nil {
t.Fatalf("unitRail(%+v) = %v, want accepted", c.rt, err)
}
})
}
}
+func TestUnitRailValidatesTheResultingRow(t *testing.T) {
+ sock := registry.Route{Host: "s.example.com", Kind: "instance", App: "relay", Socket: "/run/s.sock"}
+ if err := unitRail(sock, "s.service"); err == nil || !strings.Contains(err.Error(), "socket") {
+ t.Fatalf("stamping a socket route = %v, want a socket refusal", err)
+ }
+ hib := registry.Route{Host: "h.example.com", Kind: "instance", App: "relay", Addr: "127.0.0.1:1", Hibernate: true}
+ if err := unitRail(hib, "h.service"); err == nil || !strings.Contains(err.Error(), "hibernat") {
+ t.Fatalf("stamping a hibernating route = %v, want a hibernate refusal", err)
+ }
+ // Clearing is always allowed, whatever the old row's shape.
+ sock.Unit = "s.service"
+ if err := unitRail(sock, ""); err != nil {
+ t.Fatalf("clearing a stamp on a socket route = %v, want accepted", err)
+ }
+}
+
+func TestHibernateRailRefusesAStampedRow(t *testing.T) {
+ stamped := registry.Route{Host: "c.example.com", Kind: "instance", App: "console", Addr: "127.0.0.1:8090", Unit: "console.service"}
+ if err := hibernateRail(stamped); err == nil || !strings.Contains(err.Error(), "console.service") {
+ t.Fatalf("hibernateRail(stamped) = %v, want a refusal naming the unit", err)
+ }
+ stamped.Unit = ""
+ if err := hibernateRail(stamped); err != nil {
+ t.Fatalf("hibernateRail(unstamped) = %v, want accepted", err)
+ }
+}
+
// TestRouteUnitStampsAnExistingRoute drives the whole door as a subprocess,
// because that is the shape the fix ships in: the production consoles already
// exist, and `carlos route -unit console.service` has to stamp a LIVE