carlos / platform Public

cli: carlos route -grant/-revoke/-hibernate, with the rails around the door

`carlos route` gains the operator's door to the two operator-consented
capabilities, plus the hibernation flag whose refusal belongs at the same
door. Each is its own single-route operation: -grant and -revoke are
mutually exclusive with each other, with -hibernate, and with
-channel/-environment, because a grant is not a repoint and one command
doing both would commit two unrelated writes behind two unrelated rails.

The rails, none of which has an override:

- -host only, 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.
- The token is validated before the registry is opened for writing.
- A Platform+Canary row is refused: the reconciler rebuilds it. Upsert's
  preservation makes a grant there survivable, but survivable is not owned.
- The route must be a unit-backed instance — anywhere else a grant would
  materialize nothing.
- A port already granted to a DIFFERENT route on this box is refused naming
  the other host: :25 can be held by one process on one machine, and an
  unreadable caps column on any row fails that check CLOSED rather than
  claiming what it could not prove was free.
- -hibernate=true is refused for a route whose release declares `daemon`.
  Resolution follows this verb's existing doctrine exactly: a proven daemon
  refuses, and an UNPROVEN comparison (no pubkey, no store, an unresolvable
  channel, a bad signature, an unreadable manifest) refuses too, because "I
  could not find out" is never a licence. Both the version the route serves
  and the version its channel points at are checked. -hibernate=false needs
  no proof. The flag is tri-state via fs.Visit, so a plain flag.Bool can
  never flip a column nobody named.

`carlos add -hibernate` meets the same daemon refusal, with the one
documented exception: an unresolvable comparison there proceeds SILENTLY,
because that verb runs at provision time on boxes with no pubkey, no store
and no release yet, and refusing would break provisioning. The
authoritative refusal is the converge pass, which sees the manifest hourly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bd68622fe5266d1da65f8e5b0758517411319aa9 parent aac7cd0
3 files changed, +607 −4
  • cli.go +377 −4
  • cli_test.go +222 −0
  • main.go +8 −0
