carlos / platform Public

cli: carlos add -unit, carlos route -unit

The operator's two doors onto the new column. `carlos route -unit
console.service` is the one that matters: the production consoles already
exist, so the fix has to stamp a LIVE registry row rather than need the route
recreated, and it prints what the next adopt pass will do with the value
instead of leaving it to be discovered an hour later.

Held to the same exclusions as -backing (mutually exclusive with
-grant/-revoke, -hibernate, -backing, and with -channel/-environment/-tunnel;
-host only), for the reason recorded there: this switch runs one axis and
would otherwise drop the other on the floor while reporting success. Name
shape is checked at the flag, before the registry is opened — a typo'd unit
is invisible afterwards, since a restart that reaches nothing looks exactly
like a restart that worked. "-unit -" clears, the sentinel -environment and
-tunnel already use; a bare -unit "" is refused rather than treated as a
silent unstamp.

One structural rail: a unit-backed route is refused, because its process is
the carlos-app@<host> unit the platform derives and already restarts.
Deliberately narrower than backingRail otherwise — Platform rows and
non-instance kinds pass, since nothing derives this column and Upsert
preserves it.

`carlos instances` grows a UNIT column beside BACKING: they are two halves
of one answer, and BACKING is empty for exactly the routes this is about.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell afc0c721be201df8f4b07c55862ec3e174758f74 parent fda80b8
2 files changed, +335 −18
  • cli.go +159 −18
  • cli_test.go +176 −0
diff --git a/cli.go b/cli.go
index 6fa9272bc..246cb681b 100644
--- a/cli.go
+++ b/cli.go
@@ -79,6 +79,7 @@ func runAdd(args []string) {
hibernate := fs.Bool("hibernate", false, "hibernate when idle")
statusNamed := fs.Bool("status-named", false, "opt this app into being named on the public status page")
backing := fs.String("backing", "", `process backing: "" (agent exec child) | "unit" (carlos-app@<host> systemd unit)`)
+ unit := fs.String("unit", "", "systemd unit that owns this route's process when it is neither an exec child nor a carlos-app@ tenant (e.g. console.service)")
dbPath := fs.String("db", "", "instance database path")
fs.Parse(args)
if fs.NArg() != 1 {
@@ -91,6 +92,13 @@ func runAdd(args []string) {
if *backing != "" && *backing != registry.BackingUnit {
log.Fatalf("backing %q: want \"\" or %q", *backing, registry.BackingUnit)
}
+ // Checked here, before anything is opened or written, for the same reason
+ // -backing is: registry.SetUnit would refuse it too, but a route created
+ // with a typo'd unit is a route whose adoption silently restarts nothing
+ // — the failure this column exists to end.
+ if !registry.ValidUnitName(*unit) {
+ log.Fatalf("add %s: unit %q: want a systemd service unit name (e.g. console.service)", host, *unit)
+ }
if *socket == "" && *addr == "" && *kind == "instance" {
if *backing == registry.BackingUnit {
// The carlos-app@<host>.socket unit's path — enable that unit
@@ -154,7 +162,7 @@ func runAdd(args []string) {
Host: host, Slug: *slug, Kind: *kind, Socket: *socket, Addr: *addr,
App: *app, Account: account,
Channel: *channel, Hibernate: *hibernate, StatusNamed: *statusNamed, Backing: *backing,
- DBPath: *dbPath, Status: "ready",
+ Unit: *unit, DBPath: *dbPath, Status: "ready",
}); err != nil {
log.Fatal(err)
}
@@ -213,14 +221,19 @@ func runInstances(args []string) {
log.Fatal(err)
}
w := newTable(os.Stdout)
- fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tSTATUS\tVERSION\tHIB\tBACKING")
+ // BACKING and UNIT are the two halves of one answer — who owns this
+ // route's process — so they print side by side: a route with a UNIT is
+ // cycled by that unit on adoption, whatever BACKING says (and BACKING is
+ // empty for every such route, which is the shape that made issue #110
+ // invisible from this listing).
+ fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tSTATUS\tVERSION\tHIB\tBACKING\tUNIT")
for _, t := range routes {
hib := ""
if t.Hibernate {
hib = "yes"
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
- t.Host, t.App, t.Kind, t.Channel, t.Status, t.Version, hib, t.Backing)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ t.Host, t.App, t.Kind, t.Channel, t.Status, t.Version, hib, t.Backing, t.Unit)
}
w.Flush()
}
@@ -321,14 +334,22 @@ func runInstances(args []string) {
// make that canary's own `followed` check false, which mints a fresh canary
// host next pass. Additive, not reverted, but worth knowing.)
//
-// This verb also carries the three SINGLE-ROUTE axes, each of which stands
-// alone and short-circuits everything above (see runRouteGrant and
-// runRouteHibernate):
+// This verb also carries the four SINGLE-ROUTE axes, each of which stands
+// alone and short-circuits everything above (see runRouteGrant,
+// runRouteHibernate, runRouteBacking and runRouteUnit):
//
// carlos route -host keymail.dev -grant port:25
// carlos route -host platform.carlosframework.com -grant host-role
// carlos route -host keymail.dev -revoke port:25
// carlos route -host keymail.dev -hibernate=false
+// carlos route -host console.carloku.com -unit console.service
+//
+// -unit is how an EXISTING route — the production consoles, addr-backed under
+// a bespoke console.service — tells the adopt pass which unit to cycle when
+// its version moves. Without it adoption stamped Version (and therefore the
+// edge's X-Carlos-Version header) over a process nothing had restarted, and
+// the deploy needed a hand-run `systemctl restart console.service` to become
+// true (issue #110). Like the other three it is -host only.
//
// -grant/-revoke are the operator's door to the two operator-consented
// capabilities (spec §2 "Grants"): a manifest DECLARES `ports`/`host_role`,
@@ -351,6 +372,8 @@ func runRoute(args []string) {
revoke := fs.String("revoke", "", "capability token to revoke from this route on this box")
hibernate := fs.Bool("hibernate", false, "hibernate this route when idle; -hibernate=false stops it hibernating")
backing := fs.String("backing", "", `who owns this route's process: "unit" (a carlos-app@<host> systemd unit) | "" (an exec child of the agent)`)
+ unit := fs.String("unit", "",
+ `systemd unit that owns this route's process (e.g. console.service), so adoption can cycle it; "-" clears the stamp (host only, no -app)`)
tunnel := fs.String("tunnel", "",
`name of the reverse channel backing this route at the edge; "-" clears to a Socket/Addr-routed upstream (host only, no -app)`)
allowVersionChange := fs.Bool("allow-version-change", false,
@@ -366,14 +389,21 @@ func runRoute(args []string) {
// `-hibernate=false` rather than a quoted tri-state string.
// -backing is tri-state for the same reason, and more sharply: "" is a
// LEGAL VALUE of that column (an exec child), so a flag left unmentioned
- // and a flag set to "" cannot be told apart by the value alone.
- hibernateSet, backingSet := false, false
+ // and a flag set to "" cannot be told apart by the value alone. -unit is
+ // tri-state on the same grounds — "" is that column's default, meaning
+ // "no unit owns this route's process" — though its CLEAR spelling is
+ // "-unit -", the sentinel -environment and -tunnel already use, so that a
+ // bare `-unit ""` reads as the mistake it almost certainly is rather than
+ // as a silent unstamp.
+ hibernateSet, backingSet, unitSet := false, false, false
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "hibernate":
hibernateSet = true
case "backing":
backingSet = true
+ case "unit":
+ unitSet = true
}
})
@@ -382,7 +412,8 @@ func runRoute(args []string) {
" (-account-id names ONE account by sqid and supersedes -account, \"all\" included; -tunnel is -host only)\n" +
" or: carlos route -host <host> (-grant | -revoke) <port:n|host-role> [-registry <db>] [-dry-run]\n" +
" or: carlos route -host <host> -hibernate[=false] [-registry <db>] [-dry-run]\n" +
- ` or: carlos route -host <host> -backing <unit|""> [-registry <db>] [-dry-run]`
+ " or: carlos route -host <host> -backing <unit|\"\"> [-registry <db>] [-dry-run]\n" +
+ ` or: carlos route -host <host> -unit <name.service|-> [-registry <db>] [-dry-run]`
if *host == "" && *app == "" {
log.Fatal(usage)
}
@@ -414,11 +445,15 @@ func runRoute(args []string) {
log.Fatal("route: -backing is mutually exclusive with -grant/-revoke and -hibernate — run them as two commands, so each meets its own rail alone. " +
"The order is not arbitrary either: -backing unit comes FIRST, because a grant onto a route that is not unit-backed is refused (it would materialize nothing)")
}
- if (grantAxis || hibernateSet || backingSet) && (*channel != "" || *environment != "" || *tunnel != "") {
- log.Fatal("route: -grant/-revoke/-hibernate/-backing cannot be combined with -channel, -environment or -tunnel — a capability grant and a repoint are different operations with different rails, and one command that did both would commit them together")
+ if unitSet && (grantAxis || hibernateSet || backingSet) {
+ log.Fatal("route: -unit is mutually exclusive with -grant/-revoke, -hibernate and -backing — run them as two commands, so each meets its own rail alone. " +
+ "-unit and -backing in particular are two different answers to the same question (who owns this route's process), and a command that wrote both would leave the row saying both")
+ }
+ if (grantAxis || hibernateSet || backingSet || unitSet) && (*channel != "" || *environment != "" || *tunnel != "") {
+ log.Fatal("route: -grant/-revoke/-hibernate/-backing/-unit cannot be combined with -channel, -environment or -tunnel — a capability grant and a repoint are different operations with different rails, and one command that did both would commit them together")
}
- if (grantAxis || hibernateSet || backingSet) && *host == "" {
- log.Fatal("route: -grant/-revoke/-hibernate/-backing require -host, never -app: grants and process backing are per route per box by design, and a bulk grant is precisely the operation nobody should be able to type by accident\n\n" + usage)
+ if (grantAxis || hibernateSet || backingSet || unitSet) && *host == "" {
+ log.Fatal("route: -grant/-revoke/-hibernate/-backing/-unit require -host, never -app: grants, process backing and the owning unit are per route per box by design, and a bulk grant is precisely the operation nobody should be able to type by accident\n\n" + usage)
}
// The token is checked BEFORE the registry is opened for writing: a typo
// should cost nothing and reach nothing.
@@ -443,11 +478,31 @@ func runRoute(args []string) {
if backingSet && *backing != "" && *backing != registry.BackingUnit {
log.Fatalf("route: backing %q: want %q (a carlos-app@<host> systemd unit) or \"\" (an exec child of the agent)", *backing, registry.BackingUnit)
}
+ // -unit's value gets the same treatment at the same moment, and for the
+ // reason the column exists: an unvalidated name reaches the row verbatim
+ // and is discovered months later as an adopt pass that restarted nothing.
+ // "-" is the clear sentinel (checked first, exactly as envClear precedes
+ // ValidEnvironmentName), and it resolves to "" — the column's own default,
+ // which ValidUnitName accepts. A bare `-unit ""` is refused rather than
+ // treated as a clear: the doors that CLEAR say so out loud.
+ unitClear := *unit == "-"
+ resolvedUnit := *unit
+ if unitClear {
+ resolvedUnit = ""
+ }
+ if unitSet && !unitClear {
+ if *unit == "" {
+ log.Fatal(`route: -unit "" is refused — use "-unit -" to clear the stamp, or name the unit that owns this route's process (e.g. -unit console.service)`)
+ }
+ if !registry.ValidUnitName(*unit) {
+ log.Fatalf("route: unit %q: want a systemd service unit name (e.g. console.service), or \"-\" to clear the stamp", *unit)
+ }
+ }
if *tunnel != "" && *app != "" {
log.Fatal("route: -tunnel is only supported with -host — bulk -app tunnel repointing is not implemented\n\n" + usage)
}
- if *channel == "" && *environment == "" && *tunnel == "" && !grantAxis && !hibernateSet && !backingSet {
- log.Fatal(`route: at least one of -channel, -environment, -tunnel, -grant, -revoke, -hibernate or -backing is required. "" only means stable-by-default at route CREATION (carlos add) — ` +
+ if *channel == "" && *environment == "" && *tunnel == "" && !grantAxis && !hibernateSet && !backingSet && !unitSet {
+ log.Fatal(`route: at least one of -channel, -environment, -tunnel, -grant, -revoke, -hibernate, -backing or -unit is required. "" only means stable-by-default at route CREATION (carlos add) — ` +
"this verb sets an explicit binding, and needs an explicit target to set it to.")
}
if *channel != "" && !release.ValidChannel(*channel) {
@@ -509,7 +564,7 @@ func runRoute(args []string) {
// The single-route axes short-circuit here, before the channel/environment
// machinery below: they write one column of one row, through one narrow
// setter, and share none of that batch's all-or-nothing plumbing.
- if grantAxis || hibernateSet || backingSet {
+ if grantAxis || hibernateSet || backingSet || unitSet {
rt, err := r.Get(*host)
if err != nil {
log.Fatal(err)
@@ -522,8 +577,10 @@ func runRoute(args []string) {
runRouteGrant(r, *rt, *grant, *revoke, *dryRun)
case hibernateSet:
runRouteHibernate(r, *rt, *hibernate, *dryRun)
- default:
+ case backingSet:
runRouteBacking(r, *rt, *backing, *dryRun)
+ default:
+ runRouteUnit(r, *rt, resolvedUnit, *dryRun)
}
return
}
@@ -1193,6 +1250,90 @@ func backingRail(rt registry.Route) error {
return nil
}
+// runRouteUnit stamps the systemd unit that owns a route's process — the door
+// issue #110 needed. A console is an addr-backed route (`-addr
+// 127.0.0.1:8090`) under an operator-run console.service: the adopt pass's
+// restart dispatch could reach it by neither of its arms, so promote → adopt
+// stamped a new Version, and with it the edge's X-Carlos-Version header,
+// behind a process still running the old build. The rule was "promote →
+// adopt → ssh in and systemctl restart console.service", which is exactly the
+// kind of follow-up step a `carlos` command is not allowed to require.
+//
+// This is a stamp on an EXISTING row on a live box, deliberately: the
+// production consoles already exist, and the fix must not need them recreated.
+//
+// Two rails, both structural, neither overridable:
+//
+// - a unit-backed route is refused. Its process is a carlos-app@<host>
+// unit the platform derives and already restarts; a second unit name on
+// the same row is two answers to one question, and the dispatch would
+// honour this one and stop cycling the tenant.
+// - the name's shape is checked at the flag, before the registry is even
+// opened (registry.ValidUnitName), because a typo here is invisible
+// afterwards — a restart that reaches nothing looks exactly like a
+// restart that worked.
+//
+// It does NOT check that the unit exists. It cannot: on the box where it
+// matters the command may well run before the unit is installed, and a probe
+// that has to succeed would make the stamp unavailable exactly when it is
+// 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 {
+ log.Fatalf("route: %s: %v", rt.Host, err)
+ }
+ prefix := ""
+ if dryRun {
+ prefix = colorFor(os.Stdout).warn("(dry run)") + " "
+ }
+ if rt.Unit == unit {
+ mutedf(os.Stdout, "%s: unit already %s — nothing to do\n", rt.Host, unitWord(unit))
+ return
+ }
+ if !dryRun {
+ if err := r.SetUnit(rt.Host, unit); err != nil {
+ log.Fatalf("route: write: %v", err)
+ }
+ }
+ fmt.Printf("%s%s: unit %s -> %s\n", prefix, rt.Host, unitWord(rt.Unit), unitWord(unit))
+ if unit != "" {
+ fmt.Printf(" every adopt pass that moves this route's version now runs `systemctl try-restart %s` instead of cycling nothing — try-restart, so a unit you have stopped stays stopped\n", unit)
+ } else {
+ fmt.Printf(" adoption goes back to the backing dispatch for this route: a carlos-app@%s unit if it is unit-backed, the activator's own child otherwise — and neither exists for an addr-backed route, which is what leaves its process running the old build\n", rt.Host)
+ }
+}
+
+// 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.
+//
+// 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 {
+ 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)
+ }
+ return nil
+}
+
+// unitWord renders a unit column value for an operator — the empty string is
+// this column's default and a real answer ("nothing named"), and printing it
+// as "" reads as a bug.
+func unitWord(unit string) string {
+ if unit == "" {
+ return `"" (no unit named — adoption dispatches on backing)`
+ }
+ return unit
+}
+
// backingWord renders a backing column value for an operator — the empty
// string is a real value of this column, and printing it as "" reads as a
// bug rather than as an answer.
diff --git a/cli_test.go b/cli_test.go
index ee98ebea2..6528698f2 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -434,6 +434,182 @@ func TestBackingRailRefusals(t *testing.T) {
}
}
+// TestUnitRailRefusals: `carlos route -unit` is the stamp issue #110 needed,
+// and its one structural refusal is a unit-backed route — that route's
+// process is the carlos-app@<host> unit the platform derives and already
+// restarts, so a second unit name on the row is two answers to one question.
+//
+// Everything else clears the rail on purpose, and the omissions are the
+// interesting half: unlike backingRail this one does NOT refuse Platform rows
+// (the domains reconciler never derives this column and Upsert preserves it,
+// so a stamp survives a re-mint) and does NOT refuse non-instance kinds (a
+// `service` route is exactly a sibling process the platform only proxies, and
+// naming the unit that owns it is a true statement).
+func TestUnitRailRefusals(t *testing.T) {
+ console := registry.Route{
+ Host: "console.carloku.com", Kind: "instance", App: "console",
+ Addr: "127.0.0.1:8090",
+ }
+ unitBacked := console
+ unitBacked.Backing = registry.BackingUnit
+ err := unitRail(unitBacked)
+ if err == nil {
+ t.Fatal("unitRail(unit-backed route) accepted; want a refusal")
+ }
+ if !strings.Contains(err.Error(), "carlos-app@") {
+ t.Fatalf("refusal %q does not name the unit that already owns the process", err)
+ }
+
+ for _, c := range []struct {
+ name string
+ rt registry.Route
+ }{
+ {"addr-backed console", console},
+ {"platform apex", func() registry.Route { rt := console; rt.Platform = true; return rt }()},
+ {"platform+canary row", func() registry.Route {
+ rt := console
+ rt.Platform, rt.Canary = true, true
+ return rt
+ }()},
+ {"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 {
+ t.Fatalf("unitRail(%+v) = %v, want accepted", c.rt, 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
+// registry row rather than need the route recreated. It also pins the
+// refusals an operator can actually type — a typo'd unit name above all,
+// since a bad name is invisible afterwards (a restart that reaches nothing
+// looks exactly like one that worked).
+func TestRouteUnitStampsAnExistingRoute(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ scratch := t.TempDir()
+ regPath := filepath.Join(tmp, "registry.db")
+
+ run := func(args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t)
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout.
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // The console as it exists on a box today: addr-backed, no unit.
+ if out, err := run("add", "-registry", regPath, "-app", "console",
+ "-addr", "127.0.0.1:8090", "console.carloku.com"); err != nil {
+ t.Fatalf("add console route: %v\n%s", err, out)
+ }
+ unitOf := func(host string) string {
+ t.Helper()
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ defer reg.Close()
+ rt, err := reg.Get(host)
+ if err != nil || rt == nil {
+ t.Fatalf("get %s: %+v, %v", host, rt, err)
+ }
+ return rt.Unit
+ }
+ if got := unitOf("console.carloku.com"); got != "" {
+ t.Fatalf("a fresh route already names a unit: %q", got)
+ }
+
+ // A typo must cost nothing and reach nothing.
+ out, err := run("route", "-registry", regPath, "-host", "console.carloku.com", "-unit", "console.servcie")
+ if err == nil {
+ t.Fatalf("route -unit console.servcie: want non-zero exit, got 0\n%s", out)
+ }
+ if got := unitOf("console.carloku.com"); got != "" {
+ t.Fatalf("a refused -unit still wrote %q", got)
+ }
+
+ // -dry-run runs the rail and prints the plan, writing nothing.
+ out, err = run("route", "-registry", regPath, "-host", "console.carloku.com",
+ "-unit", "console.service", "-dry-run")
+ if err != nil {
+ t.Fatalf("route -unit -dry-run: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "console.service") {
+ t.Errorf("dry run output = %q, want it to name the unit", out)
+ }
+ if got := unitOf("console.carloku.com"); got != "" {
+ t.Fatalf("-dry-run wrote %q", got)
+ }
+
+ // The real stamp — the command the deploy runs on each box.
+ if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com",
+ "-unit", "console.service"); err != nil {
+ t.Fatalf("route -unit console.service: %v\n%s", err, out)
+ }
+ if got := unitOf("console.carloku.com"); got != "console.service" {
+ t.Fatalf("Unit = %q after the stamp, want console.service", got)
+ }
+
+ // It survives a re-`carlos add` of the same row (registry.Upsert's
+ // preservation): dropping it would silently restore issue #110.
+ if out, err := run("add", "-registry", regPath, "-app", "console",
+ "-addr", "127.0.0.1:8090", "console.carloku.com"); err != nil {
+ t.Fatalf("re-add console route: %v\n%s", err, out)
+ }
+ if got := unitOf("console.carloku.com"); got != "console.service" {
+ t.Fatalf("Unit = %q after a re-add, want the stamp preserved", got)
+ }
+
+ // The listing shows it, so an operator can see which routes adoption can
+ // cycle without opening the DB.
+ out, err = run("instances", "-registry", regPath)
+ if err != nil {
+ t.Fatalf("instances: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "UNIT") || !strings.Contains(out, "console.service") {
+ t.Errorf("instances output = %q, want a UNIT column carrying console.service", out)
+ }
+
+ // And the clear door, so a mis-stamp is reversible.
+ if out, err := run("route", "-registry", regPath, "-host", "console.carloku.com", "-unit", "-"); err != nil {
+ t.Fatalf("route -unit -: %v\n%s", err, out)
+ }
+ if got := unitOf("console.carloku.com"); got != "" {
+ t.Fatalf("Unit = %q after -unit -, want it cleared", got)
+ }
+
+ // The exclusions: each of these is a command that would otherwise do part
+ // of what was typed and report success.
+ for _, c := range []struct {
+ name string
+ args []string
+ }{
+ {"with -channel", []string{"-host", "console.carloku.com", "-unit", "console.service", "-channel", "stable"}},
+ {"with -backing", []string{"-host", "console.carloku.com", "-unit", "console.service", "-backing", "unit"}},
+ {"with -hibernate", []string{"-host", "console.carloku.com", "-unit", "console.service", "-hibernate"}},
+ {"bulk -app", []string{"-app", "console", "-unit", "console.service"}},
+ {"empty value", []string{"-host", "console.carloku.com", "-unit", ""}},
+ {"unknown host", []string{"-host", "nosuch.example.com", "-unit", "console.service"}},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ out, err := run(append([]string{"route", "-registry", regPath}, c.args...)...)
+ if err == nil {
+ t.Fatalf("route %v: want non-zero exit, got 0\n%s", c.args, out)
+ }
+ })
+ }
+ if got := unitOf("console.carloku.com"); got != "" {
+ t.Fatalf("a refused command wrote %q", got)
+ }
+}
+
// --- release versioning: the CLI surface -------------------------------
//
// (release versioning spec, 2026-08-08.) Driven through the real binary in