carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Operator CLI verbs: route management (add/remove/instances) and the
2// release pipeline (ship/promote/channels/rollback). Route verbs adapted
3// from titogo internal/carlos/cli.go — deliberately thin: a route is a row,
4// and starting the process is the agent's job.
5package main
6
7import (
8 "context"
9 "crypto/ed25519"
10 "crypto/sha256"
11 "encoding/hex"
12 "encoding/json"
13 "errors"
14 "flag"
15 "fmt"
16 "io"
17 "log"
18 "net/http"
19 "os"
20 "path/filepath"
21 "regexp"
22 "sort"
23 "strings"
24 "time"
25
26 "github.com/carlosframework/platform/internal/activator"
27 "github.com/carlosframework/platform/internal/appconfig"
28 "github.com/carlosframework/platform/internal/caps"
29 "github.com/carlosframework/platform/internal/edge"
30 "github.com/carlosframework/platform/internal/host"
31 "github.com/carlosframework/platform/internal/ledger"
32 "github.com/carlosframework/platform/internal/provision"
33 "github.com/carlosframework/platform/internal/registry"
34 "github.com/carlosframework/platform/internal/release"
35 "github.com/carlosframework/platform/internal/status"
36 "github.com/carlosframework/platform/internal/version"
37)
38
39// stdout is the print seam for the four release-pipeline verbs that now
40// speak the console API (ship/promote/channels/rollback): a package-level
41// var rather than an os.Stdout literal, so a test can swap it for a buffer
42// and assert on it. Every OTHER verb in this file still writes straight to
43// os.Stdout/fmt.Printf, unchanged — this seam exists only where Task 5 added
44// behaviour worth pinning with an assertion; log.Fatal paths are untouched by
45// it on purpose, exactly as they were before.
46var stdout io.Writer = os.Stdout
47
48func dataDir() string {
49 if d := os.Getenv("CARLOS_DATA_DIR"); d != "" {
50 return d
51 }
52 return "/data"
53}
54
55func defaultRegistry() string { return filepath.Join(dataDir(), "registry.db") }
56
57// reservedSlugs comes from configuration, not a hardcoded list (titogo's
58// lesson: its reserved list named Tito features).
59func reservedSlugs() map[string]bool {
60 out := map[string]bool{}
61 for _, s := range strings.Split(os.Getenv("CARLOS_RESERVED_SLUGS"), ",") {
62 if s = strings.TrimSpace(s); s != "" {
63 out[s] = true
64 }
65 }
66 return out
67}
68
69// refuseAddNested stops a re-add of a host whose row is on the nested
70// layout. Upsert overwrites the socket, database path and hibernate flag
71// with this verb's flat defaults, which would leave the row pointing at
72// the shared directory over files that live under the instance tree, and
73// the next wake would restore a second database there (design §8.2,
74// downgrade rules). A host with a layout journal is mid-move, row or no
75// row, and a re-add would rewrite what recovery is about to check, so it
76// is refused the same way. A host with no row, or a flat one, goes ahead.
77func refuseAddNested(host string, existing *registry.Route, dataDir string) error {
78 if (existing != nil && provision.NestedPaths(dataDir, *existing)) || provision.LayoutJournalExists(dataDir, host) {
79 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)
80 }
81 return nil
82}
83
84// addMintsNested reports whether `carlos add` on this box mints the
85// nested defaults: the box's layout intent names nested, or, with no
86// intent recorded, the box is already nested by default, which this verb
87// can only tell from the registry (nestedByEvidence: an activator-managed
88// row already at the nested paths), because the agent's readiness gate is
89// not reachable from an operator's shell. With no deploy store in the
90// environment (a box before its bootstrap, a dev shell) the registry's
91// evidence is all there is. A recorded flat intent is the opt-out and
92// wins over the evidence. An intent that cannot be read is an error, not
93// a guess: a flat row on a nested box is the thing this exists to
94// prevent, and the operator can pass --socket and --db to say so
95// explicitly.
96func addMintsNested(ctx context.Context, store release.Store, boxLabel func() string, routes []registry.Route, dd string) (bool, error) {
97 if store != nil {
98 in, found, err := release.LoadLayoutIntent(ctx, store, boxLabel())
99 if err != nil {
100 return false, fmt.Errorf("could not read the box's layout intent, so the row's paths cannot be chosen: %v. Retry, or pass --socket and --db", err)
101 }
102 if found {
103 return in.Mode == release.LayoutModeNested, nil
104 }
105 }
106 for _, rt := range routes {
107 if activator.ActivatorManaged(rt) && provision.NestedPaths(dd, rt) {
108 return true, nil
109 }
110 }
111 return false, nil
112}
113
114// boxLabelForAdd is the label this box's status publishes under, which is
115// what a layout intent's scope names. A function value so runAdd's `host`
116// string does not shadow the package.
117var boxLabelForAdd = func() string { return host.BoxLabel() }
118
119func runAdd(args []string) {
120 fs := flag.NewFlagSet("add", flag.ExitOnError)
121 reg := fs.String("registry", defaultRegistry(), "registry database")
122 app := appFlag(fs, "app whose releases this route runs")
123 acct := fs.String("account", "ops", "account (sqid or name) the app belongs to")
124 acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
125 slug := fs.String("slug", "", "root-domain short name (optional)")
126 kind := fs.String("kind", "instance", "instance | service | static")
127 socket := fs.String("socket", "", "unix socket upstream")
128 addr := fs.String("addr", "", "TCP upstream host:port (whole-app tenants)")
129 channel := aliasedString(fs, "channel", "stable", "release channel to follow")
130 hibernate := fs.Bool("hibernate", false, "hibernate when idle")
131 statusNamed := fs.Bool("status-named", false, "opt this app into being named on the public status page")
132 backing := fs.String("backing", "", `process backing: "" (agent exec child) | "unit" (carlos-app@<host> systemd unit)`)
133 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)")
134 dbPath := fs.String("db", "", "instance database path")
135 fs.Usage = flagUsage(fs, "")
136 fs.Parse(args)
137 if fs.NArg() != 1 {
138 log.Fatal("usage: carlos add [flags] <host>")
139 }
140 host := fs.Arg(0)
141 if err := addRail(host, *kind); err != nil {
142 log.Fatalf("add %s: %v", host, err)
143 }
144 if err := appRail(*app); err != nil {
145 log.Fatalf("add %s: %v", host, err)
146 }
147 if reservedSlugs()[*slug] {
148 log.Fatalf("slug %q is reserved", *slug)
149 }
150 if *backing != "" && *backing != registry.BackingUnit {
151 log.Fatalf("backing %q: want \"\" or %q", *backing, registry.BackingUnit)
152 }
153 // Checked here, before anything is opened or written, for the same reason
154 // --backing is: registry.SetUnit would refuse it too, but a route created
155 // with a typo'd unit is a route whose adoption silently restarts nothing
156 // — the failure this column exists to end.
157 if !registry.ValidUnitName(*unit) {
158 log.Fatalf("add %s: unit %q: want a systemd service unit name (e.g. console.service)", host, *unit)
159 }
160 // The same combination `carlos route --unit` refuses (unitRail), refused at
161 // the OTHER door onto these two columns — and this is the door that could
162 // mint the row from nothing. A route that is both unit-backed and
163 // unit-named is two answers to one question, and the dispatch resolves it
164 // by honouring the name: the carlos-app@<host> unit would then never be
165 // cycled by adoption again, which is issue #110 recreated for an ordinary
166 // tenant. Same wording as unitRail's, so an operator who meets it at one
167 // door recognises it at the other.
168 if *unit != "" && *backing == registry.BackingUnit {
169 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, "+
170 "so a second unit name here would be two answers to one question — drop one of the two flags", host, *unit, host)
171 }
172 if *socket == "" && *addr == "" && *kind == "instance" {
173 if *backing == registry.BackingUnit {
174 // The carlos-app@<host>.socket unit's path — enable that unit
175 // alongside this route (or use --addr for a TCP-only app). Not a
176 // provision.DefaultSocket concern: that helper names the
177 // exec-child convention, and a unit tenant's socket directory is
178 // a different (systemd-owned) tree entirely.
179 *socket = filepath.Join("/run/carlos/apps", host+".sock")
180 } else {
181 *socket = provision.DefaultSocket(host)
182 }
183 }
184 // No DB default for unit tenants: their data lives in the unit's
185 // StateDirectory (/var/lib/carlos-app/<host>), under a filename the APP
186 // chooses (no platform-wide convention the way dataDir/<host>.db is for
187 // exec children) — so there is no honest path to default to. --db stays
188 // an explicit operator choice there, and ops.FleetDBs (the box-wide
189 // litestream unit's config generator) only learns a unit tenant's
190 // database path from this flag; leave it unset and that tenant's data
191 // is replicated by nobody.
192 if *dbPath == "" && *kind == "instance" && *backing != registry.BackingUnit {
193 *dbPath = provision.DefaultDBPath(dataDir(), host)
194 }
195 // On a box whose layout intent is nested, a row this verb would make
196 // activator-managed gets the nested defaults (design §8.6 step 5,
197 // plan 5): it starts out isolated instead of running flat for a pass
198 // and being moved. Explicit --socket or --db is the operator's own
199 // choice and is left alone (the pass reports it foreign).
200 if *kind == "instance" && *backing == "" && *hibernate && *addr == "" &&
201 *socket == provision.DefaultSocket(host) && *dbPath == provision.DefaultDBPath(dataDir(), host) {
202 store, _ := deploymentReadSeam()
203 var evidence []registry.Route
204 if r, err := registry.Open(*reg); err == nil {
205 evidence, _ = r.List()
206 r.Close()
207 }
208 nested, err := addMintsNested(context.Background(), store, boxLabelForAdd, evidence, dataDir())
209 if err != nil {
210 log.Fatalf("add %s: %v", host, err)
211 }
212 if nested {
213 *socket, *dbPath = provision.NestedSocket(host), provision.NestedDBPath(dataDir(), host)
214 }
215 }
216 // A hibernating unit tenant stops and starts with systemd, not the
217 // agent — box-wide litestream (root) is the ONLY thing that can
218 // replicate it, so skipping --db here is a genuine data-loss risk
219 // (not just a missed nicety) the moment the unit is ever stopped for
220 // idleness. Require it. A non-hibernating unit tenant is always running
221 // and so has no equivalent forced moment of data being unreachable if
222 // unreplicated — --db is still strongly recommended there but stays the
223 // operator's call, unenforced.
224 if *backing == registry.BackingUnit && *hibernate && *dbPath == "" {
225 log.Fatalf("add %s: --backing unit --hibernate requires --db <path> — the box-wide litestream unit can't find a unit tenant's database on its own (see ops.FleetDBs)", host)
226 }
227 // An agent-exec child gets no STATE_DIRECTORY — the activation
228 // contract's "relative --db resolves inside $STATE_DIRECTORY" convention
229 // is systemd's half, real only under --backing unit. On the exec path the
230 // activator consumes the stored path verbatim (Restore, Seed, Start's
231 // --db flag, the replicator), so a relative path resolves against a cwd
232 // nobody chose and every wake dies as an edge 503 discoverable only in
233 // the journal (issue #70). Refuse it here, at the only door that can
234 // introduce one — every other mint path builds absolute paths.
235 if *backing != registry.BackingUnit && *dbPath != "" && !filepath.IsAbs(*dbPath) {
236 log.Fatalf("add %s: --db %q is relative, and agent-exec children get no STATE_DIRECTORY to resolve it in — pass an absolute path (the default here is %s), or use --backing unit, whose StateDirectory= makes relative paths real",
237 host, *dbPath, provision.DefaultDBPath(dataDir(), host))
238 }
239 account := resolveAccount(*reg, *acct, *acctID)
240 // A `daemon` route may not hibernate: the capability means the platform
241 // owns the process (enable, start, restart-on-adopt), and hibernation is
242 // the activator stopping the very process that declaration exists to keep
243 // up. Refused HERE when the comparison resolves — but, unlike `carlos
244 // route --hibernate`, an UNRESOLVABLE comparison proceeds silently: this
245 // verb runs at provision time on a box with no release pubkey, no
246 // deployment store, and often no release shipped yet, and refusing there
247 // would break provisioning. The authoritative refusal is the converge
248 // pass, which sees the manifest every hour on a box that has one.
249 if *hibernate {
250 store, pub := deploymentReadSeam()
251 daemon, note, proven := routeDaemonCheck(context.Background(), store, pub, account,
252 registry.Route{Host: host, App: *app, Channel: *channel})
253 if proven && daemon {
254 log.Fatalf("add %s: %s (%s)", host, daemonHibernateRefusal, note)
255 }
256 }
257 r, err := registry.Open(*reg)
258 if err != nil {
259 log.Fatal(err)
260 }
261 // The account is stamped on the row: it is what adopt and the edge build
262 // this app's bucket keys from. Reads still fall back to the legacy
263 // unqualified keys, so an unmigrated app added here keeps working.
264 //
265 // An upsert over an existing host is a rewrite, not a creation, and the
266 // verb the operator reads back must say which one happened (issue #69):
267 // "added" printed over a rewrite is how vitogo's operator was left
268 // believing a fresh row existed while a running edge kept the old one.
269 existing, err := r.Get(host)
270 if err != nil {
271 log.Fatal(err)
272 }
273 if err := refuseAddNested(host, existing, dataDir()); err != nil {
274 log.Fatalf("%v", err)
275 }
276 route := registry.Route{
277 Host: host, Slug: *slug, Kind: *kind, Socket: *socket, Addr: *addr,
278 App: *app, Account: account,
279 Channel: *channel, Hibernate: *hibernate, StatusNamed: *statusNamed, Backing: *backing,
280 Unit: *unit, DBPath: *dbPath, Status: "ready",
281 }
282 if existing != nil {
283 // Carried forward from Task 3's review (registry.go's Upsert doc names
284 // this exact gap at cli.go:194-198): this verb has no flags for a
285 // multi-upstream set or its probe, so it must not be the thing that
286 // blanks one — Upsert's addrs/health columns are plain overwrites, and
287 // a Route literal built above with no Addrs/Health always overwrites
288 // with "". Preserving what this box-local verb doesn't manage mirrors
289 // Upsert's own CASE-preserved columns (environment, caps, tunnel, ...):
290 // an absent flag keeps what was already there.
291 //
292 // An explicit --addr is different, and deliberately NOT folded into
293 // that same "flag wins" story when the row already carries an
294 // established Addrs set (Task 8 review, IMPORTANT 1): silently
295 // clearing Addrs here to let --addr win would build a row
296 // ValidateShape forbids in every other door ("addr and addrs both
297 // set"), and even if it didn't, AddrList and the edge already prefer
298 // Addrs over Addr — the --addr would be dead on arrival, and the next
299 // converge tick would revert it anyway (convergeOwned re-derives Addr
300 // FROM the record's Addrs), the exact "success printed over nothing"
301 // shape issue #69 named. The record — not this box verb — owns
302 // multi-upstream shape, so the fix is a refusal that names the door
303 // that does: `carlos instances set-upstreams`.
304 if *addr != "" && existing.Addrs != "" {
305 log.Fatalf("add %s: --addr %s would coexist with the established multi-upstream set (%s) on this row — "+
306 "a route picks one upstream shape, and the record owns it, not this box verb. Repoint the set with "+
307 "`carlos instances set-upstreams --app %s --host %s --addrs ...` instead of --addr here", host, *addr, existing.Addrs, *app, host)
308 }
309 route.Addrs, route.Health = existing.Addrs, existing.Health
310 if *addr == "" {
311 route.Addr = existing.Addr
312 }
313 // Steering (multi-region ingress design Part 4) carries no -steering
314 // flag on this box verb either, and Upsert overwrites it exactly like
315 // Addrs/Health (registry.go's Upsert doc comment) — so the same gap
316 // applies: a bare re-add would silently clear a route's Route53
317 // steering opt-in the moment the steering housekeeper duty (Task 6)
318 // starts converging DNS from it. Preserve it the same way.
319 route.Steering = existing.Steering
320 // Replicas (#162) has the same shape and a different preserve rule:
321 // the column CONVERGES from the record in both directions (the one
322 // deliberate widening), so the next converge tick repairs a re-add's
323 // blank anyway — but between this write and that tick the edges file
324 // would publish the account without its replica placements, a window
325 // of residence lying for seconds-to-minutes. Preserve it here so the
326 // window does not exist. The replica MARK is deliberately NOT
327 // preserved: it rides the record's Role, which this verb cannot
328 // express, and a re-add minting an unmarked row over a replica's is
329 // a shape change the next converge repairs — the mark is a routing
330 // annotation, not a loss of state.
331 route.Replicas = existing.Replicas
332 }
333 if err := r.Upsert(route); err != nil {
334 log.Fatal(err)
335 }
336 if existing != nil {
337 fmt.Printf("%s %s (existing route)\n", colorFor(os.Stdout).ok("updated"), host)
338 } else {
339 fmt.Printf("%s %s\n", colorFor(os.Stdout).ok("added"), host)
340 }
341}
342
343func runRemove(args []string) {
344 fs := flag.NewFlagSet("remove", flag.ExitOnError)
345 reg := fs.String("registry", defaultRegistry(), "registry database")
346 fs.Usage = flagUsage(fs, "")
347 fs.Parse(args)
348 if fs.NArg() != 1 {
349 log.Fatal("usage: carlos remove <host>")
350 }
351 r, err := registry.Open(*reg)
352 if err != nil {
353 log.Fatal(err)
354 }
355 // Data-safe by contract: stops routing the host, never touches its DB.
356 if err := r.Delete(fs.Arg(0)); err != nil {
357 log.Fatal(err)
358 }
359 fmt.Printf("%s %s\n", colorFor(os.Stdout).ok("removed"), fs.Arg(0))
360}
361
362func runInstances(args []string) {
363 // The subcommands are positional words, not flags, and must never reach
364 // fs.Parse: enable is the app's provisioning opt-in (instances_cli.go),
365 // create/list/delete are the console-API record fronts (issue #66).
366 // Every other invocation is the bare listing form below — THIS box's
367 // registry routes, operator-side — unchanged.
368 if len(args) > 0 {
369 switch args[0] {
370 case "enable":
371 runInstancesEnable(args[1:])
372 return
373 case "create":
374 runInstancesCreate(args[1:])
375 return
376 case "import":
377 runInstancesImport(args[1:])
378 return
379 case "list":
380 runInstancesList(args[1:])
381 return
382 case "delete":
383 runInstancesDelete(args[1:])
384 return
385 case "door-key":
386 runInstancesDoorKey(args[1:])
387 return
388 case "set":
389 runInstancesSet(args[1:])
390 return
391 case "update":
392 runInstancesUpdate(args[1:])
393 return
394 case "set-upstreams":
395 runInstancesSetUpstreams(args[1:])
396 return
397 case "set-channel":
398 runInstancesSetChannel(args[1:])
399 return
400 case "set-environment":
401 runInstancesSetEnvironment(args[1:])
402 return
403 case "retry":
404 runInstancesRetry(args[1:])
405 return
406 }
407 }
408 fs := flag.NewFlagSet("instances", flag.ExitOnError)
409 reg := fs.String("registry", defaultRegistry(), "registry database")
410 fs.Usage = flagUsage(fs, "")
411 fs.Parse(args)
412 r, err := registry.Open(*reg)
413 if err != nil {
414 log.Fatal(err)
415 }
416 routes, err := r.List()
417 if err != nil {
418 log.Fatal(err)
419 }
420 w := newTable(os.Stdout)
421 // BACKING and UNIT are the two halves of one answer — who owns this
422 // route's process — so they print side by side: a route with a UNIT is
423 // cycled by that unit on adoption, whatever BACKING says (and BACKING is
424 // empty for every such route, which is the shape that made issue #110
425 // invisible from this listing).
426 //
427 // ENV is the named config bundle the route is bound to (issue #64). It
428 // decides which secrets this route's process is delivered, and reading
429 // it used to mean sqlite3 against the registry — the one step of
430 // titogo's secrets procedure that still needed box access. It sits
431 // beside CHANNEL because the two are the same kind of fact about a
432 // route and are set by the same verb (`carlos route`). Empty means the
433 // app's default bundle, which is a real answer, so it prints blank
434 // rather than being singled out. Off-box, `carlos routes` answers the
435 // same question through the console.
436 //
437 // REPL is the rung of the replication ladder this route's database is
438 // replicated on (discussion #10). It prints beside HIB because the two
439 // are one fact: the class is read only where the activator replicates,
440 // which is a hibernating route. An empty cell means the pre-ladder
441 // default — change-driven ~1s sync — and until `carlos route
442 // --replication` existed it was the honest answer for every route on
443 // every box, which is precisely why the column had to become visible at
444 // the same moment it became settable.
445 fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tENV\tSTATUS\tVERSION\tHIB\tREPL\tBACKING\tUNIT")
446 for _, t := range routes {
447 hib := ""
448 if t.Hibernate {
449 hib = "yes"
450 }
451 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
452 t.Host, t.App, t.Kind, t.Channel, t.Environment, t.Status, t.Version, hib, t.Replication, t.Backing, t.Unit)
453 }
454 w.Flush()
455}
456
457// runRoute changes which release channel and/or which named config
458// environment a route follows — the verb that exists so "move keymail.dev
459// off canary/keymail" or "bind demo.go.tito.io to the demo environment" is a
460// command instead of raw sqlite against a production registry. The two axes
461// are independent (2026-08-02-pipelines-design.md §2): --channel says how
462// mature a build is, --environment says what glue it's wired to
463// (internal/appconfig's config.json default bundle, optionally overlaid by
464// a named apps/<acct>/<app>/environments/<name>.json bundle — see
465// appconfig.LoadMerged). At least one of the two flags is required; either
466// may be given alone, or both together.
467//
468// The channel safety rail is the whole point of that half: adoption reads a
469// route's channel and converges it onto whatever version that channel
470// currently points at, so repointing a route to a channel holding a
471// DIFFERENT version than the route is actually serving changes what
472// production serves on the next adopt/recheck pass. Before writing
473// anything, this resolves what the TARGET channel's signed pointer says
474// (same env/store plumbing as runAccountsMigrate/verifyMigratedPointers:
475// CARLOS_RELEASE_PUBKEY, the deployment store) and compares it — see
476// routeVersionCheck — against what the route is actually serving. Two
477// outcomes can be written: the versions agree (proceed unconditionally), or
478// they genuinely disagree and the operator passed --allow-version-change
479// (the one thing that flag means). Anything that leaves the comparison
480// UNPROVEN — no pubkey, an unresolvable channel, a bad signature — is
481// refused with NO override: --allow-version-change means "I know it's
482// different, do it anyway," never "skip finding out."
483//
484// The environment rail is simpler and has no override: --environment names a
485// bundle that must already exist in the bucket for the route's app+account
486// (checked via appconfig.ListEnvironments, the same Store the channel rail
487// uses — unavailable store fails closed exactly like the channel rail's
488// missing-pubkey/missing-store cases), or the write is refused, naming
489// `carlos env set --app <a> --environment <name> ...` as the way to create
490// it first. "--environment -" (a literal single dash) clears the binding
491// back to the app's default bundle and needs no bucket check — there is
492// nothing to prove exists.
493//
494// A third, independent axis, --tunnel, names the reverse channel a route is
495// bound to at the edge (registry.Route.Tunnel — backing for later work in
496// the remote-host-agent feature, issue #53). It is simpler than --environment
497// in one way and matches it in another: shape is checked the same way
498// (registry.ValidTunnelName, same [a-z0-9-]{1,32} pattern as
499// ValidEnvironmentName), but there is no bucket to check EXISTENCE against
500// (a tunnel only becomes real once a host-agent dials in under that name) —
501// and it is --host only, since there is no bulk sibling to SetTunnel, so --app
502// + --tunnel is refused outright rather than silently doing nothing. Same
503// "--tunnel -" clear sentinel and the same platformCanaryOwnedRow refusal as
504// --channel/--environment, for the same structural reason. It writes through
505// its own SetTunnel call, independent of the --channel/--environment switch
506// below — with only ever one candidate, there is no combined transaction for
507// it to join.
508//
509// When BOTH --channel and --environment are given, both rails must resolve cleanly before EITHER
510// axis writes: a single combined transaction (SetChannelsAndEnvironments)
511// commits both columns for every candidate together, so a combined move can
512// never leave a route repointed on one axis but not the other.
513//
514// --app's bulk form is all-or-nothing (2026-08-02 review, CRITICAL 1): every
515// matched route's rail(s) are resolved BEFORE any write, and a single
516// refusal — on EITHER axis — aborts the whole batch with every failure
517// printed — never a half-moved app. It also excludes Platform routes with a
518// printed reason, since a bulk sweep touching every tenant's minted route in
519// one command is a different, riskier operation than "move this app's
520// ordinary routes" — Platform routes stay opt-in, one --host at a time.
521//
522// A Platform+Canary route's Channel is a harder boundary: the domains
523// reconciler's canary-minting pass (internal/domains/reconcile.go) is
524// idempotent via `followed`, a check computed from THAT ROUTE'S OWN Channel
525// column — not from the `byHost` map the same loop also sets but never
526// reads for that purpose (a claim this comment got wrong in an earlier
527// revision; re-review NEW 1 caught it). So ANY change to a Platform+Canary
528// route's channel — not only a move off canary/*, moving BETWEEN two
529// canary/* channels is just as reachable — makes `followed` false for the
530// channel it left, and the next reconcile pass re-Upserts the same host
531// from `firstReadyUpstream`, silently reverting this verb's write and
532// blanking version/status/dns/cert/ENVIRONMENT columns to mintedRoute's
533// zero values (mintedRoute never sets Environment — see its doc comment).
534// platformCanaryOwnedRow refuses this unconditionally, structurally, for
535// every candidate route in pass 1 below — not only in the --host branch —
536// so it holds even if a future change lets --app include Platform routes.
537// The SAME function guards the --environment axis (2026-08-02 review,
538// IMPORTANT 4): the re-mint above rebuilds the whole ROW from mintedRoute,
539// not just the channel it derives, so an environment binding on a
540// Platform+Canary route is a binding on a row this verb does not own.
541// registry.Upsert now preserves a non-empty Environment, which makes such a
542// binding survivable rather than silently reverted — but "survives the
543// reconciler" is not the same as "is owned by the operator", and an earlier
544// revision of this comment argued from "reconcile does not DERIVE
545// Environment", which was true and not the point. A plain Platform apex
546// (non-Canary) is still bindable via --host on either axis.
547// (A static, non-Canary Platform apex has no such trap: its idempotency
548// guard, `hasApex`, never reads Channel at all, so --host may freely repoint
549// one — though doing so while it happens to sit on a canary/* channel does
550// make that canary's own `followed` check false, which mints a fresh canary
551// host next pass. Additive, not reverted, but worth knowing.)
552//
553// This verb also carries the four SINGLE-ROUTE axes, each of which stands
554// alone and short-circuits everything above (see runRouteGrant,
555// runRouteHibernate, runRouteBacking and runRouteUnit):
556//
557// carlos route --host keymail.dev --grant port:25
558// carlos route --host platform.carlosframework.com --grant host-role
559// carlos route --host gotenberg.docs.example.com --grant doc-engine
560// carlos route --host keymail.dev --revoke port:25
561// carlos route --host keymail.dev --hibernate=false
562// carlos route --host console.carloku.com --unit console.service
563//
564// --unit is how an EXISTING route — the production consoles, addr-backed under
565// a bespoke console.service — tells the adopt pass which unit to cycle when
566// its version moves. Without it adoption stamped Version (and therefore the
567// edge's X-Carlos-Version header) over a process nothing had restarted, and
568// the deploy needed a hand-run `systemctl restart console.service` to become
569// true (issue #110). Like the other three it is --host only.
570//
571// --grant/--revoke are the operator's door to the two operator-consented
572// capabilities (spec §2 "Grants"): a manifest DECLARES `ports`/`host_role`,
573// and nothing materializes until an operator has granted it on THIS box.
574// They require --host and never accept --app — grants are per route per box by
575// design, and a bulk grant is precisely the operation nobody should be able
576// to type by accident. --hibernate is here because a `daemon` route may not
577// hibernate, and that refusal belongs at the same door as the flag.
578func runRoute(args []string) {
579 fs := flag.NewFlagSet("route", flag.ExitOnError)
580 regPath := fs.String("registry", defaultRegistry(), "registry database")
581 host := fs.String("host", "", "the one route to repoint")
582 app := appFlag(fs, "repoint every non-platform route of this app")
583 acctFilter := fs.String("account", "ops", `account (sqid or name) to filter --app routes by; "all" for every account`)
584 acctIDFilter := fs.String("account-id", "", "account sqid to filter --app routes by, matched as a sqid ONLY — supersedes --account")
585 channel := channelFlag(fs, "release channel to subscribe the route(s) to")
586 environment := environmentFlag(fs,
587 `named config environment to bind the route(s) to (must already exist in the bucket); "-" clears to default`)
588 grant := fs.String("grant", "", "capability token to grant this route ON THIS BOX: port:<n> (privileged, never 22/80/443) | host-role | doc-engine")
589 revoke := fs.String("revoke", "", "capability token to revoke from this route on this box")
590 hibernate := fs.Bool("hibernate", false, "hibernate this route when idle; --hibernate=false stops it hibernating")
591 backing := fs.String("backing", "", `who owns this route's process: "unit" (a carlos-app@<host> systemd unit) | "" (an exec child of the agent)`)
592 unit := fs.String("unit", "",
593 `systemd unit that owns this route's process (e.g. console.service), so adoption can cycle it; "-" clears the stamp (host only, no --app)`)
594 tunnel := fs.String("tunnel", "",
595 `name of the reverse channel backing this route at the edge; "-" clears to a Socket/Addr-routed upstream (host only, no --app)`)
596 replication := fs.String("replication", "",
597 `replication ladder rung for this route's database: `+strings.Join(registry.ReplicationClasses(), " | ")+`; "-" clears to the default (host only, no --app)`)
598 allowVersionChange := fs.Bool("allow-version-change", false,
599 "allow repointing to a channel that RESOLVES but holds a different version than the route currently serves "+
600 "(never overrides an unresolvable target, a bad signature, or a missing pubkey)")
601 dryRun := fs.Bool("dry-run", false, "run the full safety-rail check and print the plan, but write nothing")
602 const usage = "usage: carlos route --host <host> [--channel <channel>] [--environment <name>] [--tunnel <name>] [--registry <db>] [--allow-version-change] [--dry-run]\n" +
603 " or: carlos route --app <app> [--channel <channel>] [--environment <name>] [--account <sqid|name|all>] [--account-id <sqid>] [--registry <db>] [--allow-version-change] [--dry-run]\n" +
604 " (--account-id names ONE account by sqid and supersedes --account, \"all\" included; --tunnel is --host only)\n" +
605 " or: carlos route --host <host> (--grant | --revoke) <port:n|host-role|doc-engine> [--registry <db>] [--dry-run]\n" +
606 " or: carlos route --host <host> --hibernate[=false] [--registry <db>] [--dry-run]\n" +
607 " or: carlos route --host <host> --backing <unit|\"\"> [--registry <db>] [--dry-run]\n" +
608 " or: carlos route --host <host> --unit <name.service|-> [--registry <db>] [--dry-run]\n" +
609 ` or: carlos route --host <host> --replication <stream|batched|daily|-> [--registry <db>] [--dry-run]`
610 // -h names the remote verbs, because this one only ever finds the box
611 // it runs on: someone holding just this verb could not tell that a
612 // signed-in CLI changes a route's channel or environment from anywhere
613 // (#264).
614 fs.Usage = flagUsage(fs, usage+"\n\n"+
615 "This edits the routing table on the box it runs on, so it has to run there. To change a route's channel or environment from your own machine, sign in and use carlos instances set-channel or carlos instances set-environment.")
616 fs.Parse(args)
617 // --hibernate has to be TRI-state: a plain flag.Bool cannot tell "the
618 // operator did not mention hibernation" from "the operator asked for
619 // --hibernate=false", and this verb must never flip a column nobody named.
620 // fs.Visit reports only the flags actually present on the command line,
621 // which keeps the operator's syntax the natural `--hibernate` /
622 // `--hibernate=false` rather than a quoted tri-state string.
623 // --backing is tri-state for the same reason, and more sharply: "" is a
624 // LEGAL VALUE of that column (an exec child), so a flag left unmentioned
625 // and a flag set to "" cannot be told apart by the value alone. --unit is
626 // tri-state on the same grounds — "" is that column's default, meaning
627 // "no unit owns this route's process" — though its CLEAR spelling is
628 // "--unit -", the sentinel --environment and --tunnel already use, so that a
629 // bare `--unit ""` reads as the mistake it almost certainly is rather than
630 // as a silent unstamp.
631 // --replication is tri-state on --unit's exact grounds: "" is this
632 // column's default (the pre-ladder every-second sync), so a flag left
633 // unmentioned and a flag set to "" cannot be told apart by value, and its
634 // CLEAR spelling is the same "-" sentinel.
635 hibernateSet, backingSet, unitSet, replicationSet := false, false, false, false
636 fs.Visit(func(f *flag.Flag) {
637 switch f.Name {
638 case "hibernate":
639 hibernateSet = true
640 case "backing":
641 backingSet = true
642 case "unit":
643 unitSet = true
644 case "replication":
645 replicationSet = true
646 }
647 })
648
649 if *host == "" && *app == "" {
650 log.Fatal(usage)
651 }
652 if *host != "" && *app != "" {
653 log.Fatal("route: --host and --app are mutually exclusive — name the one route, or the app whose routes to move\n\n" + usage)
654 }
655 // The three single-route axes. Each is ITS OWN operation: a grant change
656 // is not a repoint, and combining two would put two unrelated writes
657 // behind two unrelated rails in one command. --channel and --environment
658 // keep their existing combinability (one transaction, both rails resolved
659 // first); everything below stands alone.
660 if *grant != "" && *revoke != "" {
661 log.Fatal("route: --grant and --revoke are mutually exclusive — a grant and a revoke of the same route are two operations, and typing both leaves it ambiguous which one you meant to happen")
662 }
663 grantAxis := *grant != "" || *revoke != ""
664 // --backing is a single-route axis exactly like the other two, and it is
665 // held to the same exclusions (2026-08-03 review, I2). It was not, and
666 // each gap was a SILENT PARTIAL: the switch below runs grantAxis first,
667 // then hibernateSet, then --backing, so `--hibernate=false --backing unit`
668 // wrote the hibernate column and dropped the backing stamp on the floor
669 // without a word, and `--backing unit --channel canary/x` stamped the row
670 // and never repointed it. An operator who typed both wanted both; a
671 // command that does one of them and reports success is worse than one
672 // that refuses.
673 if grantAxis && hibernateSet {
674 log.Fatal("route: --grant/--revoke and --hibernate are mutually exclusive — run them as two commands, so each meets its own rail alone")
675 }
676 if backingSet && (grantAxis || hibernateSet) {
677 log.Fatal("route: --backing is mutually exclusive with --grant/--revoke and --hibernate — run them as two commands, so each meets its own rail alone. " +
678 "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)")
679 }
680 // --replication is held to the same exclusions as the other single-route
681 // axes, and for the reason recorded above --backing: the switch below
682 // runs one arm, so a command naming two axes would write one of them and
683 // drop the other on the floor without a word.
684 if replicationSet && (grantAxis || hibernateSet || backingSet || unitSet) {
685 log.Fatal("route: --replication is mutually exclusive with --grant/--revoke, --hibernate, --backing and --unit — run them as two commands, so each meets its own rail alone. " +
686 "--hibernate in particular decides whether this route's class is read AT ALL (the activator replicates hibernating routes; /etc/litestream.yml covers the rest), so the two in one command would leave the answer depending on which arm ran")
687 }
688 if unitSet && (grantAxis || hibernateSet || backingSet) {
689 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. " +
690 "--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")
691 }
692 if (grantAxis || hibernateSet || backingSet || unitSet || replicationSet) && (*channel != "" || *environment != "" || *tunnel != "") {
693 log.Fatal("route: --grant/--revoke/--hibernate/--backing/--unit/--replication 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")
694 }
695 if (grantAxis || hibernateSet || backingSet || unitSet || replicationSet) && *host == "" {
696 log.Fatal("route: --grant/--revoke/--hibernate/--backing/--unit/--replication 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)
697 }
698 // The token is checked BEFORE the registry is opened for writing: a typo
699 // should cost nothing and reach nothing.
700 if grantAxis {
701 tok := *grant
702 if tok == "" {
703 tok = *revoke
704 }
705 if err := caps.ValidGrantToken(tok); err != nil {
706 log.Fatalf("route: %v", err)
707 }
708 }
709 // And so is --backing's value, for the same reason and at the same moment
710 // (2026-08-03 review, I1): runRouteBacking's doc comment already claimed
711 // this check ("the value is \"\" or BackingUnit, checked at the flag
712 // before the registry is even opened") while nothing performed it, and
713 // registry.SetBacking writes the column verbatim. `--backing unti` was
714 // therefore accepted, wrote "unti", and left the route neither unit-backed
715 // (release.Adoptable compares against BackingUnit exactly) nor visibly
716 // wrong — the migration's one write, silently a no-op. Same wording as
717 // runAdd's check, which is the other door onto this column.
718 if backingSet && *backing != "" && *backing != registry.BackingUnit {
719 log.Fatalf("route: backing %q: want %q (a carlos-app@<host> systemd unit) or \"\" (an exec child of the agent)", *backing, registry.BackingUnit)
720 }
721 // --unit's value gets the same treatment at the same moment, and for the
722 // reason the column exists: an unvalidated name reaches the row verbatim
723 // and is discovered months later as an adopt pass that restarted nothing.
724 // "-" is the clear sentinel (checked first, exactly as envClear precedes
725 // ValidEnvironmentName), and it resolves to "" — the column's own default,
726 // which ValidUnitName accepts. A bare `--unit ""` is refused rather than
727 // treated as a clear: the doors that CLEAR say so out loud.
728 unitClear := *unit == "-"
729 resolvedUnit := *unit
730 if unitClear {
731 resolvedUnit = ""
732 }
733 if unitSet && !unitClear {
734 if *unit == "" {
735 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)`)
736 }
737 if !registry.ValidUnitName(*unit) {
738 log.Fatalf("route: unit %q: want a systemd service unit name (e.g. console.service), or \"-\" to clear the stamp", *unit)
739 }
740 }
741 // --replication's value is checked at the flag, before the registry is
742 // opened, on --unit's exact grounds: an unknown class reaches
743 // replicationSyncInterval, which answers anything it does not recognise
744 // with litestream's default — so `--replication dayly` would be written,
745 // reported as done, and discovered as a route that quietly never left the
746 // most expensive rung. "-" is the clear sentinel and resolves to "", the
747 // column's own default; a bare `--replication ""` is refused, because the
748 // doors that CLEAR say so out loud (see --unit).
749 replicationClear := *replication == "-"
750 resolvedReplication := *replication
751 if replicationClear {
752 resolvedReplication = ""
753 }
754 if replicationSet && !replicationClear {
755 if *replication == "" {
756 log.Fatalf(`route: --replication "" is refused — use "--replication -" to clear the rung, or name one: %s`, strings.Join(registry.ReplicationClasses(), ", "))
757 }
758 if !registry.ValidReplication(*replication) {
759 log.Fatalf("route: replication %q: want one of %s, or \"-\" to clear to the default", *replication, strings.Join(registry.ReplicationClasses(), ", "))
760 }
761 }
762 if *tunnel != "" && *app != "" {
763 log.Fatal("route: --tunnel is only supported with --host — bulk --app tunnel repointing is not implemented\n\n" + usage)
764 }
765 if *channel == "" && *environment == "" && *tunnel == "" && !grantAxis && !hibernateSet && !backingSet && !unitSet && !replicationSet {
766 log.Fatal(`route: at least one of --channel, --environment, --tunnel, --grant, --revoke, --hibernate, --backing, --unit or --replication is required. "" only means stable-by-default at route CREATION (carlos add) — ` +
767 "this verb sets an explicit binding, and needs an explicit target to set it to.")
768 }
769 if *channel != "" && !release.ValidChannel(*channel) {
770 log.Fatalf("route: %q is not a valid channel", *channel)
771 }
772 // "-" is the CLI-level "clear to default" sentinel, checked before
773 // ValidEnvironmentName (which would otherwise accept it — a bare hyphen
774 // fits [a-z0-9-]{1,32} — and treat it as a real bundle name). A real
775 // environment named "-" is unreachable through this verb as a result;
776 // that's an accepted asymmetry, not a bug (matches "-" as a stdin/stdout
777 // sentinel elsewhere in Unix tooling).
778 envClear := *environment == "-"
779 // "default" gets its own refusal rather than falling through to the
780 // generic one (2026-08-02 review, MINOR 7): it is a well-formed name that
781 // is reserved, not a malformed one, and the generic message sent the
782 // operator looking for a typo. It also keeps this refusal pinned to the
783 // RESERVATION — with the reservation gone, the existence rail below would
784 // still refuse "default" for an unrelated reason and a test asserting
785 // only "refused" would not notice. Same wording as checkEnvironmentFlag's
786 // (appconfig_cli.go), which every `carlos env`/`secrets` verb uses.
787 if *environment == "default" {
788 log.Fatal(`route: --environment default is refused: "default" already names the base bundle ` +
789 "(config.json) every route gets when nothing is bound — use \"--environment -\" to clear a binding back to it")
790 }
791 if *environment != "" && !envClear && !appconfig.ValidEnvironmentName(*environment) {
792 log.Fatalf("route: %q is not a valid environment name — want [a-z0-9-]{1,32}", *environment)
793 }
794 // resolvedEnv is what actually lands in the registry column: "" either
795 // because --environment was never given (untouched — see the write step)
796 // or because it was explicitly cleared.
797 resolvedEnv := *environment
798 if envClear {
799 resolvedEnv = ""
800 }
801 // tunnelClear is the same "-" clear sentinel as --environment's, checked
802 // before ValidTunnelName for the same reason envClear precedes
803 // ValidEnvironmentName above: a bare hyphen fits [a-z0-9-]{1,32} and
804 // would otherwise be treated as a real tunnel name. Unlike --environment
805 // there is no reserved name and no bucket to validate EXISTENCE against
806 // here — a tunnel name only becomes real once the host-agent dials in
807 // under it (later work in this feature) — but the shape itself is
808 // checked, same as --environment's, since this name is later matched
809 // against a dialed-in identity and may build keys.
810 tunnelClear := *tunnel == "-"
811 if *tunnel != "" && !tunnelClear && !registry.ValidTunnelName(*tunnel) {
812 log.Fatalf("route: %q is not a valid tunnel name — want [a-z0-9-]{1,32}", *tunnel)
813 }
814 resolvedTunnel := *tunnel
815 if tunnelClear {
816 resolvedTunnel = ""
817 }
818
819 r, err := registry.Open(*regPath)
820 if err != nil {
821 log.Fatal(err)
822 }
823 defer r.Close()
824
825 // The single-route axes short-circuit here, before the channel/environment
826 // machinery below: they write one column of one row, through one narrow
827 // setter, and share none of that batch's all-or-nothing plumbing.
828 if grantAxis || hibernateSet || backingSet || unitSet || replicationSet {
829 rt, err := r.Get(*host)
830 if err != nil {
831 log.Fatal(err)
832 }
833 if rt == nil {
834 log.Fatalf("route: no route for host %q", *host)
835 }
836 if err := suffixRail(*rt); err != nil {
837 log.Fatalf("route: %v", err)
838 }
839 switch {
840 case grantAxis:
841 runRouteGrant(r, *rt, *grant, *revoke, *dryRun)
842 case hibernateSet:
843 runRouteHibernate(r, *rt, *hibernate, *dryRun)
844 case backingSet:
845 runRouteBacking(r, *rt, *backing, *dryRun)
846 case replicationSet:
847 runRouteReplication(r, *rt, resolvedReplication, *dryRun)
848 default:
849 runRouteUnit(r, *rt, resolvedUnit, *dryRun)
850 }
851 return
852 }
853
854 var candidates []registry.Route
855 if *host != "" {
856 rt, err := r.Get(*host)
857 if err != nil {
858 log.Fatal(err)
859 }
860 if rt == nil {
861 log.Fatalf("route: no route for host %q", *host)
862 }
863 if err := suffixRail(*rt); err != nil {
864 log.Fatalf("route: %v", err)
865 }
866 candidates = []registry.Route{*rt}
867 } else {
868 routes, err := r.List()
869 if err != nil {
870 log.Fatal(err)
871 }
872 // --account-id supersedes --account here as everywhere, including
873 // over the "all" escape hatch: someone who names one account by
874 // its sqid means that account, not every account, whatever --account
875 // happens to still hold from a script's defaults.
876 var wantAcct string
877 if strings.TrimSpace(*acctIDFilter) != "" {
878 wantAcct = resolveAccount(*regPath, "", *acctIDFilter)
879 } else if !strings.EqualFold(*acctFilter, "all") {
880 wantAcct = resolveAccount(*regPath, *acctFilter, "")
881 }
882 for _, rt := range routes {
883 if rt.App != *app {
884 continue
885 }
886 if wantAcct != "" && release.RouteAccount(rt) != wantAcct {
887 continue
888 }
889 if rt.Platform {
890 // A bulk sweep changing every tenant's platform-minted route
891 // in one command is a different, riskier operation than
892 // "move this app's ordinary routes" — Platform routes stay
893 // opt-in, one --host at a time. (Platform+Canary routes are
894 // refused even via --host — see platformCanaryOwnedRow,
895 // applied structurally below for every candidate, not just
896 // here — so this exclusion is belt-and-braces for those, but
897 // load-bearing for a plain Platform apex.)
898 warnf(os.Stdout, "%s: skipped — platform-minted route; use --host %s to change it explicitly\n", rt.Host, rt.Host)
899 continue
900 }
901 if err := suffixRail(rt); err != nil {
902 // A real provisioned sidecar's App equals its owning app's, so
903 // this is not hypothetical: an -app sweep legitimately meets
904 // these rows. Skipped like a Platform route, not fatal — one
905 // row under the reserved suffix must not abort the rest of
906 // the batch.
907 warnf(os.Stdout, "%s: skipped — %v; use -host %s to see the refusal explicitly\n", rt.Host, err, rt.Host)
908 continue
909 }
910 candidates = append(candidates, rt)
911 }
912 if len(candidates) == 0 {
913 log.Fatalf("route: no non-platform routes for app %q (with the given --account filter, if any)", *app)
914 }
915 }
916
917 // Pass 1: resolve every candidate's rail(s) WITHOUT writing anything. A
918 // check-then-write-per-route loop can leave an --app bulk half-repointed
919 // if a later route fails after earlier ones already wrote (CRITICAL 1) —
920 // so every route's verdict, on EVERY axis touched, is known before the
921 // first write happens.
922 //
923 // The Platform+Canary refusal is decided HERE, for every candidate,
924 // rather than only in the --host branch above — a shared function both
925 // forms go through — so it holds structurally even though only --host can
926 // reach a Platform+Canary route today (--app already excludes every
927 // Platform route). See runRoute's doc comment for why this refusal has
928 // no override and applies to ANY channel change, not only leaving
929 // canary/* — and why it is Channel-specific, never blocking the
930 // --environment axis.
931 type verdictKind int
932 const (
933 vNeedsCheck verdictKind = iota // routeVersionCheck resolves this below
934 vNoop // already on the target channel
935 vSafe // resolved: proceed unconditionally
936 vVersionChange // resolved, differs: overridable
937 vUnproven // resolved, but nothing proves what would change: never overridable
938 vPlatformOwned // Platform+Canary: reconciler owns this column, never overridable
939 vRecordOwned // Provisioned: the instance record owns this column, never overridable
940 )
941 type verdict struct {
942 rt registry.Route
943 from string
944 kind verdictKind
945 note string
946 }
947 // chanVerdicts stays nil (len 0) when --channel was not given, so every
948 // loop below that ranges over it is naturally a no-op — that IS the
949 // "channel axis untouched" state, not a special case to guard against.
950 var chanVerdicts []verdict
951 if *channel != "" {
952 chanVerdicts = make([]verdict, len(candidates))
953 anyNeedsCheck := false
954 for i, rt := range candidates {
955 from := rt.Channel
956 if from == "" {
957 from = "stable"
958 }
959 switch {
960 case from == *channel:
961 chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNoop}
962 case platformCanaryOwnedRow(rt):
963 chanVerdicts[i] = verdict{rt: rt, from: from, kind: vPlatformOwned, note: fmt.Sprintf(
964 "Platform+Canary route on %s — its channel is derived and re-asserted by the domains\n"+
965 "reconciler's canary-minting pass (internal/domains/reconcile.go), which is idempotent via a\n"+
966 "check on THIS route's own Channel column; any change here is silently reverted on the next\n"+
967 "reconcile pass. This verb does not own this column", from)}
968 case recordOwnedRow(rt):
969 chanVerdicts[i] = verdict{rt: rt, from: from, kind: vRecordOwned, note: fmt.Sprintf(
970 "provisioned route on %s — its channel is owned by the instance record at\n"+
971 "apps/%s/%s/instances/%s.json, which the provisioning reconciler converges onto this row\n"+
972 "on every pass; a change here is reverted within seconds of the next serial bump.\n"+
973 "Change it where it lives:\n"+
974 " carlos instances set-channel --app %s --host %s --channel %s",
975 from, release.RouteAccount(rt), rt.App, rt.Host,
976 rt.App, rt.Host, *channel)}
977 default:
978 chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNeedsCheck}
979 anyNeedsCheck = true
980 }
981 }
982 if anyNeedsCheck {
983 // Resolved once, the same way verifyMigratedPointers does:
984 // byte/version comparison needs the public key, not the signing key
985 // — this verb never writes a release object, only the registry row.
986 // A run needing no check at all (every candidate a no-op or
987 // Platform+Canary-refused) never reaches here, so it needs no
988 // store/pubkey configured.
989 var pub ed25519.PublicKey
990 if b := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); b != "" {
991 if p, perr := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY"); perr == nil {
992 pub = p
993 }
994 }
995 var store release.Store
996 if pub != nil {
997 store = openStore()
998 }
999 ctx := context.Background()
1000 for i := range chanVerdicts {
1001 if chanVerdicts[i].kind != vNeedsCheck {
1002 continue
1003 }
1004 rt := chanVerdicts[i].rt
1005 note, safe, overridable := routeVersionCheck(ctx, store, pub, release.RouteAccount(rt), rt, chanVerdicts[i].from, *channel)
1006 chanVerdicts[i].note = note
1007 switch {
1008 case safe:
1009 chanVerdicts[i].kind = vSafe
1010 case overridable:
1011 chanVerdicts[i].kind = vVersionChange
1012 default:
1013 chanVerdicts[i].kind = vUnproven
1014 }
1015 }
1016 }
1017 }
1018
1019 // The environment rail: an existence check against the bucket, via the
1020 // same Store the channel rail uses (unavailable store fails closed
1021 // exactly like the channel rail's missing-pubkey/missing-store cases —
1022 // openStore's own log.Fatal). No override exists for a missing bundle:
1023 // unlike a version mismatch, there is no "I know it's different, do it
1024 // anyway" reading of binding a route to config that isn't there.
1025 type envVerdictKind int
1026 const (
1027 eNoop envVerdictKind = iota // already bound to the target
1028 eWillWrite // clearing, or the bundle exists — proceed
1029 eMissing // named but absent in the bucket — refuse, no override
1030 ePlatformOwned // Platform+Canary: the reconciler owns this row, never overridable
1031 eRecordOwned // Provisioned: the instance record owns this column
1032 )
1033 type envVerdict struct {
1034 rt registry.Route
1035 from string
1036 kind envVerdictKind
1037 note string
1038 }
1039 var envVerdicts []envVerdict
1040 if *environment != "" {
1041 envVerdicts = make([]envVerdict, len(candidates))
1042 var store release.Store
1043 // existsByApp caches one ListEnvironments call per distinct
1044 // (account, app) among the candidates (an --app --account all sweep can
1045 // span several accounts), mirroring the channel rail's "resolve once"
1046 // pattern — never opened at all when every candidate is already bound
1047 // to the target or the operator is clearing (envClear needs no bucket
1048 // check per the doc comment above). The key is BOTH halves of the
1049 // query it caches: keyed on account alone (2026-08-02 review, MINOR
1050 // 6) it would approve a bundle that exists for a different app in the
1051 // same account. Unreachable today — --app fixes rt.App and --host has
1052 // one candidate — which is exactly why it is worth keying correctly
1053 // now rather than after a future widening.
1054 existsByApp := map[string]map[string]bool{}
1055 ctx := context.Background()
1056 for i, rt := range candidates {
1057 from := rt.Environment
1058 switch {
1059 case from == resolvedEnv:
1060 envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eNoop}
1061 case platformCanaryOwnedRow(rt):
1062 // Refused on the same terms as the channel axis, and for the
1063 // same structural reason: the reconciler rebuilds this row.
1064 // Placed before envClear so CLEARING is refused too — a
1065 // half-owned column would be the worst of both.
1066 envVerdicts[i] = envVerdict{rt: rt, from: from, kind: ePlatformOwned, note: "Platform+Canary route — its whole row is rebuilt by the domains\n" +
1067 "reconciler's canary-minting pass (internal/domains/reconcile.go) whenever that pass's `followed`\n" +
1068 "check goes false, from values it derives itself. This verb does not own this row.\n" +
1069 "A binding already on such a route (made before this rail existed) survives re-mints and cannot be\n" +
1070 "cleared here either: `carlos remove " + rt.Host + "` and let the next reconcile pass re-mint it clean"}
1071 case recordOwnedRow(rt):
1072 envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eRecordOwned, note: fmt.Sprintf(
1073 "provisioned route — its environment is owned by the instance record at\n"+
1074 "apps/%s/%s/instances/%s.json, which the provisioning reconciler converges onto this row on every\n"+
1075 "pass. A binding made here (or cleared here) is reverted within seconds of the next serial bump.",
1076 release.RouteAccount(rt), rt.App, rt.Host)}
1077 case envClear:
1078 envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eWillWrite}
1079 default:
1080 acct := release.RouteAccount(rt)
1081 ck := acct + "/" + rt.App
1082 names, cached := existsByApp[ck]
1083 if !cached {
1084 if store == nil {
1085 store = openStore()
1086 }
1087 list, lerr := appconfig.ListEnvironments(ctx, store, acct, rt.App)
1088 if lerr != nil {
1089 log.Fatalf("route: list environments for %s/%s: %v", acct, rt.App, lerr)
1090 }
1091 names = make(map[string]bool, len(list))
1092 for _, n := range list {
1093 names[n] = true
1094 }
1095 existsByApp[ck] = names
1096 }
1097 if names[*environment] {
1098 envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eWillWrite}
1099 } else {
1100 envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eMissing, note: fmt.Sprintf(
1101 "environment %q does not exist for %s/%s — create it first with `carlos env set --app %s --environment %s ...`",
1102 *environment, acct, rt.App, rt.App, *environment)}
1103 }
1104 }
1105 }
1106 }
1107
1108 // Pass 2: any refusal, on EITHER axis, aborts the WHOLE batch — print
1109 // every one, write nothing. This is what makes the combined
1110 // --channel/--environment invocation atomic: neither axis's write call
1111 // below is reached unless every candidate cleared both rails.
1112 var refusals []string
1113 var toWrite []string
1114 for _, v := range chanVerdicts {
1115 switch v.kind {
1116 case vNoop:
1117 // handled in the final print loop
1118 case vSafe:
1119 toWrite = append(toWrite, v.rt.Host)
1120 case vVersionChange:
1121 if *allowVersionChange {
1122 toWrite = append(toWrite, v.rt.Host)
1123 } else {
1124 refusals = append(refusals, fmt.Sprintf("%s: %s (use --allow-version-change to override)", v.rt.Host, v.note))
1125 }
1126 case vUnproven:
1127 refusals = append(refusals, fmt.Sprintf(
1128 "%s: %s — refusing regardless of --allow-version-change: the comparison itself is unproven", v.rt.Host, v.note))
1129 case vPlatformOwned, vRecordOwned:
1130 refusals = append(refusals, fmt.Sprintf(
1131 "%s: %s — refusing regardless of --allow-version-change: no flag overrides a column this verb does not own",
1132 v.rt.Host, v.note))
1133 }
1134 }
1135 var envToWrite []string
1136 for _, v := range envVerdicts {
1137 switch v.kind {
1138 case eNoop:
1139 // handled in the final print loop
1140 case eWillWrite:
1141 envToWrite = append(envToWrite, v.rt.Host)
1142 case eMissing:
1143 refusals = append(refusals, fmt.Sprintf("%s: %s", v.rt.Host, v.note))
1144 case ePlatformOwned, eRecordOwned:
1145 refusals = append(refusals, fmt.Sprintf(
1146 "%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note))
1147 }
1148 }
1149 // The tunnel rail: --host only (the --app refusal above guarantees exactly
1150 // one candidate whenever --tunnel is set), and unlike --channel/--environment
1151 // there is no existence check — a tunnel only becomes real once the
1152 // host-agent dials in under that name, so there is no bucket store to
1153 // consult yet. Same Platform+Canary refusal as the other two axes, for
1154 // the same structural reason (platformCanaryOwnedRow's doc comment covers
1155 // all three columns it guards).
1156 var tunnelFrom string
1157 tunnelNoop, tunnelWillWrite := false, false
1158 if *tunnel != "" {
1159 rt := candidates[0]
1160 tunnelFrom = rt.Tunnel
1161 switch {
1162 case tunnelFrom == resolvedTunnel:
1163 tunnelNoop = true
1164 case platformCanaryOwnedRow(rt):
1165 refusals = append(refusals, fmt.Sprintf(
1166 "%s: Platform+Canary route — its whole row is rebuilt by the domains reconciler's canary-minting "+
1167 "pass (internal/domains/reconcile.go) — this verb does not own this row, no flag overrides that",
1168 rt.Host))
1169 default:
1170 tunnelWillWrite = true
1171 }
1172 }
1173 if len(refusals) > 0 {
1174 for _, msg := range refusals {
1175 fmt.Fprintln(os.Stderr, msg)
1176 }
1177 fmt.Fprintf(os.Stderr, "%s %d of %d route(s) refused — nothing written\n",
1178 colorFor(os.Stderr).bad("route:"), len(refusals), len(candidates))
1179 os.Exit(1)
1180 }
1181
1182 switch {
1183 case *channel != "" && *environment != "":
1184 // Combined: one transaction, both columns, for every candidate that
1185 // cleared both rails above — including per-axis no-ops, which is
1186 // harmless (an UPDATE to the value already there) and keeps this
1187 // branch from having to reconcile two possibly-different host lists
1188 // into one atomic write.
1189 if !*dryRun {
1190 hosts := make([]string, len(candidates))
1191 for i, rt := range candidates {
1192 hosts[i] = rt.Host
1193 }
1194 if err := r.SetChannelsAndEnvironments(hosts, *channel, resolvedEnv); err != nil {
1195 log.Fatalf("route: write: %v — nothing written; the batch is one transaction", err)
1196 }
1197 }
1198 case *channel != "":
1199 if len(toWrite) > 0 && !*dryRun {
1200 if err := r.SetChannels(toWrite, *channel); err != nil {
1201 log.Fatalf("route: write: %v — nothing written; the batch is one transaction", err)
1202 }
1203 }
1204 case *environment != "":
1205 if len(envToWrite) > 0 && !*dryRun {
1206 if err := r.SetEnvironments(envToWrite, resolvedEnv); err != nil {
1207 log.Fatalf("route: write: %v — nothing written; the batch is one transaction", err)
1208 }
1209 }
1210 }
1211 // The tunnel axis writes independently of the channel/environment switch
1212 // above — it has no bulk sibling to combine into their transactions (it
1213 // is always exactly one candidate; see the --app refusal above), so there
1214 // is nothing for it to be atomic WITH.
1215 if tunnelWillWrite && !*dryRun {
1216 if err := r.SetTunnel(candidates[0].Host, resolvedTunnel); err != nil {
1217 log.Fatalf("route: write: %v", err)
1218 }
1219 }
1220
1221 prefix := ""
1222 if *dryRun {
1223 prefix = colorFor(os.Stdout).warn("(dry run)") + " "
1224 }
1225 envLabel := func(e string) string {
1226 if e == "" {
1227 return "default"
1228 }
1229 return e
1230 }
1231 tunnelLabel := func(t string) string {
1232 if t == "" {
1233 return "none"
1234 }
1235 return t
1236 }
1237 for i := range candidates {
1238 if chanVerdicts != nil {
1239 v := chanVerdicts[i]
1240 if v.kind == vNoop {
1241 mutedf(os.Stdout, "%s: already following %s — nothing to do\n", v.rt.Host, *channel)
1242 } else {
1243 fmt.Printf("%s%s: %s -> %s (%s)\n", prefix, v.rt.Host, v.from, *channel, v.note)
1244 }
1245 }
1246 if envVerdicts != nil {
1247 v := envVerdicts[i]
1248 if v.kind == eNoop {
1249 mutedf(os.Stdout, "%s: environment already %s — nothing to do\n", v.rt.Host, envLabel(resolvedEnv))
1250 } else {
1251 fmt.Printf("%s%s: environment %s -> %s\n", prefix, v.rt.Host, envLabel(v.from), envLabel(resolvedEnv))
1252 }
1253 }
1254 if *tunnel != "" && i == 0 {
1255 if tunnelNoop {
1256 mutedf(os.Stdout, "%s: tunnel already %s — nothing to do\n", candidates[0].Host, tunnelLabel(resolvedTunnel))
1257 } else {
1258 fmt.Printf("%s%s: tunnel %s -> %s\n", prefix, candidates[0].Host, tunnelLabel(tunnelFrom), tunnelLabel(resolvedTunnel))
1259 }
1260 }
1261 }
1262}
1263
1264// platformCanaryOwnedRow reports whether the domains reconciler, not this
1265// verb, owns rt's row: true for any Platform+Canary route. See runRoute's doc
1266// comment for why — the canary-minting pass in internal/domains/reconcile.go
1267// is idempotent via `followed`, a check computed from THAT ROUTE'S OWN
1268// Channel column, not from the `byHost` map the same loop also sets but never
1269// reads for that purpose. Any change to the channel (not only a move off
1270// canary/*) makes `followed` false, and the next pass re-Upserts the whole
1271// row from mintedRoute's derived values.
1272//
1273// One function, both axes: --channel because the reconciler re-derives that
1274// column, --environment because it rebuilds the row the column lives on
1275// (2026-08-02 review, IMPORTANT 4).
1276func platformCanaryOwnedRow(rt registry.Route) bool {
1277 return rt.Platform && rt.Canary
1278}
1279
1280// recordOwnedRow reports whether the instance-provisioning reconciler, not
1281// this verb, owns this route's Channel and Environment columns.
1282//
1283// Provisioned is set only by that pass — on mint, and on adoption after it
1284// matched the row's shape against the bucket record (internal/provision).
1285// The pass converges both columns from the record on every tick, so a write
1286// here lands and is reverted within seconds of the next serial bump, with
1287// nothing said anywhere. Same structural refusal as platformCanaryOwnedRow,
1288// one more owner: the door is the record.
1289func recordOwnedRow(rt registry.Route) bool { return rt.Provisioned }
1290
1291// runRouteGrant is the operator's door to a transfer of box authority: one
1292// token, onto or off one route, on this box. Everything it can refuse it
1293// refuses before writing, and the write itself is registry.SetCaps — the
1294// only writer of that column in the codebase.
1295//
1296// A revoke is not the mirror image of a grant in one respect: it needs no
1297// conflict check, because removing a grant can never collide with anything.
1298// Everything else — the row-shape rails, the hand-edited-column refusal — is
1299// shared, since a route whose grants this verb refuses to widen is not a
1300// route whose grants it should be quietly rewriting either.
1301func runRouteGrant(r *registry.Registry, rt registry.Route, grantTok, revokeTok string, dryRun bool) {
1302 tok, granting := grantTok, true
1303 if grantTok == "" {
1304 tok, granting = revokeTok, false
1305 }
1306 if err := grantRail(rt, tok); err != nil {
1307 log.Fatalf("route: %s: %v", rt.Host, err)
1308 }
1309 cur, err := caps.ParseGrants(rt.Caps)
1310 if err != nil {
1311 // Refuse rather than repair: this column is written only by SetCaps,
1312 // so an unparseable value means a hand edit, and rewriting it here
1313 // would silently decide which half of somebody's edit survived.
1314 log.Fatalf("route: %s: %v — fix the row with `carlos route --host %s --grant/--revoke` from a known-good state, never by hand",
1315 rt.Host, err, rt.Host)
1316 }
1317
1318 prefix := ""
1319 if dryRun {
1320 prefix = colorFor(os.Stdout).warn("(dry run)") + " "
1321 }
1322 var next string
1323 if granting {
1324 routes, lerr := r.List()
1325 if lerr != nil {
1326 log.Fatal(lerr)
1327 }
1328 if cerr := portGrantConflict(routes, rt.Host, tok); cerr != nil {
1329 log.Fatalf("route: %s: %v", rt.Host, cerr)
1330 }
1331 if next, err = caps.Grant(rt.Caps, tok); err != nil {
1332 log.Fatalf("route: %s: %v", rt.Host, err)
1333 }
1334 if cur.Has(tok) {
1335 mutedf(os.Stdout, "%s: %s already granted — nothing to do\n", rt.Host, tok)
1336 return
1337 }
1338 } else {
1339 if next, err = caps.Revoke(rt.Caps, tok); err != nil {
1340 log.Fatalf("route: %s: %v", rt.Host, err)
1341 }
1342 if !cur.Has(tok) {
1343 // A quiet success: the caller's goal is "not granted", not "I
1344 // removed something".
1345 mutedf(os.Stdout, "%s: %s was not granted — nothing to revoke\n", rt.Host, tok)
1346 return
1347 }
1348 }
1349 if !dryRun {
1350 // Compare-and-swap on the value this command computed `next` FROM:
1351 // losing a race against another operator's grant/revoke refuses with
1352 // the current value rather than merging a stale read over it.
1353 if err := r.SetCaps(rt.Host, rt.Caps, next); err != nil {
1354 log.Fatalf("route: write: %v", err)
1355 }
1356 }
1357 verb := "granted"
1358 if !granting {
1359 verb = "revoked"
1360 }
1361 fmt.Printf("%s%s: %s %s (caps: %s)\n", prefix, rt.Host, verb, tok, capsLabel(next))
1362 // Deliberately no restart, no unit rewrite, and no advisory here: a grant
1363 // is a row, and the converge pass is what materializes (or unmakes) the
1364 // drop-in on its next run.
1365}
1366
1367// capsLabel renders a caps column for a human — "none" rather than an empty
1368// string, so a revoke that emptied the column reads as an answer.
1369func capsLabel(col string) string {
1370 if col == "" {
1371 return "none"
1372 }
1373 return col
1374}
1375
1376// addRail refuses the two things `carlos add` must never mint: a host under
1377// the reserved sidecar suffix, and a sidecar row itself. Sidecars are
1378// provisioned through their own record family (carlos sidecars create), so
1379// an operator minting one by hand would produce a row no reconcile pass
1380// owns and no record explains. Defence in depth behind Task 3's structural
1381// refusal to SERVE a Kind=="sidecar" row (edge.go's Reload): this rail
1382// guards the OTHER direction — an ordinary-kind row planted under a
1383// sidecar's reserved name, which edge would serve without a second thought.
1384func addRail(host, kind string) error {
1385 if strings.HasSuffix(host, provision.SidecarSuffix) {
1386 return fmt.Errorf("%s is under the reserved suffix %s — sidecar hosts are minted by `carlos sidecars create`, never by hand", host, provision.SidecarSuffix)
1387 }
1388 if kind == "sidecar" {
1389 return errors.New("carlos add does not mint sidecars — use `carlos sidecars create -app <app> <name>`")
1390 }
1391 return nil
1392}
1393
1394// appRail is `carlos add`'s side of the tmp- reservation (temporary static
1395// sites spec §3): a temporary app's routes come only from its instance
1396// records, minted through the console, so a hand-added route may never wear
1397// a tmp- name. Upgrading a box's binary upgrades this writer; there is no
1398// other copy of it.
1399func appRail(app string) error {
1400 if release.IsTemporaryName(app) {
1401 return fmt.Errorf("--app %s: names starting with tmp- belong to temporary apps, which get their routes from the console. carlos add cannot route one", app)
1402 }
1403 return nil
1404}
1405
1406// suffixRail is `carlos route`'s side of the same guarantee addRail closes
1407// at `carlos add`: this verb cannot MINT a row under the reserved sidecar
1408// suffix (r.Get returns nil for one, and every axis below refuses before
1409// opening the registry for writing) — but it CAN keep an existing one alive,
1410// repoint its channel, or grant it capabilities, and that is the door this
1411// rail closes. It applies to every axis this verb writes (-channel,
1412// -environment, -tunnel, -grant/-revoke, -hibernate, -backing, -unit): the
1413// row it guards against is not a genuine Kind=="sidecar" row (edge.go's
1414// Reload never serves those regardless) but an ordinary-kind row wearing a
1415// sidecar's reserved name, which this verb must never keep converging.
1416func suffixRail(rt registry.Route) error {
1417 if strings.HasSuffix(rt.Host, provision.SidecarSuffix) {
1418 return fmt.Errorf("%s is under the reserved suffix %s — this verb does not touch routes under it; sidecar hosts are owned by their own record family, never this one", rt.Host, provision.SidecarSuffix)
1419 }
1420 return nil
1421}
1422
1423// grantRail is everything a --grant/--revoke can be refused for from the ROW
1424// alone: the token's own validity, plus the two structural refusals. Pure, so
1425// every refusal is table-testable without a registry.
1426//
1427// Both refusals apply to a REVOKE as well as a grant, which is deliberate: on
1428// a Platform+Canary row the reconciler rebuilds the row this verb would be
1429// writing, and on a non-unit route there is nothing the platform would
1430// materialize a grant into, so a "revoke" there would be theatre — a column
1431// edit that changes nothing while reading like a de-escalation.
1432func grantRail(rt registry.Route, tok string) error {
1433 if err := caps.ValidGrantToken(tok); err != nil {
1434 return err
1435 }
1436 if platformCanaryOwnedRow(rt) {
1437 return fmt.Errorf("Platform+Canary route — the domains reconciler's canary-minting pass rebuilds this whole row " +
1438 "(internal/domains/reconcile.go) whenever its `followed` check goes false, so this verb does not own it. " +
1439 "registry.Upsert preserves a non-empty caps column, which makes a grant here survivable — but survivable is not owned, " +
1440 "and no flag overrides a row this verb does not own")
1441 }
1442 if rt.Kind != "instance" || rt.Backing != registry.BackingUnit {
1443 return fmt.Errorf("capabilities are only honoured for unit-backed instance routes (kind %q, backing %q) — "+
1444 "a grant here would materialize nothing; see the backing advisory", rt.Kind, rt.Backing)
1445 }
1446 return nil
1447}
1448
1449// portGrantConflict refuses a port grant already held by a DIFFERENT route on
1450// this box, naming the other host. `:25` can be held by exactly one process on
1451// one machine, so two routes granted it is a promise the box cannot keep — and
1452// the operator finding out here, at the moment they type it, is the whole
1453// point: converge's own CheckClaims runs hourly, long after the typing.
1454//
1455// host-role is deliberately not conflict-checked: the box's instance role is
1456// not consumed by being used, and two apps holding it is two independent
1457// operator decisions, not a collision. doc-engine is not conflict-checked for
1458// the same reason and a sharper one: the capability is per-route unit text,
1459// not a box resource, and a dedicated sidecar box running several engine
1460// routes is the expected shape.
1461//
1462// A row whose caps column does not parse fails the check CLOSED, naming that
1463// row: "I could not find out whether this port is already claimed" is never a
1464// licence to claim it.
1465func portGrantConflict(routes []registry.Route, host, tok string) error {
1466 if tok == caps.HostRoleToken || tok == caps.DocEngineToken {
1467 return nil
1468 }
1469 for _, other := range routes {
1470 if other.Host == host || other.Caps == "" {
1471 continue
1472 }
1473 g, err := caps.ParseGrants(other.Caps)
1474 if err != nil {
1475 return fmt.Errorf("cannot prove %s is unclaimed: route %s has an unreadable caps column (%v)", tok, other.Host, err)
1476 }
1477 if g.Has(tok) {
1478 return fmt.Errorf("%s is already granted to %s on this box — a privileged port can be held by exactly one process on one machine; "+
1479 "revoke it there first (`carlos route --host %s --revoke %s`)", tok, other.Host, other.Host, tok)
1480 }
1481 }
1482 return nil
1483}
1484
1485// refuseUnflagNested stops `carlos route --hibernate=false` on a row whose
1486// paths are nested. Clearing the flag takes the row out of the activator's
1487// management, and the layout revert only quiesces and moves rows the
1488// activator manages, so the row's files would be stranded under the
1489// instance tree (design §8.2, downgrade rules). The test is by path shape,
1490// whatever else the row says, or by a layout journal, which means the row
1491// is mid-move and recovery needs it managed.
1492func refuseUnflagNested(rt registry.Route, dataDir string) error {
1493 if provision.NestedPaths(dataDir, rt) || provision.LayoutJournalExists(dataDir, rt.Host) {
1494 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)
1495 }
1496 return nil
1497}
1498
1499// runRouteHibernate flips the per-route hibernation opt-in — the first CLI
1500// caller registry.SetHibernate has ever had — behind the one rail that makes
1501// the capability model coherent: a route whose release declares `daemon` may
1502// not hibernate.
1503//
1504// Resolution follows this verb's existing doctrine exactly. A PROVEN daemon
1505// refuses; an UNPROVEN comparison (no pubkey, no store, an unresolvable
1506// channel, a bad signature, an unreadable manifest) also refuses, because "I
1507// could not find out" is never a licence. --hibernate=false needs no proof and
1508// always proceeds: stopping a route from hibernating can never contradict a
1509// declaration.
1510func runRouteHibernate(r *registry.Registry, rt registry.Route, on, dryRun bool) {
1511 if platformCanaryOwnedRow(rt) {
1512 // mintedRoute (internal/domains/reconcile.go) never sets Hibernate,
1513 // and Upsert does not preserve it, so a flip here is reverted to
1514 // false by the next re-mint — the same structural refusal the other
1515 // axes carry, for the same reason.
1516 log.Fatalf("route: %s: Platform+Canary route — the domains reconciler rebuilds this row from values it derives itself, "+
1517 "and a hibernation flip here is reverted on the next reconcile pass. This verb does not own this row", rt.Host)
1518 }
1519 if rt.IsSidecar() && rt.Provisioned {
1520 // Task 11 item 5 (carried from Task 9's review), the same lesson
1521 // this verb has already paid for on the Channel and Environment
1522 // axes (recordOwnedRow) — one column later. Unlike an ORDINARY
1523 // provisioned instance route, where Hibernate is the instance
1524 // record's OPTIONAL, tri-state column (absent means this verb is
1525 // still free to set it — internal/provision/reconcile.go's
1526 // convergeSidecar-adjacent instance path), a sidecar row's
1527 // Hibernate is unconditional: convergeSidecar
1528 // (internal/provision/sidecar_reconcile.go) re-stamps it from
1529 // SidecarManifest.HibernateOrDefault EVERY pass, with no "the
1530 // record is silent, this column is free" escape. A flip here lands
1531 // and is reverted within seconds of the next serial bump, with
1532 // nothing said anywhere.
1533 //
1534 // Defence in depth, not the only door: every genuine sidecar host
1535 // is minted under provision.SidecarSuffix, so suffixRail already
1536 // refuses this axis (and every other) before this function is ever
1537 // reached in practice. This check is what fires for a row that
1538 // reaches this far anyway — a Kind=="sidecar" row outside the
1539 // reserved suffix, which suffixRail's host-string check alone
1540 // cannot see — and it is what gives an operator who does meet it
1541 // the actual reason, not just "reserved suffix".
1542 log.Fatalf("route: %s: provisioned sidecar row — its hibernation is owned by the app's sidecars manifest at\n%s, "+
1543 "which the sidecar reconcile pass converges onto this row on every pass; a flip here is reverted within\n"+
1544 "seconds of the next serial bump. This verb does not own this column",
1545 rt.Host, provision.SidecarManifestKey(release.RouteAccount(rt), rt.App))
1546 }
1547 if on {
1548 store, pub := deploymentReadSeam()
1549 daemon, note, proven := routeDaemonCheck(context.Background(), store, pub, release.RouteAccount(rt), rt)
1550 if !proven {
1551 log.Fatalf("route: %s: --hibernate refused: %s — refusing regardless: the comparison itself is unproven, and %q is never a licence",
1552 rt.Host, note, "I could not find out")
1553 }
1554 if daemon {
1555 log.Fatalf("route: %s: %s (%s)", rt.Host, daemonHibernateRefusal, note)
1556 }
1557 }
1558 if !on {
1559 if err := refuseUnflagNested(rt, dataDir()); err != nil {
1560 log.Fatalf("%v", err)
1561 }
1562 }
1563 prefix := ""
1564 if dryRun {
1565 prefix = colorFor(os.Stdout).warn("(dry run)") + " "
1566 }
1567 if rt.Hibernate == on {
1568 mutedf(os.Stdout, "%s: hibernate already %v — nothing to do\n", rt.Host, on)
1569 return
1570 }
1571 if !dryRun {
1572 if err := r.SetHibernate(rt.Host, on); err != nil {
1573 log.Fatalf("route: write: %v", err)
1574 }
1575 }
1576 fmt.Printf("%s%s: hibernate %v -> %v\n", prefix, rt.Host, rt.Hibernate, on)
1577 // Said at the moment of typing, because the write does two things and
1578 // only one of them was asked for. registry.SetHibernate clears the
1579 // replication class when hibernation goes off — a rung is only read on
1580 // the activator's wake path, and a non-hibernating database is covered
1581 // by box-wide /etc/litestream.yml at its own interval, so keeping the
1582 // column would leave `carlos routes` printing a rung nothing honours.
1583 // An operator who turns hibernation back on has to name the rung again,
1584 // and being told that here is the difference between a decision and a
1585 // discovery.
1586 if !on && rt.Replication != "" {
1587 fmt.Printf(" replication %s cleared — the rung is read only on the activator's wake path, and this route no longer sleeps.\n", replicationWord(rt.Replication))
1588 fmt.Printf(" its database is now covered by box-wide /etc/litestream.yml at that unit's own interval; re-`--hibernate` then set the rung again if you want it back\n")
1589 }
1590}
1591
1592// runRouteBacking stamps who owns a route's process — the door design §5b
1593// and §5c reference and the code did not have: backing was choosable at
1594// `carlos add` and never afterwards, so the migration's stamp step (stop the
1595// hand-provisioned unit, copy the data, stamp the row, let the next adopt
1596// pass converge its capabilities) had no command, and neither did the
1597// reverse move the migration's reversibility depends on.
1598//
1599// Three rails, all structural, none overridable:
1600//
1601// - a Platform route is refused. A platform ALIAS materializes nothing by
1602// design (a second carlos-app@ unit claiming :25 would break the real
1603// one), and the domains reconciler rebuilds these rows from mintedRoute
1604// — which sets backing from its own derivation, so a stamp here is
1605// reverted on the next pass. Same refusal, same reason, as the other
1606// single-route axes.
1607// - kind != "instance" is refused: only an instance has a process for
1608// anything to back. A service or static route has no ExecStart to move.
1609// - a UNIT-STAMPED row may not be moved TO unit backing. That is the same
1610// forbidden state unitRail guards from the other side, reached in the
1611// other order (`--unit X` first, `--backing unit` second), and it has to be
1612// refused at both ends or the pair is only half-guarded.
1613// - the value is "" or BackingUnit, checked at the flag before the
1614// registry is even opened.
1615//
1616// It does NOT check whether the data has been copied, or whether a unit
1617// exists — it cannot, and pretending to would be worse than not trying. The
1618// migration checklist is the operator's; this verb is the one write in it,
1619// and it prints what the next adopt pass will do with the new value so the
1620// operator can decide whether they are ready for it.
1621func runRouteBacking(r *registry.Registry, rt registry.Route, backing string, dryRun bool) {
1622 if err := backingRail(rt, backing); err != nil {
1623 log.Fatalf("route: %s: %v", rt.Host, err)
1624 }
1625 prefix := ""
1626 if dryRun {
1627 prefix = colorFor(os.Stdout).warn("(dry run)") + " "
1628 }
1629 if rt.Backing == backing {
1630 mutedf(os.Stdout, "%s: backing already %s — nothing to do\n", rt.Host, backingWord(backing))
1631 return
1632 }
1633 if !dryRun {
1634 if err := r.SetBacking(rt.Host, backing); err != nil {
1635 log.Fatalf("route: write: %v", err)
1636 }
1637 }
1638 fmt.Printf("%s%s: backing %s -> %s\n", prefix, rt.Host, backingWord(rt.Backing), backingWord(backing))
1639 // The consequence, named at the moment of typing rather than discovered
1640 // in an hourly log: the two halves of the platform that branch on this
1641 // column change sides together.
1642 if backing == registry.BackingUnit {
1643 fmt.Printf(" the next adopt pass converges this route's capability drop-ins and config sync writes /etc/carlos/apps/%s.env; the agent stops materializing /data/%s.config.env\n", rt.Host, rt.Host)
1644 fmt.Printf(" for a daemon route that pass also enables and STARTS carlos-app@%s.service — the adopt timer is hourly, so stamp this only once the data it will serve is in place\n", rt.Host)
1645 } else {
1646 fmt.Printf(" the next adopt pass stops converging this route's capabilities (any drop-ins already written are left exactly as they are, for an operator to remove) and config delivery returns to the agent's /data/%s.config.env\n", rt.Host)
1647 }
1648}
1649
1650// backingRail is the row-shape half of runRouteBacking's refusals, split out
1651// as a pure function for the same reason grantRail is: a rail nobody can
1652// exercise without running the process is a rail nobody tests.
1653//
1654// Refusing EVERY Platform row (not only Platform+Canary, as the channel and
1655// environment axes do) is deliberate and stricter. Those axes refuse the
1656// canary-minted rows because the reconciler re-derives them; this one refuses
1657// a plain platform apex too, because backing is not merely rebuilt for those
1658// rows — it is meaningless. A platform alias is a second name for one
1659// process, and a second carlos-app@ unit under it would claim ports and a
1660// state directory the real route already owns.
1661//
1662// It takes the TARGET value as well as the row because one of its refusals
1663// is directional: moving a unit-stamped row TO unit backing creates the state
1664// unitRail forbids from the other side, while moving one AWAY from unit
1665// backing is always fine (it can only reduce the number of things claiming to
1666// own the process). Refusing both directions would strand any row that
1667// somehow reached the forbidden state with no way back out.
1668func backingRail(rt registry.Route, backing string) error {
1669 if backing == registry.BackingUnit && rt.Unit != "" {
1670 return fmt.Errorf("route names the unit %s — a unit-backed route's process is the carlos-app@%s unit the platform derives and already restarts, "+
1671 "so a second unit name would be two answers to one question. Clear the stamp first: `carlos route --host %s --unit -`", rt.Unit, rt.Host, rt.Host)
1672 }
1673 if rt.Platform {
1674 return fmt.Errorf("Platform route — a platform alias is a second name for one process, so a unit of its own would " +
1675 "claim ports and a state directory the real route already holds; and the domains reconciler rebuilds these rows " +
1676 "from values it derives itself. This verb does not own this row")
1677 }
1678 if rt.Kind != "instance" {
1679 return fmt.Errorf("backing is only meaningful for an instance route (kind %q) — a %s route has no process of its own to back", rt.Kind, rt.Kind)
1680 }
1681 return nil
1682}
1683
1684// runRouteUnit stamps the systemd unit that owns a route's process — the door
1685// issue #110 needed. A console is an addr-backed route (`--addr
1686// 127.0.0.1:8090`) under an operator-run console.service: the adopt pass's
1687// restart dispatch could reach it by neither of its arms, so promote → adopt
1688// stamped a new Version, and with it the edge's X-Carlos-Version header,
1689// behind a process still running the old build. The rule was "promote →
1690// adopt → ssh in and systemctl restart console.service", which is exactly the
1691// kind of follow-up step a `carlos` command is not allowed to require.
1692//
1693// This is a stamp on an EXISTING row on a live box, deliberately: the
1694// production consoles already exist, and the fix must not need them recreated.
1695//
1696// Two rails, both structural, neither overridable:
1697//
1698// - a unit-backed route is refused. Its process is a carlos-app@<host>
1699// unit the platform derives and already restarts; a second unit name on
1700// the same row is two answers to one question, and the dispatch would
1701// honour this one and stop cycling the tenant.
1702// - the name's shape is checked at the flag, before the registry is even
1703// opened (registry.ValidUnitName), because a typo here is invisible
1704// afterwards — a restart that reaches nothing looks exactly like a
1705// restart that worked.
1706//
1707// It does NOT check that the unit exists. It cannot: on the box where it
1708// matters the command may well run before the unit is installed, and a probe
1709// that has to succeed would make the stamp unavailable exactly when it is
1710// most needed. What it does instead is print what the next adopt pass will do
1711// with the value, at the moment of typing.
1712func runRouteUnit(r *registry.Registry, rt registry.Route, unit string, dryRun bool) {
1713 if err := unitRail(rt); err != nil {
1714 log.Fatalf("route: %s: %v", rt.Host, err)
1715 }
1716 prefix := ""
1717 if dryRun {
1718 prefix = colorFor(os.Stdout).warn("(dry run)") + " "
1719 }
1720 if rt.Unit == unit {
1721 mutedf(os.Stdout, "%s: unit already %s — nothing to do\n", rt.Host, unitWord(unit))
1722 return
1723 }
1724 if !dryRun {
1725 if err := r.SetUnit(rt.Host, unit); err != nil {
1726 log.Fatalf("route: write: %v", err)
1727 }
1728 }
1729 fmt.Printf("%s%s: unit %s -> %s\n", prefix, rt.Host, unitWord(rt.Unit), unitWord(unit))
1730 if unit != "" {
1731 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)
1732 } else {
1733 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)
1734 }
1735}
1736
1737// unitRail is the row-shape half of runRouteUnit's refusals, split out as a
1738// pure function for the same reason backingRail is: a rail nobody can
1739// exercise without running the process is a rail nobody tests.
1740//
1741// Deliberately NARROWER than backingRail. It does not refuse Platform rows:
1742// this column is not derived by the domains reconciler (mintedRoute never
1743// sets it) and registry.Upsert preserves a non-empty value, so a stamp there
1744// survives a re-mint rather than being silently reverted — and the
1745// deployment's own console can legitimately be a platform-minted row. It does
1746// not refuse non-instance kinds either: a `service` route is precisely a
1747// sibling process the platform only proxies, and naming the unit that owns it
1748// is a true statement, even though adoption has no version to move for one
1749// today.
1750func unitRail(rt registry.Route) error {
1751 if rt.Backing == registry.BackingUnit {
1752 return fmt.Errorf("unit-backed route — its process is the carlos-app@%s unit the platform derives and already restarts, "+
1753 "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)
1754 }
1755 return nil
1756}
1757
1758// unitWord renders a unit column value for an operator — the empty string is
1759// this column's default and a real answer ("nothing named"), and printing it
1760// as "" reads as a bug.
1761func unitWord(unit string) string {
1762 if unit == "" {
1763 return `"" (no unit named — adoption dispatches on backing)`
1764 }
1765 return unit
1766}
1767
1768// runRouteReplication puts one route on a rung of the replication ladder.
1769//
1770// The ladder was built and honoured and had no door: three named rungs, one
1771// of them reachable, and it was the most expensive one. Every route on the
1772// fleet therefore ran on the every-second default, which for one tenant was
1773// about 100x the S3 traffic the alternative would have cost — a 610 MiB
1774// database, low write volume, compacted several times a day (discussion
1775// #10). This verb is the smallest thing that closes that: the activator
1776// already reads the column on wake and hands the interval to litestream, so
1777// nothing new happens on the box.
1778//
1779// It is deliberately the OPERATOR's door and not a member-facing knob. The
1780// loss window is a durability promise the platform makes — `daily` means up
1781// to a day of writes lost with the box — and whether a member may choose it,
1782// or whether it belongs to a plan tier, is a design question #10 filed
1783// rather than answered. Nothing here forecloses either answer.
1784//
1785// Two rails, both structural, and they are one premise read whole. The class
1786// is read in exactly one place — the activator's wake path — and
1787// /etc/litestream.yml covers everything box-wide litestream is NOT told to
1788// leave alone, at its own interval. What it is told to leave alone is
1789// `r.Hibernate && r.DBPath != ""` (internal/ops/litestream.go), so BOTH
1790// halves are required: a non-hibernating route, and a hibernating route
1791// whose row names no database, are each swept into the box-wide config and
1792// each would have the column written, reported as set, and read by nothing —
1793// the same silent no-op the missing door was.
1794//
1795// The column also cannot outlive the first half. registry.SetHibernate
1796// clears the rung when hibernation goes off, and Upsert refuses to carry one
1797// onto a non-hibernating row, so `carlos routes` cannot come to print a rung
1798// this rail would no longer let anyone set.
1799//
1800// The value's shape is checked at the flag, before the registry is opened.
1801func runRouteReplication(r *registry.Registry, rt registry.Route, class string, dryRun bool) {
1802 if err := replicationRail(rt); err != nil {
1803 log.Fatalf("route: %s: %v", rt.Host, err)
1804 }
1805 prefix := ""
1806 if dryRun {
1807 prefix = colorFor(os.Stdout).warn("(dry run)") + " "
1808 }
1809 if rt.Replication == class {
1810 mutedf(os.Stdout, "%s: replication already %s — nothing to do\n", rt.Host, replicationWord(class))
1811 return
1812 }
1813 if !dryRun {
1814 if err := r.SetReplication(rt.Host, class); err != nil {
1815 log.Fatalf("route: write: %v", err)
1816 }
1817 }
1818 fmt.Printf("%s%s: replication %s -> %s\n", prefix, rt.Host, replicationWord(rt.Replication), replicationWord(class))
1819 // Said every time, because the write is not the change: the activator
1820 // hands the interval to litestream when it wakes the instance, so an
1821 // instance that is awake right now keeps its current interval until it
1822 // next sleeps and wakes.
1823 fmt.Printf(" takes effect on this instance's next wake — the sync interval is handed to litestream at wake, so an instance awake now keeps the old one until it sleeps\n")
1824}
1825
1826// replicationRail is runRouteReplication's pair of refusals, split out as a
1827// pure function for the reason unitRail and backingRail are: a rail nobody
1828// can exercise without running the process is a rail nobody tests.
1829func replicationRail(rt registry.Route) error {
1830 if !rt.Hibernate {
1831 return fmt.Errorf("route does not hibernate — the replication class is read only where the activator replicates, on wake, and /etc/litestream.yml (which covers every NON-hibernating database on this box) has its own interval. "+
1832 "Setting a rung here would write a column nothing reads. Use `carlos route --host %s --hibernate` first if this route should be activator-managed", rt.Host)
1833 }
1834 // Hibernation alone was never the premise — the exclusion it stands on
1835 // is `r.Hibernate && r.DBPath != ""` (ops.FleetDBsWithSkipped), and
1836 // provision.routeFor's comment already records this trap from the other
1837 // side. A hibernating route whose row names no database is NOT excluded
1838 // from box-wide litestream: the dataDir glob claims its file anyway and
1839 // replicates it at that unit's interval, so a rung set here would be
1840 // written, reported by `carlos routes`, and honoured by nothing — the
1841 // same silent no-op the missing door was, reached through the door.
1842 //
1843 // Empty db_path on a hibernating row is not hypothetical: `carlos add`
1844 // defaults the path only for a single-upstream `instance` that is not
1845 // unit-backed, so a static or service route, or an instance record
1846 // carrying an upstream SET (provision.Record.DerivesPaths is false
1847 // there), hibernates with the column empty.
1848 if rt.DBPath == "" {
1849 return fmt.Errorf("route hibernates but its row names no database (db_path is empty) — box-wide litestream excludes a hibernating route only when it knows WHERE its database is (`r.Hibernate && r.DBPath != \"\"`, ops.FleetDBs), "+
1850 "so this route's file is still being replicated at /etc/litestream.yml's own interval and a rung set here would be honoured by nothing. "+
1851 "Give the row its path first (`carlos add --host %s --db <absolute path>`, which rewrites the existing route), then set the rung", rt.Host)
1852 }
1853 return nil
1854}
1855
1856// replicationWord renders a replication column value for an operator — "" is
1857// this column's default and a real answer, and printing it as "" reads as a
1858// bug rather than as the rung every route was on before there was a door.
1859func replicationWord(class string) string {
1860 if class == "" {
1861 return `"" (the default: change-driven ~1s sync, same as stream)`
1862 }
1863 switch class {
1864 case registry.ReplicationStream:
1865 return "stream (change-driven ~1s — loss window seconds)"
1866 case registry.ReplicationBatched:
1867 return "batched (at most every 5m — loss window minutes)"
1868 case registry.ReplicationDaily:
1869 return "daily (at wake and every 24h — loss window up to a day)"
1870 }
1871 return class
1872}
1873
1874// backingWord renders a backing column value for an operator — the empty
1875// string is a real value of this column, and printing it as "" reads as a
1876// bug rather than as an answer.
1877func backingWord(backing string) string {
1878 if backing == "" {
1879 return `"" (exec child of the agent)`
1880 }
1881 return backing
1882}
1883
1884// daemonHibernateRefusal is the one sentence both doors say — `carlos route
1885// --hibernate` and `carlos add --hibernate` — so an operator who meets it at
1886// one of them recognises it at the other.
1887const daemonHibernateRefusal = "--hibernate is refused for a route whose release declares the `daemon` capability: " +
1888 "a daemon is long-running BY DECLARATION — the platform enables it, starts it and restarts it on adopt — " +
1889 "and hibernating it would have the activator stop the very process that declaration exists to keep up"
1890
1891// routeDaemonCheck reports whether the release this route runs declares the
1892// `daemon` capability, and whether that answer is PROVEN. The three returns
1893// are the same shape routeVersionCheck uses, and for the same reason: the
1894// caller must be able to tell "no daemon" from "I could not find out", and
1895// treat them differently.
1896//
1897// It checks BOTH the version the route is currently serving and the version
1898// its channel points at, because both are "what this route runs": the first
1899// is what is running now, the second is what the next adopt converges onto.
1900// Either declaring daemon is a refusal.
1901//
1902// A route naming no app is proven NOT a daemon rather than unproven — there
1903// is no release, so there is nothing that could declare anything, and that is
1904// a proof rather than an absence of one.
1905func routeDaemonCheck(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct string, rt registry.Route) (daemon bool, note string, proven bool) {
1906 if rt.App == "" {
1907 return false, "route names no app, so no release can declare a capability for it", true
1908 }
1909 if pub == nil {
1910 return false, "cannot verify what this route's release declares: CARLOS_RELEASE_PUBKEY is not set", false
1911 }
1912 if store == nil {
1913 return false, "cannot verify what this route's release declares: no deployment store is configured " +
1914 "(set CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR)", false
1915 }
1916 ch := rt.Channel
1917 if ch == "" {
1918 ch = "stable"
1919 }
1920 ptr, err := release.Channel(ctx, store, pub, acct, rt.App, ch)
1921 if err != nil {
1922 return false, fmt.Sprintf("channel %s (%s) does not resolve: %v", ch, release.ChannelKey(acct, rt.App, ch), err), false
1923 }
1924 versions := []string{ptr.Version}
1925 if rt.Version != "" && rt.Version != ptr.Version {
1926 versions = append(versions, rt.Version)
1927 }
1928 for _, v := range versions {
1929 m, merr := release.GetManifest(ctx, store, acct, rt.App, v)
1930 if merr != nil {
1931 return false, fmt.Sprintf("manifest for %s %s does not read: %v", rt.App, v, merr), false
1932 }
1933 if m.Capabilities != nil && m.Capabilities.Daemon {
1934 return true, fmt.Sprintf("%s %s declares it", rt.App, v), true
1935 }
1936 }
1937 return false, fmt.Sprintf("no daemon capability in %s %s", rt.App, strings.Join(versions, " or ")), true
1938}
1939
1940// deploymentReadSeam resolves the public key and store used for READ-ONLY
1941// release checks, returning nils instead of exiting when either is absent.
1942// openStore's log.Fatal is right for a verb that cannot do its job without a
1943// store; it is wrong here, where "no store configured" is an ANSWER the
1944// caller has to weigh — `carlos add` proceeds on it (provision time, no
1945// bucket yet), `carlos route --hibernate` refuses on it.
1946func deploymentReadSeam() (release.Store, ed25519.PublicKey) {
1947 var pub ed25519.PublicKey
1948 if b := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); b != "" {
1949 if p, err := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY"); err == nil {
1950 pub = p
1951 }
1952 }
1953 if pub == nil {
1954 return nil, nil
1955 }
1956 if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" {
1957 s, err := release.NewDeploymentS3Store(context.Background(), b)
1958 if err != nil {
1959 return nil, pub
1960 }
1961 return s, pub
1962 }
1963 if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" {
1964 ds := release.NewDirStore(d)
1965 ds.EnableChangeIndex()
1966 return ds, pub
1967 }
1968 return nil, pub
1969}
1970
1971// routeVersionCheck resolves the rail for one route: does moving it from
1972// fromChannel to toChannel change what gets served?
1973//
1974// - safe=true: the comparison resolved and the two sides agree — proceed
1975// unconditionally, nothing changes.
1976// - safe=false, overridable=true: the comparison resolved and the two
1977// sides genuinely disagree — --allow-version-change is the intended door.
1978// - safe=false, overridable=false: nothing here PROVES what would change
1979// (no pubkey, an unresolvable channel, a bad signature) — never
1980// overridable.
1981//
1982// rt.Version — stamped by the last adopt pass — is the honest "what is this
1983// route actually serving" for an instance route. SetVersions is only ever
1984// called for Kind == instance, though, so a static route (or an instance
1985// never yet adopted) always has Version == "" — comparing that against a
1986// resolved target would either garble the message or force
1987// --allow-version-change unconditionally. For those, the only available proxy
1988// is what the route's CURRENT channel resolves to, compared against the
1989// target — both sides read through the identical release.Channel path the
1990// runtime itself uses.
1991func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct string, rt registry.Route, fromChannel, toChannel string) (note string, safe, overridable bool) {
1992 if pub == nil {
1993 return "cannot verify what the target channel serves: CARLOS_RELEASE_PUBKEY is not set", false, false
1994 }
1995 target, err := release.Channel(ctx, store, pub, acct, rt.App, toChannel)
1996 if err != nil {
1997 return fmt.Sprintf("target channel %s (%s) does not resolve: %v",
1998 toChannel, release.ChannelKey(acct, rt.App, toChannel), err), false, false
1999 }
2000 if rt.Version != "" {
2001 if target.Version == rt.Version {
2002 return fmt.Sprintf("version unchanged: %s", target.Version), true, false
2003 }
2004 return fmt.Sprintf("version changing: %s -> %s (route is running %s, %s currently serves %s)",
2005 rt.Version, target.Version, rt.Version, toChannel, target.Version), false, true
2006 }
2007 current, err := release.Channel(ctx, store, pub, acct, rt.App, fromChannel)
2008 if err != nil {
2009 return fmt.Sprintf("route has no recorded version, and its current channel %s (%s) does not resolve\n"+
2010 "either, so there is nothing to compare against: %v",
2011 fromChannel, release.ChannelKey(acct, rt.App, fromChannel), err), false, false
2012 }
2013 if current.Version == target.Version {
2014 return fmt.Sprintf("version unchanged: %s (route has no adopted Version; compared %s's pointer against %s's)",
2015 target.Version, fromChannel, toChannel), true, false
2016 }
2017 return fmt.Sprintf("version changing: %s -> %s (route has no adopted Version; %s currently serves %s, %s currently serves %s)",
2018 current.Version, target.Version, fromChannel, current.Version, toChannel, target.Version), false, true
2019}
2020
2021// accountFlags registers the --account/--account-id pair (and the --registry
2022// seam) every app verb carries. --account takes a sqid OR a name, resolved
2023// symmetrically against the registry's materialized accounts — neither space
2024// wins, so a name that collides with some other account's sqid is refused,
2025// not silently redirected. --account-id is the way out of that refusal, and
2026// the reference a script should carry: a sqid, matched as a sqid only, never
2027// looked up as a name. --account defaults to the built-in ops account, so a
2028// single-tenant deployment never types either flag. Reads fall back to the
2029// legacy unqualified keys either way, so an unmigrated app keeps working.
2030//
2031// Returns the raw flag values alongside a resolver thunk, rather than the
2032// resolved value alone, because resolution has to happen AFTER fs.Parse —
2033// and it opens the registry, which a verb that never uses the account should
2034// not pay for. The raw pointer exists for API mode (cli.go's four
2035// console-API verbs): they must send the --account value verbatim (a
2036// developer laptop has no local registry to resolve it against, and the
2037// console does its own resolution against the caller's memberships), and
2038// reading it straight off this return value keeps that path from being
2039// string-coupled to this function's internal flag name the way
2040// fs.Lookup("account") would be.
2041func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string) {
2042 raw, rawID, _, resolve = accountFlagsWithRegistry(fs)
2043 return raw, rawID, resolve
2044}
2045
2046// accountFlagsWithRegistry is accountFlags for the handful of verbs that
2047// need the registry PATH as well as the account it resolves — not the flag's
2048// default, but whatever --registry actually says.
2049//
2050// It exists so that those verbs cannot end up reading a different database
2051// than the one their own --account was resolved against. A verb that called
2052// defaultRegistry() directly would do exactly that the moment anyone passed
2053// --registry, and would do it silently, which for `env set`'s restart notice
2054// (appconfig_cli.go) would mean answering "which routes did this reach" from
2055// the wrong box's registry.
2056func accountFlagsWithRegistry(fs *flag.FlagSet) (raw, rawID, regPath *string, resolve func() string) {
2057 // Empty default, two meanings by mode: API mode resolves it to the
2058 // caller's own account (resolvedAccount, apimode.go); direct mode's
2059 // resolveAccount reads "" as ops exactly as it always has, so box and
2060 // script behavior is unchanged.
2061 acct := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one; ops in direct mode)")
2062 acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account; this is the flag an ambiguity error tells you to use")
2063 reg := fs.String("registry", defaultRegistry(), "registry database (resolves --account)")
2064 return acct, acctID, reg, func() string { return resolveAccount(*reg, *acct, *acctID) }
2065}
2066
2067func openStore() release.Store {
2068 if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" {
2069 s, err := release.NewDeploymentS3Store(context.Background(), b)
2070 if err != nil {
2071 log.Fatal(err)
2072 }
2073 return s
2074 }
2075 if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" {
2076 ds := release.NewDirStore(d)
2077 ds.EnableChangeIndex()
2078 return ds
2079 }
2080 log.Fatal("set CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (local)")
2081 return nil
2082}
2083
2084func signingKey() ed25519.PrivateKey {
2085 k, err := release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "CARLOS_RELEASE_KEY")
2086 if err != nil {
2087 log.Fatal(err)
2088 }
2089 return k
2090}
2091
2092// releaseSigner is the identity the write verbs sign with: the root key, or a
2093// scoped key travelling with the grant that authorises it.
2094//
2095// The grant is discovered from CARLOS_RELEASE_GRANT, exactly as the key is
2096// discovered from CARLOS_RELEASE_KEY — secret manager to shell variable to
2097// process memory, never disk. A scoped key with no grant beside it is fatal
2098// here rather than three machines away: a scoped seed alone proves nothing,
2099// so it would sign pointers nothing in the fleet will ever accept, and the
2100// failure would surface as an unexplained signature error on a box.
2101//
2102// That check is only possible because a scoped seed is tagged (release's
2103// ScopedKeyPrefix). Both kinds of key are otherwise 32 random bytes.
2104func releaseSigner() release.Signer {
2105 raw := os.Getenv("CARLOS_RELEASE_KEY")
2106 key, err := release.ParseSigningKey(raw, "CARLOS_RELEASE_KEY")
2107 if err != nil {
2108 log.Fatal(err)
2109 }
2110 blob := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_GRANT"))
2111 if blob == "" {
2112 if release.IsScopedSeed(raw) {
2113 log.Fatal("CARLOS_RELEASE_KEY holds a scoped key but CARLOS_RELEASE_GRANT is not set — a scoped key alone proves nothing; set the grant blob that was printed beside the key by `carlos release-keygen --scope`")
2114 }
2115 return release.RootSigner(key)
2116 }
2117 // A grant is only meaningful against the key the fleet trusts, so scoped
2118 // signing needs the deployment's public key too — the same value every box
2119 // is configured with, not a new secret.
2120 if strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")) == "" {
2121 log.Fatal("CARLOS_RELEASE_GRANT is set, so this is a scoped key: signing also needs the deployment's root public key in CARLOS_RELEASE_PUBKEY, which is what the grant is checked against")
2122 }
2123 sg, err := release.ScopedSigner(key, blob, publicKey())
2124 if err != nil {
2125 log.Fatal(err)
2126 }
2127 return sg
2128}
2129
2130func publicKey() ed25519.PublicKey {
2131 k, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
2132 if err != nil {
2133 log.Fatal(err)
2134 }
2135 return k
2136}
2137
2138// shipNow is the clock direct-mode `carlos ship` stamps BuiltAt with. A test
2139// seam only: a re-ship of an existing version is byte-compared with the
2140// stored manifest, BuiltAt included, so a test of the re-ship path pins it.
2141var shipNow = time.Now
2142
2143const (
2144 shipSwapFlagHelp = "hold: this release stops each instance before starting it, for a migration the previous build cannot share the database with"
2145 shipBadSwap = "--swap takes hold, or leave it off (%q is not a swap demand)"
2146 shipHoldNote = " this release swaps in hold mode: each instance stops before the new build starts"
2147)
2148
2149// validSwapFlag is --swap's client-side gate on ship and deploy (spec §4.2):
2150// a release may demand hold, or nothing.
2151func validSwapFlag(s string) error {
2152 if s == "" || s == release.DemandHold {
2153 return nil
2154 }
2155 return fmt.Errorf(shipBadSwap, s)
2156}
2157
2158func runShip(args []string) {
2159 fs := flag.NewFlagSet("ship", flag.ExitOnError)
2160 messageFlags := addMutationMessageFlags(fs)
2161 app := appFlag(fs, "")
2162 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
2163 consoleFlag := consoleOption(fs, "console origin to ship through (API mode only; default: the only logged-in one)")
2164 kind := fs.String("kind", "binary", "binary | static")
2165 ver := versionFlag(fs, "version (git short sha); omit to mint the next iteration under the app's version target")
2166 target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
2167 capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest")
2168 label := fs.String("label", "", "one-line human label for this release (\"fixing modals\")")
2169 notes := fs.String("notes", "", "changelog fragment for this release")
2170 notesFile := fs.String("notes-file", "", "file holding the changelog fragment (mutually exclusive with --notes)")
2171 swap := fs.String("swap", "", shipSwapFlagHelp)
2172 programChange := fs.Bool("program-change", false, "accept that this release's Go main package differs from the app's previous release (recorded in the manifest)")
2173 fs.Usage = flagUsage(fs, "")
2174 fs.Parse(args)
2175 // --version is no longer required: with a version target set, the next
2176 // iteration under it is inferable, and inferring it is the whole point of
2177 // the target (release versioning spec, 2026-08-08). Everything else about
2178 // this check is unchanged — an app and at least one file are still the
2179 // minimum a ship can mean.
2180 usage := "usage: carlos ship --app <a> [--version <sha>] [--label <one line>] [--notes <text> | --notes-file <file>] [--kind binary|static] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [--capabilities <file.json>] [--program-change] [--swap hold] <files-or-site-dir>"
2181 if fs.NArg() == 0 {
2182 log.Fatal(usage)
2183 }
2184
2185 // --notes and --notes-file are two spellings of ONE manifest field, so both
2186 // together is a refusal rather than a precedence rule nobody remembers.
2187 if *notes != "" && *notesFile != "" {
2188 log.Fatal("pass --notes or --notes-file, not both")
2189 }
2190 noteText := *notes
2191 if *notesFile != "" {
2192 b, err := os.ReadFile(*notesFile)
2193 if err != nil {
2194 log.Fatal(err)
2195 }
2196 noteText = string(b)
2197 }
2198 // Bounded HERE, before the mode split, for the same reason the capability
2199 // block is: both doors must refuse the same bytes with the same message
2200 // at the operator's terminal. release.ValidateLabelNotes is the one home
2201 // for the limits and their wording — direct mode would reach it again
2202 // inside release.Ship, API mode again in the console, but neither of
2203 // those is where a human is standing.
2204 if err := release.ValidateLabelNotes(*label, noteText); err != nil {
2205 log.Fatal(err)
2206 }
2207 if err := validSwapFlag(*swap); err != nil {
2208 log.Fatal(err)
2209 }
2210
2211 // Read and decode the declaration BEFORE the mode split, so both doors
2212 // refuse the same bytes with the same message at the operator's terminal
2213 // — the strict decoder for an unknown capability name, ValidCapabilities
2214 // for anything semantically wrong. Direct mode would find out again in
2215 // release.Ship and API mode again in the console, but neither of those is
2216 // where a human is standing.
2217 // Validated against the app NAME, which may still be empty here when
2218 // --app is left to resolution — so with an omitted --app the read happens
2219 // again after resolution (below), and this pre-split pass only runs
2220 // when the name is already known. Both doors still refuse the same
2221 // bytes with the same message; an omitted --app just moves the moment.
2222 var capsSet *caps.Set
2223 if *app != "" {
2224 var err error
2225 capsSet, err = readCapabilities(*capsFile, *app)
2226 if err != nil {
2227 log.Fatal(err)
2228 }
2229 }
2230
2231 sess, err := resolveMode(*consoleFlag)
2232 if err != nil {
2233 log.Fatal(err)
2234 }
2235 if sess != nil {
2236 message, merr := resolveMutationMessage(fs, messageFlags)
2237 if merr != nil {
2238 log.Fatal(merr)
2239 }
2240 // The banner prints BEFORE the account resolves: resolution may put
2241 // a muted "account: …" line — or the whole interactive chooser — on
2242 // the terminal, and a prompt with no console named yet is a question
2243 // with no context. The account is still resolved HERE, not inside
2244 // runShipAPI: resolvedAccount fatals (on a name in --account-id, or
2245 // an ambiguous omitted --account), and log.Fatal skips every deferred
2246 // function — which is the one thing runShipAPI exists to avoid (its
2247 // static branch defers the cleanup of a scratch tarball up to
2248 // 256MiB). Resolved before the call, the refusal lands before
2249 // runShipAPI's body has registered a defer at all.
2250 printUsing(stdout, sess)
2251 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
2252 app2 := resolvedApp(sess, acct, *app, usage)
2253 if *app == "" {
2254 // --app came from resolution: the capability block is validated
2255 // now, against the real name.
2256 capsSet, err = readCapabilities(*capsFile, app2)
2257 if err != nil {
2258 log.Fatal(err)
2259 }
2260 }
2261 sess.shipSwap = *swap
2262 if _, err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false, *programChange, message); err != nil {
2263 log.Fatal(err)
2264 }
2265 return
2266 }
2267
2268 if *app == "" {
2269 log.Fatal(usage)
2270 }
2271 files := map[string][]byte{}
2272 arts := map[string]release.Artifact{}
2273 addFile := func(name string, data []byte) {
2274 files[name] = data
2275 sum := sha256.Sum256(data)
2276 arts[name] = release.Artifact{File: name, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(data))}
2277 }
2278 if *kind == "static" {
2279 // A static release is one deterministic artifact: site.tar.gz of the
2280 // given directory, so re-shipping an unchanged tree is byte-identical.
2281 if fs.NArg() != 1 {
2282 log.Fatal("static ship takes exactly one site directory")
2283 }
2284 data, err := edge.PackDir(fs.Arg(0))
2285 if err != nil {
2286 log.Fatal(err)
2287 }
2288 addFile("site.tar.gz", data)
2289 } else if fs.NArg() == 1 {
2290 // The adopt pass looks artifacts up by GOOS-GOARCH, so a binary's
2291 // artifact key is its target, not its build filename (learned live
2292 // 2026-08-01: a basename-keyed artifact is invisible to every host).
2293 data, err := os.ReadFile(fs.Arg(0))
2294 if err != nil {
2295 log.Fatal(err)
2296 }
2297 addFile(*target, data)
2298 } else {
2299 // Multiple files: pre-named per target (linux-arm64, linux-amd64, ...).
2300 for _, path := range fs.Args() {
2301 data, err := os.ReadFile(path)
2302 if err != nil {
2303 log.Fatal(err)
2304 }
2305 addFile(filepath.Base(path), data)
2306 }
2307 }
2308 // Direct mode opens the store ONCE, here: the mint below reads it (the
2309 // release dirs are the iteration counter — there is no side counter to
2310 // drift from them), and release.Ship writes through the same handle.
2311 st := openStore()
2312 acct := acctOf()
2313 shipVersion := *ver
2314 if shipVersion == "" {
2315 vt, _, err := release.GetTarget(context.Background(), st, acct, *app)
2316 if err != nil {
2317 log.Fatal(err)
2318 }
2319 if vt == nil {
2320 log.Fatal("no --version and no version target set — set one with `carlos version target --app " + *app + " <semver>`, or pass --version")
2321 }
2322 t, ok := release.ParseSemVer(vt.Target)
2323 if !ok {
2324 // GetTarget already refuses a stored target that isn't a bare
2325 // semver, so this is unreachable through that door — kept because
2326 // the alternative to a named refusal here is a silent zero SemVer
2327 // minting "0.0.0-1" against an app nobody meant to touch.
2328 log.Fatalf("stored version target %q is not a semver — set it again", vt.Target)
2329 }
2330 shipVersion, err = release.NextIteration(context.Background(), st, acct, *app, t)
2331 if err != nil {
2332 log.Fatal(err)
2333 }
2334 }
2335 m := release.Manifest{
2336 Schema: 1, App: *app, Version: shipVersion, Kind: *kind,
2337 BuiltBy: "carlos " + version.Build, BuiltAt: shipNow().UTC().Format(time.RFC3339),
2338 Artifacts: arts,
2339 // Empty when the flags were not given: both are omitempty, so the
2340 // manifest bytes stay byte-identical to what this verb wrote before
2341 // labels and notes existed.
2342 Label: *label,
2343 Notes: noteText,
2344 // nil when --capabilities was not given: omitempty then keeps the
2345 // manifest bytes byte-identical to what this verb wrote yesterday.
2346 Capabilities: capsSet,
2347 // "" when --swap was not given: omitempty keeps the bytes as before.
2348 Swap: *swap,
2349 }
2350 // The wrong-program rule, shared with the console's API door: refuse a
2351 // release whose Go main package differs from the app's previous release
2352 // unless --program-change says it is meant. Only a NEW version meets the
2353 // comparison: re-shipping a version already on record stays idempotent.
2354 // A re-ship is still STAMPED the way its first ship was (Program from the
2355 // bytes, ProgramChange from the flag), because release.Ship below decides
2356 // between identical and conflict by comparing manifest bytes, and an
2357 // unstamped manifest never matches a stamped one.
2358 if _, gerr := release.GetManifest(context.Background(), st, acct, *app, shipVersion); gerr != nil {
2359 if err := release.CheckShipProgram(context.Background(), st, acct, &m, files, *programChange); err != nil {
2360 log.Fatal(err)
2361 }
2362 } else if err := release.StampProgram(&m, files, *programChange); err != nil {
2363 log.Fatal(err)
2364 }
2365 if err := release.Ship(context.Background(), st, acct, m, files); err != nil {
2366 log.Fatal(err)
2367 }
2368 fmt.Fprintf(stdout, "%s %s %s (%d artifacts)\n", colorFor(stdout).ok("shipped"), *app, shipVersion, len(files))
2369 if *swap == release.DemandHold {
2370 mutedf(stdout, shipHoldNote)
2371 }
2372}
2373
2374// readCapabilities is the ship-time declaration surface: a file holding
2375// EXACTLY the manifest's "capabilities" object (not a whole manifest, not a
2376// wrapper), decoded through caps.Set's own strict door and then through the
2377// same ValidCapabilities gate the box runs at adopt.
2378//
2379// The file is the whole surface on purpose. There are no per-capability
2380// flags, because a flag per capability is a vocabulary that drifts from the
2381// one in internal/caps, and the block belongs with the app's source anyway —
2382// it is a property of the build, versioned with it.
2383//
2384// An empty path means no declaration: nil, and a manifest byte-identical to
2385// yesterday's. An explicit `null` in the file is refused rather than folded
2386// into nil — a human who passed --capabilities meant to declare something, and
2387// silently shipping nothing is the config-that-lies failure mode.
2388func readCapabilities(path, app string) (*caps.Set, error) {
2389 if path == "" {
2390 return nil, nil
2391 }
2392 raw, err := os.ReadFile(path)
2393 if err != nil {
2394 return nil, fmt.Errorf("ship: --capabilities: %w", err)
2395 }
2396 var set *caps.Set
2397 if err := json.Unmarshal(raw, &set); err != nil {
2398 return nil, fmt.Errorf("ship: --capabilities %s: %w", path, err)
2399 }
2400 if set == nil {
2401 return nil, fmt.Errorf("ship: --capabilities %s: the file declares null — omit the flag to ship no capabilities", path)
2402 }
2403 if err := caps.ValidCapabilities(app, set); err != nil {
2404 return nil, fmt.Errorf("ship: --capabilities %s: %w", path, err)
2405 }
2406 for _, n := range set.Notes() {
2407 fmt.Fprintf(stdout, "%s\n", n)
2408 }
2409 return set, nil
2410}
2411
2412// shipTargetRe validates a binary artifact's part name client-side, against
2413// the exact shape the server enforces (targetRe, internal/console/apicli.go)
2414// — checked here too so a malformed name (a build filename shipped without
2415// --target, say) is refused before a single byte crosses the wire, rather
2416// than after paying for the whole upload and hitting the server's own 400.
2417var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
2418
2419// runShipAPI is runShip's API-mode body: acct is the caller's --account /
2420// --account-id as apiAccount read them, and goes straight to the console
2421// (never acctOf/resolveAccount — there is no local registry to resolve
2422// against on a developer laptop). It arrives already resolved because
2423// apiAccount can fatal, and nothing that fatals may run inside this
2424// function — see the deferred-cleanup note below. Unlike the direct-mode
2425// body above, artifacts are streamed from disk by path (apiSession.ship
2426// reads them lazily) rather than buffered into memory here first — for a
2427// static release, whose bytes only exist in memory (edge.PackDir's tarball),
2428// that means writing them to a scratch temp file first so there is a path to
2429// hand over at all.
2430//
2431// Returns an error rather than calling log.Fatal itself (the caller does
2432// that): log.Fatal calls os.Exit immediately, which skips every deferred
2433// function — including the static kind's scratch temp file cleanup below,
2434// up to 256MiB per invocation. Returning lets that defer actually run on
2435// every exit path, fatal or not.
2436//
2437// printBanner controls only the "using <console> as <you>" line: true for
2438// runShip's own call (unchanged), false for a caller — `carlos deploy` —
2439// that already printed its own banner earlier in the same run (before its
2440// instance-list call, so a listing failure still names the console) and
2441// would otherwise print it twice. The "shipped"/"already shipped" line
2442// below is never suppressed; it is the one true account of what ship did,
2443// and deploy relies on that rather than printing its own.
2444func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target, label, notes string, capsSet *caps.Set, printBanner, programChange bool, messages ...string) (apiShipResult, error) {
2445 message := firstMutationMessage(messages)
2446 files := map[string]string{}
2447 if kind == "static" {
2448 if fs.NArg() != 1 {
2449 return apiShipResult{}, errors.New("static ship takes exactly one site directory")
2450 }
2451 data, err := edge.PackDir(fs.Arg(0))
2452 if err != nil {
2453 return apiShipResult{}, err
2454 }
2455 tmp, err := os.CreateTemp("", "carlos-ship-*.tar.gz")
2456 if err != nil {
2457 return apiShipResult{}, err
2458 }
2459 defer os.Remove(tmp.Name())
2460 if _, err := tmp.Write(data); err != nil {
2461 tmp.Close()
2462 return apiShipResult{}, err
2463 }
2464 if err := tmp.Close(); err != nil {
2465 return apiShipResult{}, err
2466 }
2467 files["site.tar.gz"] = tmp.Name()
2468 } else if fs.NArg() == 1 {
2469 files[target] = fs.Arg(0)
2470 } else {
2471 // Multiple files: pre-named per target (linux-arm64, linux-amd64,
2472 // ...) by basename, exactly like the direct-mode body above.
2473 for _, path := range fs.Args() {
2474 files[filepath.Base(path)] = path
2475 }
2476 }
2477
2478 // Client-side name check, binary kind only (a static release's one part
2479 // is always named "site.tar.gz" above, never a caller-supplied name) —
2480 // same shape the server enforces, checked here so a bad basename is
2481 // named and refused before anything is stat'd or dialed.
2482 if kind != "static" {
2483 for name, path := range files {
2484 if !shipTargetRe.MatchString(name) {
2485 return apiShipResult{}, fmt.Errorf("ship: invalid artifact name %q (from %s) — want a GOOS-GOARCH shape like linux-arm64 "+
2486 "(pass --target to name a single file explicitly)", name, path)
2487 }
2488 }
2489 }
2490
2491 // Pre-flight: every artifact path must exist before the console is ever
2492 // dialed or a byte moves — a typo'd path must fail clean here, not after
2493 // the "using ..." line already printed.
2494 for name, path := range files {
2495 if _, err := os.Stat(path); err != nil {
2496 return apiShipResult{}, fmt.Errorf("ship: artifact %q: %w", name, err)
2497 }
2498 }
2499
2500 if printBanner {
2501 printUsing(stdout, sess)
2502 }
2503 res, err := sess.ship(context.Background(), acct, app, ver, kind, label, notes, files, capsSet, programChange, message)
2504 if err != nil {
2505 return apiShipResult{}, err
2506 }
2507 // res.Version, never ver: when the caller passed no --version the CONSOLE
2508 // minted one from the app's target, and the response is the only place
2509 // that name exists. It echoes the sent version verbatim otherwise, so
2510 // this is the right thing to print on both paths rather than two.
2511 if res.NoOp {
2512 mutedf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, res.Version)
2513 } else {
2514 fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, res.Version)
2515 }
2516 if sess.shipSwap == release.DemandHold {
2517 mutedf(stdout, shipHoldNote)
2518 }
2519 // res carries more than the print above needs: BuildProbe is the app's
2520 // build-report path, which `carlos deploy` turns into its watch's exit
2521 // criterion. Returned rather than acted on here — ship itself has no
2522 // watch.
2523 return res, nil
2524}
2525
2526// runPromote points a channel at a shipped version, with the ladder enforced:
2527// beta is entered from edge and stable from beta, so "it was signed off on
2528// beta" means something. Canary channels are never refused (the fleet runs on
2529// them), a repeated command is a no-op success, and --hotfix is the one
2530// bypass — explicit, so it can be seen.
2531func runPromote(args []string) {
2532 fs := flag.NewFlagSet("promote", flag.ExitOnError)
2533 messageFlags := addMutationMessageFlags(fs)
2534 app := appFlag(fs, "")
2535 hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)")
2536 retry := fs.Bool("retry", false, "re-sign the channel's current promotion of this version with a fresh time, so a box that refused it tries again")
2537 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
2538 consoleFlag := consoleOption(fs, "console origin to promote through (API mode only; default: the only logged-in one)")
2539 fs.Usage = flagUsage(fs, "")
2540 fs.Parse(args)
2541 usage := "usage: carlos promote --app <a> [--hotfix] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] <version> [channel]"
2542 if fs.NArg() != 1 && fs.NArg() != 2 {
2543 log.Fatal(usage)
2544 }
2545 version, channel := fs.Arg(0), fs.Arg(1) // Arg(1) is "" for the one-arg form
2546
2547 sess, err := resolveMode(*consoleFlag)
2548 if err != nil {
2549 log.Fatal(err)
2550 }
2551 if channel == "" && sess == nil {
2552 // Direct mode is the operator's, against a bucket rather than a
2553 // console: there is no channels door to ask, and an operator
2554 // promoting by hand is the one caller who always knows the name.
2555 log.Fatal(usage)
2556 }
2557 if sess != nil {
2558 // --hotfix now travels over the wire (release pipelines v2, Task 10):
2559 // handleAPIPromote reads it strictly after its own rules decision
2560 // (its doc comment), so this is never itself a way past a channel's
2561 // declared rules — only past the ladder's ORDER check, exactly as it
2562 // is in direct mode below.
2563 printUsing(stdout, sess)
2564 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
2565 app2 := resolvedApp(sess, acct, *app, usage)
2566 if channel == "" {
2567 resolved, cerr := promoteChannel(context.Background(), sess, acct, app2)
2568 if cerr != nil {
2569 log.Fatal(cerr)
2570 }
2571 channel = resolved
2572 }
2573 message, merr := resolveMutationMessage(fs, messageFlags)
2574 if merr != nil {
2575 log.Fatal(merr)
2576 }
2577 res, err := sess.promote(context.Background(), acct, app2, version, channel, *hotfix, *retry, message)
2578 if err != nil {
2579 var prop *apiProposedError
2580 if errors.As(err, &prop) {
2581 printProposed(stdout, prop)
2582 return
2583 }
2584 log.Fatal(err)
2585 }
2586 if res.NoOp {
2587 mutedf(stdout, "%s %s is already on %s — nothing to do\n", app2, version, channel)
2588 } else {
2589 fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), app2, version, channel)
2590 }
2591 return
2592 }
2593
2594 if *app == "" {
2595 log.Fatal(usage)
2596 }
2597 res, err := releaseSigner().PromoteOnLadder(context.Background(), openStore(),
2598 acctOf(), *app, channel, version,
2599 release.PromoteOptions{Hotfix: *hotfix, Retry: *retry, Actor: promoteActor()})
2600 if err != nil {
2601 log.Fatal(err)
2602 }
2603 switch {
2604 case res.NoOp:
2605 mutedf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
2606 case res.Hotfix:
2607 c := colorFor(stdout)
2608 fmt.Fprintf(stdout, "%s %s %s -> %s %s\n", c.ok("promoted"), *app, version, channel,
2609 c.warn("(HOTFIX: ladder bypassed, recorded)"))
2610 default:
2611 fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, version, channel)
2612 }
2613 // The tag a stable promotion cut, printed after EVERY branch above rather
2614 // than only the fresh ones: PromoteOnLadder cuts the tag on the no-op
2615 // path too (a repeat promote heals a version that reached stable before
2616 // tags existed, or whose tag write failed the first time), and a name
2617 // that was just minted with nothing said about it is a name the operator
2618 // never learns. Empty off stable, so nothing new prints anywhere else.
2619 //
2620 // TagWarning is a warning, not an error, for the reason release.stableTag
2621 // gives: the pointer has already moved, so a promotion that happened must
2622 // report success. It is printed verbatim.
2623 if res.Tag != "" {
2624 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("tagged"), res.Tag)
2625 }
2626 if res.TagWarning != "" {
2627 fmt.Fprintf(stdout, "%s\n", colorFor(stdout).warn(res.TagWarning))
2628 }
2629}
2630
2631// promoteChannel resolves the channel a one-arg `carlos promote` lands on,
2632// from the app's own channels rather than from the ladder in --help.
2633//
2634// The ladder printed by --help (canary → edge → beta → stable) is the LEGACY
2635// frozen one, and a pipelined app's channels are its own names, in its own
2636// order. An agent reading that help and promoting onto "beta" is reading a
2637// document about a different app — which is exactly what happened
2638// (skills#29): a two-channel app, a confident guess at a third name, and a
2639// refusal that (to its credit) named the real one.
2640//
2641// So the rule is the same one staticDeployChannel uses: resolve when the
2642// answer is unambiguous, and when it is not, say what this app actually has
2643// and let a human pick. A pipeline's declared list wins over the promoted
2644// rows — a channel with no pointer yet is still a channel you may promote
2645// onto, and it is the rows that are the incomplete picture, not the pipeline.
2646//
2647// canary/* is excluded from the count, not from the choice: it is a reserved
2648// per-session namespace outside any pipeline (a dead end by design, always
2649// allowed, zero bake), so a canary someone cut this morning must not turn
2650// tomorrow's ordinary promote into an ambiguity. Naming one explicitly works
2651// exactly as it always did.
2652func promoteChannel(ctx context.Context, sess *apiSession, acct apiAcct, app string) (string, error) {
2653 res, err := sess.channels(ctx, acct, app)
2654 if err != nil {
2655 return "", fmt.Errorf("promote: list channels: %w (name the channel to promote onto)", err)
2656 }
2657 return pickPromoteChannel(res, app)
2658}
2659
2660// pickPromoteChannel is promoteChannel's decision, split from its round trip
2661// so the rules above can be read — and tested — without a console. Same shape
2662// as staticDeployChannel, for the same reason.
2663func pickPromoteChannel(res apiChannelsResult, app string) (string, error) {
2664 if len(res.PipelineChannels) == 1 {
2665 return res.PipelineChannels[0], nil
2666 }
2667 if len(res.PipelineChannels) > 1 {
2668 return "", fmt.Errorf("promote: %s releases through %s — name which one to promote onto "+
2669 "(its entry channel is %s; see carlos pipeline)",
2670 app, strings.Join(res.PipelineChannels, " → "), res.PipelineEntry)
2671 }
2672 var named []string
2673 for _, ch := range res.Channels {
2674 if rung, ok := release.Rung(ch.Channel); ok && rung == release.RungCanary {
2675 continue
2676 }
2677 named = append(named, ch.Channel)
2678 }
2679 switch len(named) {
2680 case 1:
2681 return named[0], nil
2682 case 0:
2683 return "", fmt.Errorf("promote: %s has no channels yet — name the channel to promote onto", app)
2684 default:
2685 return "", fmt.Errorf("promote: %s has %d channels (%s) — name which one to promote onto",
2686 app, len(named), strings.Join(named, ", "))
2687 }
2688}
2689
2690// promoteActor names who is promoting, for the pointer's audit trail: the
2691// logged-in console identity when this terminal has one, and otherwise the
2692// operator's user@host. Never fatal and never blocking — a release verb runs
2693// from CI as often as from a laptop, and "we don't know who" is a worse
2694// answer than "someone on this machine", but neither is worth refusing a
2695// promotion over.
2696func promoteActor() string {
2697 if c, err := loggedInCredential(""); err == nil && c.Address != "" {
2698 return c.Address
2699 }
2700 return defaultLabel()
2701}
2702
2703func runChannels(args []string) {
2704 fs := flag.NewFlagSet("channels", flag.ExitOnError)
2705 app := appFlag(fs, "")
2706 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
2707 consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)")
2708 fs.Usage = flagUsage(fs, "")
2709 fs.Parse(args)
2710 usage := "usage: carlos channels --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
2711
2712 sess, err := resolveMode(*consoleFlag)
2713 if err != nil {
2714 log.Fatal(err)
2715 }
2716 if sess != nil {
2717 printUsing(stdout, sess)
2718 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
2719 app2 := resolvedApp(sess, acct, *app, usage)
2720 chansRes, err := sess.channels(context.Background(), acct, app2)
2721 if err != nil {
2722 log.Fatal(err)
2723 }
2724 chans := chansRes.Channels
2725 // A separate read rather than a new field on the channels response:
2726 // the channels endpoint is what the box-facing world already parses,
2727 // and a display join belongs on the display side. A tag read that
2728 // fails degrades to a tag-less table — the question this verb answers
2729 // is "what is each channel serving", and no tag has ever been part of
2730 // that answer.
2731 tags, err := sess.tags(context.Background(), acct, app2)
2732 if err != nil {
2733 mutedf(stdout, "(tags unreadable: %v)\n", err)
2734 }
2735 byRelease := make(map[string]string, len(tags))
2736 for _, t := range tags {
2737 byRelease[t.Release] = t.Version
2738 }
2739 w := newTable(stdout)
2740 fmt.Fprintln(w, textChannelsHeader)
2741 for _, ch := range chans {
2742 if ch.Err != "" {
2743 fmt.Fprintf(w, "%s\tERROR: %s\t\t\t\n", ch.Channel, ch.Err)
2744 continue
2745 }
2746 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt, byRelease[ch.Version], digestCell(ch.Digests, ch.Straggler))
2747 }
2748 w.Flush()
2749 return
2750 }
2751
2752 if *app == "" {
2753 log.Fatal(usage)
2754 }
2755 acct := acctOf()
2756 s := openStore()
2757 prefix := release.ChannelsPrefix(acct, *app)
2758 keys, err := s.List(context.Background(), prefix)
2759 if err != nil {
2760 log.Fatal(err)
2761 }
2762 // Same join as API mode above, from the store rather than the console,
2763 // and degrading the same way.
2764 tags, err := release.ListTags(context.Background(), s, acct, *app)
2765 if err != nil {
2766 mutedf(stdout, "(tags unreadable: %v)\n", err)
2767 }
2768 byRelease := make(map[string]string, len(tags))
2769 for _, t := range tags {
2770 byRelease[t.Release] = t.Version
2771 }
2772 pub := publicKey()
2773 marker, _ := release.ReadDigestMarker(context.Background(), s, pub, acct)
2774 w := newTable(stdout)
2775 fmt.Fprintln(w, textChannelsHeader)
2776 for _, k := range keys {
2777 name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
2778 cp, err := release.Channel(context.Background(), s, pub, acct, *app, name)
2779 if err != nil {
2780 fmt.Fprintf(w, "%s\tERROR: %v\t\t\t\n", name, err)
2781 continue
2782 }
2783 st := release.DigestStateOf(cp, marker)
2784 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, cp.Version, cp.PromotedAt.Format(time.RFC3339), byRelease[cp.Version], digestCell(st.HeadDigest, st.Straggler))
2785 }
2786 w.Flush()
2787}
2788
2789// runVersionTarget is `carlos version target`: the per-app semver that
2790// `carlos ship` counts iterations under (release versioning spec,
2791// 2026-08-08). No positional argument reads it, one sets it — rather than a
2792// -set flag, which would make a mistyped read (a stray value, a shell glob)
2793// into a write of the one number every future version of this app hangs off.
2794//
2795// This is a SUB-verb of `carlos version` (main.go), which on its own still
2796// prints the binary's build id and nothing else: the oldest output in this
2797// program, typed by every install script there is.
2798func runVersionTarget(args []string) {
2799 fs := flag.NewFlagSet("version target", flag.ExitOnError)
2800 app := appFlag(fs, "")
2801 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
2802 consoleFlag := consoleOption(fs, "console origin to read or set through (API mode only; default: the only logged-in one)")
2803 fs.Usage = flagUsage(fs, "")
2804 fs.Parse(args)
2805 usage := "usage: carlos version target --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [<semver>]"
2806 if fs.NArg() > 1 {
2807 log.Fatal(usage)
2808 }
2809
2810 sess, err := resolveMode(*consoleFlag)
2811 if err != nil {
2812 log.Fatal(err)
2813 }
2814 if sess != nil {
2815 printUsing(stdout, sess)
2816 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
2817 app2 := resolvedApp(sess, acct, *app, usage)
2818 // unset is what both modes print when no target has ever been set, and
2819 // it names the way to set one: an operator meeting this line is one
2820 // command away from the feature, and the command is not guessable
2821 // from "none".
2822 unset := func() {
2823 mutedf(stdout, "no version target set\n")
2824 mutedf(stdout, " set one with: carlos version target --app %s 0.1.0\n", app2)
2825 }
2826 if fs.NArg() == 0 {
2827 vt, err := sess.versionTarget(context.Background(), acct, app2)
2828 if err != nil {
2829 log.Fatal(err)
2830 }
2831 if vt.Target == "" {
2832 unset()
2833 return
2834 }
2835 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), vt.Target)
2836 if vt.SetBy != "" {
2837 mutedf(stdout, " set by %s at %s\n", vt.SetBy, vt.SetAt)
2838 }
2839 return
2840 }
2841 stored, err := sess.setVersionTarget(context.Background(), acct, app2, fs.Arg(0))
2842 if err != nil {
2843 log.Fatal(err)
2844 }
2845 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), stored)
2846 return
2847 }
2848
2849 if *app == "" {
2850 log.Fatal(usage)
2851 }
2852 unset := func() {
2853 mutedf(stdout, "no version target set\n")
2854 mutedf(stdout, " set one with: carlos version target --app %s 0.1.0\n", *app)
2855 }
2856 acct := acctOf()
2857 s := openStore()
2858 if fs.NArg() == 0 {
2859 vt, _, err := release.GetTarget(context.Background(), s, acct, *app)
2860 if err != nil {
2861 log.Fatal(err)
2862 }
2863 if vt == nil {
2864 unset()
2865 return
2866 }
2867 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), vt.Target)
2868 if vt.SetBy != "" {
2869 mutedf(stdout, " set by %s at %s\n", vt.SetBy, vt.SetAt.Format(time.RFC3339))
2870 }
2871 return
2872 }
2873 // SetTarget carries both refusals — a target that isn't a bare semver,
2874 // and one that is not above every tag already cut — and its messages name
2875 // the offending value, so they pass through untouched.
2876 target := fs.Arg(0)
2877 if err := release.SetTarget(context.Background(), s, acct, *app, target, promoteActor(), time.Now().UTC()); err != nil {
2878 log.Fatal(err)
2879 }
2880 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("version target"), target)
2881}
2882
2883// releaseRow is one row of `carlos releases`, in either mode: the console
2884// joins the tags server-side (handleAPIReleasesList) and direct mode joins
2885// them here, but both arrive at the same five columns so the table renders
2886// once below. Err is not a column — it is the direct-mode "this manifest
2887// would not read" row, printed in the LABEL cell the way runChannels prints
2888// an unverifiable pointer.
2889type releaseRow struct {
2890 Version, Label, Kind, BuiltAt, Tag, Err string
2891}
2892
2893// releasesCap bounds the list, matching the console's own versionsCap: a
2894// long-lived app has thousands of releases and nobody reads past the first
2895// screen of them.
2896const releasesCap = 20
2897
2898// localReleases is the direct-mode half of `carlos releases`: pick the
2899// releases worth reading, read those manifests, join the tags, newest first.
2900//
2901// It is a local sibling of console.Versions rather than a call to it,
2902// deliberately: the CLI does not import internal/console — that package
2903// carries templates, sessions, a registry and a whole HTTP server. What the
2904// two DO share is release.VersionCandidates, so both surfaces pick the same
2905// releasesCap versions (by manifest mod time, when the store carries stamps
2906// — a bare *S3Store or *DirStore, which is all openStore hands out) and read
2907// nothing else; the two modes of this one command agree about what "the
2908// latest release" is. A manifest that will not read is a row with Err set,
2909// exactly as it is on the console's app page and in runChannels above, not a
2910// failure of the whole listing.
2911func localReleases(ctx context.Context, s release.Store, acct, app string) ([]releaseRow, error) {
2912 cands, _, err := release.VersionCandidates(ctx, s, acct, app, releasesCap)
2913 if err != nil {
2914 return nil, err
2915 }
2916 tags, err := release.ListTags(ctx, s, acct, app)
2917 if err != nil {
2918 return nil, err
2919 }
2920 byRelease := make(map[string]string, len(tags))
2921 for _, t := range tags {
2922 byRelease[t.Release] = t.Version
2923 }
2924 var out []releaseRow
2925 for _, v := range cands {
2926 m, err := release.GetManifest(ctx, s, acct, app, v)
2927 if err != nil {
2928 out = append(out, releaseRow{Version: v, Tag: byRelease[v], Err: err.Error()})
2929 continue
2930 }
2931 out = append(out, releaseRow{
2932 Version: v, Label: m.Label, Kind: m.Kind, BuiltAt: m.BuiltAt, Tag: byRelease[v],
2933 })
2934 }
2935 // Newest first by build time, version-descending within a tie — the same
2936 // order console.Versions sorts by, so the two surfaces agree about what
2937 // "the latest release" is. An unparseable BuiltAt sorts as the zero time
2938 // (last), which is where a manifest that could not be read belongs.
2939 parse := func(s string) time.Time {
2940 t, _ := time.Parse(time.RFC3339, s)
2941 return t
2942 }
2943 sort.SliceStable(out, func(i, j int) bool {
2944 ti, tj := parse(out[i].BuiltAt), parse(out[j].BuiltAt)
2945 if !ti.Equal(tj) {
2946 return ti.After(tj)
2947 }
2948 return out[i].Version > out[j].Version
2949 })
2950 if len(out) > releasesCap {
2951 out = out[:releasesCap]
2952 }
2953 return out, nil
2954}
2955
2956// runReleases is `carlos releases`: what has been shipped, newest first,
2957// with the label its author typed and the tag it earned if it reached
2958// stable. `carlos channels` answers "what is running"; this answers "what is
2959// there to run".
2960func runReleases(args []string) {
2961 fs := flag.NewFlagSet("releases", flag.ExitOnError)
2962 app := appFlag(fs, "")
2963 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
2964 consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)")
2965 fs.Usage = flagUsage(fs, "")
2966 fs.Parse(args)
2967 usage := "usage: carlos releases --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
2968
2969 sess, err := resolveMode(*consoleFlag)
2970 if err != nil {
2971 log.Fatal(err)
2972 }
2973 var rows []releaseRow
2974 appName := *app
2975 if sess != nil {
2976 printUsing(stdout, sess)
2977 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
2978 appName = resolvedApp(sess, acct, *app, usage)
2979 rels, err := sess.releases(context.Background(), acct, appName)
2980 if err != nil {
2981 log.Fatal(err)
2982 }
2983 for _, r := range rels {
2984 rows = append(rows, releaseRow{Version: r.Version, Label: r.Label, Kind: r.Kind, BuiltAt: r.BuiltAt, Tag: r.Tag})
2985 }
2986 } else {
2987 if appName == "" {
2988 log.Fatal(usage)
2989 }
2990 rows, err = localReleases(context.Background(), openStore(), acctOf(), appName)
2991 if err != nil {
2992 log.Fatal(err)
2993 }
2994 }
2995
2996 if len(rows) == 0 {
2997 mutedf(stdout, "no releases yet — carlos ship --app %s <files>\n", appName)
2998 return
2999 }
3000 w := newTable(stdout)
3001 fmt.Fprintln(w, "VERSION\tLABEL\tKIND\tBUILT\tTAG")
3002 for _, r := range rows {
3003 if r.Err != "" {
3004 fmt.Fprintf(w, "%s\tERROR: %s\t\t\t%s\n", r.Version, r.Err, r.Tag)
3005 continue
3006 }
3007 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Version, r.Label, r.Kind, r.BuiltAt, r.Tag)
3008 }
3009 w.Flush()
3010}
3011
3012func runRollback(args []string) {
3013 fs := flag.NewFlagSet("rollback", flag.ExitOnError)
3014 messageFlags := addMutationMessageFlags(fs)
3015 app := appFlag(fs, "")
3016 acctRaw, acctIDRaw, acctOf := accountFlags(fs)
3017 consoleFlag := consoleOption(fs, "console origin to roll back through (API mode only; default: the only logged-in one)")
3018 usage := "usage: carlos rollback --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] <channel>"
3019 fs.Usage = flagUsage(fs, usage)
3020 fs.Parse(args)
3021 if fs.NArg() != 1 {
3022 log.Fatal(usage)
3023 }
3024
3025 sess, err := resolveMode(*consoleFlag)
3026 if err != nil {
3027 log.Fatal(err)
3028 }
3029 if sess != nil {
3030 printUsing(stdout, sess)
3031 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
3032 app2 := resolvedApp(sess, acct, *app, usage)
3033 message, merr := resolveMutationMessage(fs, messageFlags)
3034 if merr != nil {
3035 log.Fatal(merr)
3036 }
3037 if err := sess.rollback(context.Background(), acct, app2, fs.Arg(0), message); err != nil {
3038 var prop *apiProposedError
3039 if errors.As(err, &prop) {
3040 printProposed(stdout, prop)
3041 return
3042 }
3043 log.Fatal(err)
3044 }
3045 fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), app2, fs.Arg(0))
3046 return
3047 }
3048
3049 if *app == "" {
3050 log.Fatal(usage)
3051 }
3052 if err := releaseSigner().Rollback(context.Background(), openStore(), acctOf(), *app, fs.Arg(0)); err != nil {
3053 log.Fatal(err)
3054 }
3055 fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), *app, fs.Arg(0))
3056}
3057
3058// ledgerUsage lists every `carlos ledger` sub-verb — printed for a bare
3059// `carlos ledger`, an unrecognized sub, and `-h`, matching RunAuth/
3060// RunAccounts/RunEnv's own bare/unknown/-h behaviour (auth.go, accounts.go,
3061// appconfig_cli.go): "-h" is never special-cased inside a sub's own
3062// flag.FlagSet, it just isn't a recognized args[0] here either, so it falls
3063// through to this same text.
3064const ledgerUsage = `usage: carlos ledger <command>
3065
3066commands:
3067 append --app <a> [--account <acct>] [--account-id <sqid>] <ledger> <entry.json|->
3068 append one JSON entry to the chain —
3069 reads the file, or stdin with "-";
3070 validated as JSON before it is sent
3071 head --app <a> [--account <acct>] [--account-id <sqid>] <ledger>
3072 print the chain's HEAD.json verbatim
3073 list --app <a> [--account <acct>] [--account-id <sqid>]
3074 list this app's ledger names and its
3075 publish manifest (what's public, if
3076 anything)
3077 publish --app <a> [--account <acct>] [--account-id <sqid>] [--public=true|false] [--title <t>] <ledger>...
3078 add these ledgers to what this app
3079 serves publicly (additive: the ones
3080 already public stay public, and an
3081 omitted --title keeps the stored one);
3082 --public=false removes just these,
3083 taking the app private only when
3084 nothing is left
3085 blob --app <a> [--account <acct>] [--account-id <sqid>] <ledger> <file>...
3086 upload one or more content-addressed
3087 blobs (create-only; re-upload of
3088 identical bytes is a no-op)
3089 verify --app <a> --account <sqid> --url <https://status-host> <ledger>
3090 walk a PUBLISHED chain over plain
3091 HTTPS and re-hash every entry — needs
3092 NO credentials, so anyone can check a
3093 published ledger. --account here is the
3094 account SQID, not a name: the public
3095 status-host path is keyed by sqid.
3096
3097Every sub above except "verify --url" goes through the console API — this
3098terminal never touches bucket credentials directly (open-ledgers design
3099spec §2.3). [--console
3100<origin>] picks which logged-in console when more than one is configured.
3101`
3102
3103// RunLedger dispatches the ledger sub-verbs.
3104func RunLedger(args []string) {
3105 if len(args) == 0 {
3106 fmt.Fprint(os.Stderr, ledgerUsage)
3107 os.Exit(1)
3108 }
3109 switch args[0] {
3110 case "append":
3111 runLedgerAppend(args[1:])
3112 case "head":
3113 runLedgerHead(args[1:])
3114 case "list":
3115 runLedgerList(args[1:])
3116 case "publish":
3117 runLedgerPublish(args[1:])
3118 case "blob":
3119 runLedgerBlob(args[1:])
3120 case "verify":
3121 runLedgerVerify(args[1:])
3122 default:
3123 fmt.Fprint(os.Stderr, ledgerUsage)
3124 os.Exit(1)
3125 }
3126}
3127
3128// ledgerAPISession resolves this terminal's console session for a ledger
3129// sub-verb that writes or reads through the console (every sub except
3130// `verify --url`, which never calls this at all). Unlike promote/channels/
3131// rollback/ship, ledgers have no direct-bucket mode to fall back to — spec
3132// §2.3 is unconditional: a contributor machine gets no bucket credentials,
3133// full stop. resolveMode returning (nil, nil) means CARLOS_DEPLOYMENT_BUCKET
3134// or _DIR is set, i.e. direct mode is what this terminal is configured for —
3135// which for every OTHER release verb is a valid path to take, but for ledger
3136// is refused here rather than silently reinterpreted or left for a caller
3137// to nil-dereference.
3138func ledgerAPISession(consoleFlag string) *apiSession {
3139 sess, err := resolveMode(consoleFlag)
3140 if err != nil {
3141 log.Fatal(err)
3142 }
3143 if sess == nil {
3144 log.Fatal("carlos ledger always goes through the console API — no bucket credentials in a terminal " +
3145 "(spec §2.3); run carlos auth login --console <url>, or unset CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR")
3146 }
3147 return sess
3148}
3149
3150// ledgerAccountFlags registers the account flags a ledger verb has, in
3151// place of accountFlags. accountFlags also registers --registry, the local
3152// registry database that resolves an account name in DIRECT-bucket mode —
3153// and ledgerAPISession refuses direct mode outright (spec §2.3: no bucket
3154// credentials in a terminal). A --registry flag on these verbs is therefore
3155// dead: nothing here ever opens a registry, and offering the flag advertises
3156// a mode that does not exist. The account is resolved server-side, by the
3157// console, against the caller's own memberships — which is why --account
3158// takes a sqid OR a name here and needs no local database to do it —
3159// and why --account-id needs no local database either: it rides the wire as
3160// ?exact=sqid, the console-side switch that makes the segment match as a
3161// sqid only, so the console's own symmetric resolution can never name-match
3162// it.
3163func ledgerAccountFlags(fs *flag.FlagSet) (raw, rawID *string) {
3164 return fs.String("account", "", "account (sqid or name) the app belongs to — resolved by the console (default: your only account, or the configured/chosen one)"),
3165 fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
3166}
3167
3168func runLedgerAppend(args []string) {
3169 fs := flag.NewFlagSet("ledger append", flag.ExitOnError)
3170 app := appFlag(fs, "")
3171 acctRaw, acctIDRaw := ledgerAccountFlags(fs)
3172 consoleFlag := consoleOption(fs, "console origin to append through (default: the only logged-in one)")
3173 fs.Usage = flagUsage(fs, "")
3174 fs.Parse(args)
3175 usage := "usage: carlos ledger append --app <a> [--account <acct>] [--account-id <sqid>] <ledger> <entry.json|->"
3176 if fs.NArg() != 2 {
3177 log.Fatal(usage)
3178 }
3179 name, src := fs.Arg(0), fs.Arg(1)
3180 if !ledger.ValidName(name) {
3181 log.Fatalf("ledger append: invalid ledger name %q", name)
3182 }
3183
3184 // Read and validate the payload BEFORE ever dialing the console — a
3185 // malformed entry is the caller's mistake to see immediately, not a
3186 // round trip and a server-side 400.
3187 var raw []byte
3188 var err error
3189 if src == "-" {
3190 raw, err = io.ReadAll(os.Stdin)
3191 } else {
3192 raw, err = os.ReadFile(src)
3193 }
3194 if err != nil {
3195 log.Fatalf("ledger append: %v", err)
3196 }
3197 if !json.Valid(raw) {
3198 log.Fatalf("ledger append: %s is not valid JSON", src)
3199 }
3200
3201 sess := ledgerAPISession(*consoleFlag)
3202 printUsing(stdout, sess)
3203 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
3204 app2 := resolvedApp(sess, acct, *app, usage)
3205 a, err := sess.ledgerAppend(context.Background(), acct, app2, name, json.RawMessage(raw))
3206 if err != nil {
3207 log.Fatal(err)
3208 }
3209 fmt.Fprintf(stdout, "%s %s: entry #%d (sha %s)\n", colorFor(stdout).ok("appended to"), name, a.Count, a.SHA256[:12])
3210}
3211
3212// runLedgerHead prints HEAD.json verbatim — no "using ... as ..." banner
3213// line first, unlike every other sub here, so the output stays valid JSON a
3214// script can pipe straight into `jq` rather than a human-readable line plus
3215// a JSON blob.
3216func runLedgerHead(args []string) {
3217 fs := flag.NewFlagSet("ledger head", flag.ExitOnError)
3218 app := appFlag(fs, "")
3219 acctRaw, acctIDRaw := ledgerAccountFlags(fs)
3220 consoleFlag := consoleOption(fs, "console origin to read through (default: the only logged-in one)")
3221 fs.Usage = flagUsage(fs, "")
3222 fs.Parse(args)
3223 usage := "usage: carlos ledger head --app <a> [--account <acct>] [--account-id <sqid>] <ledger>"
3224 if fs.NArg() != 1 {
3225 log.Fatal(usage)
3226 }
3227 name := fs.Arg(0)
3228 if !ledger.ValidName(name) {
3229 log.Fatalf("ledger head: invalid ledger name %q", name)
3230 }
3231
3232 sess := ledgerAPISession(*consoleFlag)
3233 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
3234 app2 := resolvedApp(sess, acct, *app, usage)
3235 h, err := sess.ledgerHead(context.Background(), acct, app2, name)
3236 if err != nil {
3237 log.Fatal(err)
3238 }
3239 b, err := json.MarshalIndent(h, "", " ")
3240 if err != nil {
3241 log.Fatal(err)
3242 }
3243 fmt.Fprintln(stdout, string(b))
3244}
3245
3246func runLedgerList(args []string) {
3247 fs := flag.NewFlagSet("ledger list", flag.ExitOnError)
3248 app := appFlag(fs, "")
3249 acctRaw, acctIDRaw := ledgerAccountFlags(fs)
3250 consoleFlag := consoleOption(fs, "console origin to read through (default: the only logged-in one)")
3251 fs.Usage = flagUsage(fs, "")
3252 fs.Parse(args)
3253 usage := "usage: carlos ledger list --app <a> [--account <acct>] [--account-id <sqid>]"
3254
3255 sess := ledgerAPISession(*consoleFlag)
3256 printUsing(stdout, sess)
3257 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
3258 app2 := resolvedApp(sess, acct, *app, usage)
3259 body, err := sess.ledgerList(context.Background(), acct, app2)
3260 if err != nil {
3261 log.Fatal(err)
3262 }
3263 var res struct {
3264 Manifest *ledger.Manifest `json:"manifest"`
3265 Ledgers []string `json:"ledgers"`
3266 }
3267 if err := json.Unmarshal(body, &res); err != nil {
3268 log.Fatalf("ledger list: unreadable response: %v", err)
3269 }
3270
3271 switch {
3272 case res.Manifest == nil:
3273 mutedf(stdout, "not published — nothing is public for this app yet")
3274 case res.Manifest.Public:
3275 title := res.Manifest.Title
3276 if title == "" {
3277 title = app2
3278 }
3279 fmt.Fprintf(stdout, "published %q: %s\n", title, strings.Join(res.Manifest.Ledgers, ", "))
3280 default:
3281 mutedf(stdout, "unpublished (public=false)")
3282 }
3283 if len(res.Ledgers) == 0 {
3284 mutedf(stdout, "no ledgers yet")
3285 return
3286 }
3287 w := newTable(stdout)
3288 fmt.Fprintln(w, "LEDGER")
3289 for _, n := range res.Ledgers {
3290 fmt.Fprintln(w, n)
3291 }
3292 w.Flush()
3293}
3294
3295func runLedgerPublish(args []string) {
3296 fs := flag.NewFlagSet("ledger publish", flag.ExitOnError)
3297 app := appFlag(fs, "")
3298 acctRaw, acctIDRaw := ledgerAccountFlags(fs)
3299 public := fs.Bool("public", true, "serve these ledgers publicly (--public=false unpublishes)")
3300 title := fs.String("title", "", "display title for the published set")
3301 consoleFlag := consoleOption(fs, "console origin to publish through (default: the only logged-in one)")
3302 fs.Usage = flagUsage(fs, "")
3303 fs.Parse(args)
3304 usage := "usage: carlos ledger publish --app <a> [--account <acct>] [--account-id <sqid>] [--public=true|false] [--title <t>] <ledger>..."
3305 if fs.NArg() == 0 {
3306 log.Fatal(usage)
3307 }
3308 names := fs.Args()
3309 for _, n := range names {
3310 if !ledger.ValidName(n) {
3311 log.Fatalf("ledger publish: invalid ledger name %q", n)
3312 }
3313 }
3314
3315 sess := ledgerAPISession(*consoleFlag)
3316 printUsing(stdout, sess)
3317 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
3318 app2 := resolvedApp(sess, acct, *app, usage)
3319 m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title}
3320 got, err := sess.ledgerPublish(context.Background(), acct, app2, m)
3321 if err != nil {
3322 log.Fatal(err)
3323 }
3324 // Report the RESULTING public set, never the argument: publish is
3325 // additive and unpublish removes by name, so the app's public set after
3326 // this call is something only the server can say. Echoing `names` here
3327 // would tell a caller who published `prompts` that prompts is what is
3328 // public — when carbon is public too, or, on an unpublish, when carbon is
3329 // what is left.
3330 verb := "published"
3331 if !*public {
3332 verb = "unpublished"
3333 }
3334 if len(got.Ledgers) == 0 {
3335 fmt.Fprintf(stdout, "%s %s: no public ledgers\n", verb, app2)
3336 return
3337 }
3338 fmt.Fprintf(stdout, "%s %s: public ledgers now [%s]\n", verb, app2, strings.Join(got.Ledgers, " "))
3339}
3340
3341func runLedgerBlob(args []string) {
3342 fs := flag.NewFlagSet("ledger blob", flag.ExitOnError)
3343 app := appFlag(fs, "")
3344 acctRaw, acctIDRaw := ledgerAccountFlags(fs)
3345 consoleFlag := consoleOption(fs, "console origin to upload through (default: the only logged-in one)")
3346 fs.Usage = flagUsage(fs, "")
3347 fs.Parse(args)
3348 usage := "usage: carlos ledger blob --app <a> [--account <acct>] [--account-id <sqid>] <ledger> <file>..."
3349 if fs.NArg() < 2 {
3350 log.Fatal(usage)
3351 }
3352 name := fs.Arg(0)
3353 if !ledger.ValidName(name) {
3354 log.Fatalf("ledger blob: invalid ledger name %q", name)
3355 }
3356 files := fs.Args()[1:]
3357
3358 sess := ledgerAPISession(*consoleFlag)
3359 printUsing(stdout, sess)
3360 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
3361 app2 := resolvedApp(sess, acct, *app, usage)
3362 for _, path := range files {
3363 data, err := os.ReadFile(path)
3364 if err != nil {
3365 log.Fatalf("ledger blob: %v", err)
3366 }
3367 if len(data) > ledger.MaxBlob {
3368 log.Fatalf("ledger blob: %s is %d bytes, exceeds the %d byte cap", path, len(data), ledger.MaxBlob)
3369 }
3370 sha, err := sess.ledgerBlob(context.Background(), acct, app2, name, data)
3371 if err != nil {
3372 log.Fatal(err)
3373 }
3374 fmt.Fprintf(stdout, "%s: %s\n", path, sha)
3375 }
3376}
3377
3378// runLedgerVerify never calls resolveMode/ledgerAPISession — verifying a
3379// published chain needs no console session and no credential at all (spec
3380// §2.5): it is a plain HTTPS walk against the public status-host, which is
3381// the entire point of publishing one. --account here is the account SQID,
3382// not a name — the public path is keyed by sqid (apimode.go's apiPath, by
3383// contrast, accepts either, because the console resolves a name against the
3384// caller's own memberships; there is no caller and no membership check
3385// here).
3386func runLedgerVerify(args []string) {
3387 fs := flag.NewFlagSet("ledger verify", flag.ExitOnError)
3388 app := appFlag(fs, "")
3389 account := fs.String("account", "", "account SQID (not name) — the public status-host path is keyed by sqid")
3390 urlBase := fs.String("url", "", "public status-host base URL, e.g. https://status.example.com — no credentials needed")
3391 fs.Usage = flagUsage(fs, "")
3392 fs.Parse(args)
3393 if *app == "" || *account == "" || *urlBase == "" || fs.NArg() != 1 {
3394 log.Fatal("usage: carlos ledger verify --app <a> --account <sqid> --url <https://status-host> <ledger>")
3395 }
3396 name := fs.Arg(0)
3397 if !ledger.ValidName(name) {
3398 log.Fatalf("ledger verify: invalid ledger name %q", name)
3399 }
3400
3401 g := ledger.HTTPGetter(http.DefaultClient, strings.TrimRight(*urlBase, "/"))
3402 res, err := ledger.Verify(context.Background(), g, *account, *app, name)
3403 if err != nil {
3404 log.Fatal(err)
3405 }
3406 fmt.Fprintf(stdout, "%s %d entries, head %s\n", colorFor(stdout).ok("verified:"), res.Count, res.HeadSHA256[:12])
3407}
3408
3409// defaultGrantTTL is how long a scoped key lives when the operator does not
3410// say: a quarter. Short enough that a forgotten builder key stops working
3411// while someone still remembers what it was for, long enough that rotating it
3412// is a calendar item rather than a chore.
3413const defaultGrantTTL = 90 * 24 * time.Hour
3414
3415// runKeygen has two modes in one verb, because they mint the same kind of
3416// thing for the same deployment.
3417//
3418// Without --scope it is what it has always been: a fresh root pair, printed as
3419// the two variables a deployment is configured with. Nothing about that
3420// output or its meaning changes.
3421//
3422// With --scope it mints a SCOPED key: a fresh pair plus a grant signed by the
3423// root key read from CARLOS_RELEASE_KEY (same env discipline as promote — the
3424// root key is read, never printed, never written). What comes out is the
3425// scoped seed and the grant blob, which travel together. The deployment's
3426// CARLOS_RELEASE_PUBKEY does NOT change and is deliberately not reprinted
3427// here: the fleet's trust anchor stays exactly one key, and a second pubkey
3428// in this output would invite someone to install it on a box.
3429func runKeygen(args []string) {
3430 fs := flag.NewFlagSet("release-keygen", flag.ExitOnError)
3431 scope := fs.String("scope", "", "mint a SCOPED key for these rungs, comma separated (canary,edge,beta) — stable is never delegable")
3432 name := fs.String("name", "", "label for the scoped key; it appears in every refusal it causes")
3433 expires := fs.Duration("expires", defaultGrantTTL, "how long the scoped key may sign for (max 8760h)")
3434 fs.Usage = flagUsage(fs, "")
3435 fs.Parse(args)
3436
3437 if *scope == "" {
3438 var stray []string
3439 fs.Visit(func(f *flag.Flag) { stray = append(stray, "--"+f.Name) })
3440 if len(stray) > 0 {
3441 log.Fatalf("%s only apply with --scope; without it this mints the deployment's ROOT key", strings.Join(stray, " and "))
3442 }
3443 seed, pub, err := release.GenerateKey()
3444 if err != nil {
3445 log.Fatal(err)
3446 }
3447 fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_PUBKEY=%s\n", seed, pub)
3448 fmt.Fprintln(os.Stderr, "store the KEY in your secret manager; the PUBKEY goes in host config")
3449 return
3450 }
3451
3452 if *name == "" {
3453 log.Fatal("usage: carlos release-keygen --scope <rungs> --name <label> [--expires <dur>]")
3454 }
3455 if release.IsScopedSeed(os.Getenv("CARLOS_RELEASE_KEY")) {
3456 log.Fatal("CARLOS_RELEASE_KEY holds a scoped key: only the ROOT key can mint a grant, which is what makes a scoped key unable to widen itself")
3457 }
3458 var rungs []string
3459 for _, r := range strings.Split(*scope, ",") {
3460 if r = strings.TrimSpace(r); r != "" {
3461 rungs = append(rungs, r)
3462 }
3463 }
3464 seed, grant, err := release.MintKeyGrant(signingKey(), *name, rungs, *expires)
3465 if err != nil {
3466 log.Fatal(err)
3467 }
3468 fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_GRANT=%s\n", seed, grant)
3469 fmt.Fprintf(os.Stderr, `scoped key %q for %s, expiring %s
3470
3471 The KEY is the secret — store it wherever the builder's secrets live.
3472 The GRANT is not secret (public material only) but must travel WITH the
3473 key: a scoped key alone signs pointers nothing will accept.
3474 CARLOS_RELEASE_PUBKEY does not change; the fleet still trusts one key.
3475
3476 Deploy order: fleet binaries that understand grants FIRST, this key
3477 second. An older binary reads a grant-signed pointer as a bad signature,
3478 which is the right way round to fail but will stop that box adopting.
3479`, *name, strings.Join(rungs, ", "), time.Now().UTC().Add(*expires).Format(time.RFC3339))
3480}
3481
3482// statusUsage is shared between "no flag given" and "-h/--help" so an
3483// operator gets the same explanation either way: this verb reads and checks,
3484// it never publishes. The fleet status page's PUBLISH loop (CollectBox ->
3485// WriteBox on every box, BuildFleet -> PublishFleet on the lease holder) runs
3486// inside the box agent's own 5-minute tick — there is deliberately no
3487// "carlos status publish" here; --live only reads what those ticks wrote.
3488const statusUsage = `usage: carlos status --dry-run <dir>
3489 carlos status --live
3490 carlos status --instances [--account <sqid>]
3491 carlos status --layout
3492
3493 --dry-run <dir> build a throwaway fleet stuffed with fixture secrets
3494 (hostnames, a machine id, mid-band fractions), run it
3495 through the real CollectBox/WriteBox/BuildFleet/
3496 PublishFleet path into <dir>, then read every published
3497 object back and fail if any fixture leaked. The self-
3498 hoster's "what would I publish?" check, run against a
3499 scratch directory before ever pointing this binary at a
3500 real bucket.
3501
3502 --live read status/boxes/*.json out of the status bucket and print
3503 one line per host: release, tunnel state, memory, load and
3504 free disk — raw figures, never published on the public
3505 status page. A host that has stopped reporting prints as
3506 UNREACHABLE with how long it has been dark and none of its
3507 last readings. Needs bucket credentials and
3508 CARLOS_STATUS_BUCKET (or CARLOS_DEPLOYMENT_BUCKET, the same
3509 defaulting the agent's own status loop applies).
3510
3511 --instances who is using what right now: one row per AWAKE instance
3512 across the fleet, with the account and app it belongs to
3513 and what it is consuming. Signed in through the console
3514 (carlos auth login), operator only — a non-operator token
3515 gets the same bare 404 an unknown route gets. --account
3516 <sqid> narrows it to one account. A sleeping instance has
3517 no process and therefore no reading, so it does not appear;
3518 and accounts are joined against the console's own box's
3519 route registry, so an instance on another box in a fleet
3520 can land under (platform). Both limits print with the
3521 table.
3522
3523 --layout which layout each instance is on, one row per host, box by
3524 box: flat (shared /data), nested (its own directory), fenced
3525 (mid-move, will not wake), conflict (two paths claim it,
3526 will not wake), foreign (paths the pass will not touch) or
3527 external (another supervisor owns it). Operator only, through
3528 the console, like --instances.
3529`
3530
3531// RunStatus is the "carlos status" verb: a fixture dry-run and a live read of
3532// what the fleet's boxes last reported. Modeled on ops.Run's FlagSet shape
3533// (main.go's "ops" case), but flatter — two independent modes, no sub-verb
3534// dispatch.
3535func RunStatus(args []string) {
3536 fs := flag.NewFlagSet("status", flag.ExitOnError)
3537 dryRunDir := fs.String("dry-run", "", "build a fixture fleet, publish it into this directory, and verify no leaks")
3538 liveFlag := fs.Bool("live", false, "read status/boxes/*.json from the status bucket and print per-host metrics")
3539 instancesFlag := fs.Bool("instances", false, "one row per awake instance across the fleet, with the account and app it belongs to (operator only, through the console)")
3540 acctFilter := fs.String("account", "", "narrow --instances to one account (sqid)")
3541 layoutFlag := fs.Bool("layout", false, "which layout each instance is on, one row per host, box by box (operator only, through the console, like --instances)")
3542 consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)")
3543 fs.Usage = flagUsage(fs, statusUsage)
3544 fs.Parse(args)
3545
3546 if *instancesFlag {
3547 // Console-backed, unlike --live: the per-unit readings live in the
3548 // deployment's metrics objects, which the console already sweeps, and
3549 // the account each one belongs to is a registry join only the console
3550 // can do. --live reads the status bucket directly with the operator's
3551 // own bucket credentials; this deliberately needs none, which is the
3552 // whole point (discussion #11: the alternative was `aws s3 cp`).
3553 if err := statusInstances(os.Stdout, *consoleFlag, *acctFilter); err != nil {
3554 log.Fatal(err)
3555 }
3556 return
3557 }
3558 if *layoutFlag {
3559 // Console-backed for the identical reason --instances is: the
3560 // layout report lives in each box's own private snapshot, and the
3561 // console already reads it for its operator page.
3562 if err := statusLayout(os.Stdout, *consoleFlag); err != nil {
3563 log.Fatal(err)
3564 }
3565 return
3566 }
3567 if *liveFlag {
3568 if err := statusLive(os.Stdout); err != nil {
3569 log.Fatal(err)
3570 }
3571 return
3572 }
3573 if *dryRunDir == "" {
3574 fmt.Fprint(os.Stderr, statusUsage)
3575 os.Exit(1)
3576 }
3577 if err := status.DryRun(*dryRunDir, os.Stdout); err != nil {
3578 log.Fatal(err)
3579 }
3580}
3581
3582// statusLive prints what every box in the fleet last reported about itself,
3583// read straight from status/boxes/ — the private snapshots, raw numbers and
3584// all, which is why this needs the operator's own bucket credentials and why
3585// nothing here is ever served publicly (see internal/status's package
3586// comment). Bucket resolution is host.ResolveStatusBucket, the identical
3587// CARLOS_STATUS_BUCKET-then-CARLOS_DEPLOYMENT_BUCKET rule the agent's status
3588// loop uses, so this reads exactly the bucket the agents write.
3589func statusLive(w io.Writer) error {
3590 ctx := context.Background()
3591 bucket := host.ResolveStatusBucket(os.Getenv)
3592 if bucket == "" {
3593 return fmt.Errorf("set CARLOS_STATUS_BUCKET or CARLOS_DEPLOYMENT_BUCKET")
3594 }
3595 s, err := release.NewDeploymentS3Store(ctx, bucket)
3596 if err != nil {
3597 return err
3598 }
3599 boxes, err := status.LoadBoxes(ctx, s)
3600 if err != nil {
3601 return err
3602 }
3603 printLiveBoxes(w, boxes, time.Now())
3604 return nil
3605}
3606
3607// printLiveBoxes is statusLive's rendering half, split out so it can be
3608// asserted on without a bucket. One line per box, label-sorted so two runs
3609// diff on the fleet rather than on list order.
3610//
3611// A stale box (status.BoxStale — the same rule the console and the published
3612// boxes table use) prints UNREACHABLE and how long it has been dark, and
3613// deliberately NOT its last readings: those numbers described a machine that
3614// has since stopped answering, and printing them in the same columns as a
3615// live host's is how an outage gets read as fine. A fresh box that carries no
3616// metrics block at all is a host running code from before the block existed —
3617// which is a fact worth stating, not a row of zeros.
3618func printLiveBoxes(w io.Writer, boxes []status.Box, now time.Time) {
3619 sorted := append([]status.Box(nil), boxes...)
3620 sort.Slice(sorted, func(i, j int) bool { return sorted[i].Label < sorted[j].Label })
3621 for _, b := range sorted {
3622 if status.BoxStale(b, now, status.SnapshotPeriod) {
3623 fmt.Fprintf(w, "%-24s %-12s UNREACHABLE (last heartbeat %s ago)\n",
3624 b.Label, orDash(b.Region), now.Sub(b.GeneratedAt).Round(time.Minute))
3625 continue
3626 }
3627 m := b.Metrics
3628 if m == nil {
3629 fmt.Fprintf(w, "%-24s %-12s ok (no metrics block — pre-upgrade host)\n", b.Label, orDash(b.Region))
3630 continue
3631 }
3632 fmt.Fprintf(w, "%-24s %-12s release=%s tunnel=%s mem=%s/%s load=%s disk-free=%s alerts=%s\n",
3633 b.Label, orDash(b.Region), orDash(m.Release), orDash(m.Tunnel),
3634 humanBytes(m.MemAvailBytes), humanBytes(m.MemTotalBytes),
3635 humanFloat(m.Load1), humanBytes(m.DiskFreeBytes), alertsWord(m.Alerts))
3636 for _, r := range m.ReleaseRefusals {
3637 if r.Kind == release.RefusalWrongProgram {
3638 fmt.Fprintf(w, " refused %s %s since %s: wrong program (%s)\n", r.Host, r.Version, r.RefusedAt.UTC().Format(time.RFC3339), strings.TrimPrefix(r.Reason, "wrong program: "))
3639 continue
3640 }
3641 if r.Kind == release.RefusalNotProven {
3642 fmt.Fprintf(w, " refused %s %s since %s: staged bytes do not match dev's proof (%s)\n", r.Host, r.Version, r.RefusedAt.UTC().Format(time.RFC3339), strings.TrimPrefix(r.Reason, "not proven: "))
3643 continue
3644 }
3645 failed := ""
3646 if r.RecoveryError != "" {
3647 failed = " (rollback failed: " + r.RecoveryError + ")"
3648 }
3649 fmt.Fprintf(w, " refused %s %s since %s: %s%s\n", r.Host, r.Version, r.RefusedAt.UTC().Format(time.RFC3339), r.Reason, failed)
3650 }
3651 for _, pw := range m.ProofWaits {
3652 sha := pw.SHA256
3653 if len(sha) > 12 {
3654 sha = sha[:12]
3655 }
3656 // No prover means the wait is the policy file's, not a missing
3657 // proof: say so, with the load error exactly as given.
3658 what := "a " + pw.Prover + " proof"
3659 if pw.Prover == "" {
3660 what = "a proof (policy: " + pw.Reason + ")"
3661 }
3662 line := fmt.Sprintf(" waiting for %s: %s %s (sha256 %s) since %s", what, pw.Host, pw.Version, orDash(sha), pw.Since.UTC().Format(time.RFC3339))
3663 if pw.Prover != "" && pw.Reason != "" {
3664 line += ": " + pw.Reason
3665 }
3666 fmt.Fprintln(w, line)
3667 }
3668 }
3669}
3670
3671// alertsWord renders a box's alert-sink answer for the live listing. "not
3672// delivered" is spelled out because it is the state discussion #192 found
3673// silent for weeks: a box whose own disk, memory and cpu rules have nowhere
3674// to send. A box that predates the field did not say, so it is a dash.
3675func alertsWord(a *status.AlertDelivery) string {
3676 if a == nil {
3677 return "-"
3678 }
3679 var sinks []string
3680 if a.Webhook {
3681 sinks = append(sinks, "webhook")
3682 }
3683 if a.SNS {
3684 sinks = append(sinks, "sns")
3685 }
3686 if len(sinks) == 0 && !a.SNSUnavailable {
3687 return "not-delivered"
3688 }
3689 out := strings.Join(sinks, "+")
3690 if a.SNSUnavailable {
3691 // A topic is configured but its client could not be built. Alone it
3692 // is the whole answer; next to a working sink it is a warning.
3693 if out == "" {
3694 out = "sns-unavailable"
3695 } else {
3696 out += " (sns-unavailable)"
3697 }
3698 }
3699 if a.WebhookFailures > 0 {
3700 out += fmt.Sprintf(" (webhook failed %d)", a.WebhookFailures)
3701 }
3702 if a.SNSFailures > 0 {
3703 out += fmt.Sprintf(" (sns failed %d)", a.SNSFailures)
3704 }
3705 return out
3706}
3707
3708// orDash, humanBytes and humanFloat render one field of a metrics block.
3709// Unknown is a dash in all three — never a zero, never a blank column: the
3710// status package carries "I couldn't measure this" as a nil pointer all the
3711// way to here precisely so the reader can tell it apart from a measured zero.
3712func orDash(s string) string {
3713 if s == "" {
3714 return "-"
3715 }
3716 return s
3717}
3718
3719func humanBytes(v *int64) string {
3720 if v == nil {
3721 return "-"
3722 }
3723 b := float64(*v)
3724 switch {
3725 case b >= 1<<30:
3726 return fmt.Sprintf("%.1fGB", b/(1<<30))
3727 case b >= 1<<20:
3728 return fmt.Sprintf("%.1fMB", b/(1<<20))
3729 case b >= 1<<10:
3730 return fmt.Sprintf("%.1fKB", b/(1<<10))
3731 default:
3732 return fmt.Sprintf("%dB", *v)
3733 }
3734}
3735
3736func humanFloat(v *float64) string {
3737 if v == nil {
3738 return "-"
3739 }
3740 return fmt.Sprintf("%.2f", *v)
3741}
3742