| 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. |
| 5 | package main |
| 6 | |
| 7 | import ( |
| 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. |
| 46 | var stdout io.Writer = os.Stdout |
| 47 | |
| 48 | func dataDir() string { |
| 49 | if d := os.Getenv("CARLOS_DATA_DIR"); d != "" { |
| 50 | return d |
| 51 | } |
| 52 | return "/data" |
| 53 | } |
| 54 | |
| 55 | func 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). |
| 59 | func 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. |
| 77 | func 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. |
| 96 | func 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. |
| 117 | var boxLabelForAdd = func() string { return host.BoxLabel() } |
| 118 | |
| 119 | func 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 | |
| 343 | func 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 | |
| 362 | func 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. |
| 578 | func 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). |
| 1276 | func 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. |
| 1289 | func 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. |
| 1301 | func 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. |
| 1369 | func 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. |
| 1384 | func 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. |
| 1399 | func 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. |
| 1416 | func 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. |
| 1432 | func 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. |
| 1465 | func 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. |
| 1492 | func 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. |
| 1510 | func 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. |
| 1621 | func 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. |
| 1668 | func 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. |
| 1712 | func 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. |
| 1750 | func 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. |
| 1761 | func 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. |
| 1801 | func 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. |
| 1829 | func 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. |
| 1859 | func 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. |
| 1877 | func 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. |
| 1887 | const 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. |
| 1905 | func 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. |
| 1946 | func 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. |
| 1991 | func 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. |
| 2041 | func 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. |
| 2056 | func 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 | |
| 2067 | func 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 | |
| 2084 | func 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. |
| 2104 | func 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 | |
| 2130 | func 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. |
| 2141 | var shipNow = time.Now |
| 2142 | |
| 2143 | const ( |
| 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. |
| 2151 | func validSwapFlag(s string) error { |
| 2152 | if s == "" || s == release.DemandHold { |
| 2153 | return nil |
| 2154 | } |
| 2155 | return fmt.Errorf(shipBadSwap, s) |
| 2156 | } |
| 2157 | |
| 2158 | func 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. |
| 2388 | func 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. |
| 2417 | var 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. |
| 2444 | func 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. |
| 2531 | func 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. |
| 2652 | func 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. |
| 2663 | func 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. |
| 2696 | func promoteActor() string { |
| 2697 | if c, err := loggedInCredential(""); err == nil && c.Address != "" { |
| 2698 | return c.Address |
| 2699 | } |
| 2700 | return defaultLabel() |
| 2701 | } |
| 2702 | |
| 2703 | func 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. |
| 2798 | func 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. |
| 2889 | type 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. |
| 2896 | const 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. |
| 2911 | func 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". |
| 2960 | func 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 | |
| 3012 | func 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. |
| 3064 | const ledgerUsage = `usage: carlos ledger <command> |
| 3065 | |
| 3066 | commands: |
| 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 | |
| 3097 | Every sub above except "verify --url" goes through the console API — this |
| 3098 | terminal never touches bucket credentials directly (open-ledgers design |
| 3099 | spec §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. |
| 3104 | func 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. |
| 3138 | func 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. |
| 3163 | func 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 | |
| 3168 | func 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. |
| 3216 | func 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 | |
| 3246 | func 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 | |
| 3295 | func 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 | |
| 3341 | func 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). |
| 3386 | func 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. |
| 3413 | const 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. |
| 3429 | func 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. |
| 3488 | const 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. |
| 3535 | func 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. |
| 3589 | func 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. |
| 3618 | func 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. |
| 3675 | func 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. |
| 3712 | func orDash(s string) string { |
| 3713 | if s == "" { |
| 3714 | return "-" |
| 3715 | } |
| 3716 | return s |
| 3717 | } |
| 3718 | |
| 3719 | func 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 | |
| 3736 | func humanFloat(v *float64) string { |
| 3737 | if v == nil { |
| 3738 | return "-" |
| 3739 | } |
| 3740 | return fmt.Sprintf("%.2f", *v) |
| 3741 | } |
| 3742 | |