| index c68110f21..1770b8bc5 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -117,6 +117,24 @@ func runAdd(args []string) { |
| if *backing == registry.BackingUnit && *hibernate && *dbPath == "" { |
| 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) |
| } |
| + account := resolveAccount(*reg, *acct) |
| + // A `daemon` route may not hibernate: the capability means the platform |
| + // owns the process (enable, start, restart-on-adopt), and hibernation is |
| + // the activator stopping the very process that declaration exists to keep |
| + // up. Refused HERE when the comparison resolves — but, unlike `carlos |
| + // route -hibernate`, an UNRESOLVABLE comparison proceeds silently: this |
| + // verb runs at provision time on a box with no release pubkey, no |
| + // deployment store, and often no release shipped yet, and refusing there |
| + // would break provisioning. The authoritative refusal is the converge |
| + // pass, which sees the manifest every hour on a box that has one. |
| + if *hibernate { |
| + store, pub := deploymentReadSeam() |
| + daemon, note, proven := routeDaemonCheck(context.Background(), store, pub, account, |
| + registry.Route{Host: host, App: *app, Channel: *channel}) |
| + if proven && daemon { |
| + log.Fatalf("add %s: %s (%s)", host, daemonHibernateRefusal, note) |
| + } |
| + } |
| r, err := registry.Open(*reg) |
| if err != nil { |
| log.Fatal(err) |
| @@ -126,7 +144,7 @@ func runAdd(args []string) { |
| // unqualified keys, so an unmigrated app added here keeps working. |
| if err := r.Upsert(registry.Route{ |
| Host: host, Slug: *slug, Kind: *kind, Socket: *socket, Addr: *addr, |
| - App: *app, Account: resolveAccount(*reg, *acct), |
| + App: *app, Account: account, |
| Channel: *channel, Hibernate: *hibernate, StatusNamed: *statusNamed, Backing: *backing, |
| DBPath: *dbPath, Status: "ready", |
| }); err != nil { |
| @@ -258,6 +276,23 @@ func runInstances(args []string) { |
| // one — though doing so while it happens to sit on a canary/* channel does |
| // make that canary's own `followed` check false, which mints a fresh canary |
| // host next pass. Additive, not reverted, but worth knowing.) |
| +// |
| +// This verb also carries the three SINGLE-ROUTE axes, each of which stands |
| +// alone and short-circuits everything above (see runRouteGrant and |
| +// runRouteHibernate): |
| +// |
| +// carlos route -host keymail.dev -grant port:25 |
| +// carlos route -host platform.carlosframework.com -grant host-role |
| +// carlos route -host keymail.dev -revoke port:25 |
| +// carlos route -host keymail.dev -hibernate=false |
| +// |
| +// -grant/-revoke are the operator's door to the two operator-consented |
| +// capabilities (spec §2 "Grants"): a manifest DECLARES `ports`/`host_role`, |
| +// and nothing materializes until an operator has granted it on THIS box. |
| +// They require -host and never accept -app — grants are per route per box by |
| +// design, and a bulk grant is precisely the operation nobody should be able |
| +// to type by accident. -hibernate is here because a `daemon` route may not |
| +// hibernate, and that refusal belongs at the same door as the flag. |
| func runRoute(args []string) { |
| fs := flag.NewFlagSet("route", flag.ExitOnError) |
| regPath := fs.String("registry", defaultRegistry(), "registry database") |
| @@ -267,22 +302,68 @@ func runRoute(args []string) { |
| channel := fs.String("channel", "", "release channel to subscribe the route(s) to") |
| environment := fs.String("environment", "", |
| `named config environment to bind the route(s) to (must already exist in the bucket); "-" clears to default`) |
| + grant := fs.String("grant", "", "capability token to grant this route ON THIS BOX: port:<n> (privileged, never 22/80/443) | host-role") |
| + revoke := fs.String("revoke", "", "capability token to revoke from this route on this box") |
| + hibernate := fs.Bool("hibernate", false, "hibernate this route when idle; -hibernate=false stops it hibernating") |
| allowVersionChange := fs.Bool("allow-version-change", false, |
| "allow repointing to a channel that RESOLVES but holds a different version than the route currently serves "+ |
| "(never overrides an unresolvable target, a bad signature, or a missing pubkey)") |
| dryRun := fs.Bool("dry-run", false, "run the full safety-rail check and print the plan, but write nothing") |
| fs.Parse(args) |
| + // -hibernate has to be TRI-state: a plain flag.Bool cannot tell "the |
| + // operator did not mention hibernation" from "the operator asked for |
| + // -hibernate=false", and this verb must never flip a column nobody named. |
| + // fs.Visit reports only the flags actually present on the command line, |
| + // which keeps the operator's syntax the natural `-hibernate` / |
| + // `-hibernate=false` rather than a quoted tri-state string. |
| + hibernateSet := false |
| + fs.Visit(func(f *flag.Flag) { |
| + if f.Name == "hibernate" { |
| + hibernateSet = true |
| + } |
| + }) |
| |
| const usage = "usage: carlos route -host <host> [-channel <channel>] [-environment <name>] [-registry <db>] [-allow-version-change] [-dry-run]\n" + |
| - " or: carlos route -app <app> [-channel <channel>] [-environment <name>] [-account <sqid|name|all>] [-registry <db>] [-allow-version-change] [-dry-run]" |
| + " or: carlos route -app <app> [-channel <channel>] [-environment <name>] [-account <sqid|name|all>] [-registry <db>] [-allow-version-change] [-dry-run]\n" + |
| + " or: carlos route -host <host> (-grant | -revoke) <port:n|host-role> [-registry <db>] [-dry-run]\n" + |
| + " or: carlos route -host <host> -hibernate[=false] [-registry <db>] [-dry-run]" |
| if *host == "" && *app == "" { |
| log.Fatal(usage) |
| } |
| if *host != "" && *app != "" { |
| log.Fatal("route: -host and -app are mutually exclusive — name the one route, or the app whose routes to move\n\n" + usage) |
| } |
| - if *channel == "" && *environment == "" { |
| - log.Fatal(`route: at least one of -channel or -environment is required. "" only means stable-by-default at route CREATION (carlos add) — ` + |
| + // The three single-route axes. Each is ITS OWN operation: a grant change |
| + // is not a repoint, and combining two would put two unrelated writes |
| + // behind two unrelated rails in one command. -channel and -environment |
| + // keep their existing combinability (one transaction, both rails resolved |
| + // first); everything below stands alone. |
| + if *grant != "" && *revoke != "" { |
| + 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") |
| + } |
| + grantAxis := *grant != "" || *revoke != "" |
| + if grantAxis && hibernateSet { |
| + log.Fatal("route: -grant/-revoke and -hibernate are mutually exclusive — run them as two commands, so each meets its own rail alone") |
| + } |
| + if (grantAxis || hibernateSet) && (*channel != "" || *environment != "") { |
| + log.Fatal("route: -grant/-revoke/-hibernate cannot be combined with -channel or -environment — a capability grant and a repoint are different operations with different rails, and one command that did both would commit them together") |
| + } |
| + if (grantAxis || hibernateSet) && *host == "" { |
| + log.Fatal("route: -grant/-revoke/-hibernate require -host, never -app: grants are per route per box by design, and a bulk grant is precisely the operation nobody should be able to type by accident\n\n" + usage) |
| + } |
| + // The token is checked BEFORE the registry is opened for writing: a typo |
| + // should cost nothing and reach nothing. |
| + if grantAxis { |
| + tok := *grant |
| + if tok == "" { |
| + tok = *revoke |
| + } |
| + if err := caps.ValidGrantToken(tok); err != nil { |
| + log.Fatalf("route: %v", err) |
| + } |
| + } |
| + if *channel == "" && *environment == "" && !grantAxis && !hibernateSet { |
| + log.Fatal(`route: at least one of -channel, -environment, -grant, -revoke or -hibernate is required. "" only means stable-by-default at route CREATION (carlos add) — ` + |
| "this verb sets an explicit binding, and needs an explicit target to set it to.") |
| } |
| if *channel != "" && !release.ValidChannel(*channel) { |
| @@ -324,6 +405,25 @@ func runRoute(args []string) { |
| } |
| defer r.Close() |
| |
| + // The single-route axes short-circuit here, before the channel/environment |
| + // machinery below: they write one column of one row, through one narrow |
| + // setter, and share none of that batch's all-or-nothing plumbing. |
| + if grantAxis || hibernateSet { |
| + rt, err := r.Get(*host) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if rt == nil { |
| + log.Fatalf("route: no route for host %q", *host) |
| + } |
| + if grantAxis { |
| + runRouteGrant(r, *rt, *grant, *revoke, *dryRun) |
| + } else { |
| + runRouteHibernate(r, *rt, *hibernate, *dryRun) |
| + } |
| + return |
| + } |
| + |
| var candidates []registry.Route |
| if *host != "" { |
| rt, err := r.Get(*host) |
| @@ -668,6 +768,279 @@ func platformCanaryOwnedRow(rt registry.Route) bool { |
| return rt.Platform && rt.Canary |
| } |
| |
| +// runRouteGrant is the operator's door to a transfer of box authority: one |
| +// token, onto or off one route, on this box. Everything it can refuse it |
| +// refuses before writing, and the write itself is registry.SetCaps — the |
| +// only writer of that column in the codebase. |
| +// |
| +// A revoke is not the mirror image of a grant in one respect: it needs no |
| +// conflict check, because removing a grant can never collide with anything. |
| +// Everything else — the row-shape rails, the hand-edited-column refusal — is |
| +// shared, since a route whose grants this verb refuses to widen is not a |
| +// route whose grants it should be quietly rewriting either. |
| +func runRouteGrant(r *registry.Registry, rt registry.Route, grantTok, revokeTok string, dryRun bool) { |
| + tok, granting := grantTok, true |
| + if grantTok == "" { |
| + tok, granting = revokeTok, false |
| + } |
| + if err := grantRail(rt, tok); err != nil { |
| + log.Fatalf("route: %s: %v", rt.Host, err) |
| + } |
| + cur, err := caps.ParseGrants(rt.Caps) |
| + if err != nil { |
| + // Refuse rather than repair: this column is written only by SetCaps, |
| + // so an unparseable value means a hand edit, and rewriting it here |
| + // would silently decide which half of somebody's edit survived. |
| + log.Fatalf("route: %s: %v — fix the row with `carlos route -host %s -grant/-revoke` from a known-good state, never by hand", |
| + rt.Host, err, rt.Host) |
| + } |
| + |
| + prefix := "" |
| + if dryRun { |
| + prefix = "(dry run) " |
| + } |
| + var next string |
| + if granting { |
| + routes, lerr := r.List() |
| + if lerr != nil { |
| + log.Fatal(lerr) |
| + } |
| + if cerr := portGrantConflict(routes, rt.Host, tok); cerr != nil { |
| + log.Fatalf("route: %s: %v", rt.Host, cerr) |
| + } |
| + if next, err = caps.Grant(rt.Caps, tok); err != nil { |
| + log.Fatalf("route: %s: %v", rt.Host, err) |
| + } |
| + if cur.Has(tok) { |
| + fmt.Printf("%s: %s already granted — nothing to do\n", rt.Host, tok) |
| + return |
| + } |
| + } else { |
| + if next, err = caps.Revoke(rt.Caps, tok); err != nil { |
| + log.Fatalf("route: %s: %v", rt.Host, err) |
| + } |
| + if !cur.Has(tok) { |
| + // A quiet success: the caller's goal is "not granted", not "I |
| + // removed something". |
| + fmt.Printf("%s: %s was not granted — nothing to revoke\n", rt.Host, tok) |
| + return |
| + } |
| + } |
| + if !dryRun { |
| + if err := r.SetCaps(rt.Host, next); err != nil { |
| + log.Fatalf("route: write: %v", err) |
| + } |
| + } |
| + verb := "granted" |
| + if !granting { |
| + verb = "revoked" |
| + } |
| + fmt.Printf("%s%s: %s %s (caps: %s)\n", prefix, rt.Host, verb, tok, capsLabel(next)) |
| + // Deliberately no restart, no unit rewrite, and no advisory here: a grant |
| + // is a row, and the converge pass is what materializes (or unmakes) the |
| + // drop-in on its next run. |
| +} |
| + |
| +// capsLabel renders a caps column for a human — "none" rather than an empty |
| +// string, so a revoke that emptied the column reads as an answer. |
| +func capsLabel(col string) string { |
| + if col == "" { |
| + return "none" |
| + } |
| + return col |
| +} |
| + |
| +// grantRail is everything a -grant/-revoke can be refused for from the ROW |
| +// alone: the token's own validity, plus the two structural refusals. Pure, so |
| +// every refusal is table-testable without a registry. |
| +// |
| +// Both refusals apply to a REVOKE as well as a grant, which is deliberate: on |
| +// a Platform+Canary row the reconciler rebuilds the row this verb would be |
| +// writing, and on a non-unit route there is nothing the platform would |
| +// materialize a grant into, so a "revoke" there would be theatre — a column |
| +// edit that changes nothing while reading like a de-escalation. |
| +func grantRail(rt registry.Route, tok string) error { |
| + if err := caps.ValidGrantToken(tok); err != nil { |
| + return err |
| + } |
| + if platformCanaryOwnedRow(rt) { |
| + return fmt.Errorf("Platform+Canary route — the domains reconciler's canary-minting pass rebuilds this whole row " + |
| + "(internal/domains/reconcile.go) whenever its `followed` check goes false, so this verb does not own it. " + |
| + "registry.Upsert preserves a non-empty caps column, which makes a grant here survivable — but survivable is not owned, " + |
| + "and no flag overrides a row this verb does not own") |
| + } |
| + if rt.Kind != "instance" || rt.Backing != registry.BackingUnit { |
| + return fmt.Errorf("capabilities are only honoured for unit-backed instance routes (kind %q, backing %q) — "+ |
| + "a grant here would materialize nothing; see the backing advisory", rt.Kind, rt.Backing) |
| + } |
| + return nil |
| +} |
| + |
| +// portGrantConflict refuses a port grant already held by a DIFFERENT route on |
| +// this box, naming the other host. `:25` can be held by exactly one process on |
| +// one machine, so two routes granted it is a promise the box cannot keep — and |
| +// the operator finding out here, at the moment they type it, is the whole |
| +// point: converge's own CheckClaims runs hourly, long after the typing. |
| +// |
| +// host-role is deliberately not conflict-checked: the box's instance role is |
| +// not consumed by being used, and two apps holding it is two independent |
| +// operator decisions, not a collision. |
| +// |
| +// A row whose caps column does not parse fails the check CLOSED, naming that |
| +// row: "I could not find out whether this port is already claimed" is never a |
| +// licence to claim it. |
| +func portGrantConflict(routes []registry.Route, host, tok string) error { |
| + if tok == caps.HostRoleToken { |
| + return nil |
| + } |
| + for _, other := range routes { |
| + if other.Host == host || other.Caps == "" { |
| + continue |
| + } |
| + g, err := caps.ParseGrants(other.Caps) |
| + if err != nil { |
| + return fmt.Errorf("cannot prove %s is unclaimed: route %s has an unreadable caps column (%v)", tok, other.Host, err) |
| + } |
| + if g.Has(tok) { |
| + 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; "+ |
| + "revoke it there first (`carlos route -host %s -revoke %s`)", tok, other.Host, other.Host, tok) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// runRouteHibernate flips the per-route hibernation opt-in — the first CLI |
| +// caller registry.SetHibernate has ever had — behind the one rail that makes |
| +// the capability model coherent: a route whose release declares `daemon` may |
| +// not hibernate. |
| +// |
| +// Resolution follows this verb's existing doctrine exactly. A PROVEN daemon |
| +// refuses; an UNPROVEN comparison (no pubkey, no store, an unresolvable |
| +// channel, a bad signature, an unreadable manifest) also refuses, because "I |
| +// could not find out" is never a licence. -hibernate=false needs no proof and |
| +// always proceeds: stopping a route from hibernating can never contradict a |
| +// declaration. |
| +func runRouteHibernate(r *registry.Registry, rt registry.Route, on, dryRun bool) { |
| + if platformCanaryOwnedRow(rt) { |
| + // mintedRoute (internal/domains/reconcile.go) never sets Hibernate, |
| + // and Upsert does not preserve it, so a flip here is reverted to |
| + // false by the next re-mint — the same structural refusal the other |
| + // axes carry, for the same reason. |
| + log.Fatalf("route: %s: Platform+Canary route — the domains reconciler rebuilds this row from values it derives itself, "+ |
| + "and a hibernation flip here is reverted on the next reconcile pass. This verb does not own this row", rt.Host) |
| + } |
| + if on { |
| + store, pub := deploymentReadSeam() |
| + daemon, note, proven := routeDaemonCheck(context.Background(), store, pub, release.RouteAccount(rt), rt) |
| + if !proven { |
| + log.Fatalf("route: %s: -hibernate refused: %s — refusing regardless: the comparison itself is unproven, and %q is never a licence", |
| + rt.Host, note, "I could not find out") |
| + } |
| + if daemon { |
| + log.Fatalf("route: %s: %s (%s)", rt.Host, daemonHibernateRefusal, note) |
| + } |
| + } |
| + prefix := "" |
| + if dryRun { |
| + prefix = "(dry run) " |
| + } |
| + if rt.Hibernate == on { |
| + fmt.Printf("%s: hibernate already %v — nothing to do\n", rt.Host, on) |
| + return |
| + } |
| + if !dryRun { |
| + if err := r.SetHibernate(rt.Host, on); err != nil { |
| + log.Fatalf("route: write: %v", err) |
| + } |
| + } |
| + fmt.Printf("%s%s: hibernate %v -> %v\n", prefix, rt.Host, rt.Hibernate, on) |
| +} |
| + |
| +// daemonHibernateRefusal is the one sentence both doors say — `carlos route |
| +// -hibernate` and `carlos add -hibernate` — so an operator who meets it at |
| +// one of them recognises it at the other. |
| +const daemonHibernateRefusal = "-hibernate is refused for a route whose release declares the `daemon` capability: " + |
| + "a daemon is long-running BY DECLARATION — the platform enables it, starts it and restarts it on adopt — " + |
| + "and hibernating it would have the activator stop the very process that declaration exists to keep up" |
| + |
| +// routeDaemonCheck reports whether the release this route runs declares the |
| +// `daemon` capability, and whether that answer is PROVEN. The three returns |
| +// are the same shape routeVersionCheck uses, and for the same reason: the |
| +// caller must be able to tell "no daemon" from "I could not find out", and |
| +// treat them differently. |
| +// |
| +// It checks BOTH the version the route is currently serving and the version |
| +// its channel points at, because both are "what this route runs": the first |
| +// is what is running now, the second is what the next adopt converges onto. |
| +// Either declaring daemon is a refusal. |
| +// |
| +// A route naming no app is proven NOT a daemon rather than unproven — there |
| +// is no release, so there is nothing that could declare anything, and that is |
| +// a proof rather than an absence of one. |
| +func routeDaemonCheck(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct string, rt registry.Route) (daemon bool, note string, proven bool) { |
| + if rt.App == "" { |
| + return false, "route names no app, so no release can declare a capability for it", true |
| + } |
| + if pub == nil { |
| + return false, "cannot verify what this route's release declares: CARLOS_RELEASE_PUBKEY is not set", false |
| + } |
| + if store == nil { |
| + return false, "cannot verify what this route's release declares: no deployment store is configured " + |
| + "(set CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR)", false |
| + } |
| + ch := rt.Channel |
| + if ch == "" { |
| + ch = "stable" |
| + } |
| + ptr, err := release.Channel(ctx, store, pub, acct, rt.App, ch) |
| + if err != nil { |
| + return false, fmt.Sprintf("channel %s (%s) does not resolve: %v", ch, release.ChannelKey(acct, rt.App, ch), err), false |
| + } |
| + versions := []string{ptr.Version} |
| + if rt.Version != "" && rt.Version != ptr.Version { |
| + versions = append(versions, rt.Version) |
| + } |
| + for _, v := range versions { |
| + m, merr := release.GetManifest(ctx, store, acct, rt.App, v) |
| + if merr != nil { |
| + return false, fmt.Sprintf("manifest for %s %s does not read: %v", rt.App, v, merr), false |
| + } |
| + if m.Capabilities != nil && m.Capabilities.Daemon { |
| + return true, fmt.Sprintf("%s %s declares it", rt.App, v), true |
| + } |
| + } |
| + return false, fmt.Sprintf("no daemon capability in %s %s", rt.App, strings.Join(versions, " or ")), true |
| +} |
| + |
| +// deploymentReadSeam resolves the public key and store used for READ-ONLY |
| +// release checks, returning nils instead of exiting when either is absent. |
| +// openStore's log.Fatal is right for a verb that cannot do its job without a |
| +// store; it is wrong here, where "no store configured" is an ANSWER the |
| +// caller has to weigh — `carlos add` proceeds on it (provision time, no |
| +// bucket yet), `carlos route -hibernate` refuses on it. |
| +func deploymentReadSeam() (release.Store, ed25519.PublicKey) { |
| + var pub ed25519.PublicKey |
| + if b := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); b != "" { |
| + if p, err := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY"); err == nil { |
| + pub = p |
| + } |
| + } |
| + if pub == nil { |
| + return nil, nil |
| + } |
| + if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| + s, err := release.NewS3Store(context.Background(), b) |
| + if err != nil { |
| + return nil, pub |
| + } |
| + return s, pub |
| + } |
| + if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { |
| + return release.NewDirStore(d), pub |
| + } |
| + return nil, pub |
| +} |
| + |
| // routeVersionCheck resolves the rail for one route: does moving it from |
| // fromChannel to toChannel change what gets served? |
| // |