diff --git a/cli.go b/cli.go
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?
//
diff --git a/cli_test.go b/cli_test.go
index 73ccdd373..f0f06410a 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -7,12 +7,14 @@ package main
import (
"context"
+ "crypto/ed25519"
"os"
"path/filepath"
"strings"
"testing"
"github.com/carlosframework/platform/internal/caps"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -152,3 +154,223 @@ func TestShipDirectModeEmbedsTheCapabilityFile(t *testing.T) {
t.Fatalf("a ship with no -capabilities grew a key:\n%s", raw)
}
}
+
+// --- the grant door (Task 4) -------------------------------------------
+//
+// `carlos route -grant/-revoke` is the operator's door to a transfer of box
+// authority, so its refusals are pinned as pure predicates: everything below
+// is decided before the registry is opened for writing.
+
+// TestGrantRailRefusals: every structural reason a grant or a revoke is
+// refused from the row alone. The two row-shape refusals apply to a REVOKE
+// as well — a row this verb does not own is not a row it should be rewriting
+// in either direction.
+func TestGrantRailRefusals(t *testing.T) {
+ unit := registry.Route{Host: "keymail.dev", Kind: "instance", Backing: registry.BackingUnit, App: "keymail"}
+ cases := []struct {
+ name string
+ rt registry.Route
+ tok string
+ says string
+ }{
+ {"reserved port", unit, "port:443", "reserved"},
+ {"high port", unit, "port:8080", "1..1023"},
+ {"nonsense token", unit, "gpu", "unknown grant token"},
+ {"platform+canary row", func() registry.Route {
+ rt := unit
+ rt.Platform, rt.Canary = true, true
+ return rt
+ }(), "port:25", "does not own"},
+ {"exec-child backing", func() registry.Route {
+ rt := unit
+ rt.Backing = ""
+ return rt
+ }(), "port:25", "unit-backed instance routes"},
+ {"service route", func() registry.Route {
+ rt := unit
+ rt.Kind = "service"
+ return rt
+ }(), "host-role", "unit-backed instance routes"},
+ {"static route", func() registry.Route {
+ rt := unit
+ rt.Kind = "static"
+ rt.Backing = ""
+ return rt
+ }(), "port:25", "unit-backed instance routes"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ err := grantRail(c.rt, c.tok)
+ if err == nil {
+ t.Fatalf("grantRail(%+v, %q) accepted; want a refusal", c.rt, c.tok)
+ }
+ if !strings.Contains(err.Error(), c.says) {
+ t.Fatalf("refusal %q does not say %q", err, c.says)
+ }
+ })
+ }
+ for _, tok := range []string{"port:25", "host-role"} {
+ if err := grantRail(unit, tok); err != nil {
+ t.Fatalf("grantRail(unit-backed instance, %q) = %v, want accepted", tok, err)
+ }
+ }
+ // A plain Platform apex (not Canary) is grantable: nothing rebuilds that
+ // row from derived values the way the canary-minting pass does.
+ apex := unit
+ apex.Platform = true
+ if err := grantRail(apex, "port:25"); err != nil {
+ t.Fatalf("grantRail(platform apex) = %v, want accepted", err)
+ }
+}
+
+// TestPortGrantConflict: a privileged port can be held by exactly one process
+// on one machine, so granting one already granted elsewhere on this box is
+// refused NAMING the other host — the operator-facing half of the converge
+// pass's own claim check, which otherwise would not run until an hour after
+// the typing.
+func TestPortGrantConflict(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "keymail.dev", Caps: "host-role,port:25"},
+ {Host: "console.example.com", Caps: "host-role"},
+ {Host: "plain.example.com"},
+ }
+ err := portGrantConflict(routes, "other.example.com", "port:25")
+ if err == nil {
+ t.Fatal("granting a port another route already holds must be refused")
+ }
+ if !strings.Contains(err.Error(), "keymail.dev") {
+ t.Fatalf("refusal %q does not name the route already holding the port", err)
+ }
+ // Re-granting to the route that already holds it is not a conflict with
+ // itself — that path is the idempotent no-op, not a refusal.
+ if err := portGrantConflict(routes, "keymail.dev", "port:25"); err != nil {
+ t.Fatalf("re-granting to the holder = %v, want no conflict", err)
+ }
+ // A different port is free.
+ if err := portGrantConflict(routes, "other.example.com", "port:465"); err != nil {
+ t.Fatalf("port:465 = %v, want no conflict", err)
+ }
+ // host-role is never conflict-checked: the box role is not consumed by
+ // being used, and two apps holding it is two operator decisions.
+ if err := portGrantConflict(routes, "other.example.com", "host-role"); err != nil {
+ t.Fatalf("host-role = %v, want no conflict even though another route holds it", err)
+ }
+ // A row whose caps column does not parse fails CLOSED, naming that row:
+ // "I could not find out whether this port is claimed" is not a licence.
+ bad := []registry.Route{{Host: "handedited.example.com", Caps: "port:25,gpu"}}
+ err = portGrantConflict(bad, "other.example.com", "port:25")
+ if err == nil {
+ t.Fatal("an unreadable caps column on another row must fail the conflict check closed")
+ }
+ if !strings.Contains(err.Error(), "handedited.example.com") {
+ t.Fatalf("refusal %q does not name the unreadable row", err)
+ }
+}
+
+// TestRouteDaemonCheck pins the tri-state the -hibernate rail reads: a proven
+// daemon, a proven non-daemon, and — the case that matters most — every way
+// the comparison can fail to resolve, each of which reports UNPROVEN so the
+// caller refuses rather than guesses. `carlos route` refuses on unproven;
+// `carlos add` (provision time, no pubkey, no store, no release) proceeds on
+// it, which is why the two are separated here rather than folded into a bool.
+func TestRouteDaemonCheck(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ store := release.NewDirStore(dir)
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ship := func(app, version string, daemon bool) {
+ t.Helper()
+ m := release.Manifest{App: app, Version: version, Kind: "binary",
+ Artifacts: map[string]release.Artifact{"linux-arm64": {}}}
+ if daemon {
+ m.Capabilities = &caps.Set{V: 1, Daemon: true}
+ }
+ if err := release.Ship(ctx, store, release.OpsAccount, m,
+ map[string][]byte{"linux-arm64": []byte("bytes " + version)}); err != nil {
+ t.Fatalf("ship %s %s: %v", app, version, err)
+ }
+ }
+ ship("keymail", "abc1234", true)
+ ship("kass", "def5678", false)
+ ship("kass", "0badbad", true)
+ if err := release.Promote(ctx, store, priv, release.OpsAccount, "keymail", "canary/keymail", "abc1234"); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.Promote(ctx, store, priv, release.OpsAccount, "kass", "canary/kass", "def5678"); err != nil {
+ t.Fatal(err)
+ }
+
+ keymail := registry.Route{Host: "keymail.dev", App: "keymail", Channel: "canary/keymail"}
+ daemon, note, proven := routeDaemonCheck(ctx, store, pub, release.OpsAccount, keymail)
+ if !daemon || !proven {
+ t.Fatalf("a declared daemon = (%v, %q, %v), want proven daemon", daemon, note, proven)
+ }
+
+ kass := registry.Route{Host: "kass.example.com", App: "kass", Channel: "canary/kass"}
+ daemon, note, proven = routeDaemonCheck(ctx, store, pub, release.OpsAccount, kass)
+ if daemon || !proven {
+ t.Fatalf("a release with no daemon = (%v, %q, %v), want proven non-daemon", daemon, note, proven)
+ }
+
+ // BOTH versions count as "what this route runs": the one it serves now,
+ // and the one its channel points at (what the next adopt converges onto).
+ // A route still serving a daemon build may not hibernate just because the
+ // channel has since moved to one that isn't.
+ stale := kass
+ stale.Version = "0badbad"
+ daemon, note, proven = routeDaemonCheck(ctx, store, pub, release.OpsAccount, stale)
+ if !daemon || !proven {
+ t.Fatalf("a route still serving a daemon build = (%v, %q, %v), want proven daemon", daemon, note, proven)
+ }
+
+ // A route naming no app is PROVEN not a daemon: there is no release, so
+ // there is nothing that could declare anything.
+ daemon, _, proven = routeDaemonCheck(ctx, store, pub, release.OpsAccount, registry.Route{Host: "static.example.com"})
+ if daemon || !proven {
+ t.Fatalf("a route with no app = (%v, %v), want a proven non-daemon", daemon, proven)
+ }
+
+ // The four unproven shapes. Each must report proven=false, so `carlos
+ // route -hibernate` refuses: "I could not find out" is never a licence.
+ unproven := []struct {
+ name string
+ store release.Store
+ pub ed25519.PublicKey
+ rt registry.Route
+ }{
+ {"no pubkey", store, nil, keymail},
+ {"no store", nil, pub, keymail},
+ {"unresolvable channel", store, pub, registry.Route{Host: "keymail.dev", App: "keymail", Channel: "canary/nope"}},
+ // The channel resolves and its manifest declares no daemon, but the
+ // version the route is actually SERVING has no readable manifest —
+ // so half the question is unanswered, and half an answer is none.
+ {"manifest missing for the served version", store, pub, func() registry.Route {
+ rt := kass
+ rt.Version = "9999999"
+ return rt
+ }()},
+ }
+ for _, c := range unproven {
+ t.Run(c.name, func(t *testing.T) {
+ _, note, proven := routeDaemonCheck(ctx, c.store, c.pub, release.OpsAccount, c.rt)
+ if proven {
+ t.Fatalf("%s reported a proven answer (%q); want unproven so the caller refuses", c.name, note)
+ }
+ if note == "" {
+ t.Fatal("an unproven verdict must carry a note saying what could not be found out")
+ }
+ })
+ }
+
+ // A wrong key proves nothing either: the pointer's signature must verify.
+ otherPub, _, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, proven := routeDaemonCheck(ctx, store, otherPub, release.OpsAccount, keymail); proven {
+ t.Fatal("a pointer signed by another key must not prove anything")
+ }
+}
diff --git a/main.go b/main.go
index eee1262a7..5c76151c6 100644
--- a/main.go
+++ b/main.go
@@ -90,6 +90,14 @@ commands:
version change unless -allow-version-change is
passed; -environment refuses a bundle that
does not exist yet, and "-" clears to default)
+ -host <h> (-grant | -revoke) <port:n|host-role>:
+ grant or revoke an operator-consented capability
+ for ONE route on THIS box (never -app, never
+ combined with a repoint; 22/80/443 are reserved
+ and ungrantable)
+ -host <h> -hibernate[=false]: hibernate when idle
+ (refused for a route whose release declares the
+ daemon capability)
auth login | whoami | logout — who this terminal is
(headless? don't log in: pass -owner instead)
accounts create | list | migrate — the tenancy primitive