| 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. |