registry: per-route capability grants — the caps column and its two setters
An app DECLARES `ports`/`host_role` in its manifest; nothing materializes until an operator has granted it on THIS box. This is where that grant lives: an additive `caps TEXT NOT NULL DEFAULT ''` column, written only by SetCaps behind `carlos route -grant/-revoke`, in the one place on the box no tenant can write. internal/caps gains the token vocabulary (`host-role`, `port:<n>`), and it is closed and strict in both directions. ValidGrantToken refuses 22/80/443 absolutely — an operator typo must not be able to hand a tenant the edge's ports or the box's SSH — refuses anything outside 1..1023, and refuses a non-canonical port spelling, so one grant has exactly one spelling and the comma-separated column stays unambiguous. ParseGrants refuses a token the formatter would never have written rather than silently honouring the subset that parses: this column has one writer, so anything else in it means a hand edit, and the honest answer is to refuse to materialize rather than guess. Upsert PRESERVES a non-empty caps column, exactly as it preserves environment (2026-08-02 review, IMPORTANT 3). No full-row Upsert caller knows anything about grants — `carlos add` re-adds, the domains reconciler re-mints — so a plain excluded.caps would silently revoke an operator's port:25 on a re-run nobody would connect to the capability vanishing an hour later on the next converge pass. Fingerprint deliberately does NOT gain caps: a grant changes unit shape, not routing, and the edge has no reason to reload for one. SetDBPath joins it as the converge pass's narrow stamp, so ops.FleetDBs can learn where a platform-replicated app's databases live from the manifest rather than from an operator having remembered -db at `carlos add`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+719
−10
internal/caps/grants.go+214 −0internal/caps/grants_test.go+184 −0internal/registry/registry.go+97 −8internal/registry/registry_test.go+224 −2
diff --git a/internal/caps/grants.go b/internal/caps/grants.go| new file mode 100644 |
| index 000000000..8c5327f66 |
| --- /dev/null |
| +++ b/internal/caps/grants.go |
| @@ -0,0 +1,214 @@ |
| +package caps |
| + |
| +// Grants are the OPERATOR half of the capability model (spec §2 "Grants", |
| +// §7): a manifest DECLARES `ports` and `host_role`, and nothing materializes |
| +// until an operator has granted it ON THIS BOX. The grant lives in the |
| +// registry's `caps` column — a place no tenant can write — and this file is |
| +// the whole vocabulary of what that column may say. |
| +// |
| +// The token set is closed and tiny: |
| +// |
| +// host-role the app's processes may use the box's instance role |
| +// port:<n> the app may bind privileged port n (1..1023, never 22/80/443) |
| +// |
| +// Per route, per box, deliberately (decision 4): `:25` can be held by exactly |
| +// one process on one machine, and the IMDS gate is that box's firewall. Two |
| +// boxes running the same app is two grants. |
| + |
| +import ( |
| + "fmt" |
| + "sort" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// HostRoleToken is the grant that hands an app the box's cloud instance |
| +// role — the one real transfer of authority in the model, and the reason |
| +// grants are typed by an operator on the box rather than declared by an app |
| +// in a manifest (spec §7). |
| +const HostRoleToken = "host-role" |
| + |
| +// portTokenPrefix is the only other token shape. Kept as a constant so the |
| +// parser and the formatter can never drift apart by a character. |
| +const portTokenPrefix = "port:" |
| + |
| +// PortToken is the canonical grant token for a privileged port ("port:25"). |
| +// It is the ONLY producer of that string: ValidGrantToken refuses any port |
| +// token this function would not have written, so "port:025" and "port:+25" |
| +// never reach the column and two spellings of one grant can never coexist. |
| +func PortToken(p int) string { return portTokenPrefix + strconv.Itoa(p) } |
| + |
| +// Grants is a parsed `caps` column: which privileged ports this route may |
| +// bind on this box, and whether it may use the box's instance role. |
| +type Grants struct { |
| + Ports map[int]bool |
| + HostRole bool |
| +} |
| + |
| +// Has reports whether g holds tok. The token is not validated — an |
| +// unparseable token is simply not held, which is the honest answer for |
| +// "is this granted?". |
| +func (g Grants) Has(tok string) bool { |
| + if tok == HostRoleToken { |
| + return g.HostRole |
| + } |
| + if p, ok := parsePortToken(tok); ok { |
| + return g.Ports[p] |
| + } |
| + return false |
| +} |
| + |
| +// String is the canonical column value: every held grant as a token, sorted |
| +// lexicographically ("host-role,port:25"), so re-granting a set already |
| +// granted writes the same bytes and a diff of two registries is meaningful. |
| +// The zero Grants is "". |
| +// |
| +// The sort is over the token STRINGS, not the port numbers, so ports order |
| +// as "port:25", "port:465", "port:5". That is deliberate: the only property |
| +// this value needs is that one set has exactly one spelling. |
| +func (g Grants) String() string { |
| + var toks []string |
| + if g.HostRole { |
| + toks = append(toks, HostRoleToken) |
| + } |
| + for p, ok := range g.Ports { |
| + if ok { |
| + toks = append(toks, PortToken(p)) |
| + } |
| + } |
| + sort.Strings(toks) |
| + return strings.Join(toks, ",") |
| +} |
| + |
| +// ParseGrants reads the registry's `caps` column. Unknown or malformed |
| +// tokens are an ERROR, not a silent skip: this column is written ONLY by |
| +// registry.SetCaps behind `carlos route -grant/-revoke`, so anything else in |
| +// it means the row was edited by hand, and the honest answer is to refuse to |
| +// materialize the route rather than guess which subset was meant. A |
| +// duplicate token is refused on the same grounds — String never writes one. |
| +// |
| +// "" is the zero Grants with no error: nothing granted, which is every row |
| +// on every box today and the behaviour byte for byte. |
| +func ParseGrants(col string) (Grants, error) { |
| + var g Grants |
| + if col == "" { |
| + return g, nil |
| + } |
| + for _, tok := range strings.Split(col, ",") { |
| + if err := ValidGrantToken(tok); err != nil { |
| + return Grants{}, fmt.Errorf("caps column %q: %w", col, err) |
| + } |
| + if g.Has(tok) { |
| + return Grants{}, fmt.Errorf("caps column %q: %q appears more than once — this column is written only by `carlos route -grant`, so a duplicate means a hand edit", col, tok) |
| + } |
| + if tok == HostRoleToken { |
| + g.HostRole = true |
| + continue |
| + } |
| + p, _ := parsePortToken(tok) // ValidGrantToken proved the shape |
| + if g.Ports == nil { |
| + g.Ports = map[int]bool{} |
| + } |
| + g.Ports[p] = true |
| + } |
| + return g, nil |
| +} |
| + |
| +// ValidGrantToken is the rail an operator meets at `carlos route -grant`, |
| +// and the same rail ParseGrants applies to the stored column. It refuses: |
| +// |
| +// - an unknown token shape (only "host-role" and "port:<n>" exist); |
| +// - a non-canonical port spelling ("port:025", "port:+25", "port: 25") — |
| +// one grant has exactly one spelling; |
| +// - a port outside 1..1023, because a high port needs no help from the |
| +// platform and letting the vocabulary cover them would blur what the |
| +// capability is for (spec §7); |
| +// - 22, 80 and 443, which are reserved ABSOLUTELY and are not grantable: |
| +// an operator typo must not be able to hand a tenant the edge's ports or |
| +// the box's SSH. |
| +// |
| +// A token can therefore never contain a comma or a newline, which is what |
| +// keeps the comma-separated column unambiguous. |
| +func ValidGrantToken(tok string) error { |
| + if tok == "" { |
| + return fmt.Errorf("grant token is empty — want %q or %q", HostRoleToken, PortToken(25)) |
| + } |
| + if tok == HostRoleToken { |
| + return nil |
| + } |
| + if !strings.HasPrefix(tok, portTokenPrefix) { |
| + return fmt.Errorf("unknown grant token %q — the vocabulary is %q and %q", tok, HostRoleToken, portTokenPrefix+"<n>") |
| + } |
| + p, ok := parsePortToken(tok) |
| + if !ok { |
| + return fmt.Errorf("grant token %q: %s must be a decimal port number, canonically spelled (e.g. %q)", tok, portTokenPrefix+"<n>", PortToken(25)) |
| + } |
| + if p < 1 || p > 1023 { |
| + return fmt.Errorf("grant token %q: port %d is outside 1..1023 — only privileged ports are a capability; a high port needs no help from the platform", tok, p) |
| + } |
| + if reservedPort[p] { |
| + return fmt.Errorf("grant token %q: port %d is reserved absolutely and is not grantable — it belongs to the edge or to SSH", tok, p) |
| + } |
| + return nil |
| +} |
| + |
| +// parsePortToken returns the port a "port:<n>" token names, and whether the |
| +// token is that shape AND spelled the way PortToken would have spelled it. |
| +// The round-trip check is what refuses "port:025" and "port:+25": strconv |
| +// accepts both, and accepting them would let one grant have two spellings in |
| +// one column. |
| +func parsePortToken(tok string) (int, bool) { |
| + rest, ok := strings.CutPrefix(tok, portTokenPrefix) |
| + if !ok { |
| + return 0, false |
| + } |
| + p, err := strconv.Atoi(rest) |
| + if err != nil || PortToken(p) != tok { |
| + return 0, false |
| + } |
| + return p, true |
| +} |
| + |
| +// Grant returns the canonical column value with tok added. Granting what is |
| +// already granted is idempotent — same set, same bytes — so a re-run of an |
| +// operator's command is a success, not a conflict. |
| +func Grant(col, tok string) (string, error) { |
| + if err := ValidGrantToken(tok); err != nil { |
| + return "", err |
| + } |
| + g, err := ParseGrants(col) |
| + if err != nil { |
| + return "", err |
| + } |
| + if tok == HostRoleToken { |
| + g.HostRole = true |
| + } else { |
| + p, _ := parsePortToken(tok) |
| + if g.Ports == nil { |
| + g.Ports = map[int]bool{} |
| + } |
| + g.Ports[p] = true |
| + } |
| + return g.String(), nil |
| +} |
| + |
| +// Revoke returns the canonical column value with tok removed. Revoking a |
| +// grant that was never made is a quiet success: the caller's goal is "not |
| +// granted", not "I removed something" (the same contract DeleteAccount |
| +// documents). The token is still validated — an operator who typed a |
| +// nonsense token deserves to hear so rather than a cheerful no-op. |
| +func Revoke(col, tok string) (string, error) { |
| + if err := ValidGrantToken(tok); err != nil { |
| + return "", err |
| + } |
| + g, err := ParseGrants(col) |
| + if err != nil { |
| + return "", err |
| + } |
| + if tok == HostRoleToken { |
| + g.HostRole = false |
| + } else if p, ok := parsePortToken(tok); ok { |
| + delete(g.Ports, p) |
| + } |
| + return g.String(), nil |
| +} |
diff --git a/internal/caps/grants_test.go b/internal/caps/grants_test.go| new file mode 100644 |
| index 000000000..a86c81931 |
| --- /dev/null |
| +++ b/internal/caps/grants_test.go |
| @@ -0,0 +1,184 @@ |
| +package caps |
| + |
| +// The operator half of the model: what may be written into the registry's |
| +// `caps` column, what a hand-edited column does, and why one granted set has |
| +// exactly one spelling. |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestValidGrantTokenRefusals pins the closed vocabulary an operator meets at |
| +// `carlos route -grant`. The three reserved ports are the load-bearing rows: |
| +// they are refused ABSOLUTELY, so an operator typo can never hand a tenant |
| +// the edge's ports or the box's SSH (spec §7). |
| +func TestValidGrantTokenRefusals(t *testing.T) { |
| + for _, tok := range []string{HostRoleToken, "port:25", "port:465", "port:1", "port:1023"} { |
| + if err := ValidGrantToken(tok); err != nil { |
| + t.Fatalf("ValidGrantToken(%q) = %v, want accepted", tok, err) |
| + } |
| + } |
| + cases := []struct { |
| + tok string |
| + says string |
| + }{ |
| + {"port:22", "reserved"}, |
| + {"port:80", "reserved"}, |
| + {"port:443", "reserved"}, |
| + {"port:8080", "1..1023"}, |
| + {"port:0", "1..1023"}, |
| + {"port:1024", "1..1023"}, |
| + {"port:99999", "1..1023"}, |
| + {"port:-1", "1..1023"}, |
| + {"hostrole", "unknown grant token"}, |
| + {"host-role:1", "unknown grant token"}, |
| + {"", "empty"}, |
| + {"port:", "decimal port number"}, |
| + {"port:x", "decimal port number"}, |
| + // One grant, one spelling: strconv would take all three. |
| + {"port:025", "canonically spelled"}, |
| + {"port:+25", "canonically spelled"}, |
| + {"port: 25", "decimal port number"}, |
| + // A token can never carry the column's own separator, or a newline. |
| + {"port:25,host-role", "decimal port number"}, |
| + {"host-role\n", "unknown grant token"}, |
| + } |
| + for _, c := range cases { |
| + err := ValidGrantToken(c.tok) |
| + if err == nil { |
| + t.Fatalf("ValidGrantToken(%q) accepted; want a refusal", c.tok) |
| + } |
| + if !strings.Contains(err.Error(), c.says) { |
| + t.Fatalf("ValidGrantToken(%q) = %q, want it to say %q", c.tok, err, c.says) |
| + } |
| + } |
| +} |
| + |
| +// TestParseGrantsRoundTrip: the column round-trips, and "" — every row on |
| +// every box today — is the zero Grants with no error, meaning "nothing |
| +// granted". |
| +func TestParseGrantsRoundTrip(t *testing.T) { |
| + g, err := ParseGrants("") |
| + if err != nil || g.HostRole || len(g.Ports) != 0 { |
| + t.Fatalf(`ParseGrants("") = (%+v, %v), want the zero Grants and no error`, g, err) |
| + } |
| + if g.String() != "" { |
| + t.Fatalf("zero Grants formats as %q, want %q", g.String(), "") |
| + } |
| + |
| + g, err = ParseGrants("host-role,port:25") |
| + if err != nil { |
| + t.Fatalf("ParseGrants: %v", err) |
| + } |
| + if !g.HostRole || !g.Ports[25] || len(g.Ports) != 1 { |
| + t.Fatalf("ParseGrants = %+v, want host-role and port 25", g) |
| + } |
| + if got := g.String(); got != "host-role,port:25" { |
| + t.Fatalf("String() = %q, want the canonical column value", got) |
| + } |
| + // A hand-written column in the other order normalises to the same bytes. |
| + g, err = ParseGrants("port:25,host-role") |
| + if err != nil { |
| + t.Fatalf("ParseGrants: %v", err) |
| + } |
| + if got := g.String(); got != "host-role,port:25" { |
| + t.Fatalf("String() = %q, want the canonical order regardless of input order", got) |
| + } |
| + if !g.Has(HostRoleToken) || !g.Has("port:25") || g.Has("port:465") || g.Has("nonsense") { |
| + t.Fatalf("Has is wrong for %+v", g) |
| + } |
| +} |
| + |
| +// TestParseGrantsRefusesAHandEditedColumn: this column is written only by |
| +// registry.SetCaps behind `carlos route -grant`, so anything the formatter |
| +// would not have written means a human edited the row — and the honest |
| +// answer is to refuse to materialize, never to silently honour the subset |
| +// that happens to parse. |
| +func TestParseGrantsRefusesAHandEditedColumn(t *testing.T) { |
| + for _, col := range []string{ |
| + "port:99999", // out of range |
| + "port:22", // reserved, ungrantable |
| + "gpu", // not in the vocabulary |
| + "host-role,", // empty element |
| + ",port:25", // empty element |
| + "host-role, port:25", // whitespace is not trimmed: one spelling only |
| + "host-role,host-role", // the formatter never writes a duplicate |
| + "port:25,port:025", // nor two spellings of one grant |
| + } { |
| + if g, err := ParseGrants(col); err == nil { |
| + t.Fatalf("ParseGrants(%q) = %+v, want a refusal", col, g) |
| + } |
| + } |
| +} |
| + |
| +// TestGrantRevokeIdempotent: granting what is granted writes the same bytes, |
| +// and revoking what was never granted is a quiet success — the caller's goal |
| +// is "not granted", not "I removed something". |
| +func TestGrantRevokeIdempotent(t *testing.T) { |
| + col, err := Grant("", "port:25") |
| + if err != nil || col != "port:25" { |
| + t.Fatalf(`Grant("", "port:25") = (%q, %v)`, col, err) |
| + } |
| + again, err := Grant(col, "port:25") |
| + if err != nil || again != col { |
| + t.Fatalf("re-granting changed the column: %q -> %q (%v)", col, again, err) |
| + } |
| + col, err = Grant(col, HostRoleToken) |
| + if err != nil || col != "host-role,port:25" { |
| + t.Fatalf("Grant(host-role) = (%q, %v), want the canonical two-token column", col, err) |
| + } |
| + // Order of granting does not change the stored bytes. |
| + other, err := Grant("host-role", "port:25") |
| + if err != nil || other != col { |
| + t.Fatalf("granting in the other order = %q, want %q", other, col) |
| + } |
| + |
| + col, err = Revoke(col, "port:25") |
| + if err != nil || col != "host-role" { |
| + t.Fatalf("Revoke(port:25) = (%q, %v), want just host-role left", col, err) |
| + } |
| + // Absent grant: no-op, no error. |
| + col, err = Revoke(col, "port:25") |
| + if err != nil || col != "host-role" { |
| + t.Fatalf("revoking an absent grant = (%q, %v), want a quiet no-op", col, err) |
| + } |
| + col, err = Revoke(col, HostRoleToken) |
| + if err != nil || col != "" { |
| + t.Fatalf("Revoke(host-role) = (%q, %v), want an empty column", col, err) |
| + } |
| + // Revoking from an empty column is still a quiet success. |
| + if col, err = Revoke("", HostRoleToken); err != nil || col != "" { |
| + t.Fatalf(`Revoke("", host-role) = (%q, %v), want ("", nil)`, col, err) |
| + } |
| + // But a nonsense token is refused rather than cheerfully no-oped, on |
| + // both verbs: an operator who typed rubbish deserves to hear so. |
| + if _, err := Revoke("port:25", "port:22"); err == nil { |
| + t.Fatal("Revoke accepted an ungrantable token") |
| + } |
| + if _, err := Grant("", "gpu"); err == nil { |
| + t.Fatal("Grant accepted a token outside the vocabulary") |
| + } |
| + // A hand-edited column is refused by both, not repaired by them. |
| + if _, err := Grant("gpu", "port:25"); err == nil { |
| + t.Fatal("Grant repaired a hand-edited column instead of refusing it") |
| + } |
| + if _, err := Revoke("gpu", "port:25"); err == nil { |
| + t.Fatal("Revoke repaired a hand-edited column instead of refusing it") |
| + } |
| +} |
| + |
| +// TestPortTokenMatchesTheManifestVocabulary: a declared port and its grant |
| +// token must agree, or a grant an operator types could never satisfy the |
| +// declaration it was typed for. ValidCapabilities and ValidGrantToken share |
| +// the reserved list; this pins that they also share the range. |
| +func TestPortTokenMatchesTheManifestVocabulary(t *testing.T) { |
| + for p := 1; p <= 1023; p++ { |
| + declarable := validPorts([]Port{{Port: p}}) == nil |
| + grantable := ValidGrantToken(PortToken(p)) == nil |
| + if declarable != grantable { |
| + t.Fatalf("port %d: declarable=%v grantable=%v — a declaration nobody can grant, or a grant nothing can declare", |
| + p, declarable, grantable) |
| + } |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index d3431f19e..cefa35757 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -69,6 +69,25 @@ type Route struct { |
| // rebuilds is not one this verb should be handing an operator. |
| Environment string |
| + // Caps is the OPERATOR-granted capability tokens for this route ON THIS |
| + // BOX: comma-separated and canonically sorted, so "port:25,host-role" |
| + // normalises to "host-role,port:25" (the vocabulary and the formatter are |
| + // internal/caps' Grants — this column's only reader and writer). Default |
| + // '' = nothing granted = today's behaviour for every row already on every |
| + // box. |
| + // |
| + // Written ONLY by SetCaps, behind `carlos route -grant/-revoke`. A |
| + // manifest DECLARES `ports`/`host_role`; nothing materializes until an |
| + // operator has granted it here, on this box — which is the whole point of |
| + // the column living in the registry, where no tenant can write (see |
| + // docs/superpowers/specs/2026-08-03-app-capabilities-design.md §2, §7). |
| + // Upsert preserves a non-empty existing value for the same reason it |
| + // preserves Environment; see its doc comment. |
| + // |
| + // Deliberately NOT in Fingerprint(): grants change unit shape, not |
| + // routing, and the edge has no reason to reload for one. |
| + Caps string |
| + |
| Hibernate bool |
| Canary bool |
| Replication string // "" | "stream" | "batched" | "daily" |
| @@ -179,6 +198,7 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN cert_expires_at INTEGER NOT NULL DEFAULT 0`, |
| `ALTER TABLE routes ADD COLUMN account TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN environment TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN caps TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -341,10 +361,16 @@ func (r *Registry) Accounts() ([]Account, error) { |
| return out, rows.Err() |
| } |
| +// routeCols is the column list every route read and the one route write |
| +// share — 28 names, in this exact order. scanRoute's destinations, Upsert's |
| +// placeholder row and Upsert's argument list all count from here: adding a |
| +// column means adding it in FOUR places, and a miscount is a silent column |
| +// shift rather than an error (TestUpsertRoundTripsAllColumns is what catches |
| +// one). |
| const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path, |
| version, prev_version, app, pool, region, channel, hibernate, canary, |
| replication, next_due, backing, status_named, platform, dns_status, |
| -dns_checked_at, cert_expires_at, account, environment, created_at` |
| +dns_checked_at, cert_expires_at, account, environment, caps, created_at` |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| @@ -353,7 +379,7 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| &t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.App, &t.Pool, |
| &t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing, |
| &sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account, |
| - &t.Environment, &t.CreatedAt) |
| + &t.Environment, &t.Caps, &t.CreatedAt) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -413,9 +439,10 @@ func (r *Registry) Delete(host string) error { |
| return err |
| } |
| -// Upsert inserts or updates a route by host. One column is deliberately not |
| -// a plain overwrite: `environment` keeps the existing row's value when the |
| -// incoming Route carries the zero value (2026-08-02 review, IMPORTANT 3). |
| +// Upsert inserts or updates a route by host. Two columns are deliberately not |
| +// a plain overwrite: `environment` and `caps` keep the existing row's value |
| +// when the incoming Route carries the zero value (2026-08-02 review, |
| +// IMPORTANT 3, and the same lesson applied to grants). |
| // Every full-row Upsert caller — `carlos add` and the domains reconciler's |
| // two mint paths — builds its Route without knowing anything about |
| // environment bindings, so a plain excluded.environment silently unbound a |
| @@ -424,6 +451,14 @@ func (r *Registry) Delete(host string) error { |
| // explicit non-empty value still wins, and clearing goes through |
| // SetEnvironment (what `carlos route -environment -` calls), which is the |
| // only place a clear is ever intended. |
| +// |
| +// `caps` is the same shape of bug with a worse blast radius: no full-row |
| +// Upsert caller knows anything about grants either, so a plain excluded.caps |
| +// would silently REVOKE an operator's `port:25` the next time `carlos add` |
| +// re-ran or the domains reconciler re-minted the row — a capability |
| +// disappearing on a re-add nobody connected to it, hours later, when the |
| +// converge pass next ran. Revoking has exactly one door, SetCaps, behind |
| +// `carlos route -revoke`. |
| func (r *Registry) Upsert(t Route) error { |
| if t.CreatedAt == 0 { |
| t.CreatedAt = time.Now().Unix() |
| @@ -442,7 +477,7 @@ func (r *Registry) Upsert(t Route) error { |
| } |
| _, err := r.sql.Exec(` |
| INSERT INTO routes (`+routeCols+`) |
| -VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |
| ON CONFLICT(host) DO UPDATE SET |
| slug=excluded.slug, kind=excluded.kind, socket=excluded.socket, |
| addr=excluded.addr, status=excluded.status, last_error=excluded.last_error, |
| @@ -455,12 +490,13 @@ ON CONFLICT(host) DO UPDATE SET |
| platform=excluded.platform, dns_status=excluded.dns_status, |
| dns_checked_at=excluded.dns_checked_at, |
| cert_expires_at=excluded.cert_expires_at, account=excluded.account, |
| - environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END`, |
| + environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END, |
| + caps=CASE WHEN excluded.caps = '' THEN routes.caps ELSE excluded.caps END`, |
| t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, |
| t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel, |
| b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing, |
| b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt, |
| - t.CertExpiresAt, t.Account, t.Environment, t.CreatedAt) |
| + t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.CreatedAt) |
| return err |
| } |
| @@ -494,6 +530,59 @@ func (r *Registry) SetChannel(host, channel string) error { |
| return nil |
| } |
| +// SetCaps writes a route's operator-granted capability tokens — the ONLY |
| +// writer of that column, and deliberately the only one: `carlos route |
| +// -grant/-revoke` is the operator's door to a transfer of box authority, and |
| +// a second writer (a full-row Upsert, a reconcile pass) would be a way for a |
| +// grant to appear or disappear without anyone typing it. Callers pass the |
| +// canonical value caps.Grant/caps.Revoke returned; this setter validates |
| +// nothing, exactly as SetChannel validates no channel name — the rails live |
| +// at the door. |
| +// |
| +// Same contract and same rationale as SetChannel: a narrow UPDATE (so it |
| +// cannot clobber a concurrent SetVersions or SetNextDue), and an error when |
| +// no row matched, because a human types a hostname into this one directly |
| +// and a silent no-op on a typo would be worse than a refusal. |
| +func (r *Registry) SetCaps(host, caps string) error { |
| + res, err := r.sql.Exec(`UPDATE routes SET caps = ? WHERE host = ?`, caps, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| + |
| +// SetDBPath stamps a route's database path narrowly. The converge pass uses |
| +// it to teach ops.FleetDBs (the box-wide litestream unit's config generator) |
| +// where a `data.replicate: platform` app's databases actually live, instead |
| +// of depending on an operator having remembered `-db` at `carlos add` — the |
| +// manifest names the database FILES, the platform knows the state directory, |
| +// and this is where the two meet. |
| +// |
| +// Errors on an unmatched host, like SetChannel and SetCaps: the caller is a |
| +// pass that has just read the row, so "the row is gone" is a race worth |
| +// reporting rather than swallowing. |
| +func (r *Registry) SetDBPath(host, path string) error { |
| + res, err := r.sql.Exec(`UPDATE routes SET db_path = ? WHERE host = ?`, path, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| + |
| // SetChannels repoints every host in hosts to channel as ONE transaction — |
| // every row moves or none do. `carlos route -app` uses this for its bulk |
| // form: the caller runs every route's safety-rail check to completion FIRST |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index aba9dc0b4..4a7da1d0f 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1,6 +1,7 @@ |
| package registry |
| import ( |
| + "database/sql" |
| "os" |
| "path/filepath" |
| "testing" |
| @@ -52,6 +53,12 @@ func TestListSeesOtherHandlesCommits(t *testing.T) { |
| } |
| } |
| +// TestUpsertRoundTripsAllColumns is the column-shift canary: routeCols, |
| +// scanRoute's destination list, Upsert's placeholder row and Upsert's |
| +// argument list are four hand-maintained lists that must agree, and a |
| +// miscount among them is a silent shift (a grant landing in created_at) |
| +// rather than an error. Every column carries a DISTINCT value here on |
| +// purpose — two columns sharing a value would let a swap pass. |
| func TestUpsertRoundTripsAllColumns(t *testing.T) { |
| reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| if err != nil { |
| @@ -63,8 +70,8 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) { |
| Status: "ready", LastError: "prior fail", |
| DBPath: "/data/b.db", Version: "abc1234", PrevVersion: "def5678", |
| App: "kass", Pool: "shared", Region: "ie", Channel: "stable", |
| - Environment: "demo", |
| - Hibernate: true, Canary: true, Replication: "batched", |
| + Environment: "demo", Caps: "host-role,port:25", |
| + Hibernate: true, Canary: true, Replication: "batched", |
| NextDue: 4200, Backing: BackingUnit, CreatedAt: 1700000000, |
| } |
| if err := reg.Upsert(want); err != nil { |
| @@ -778,3 +785,218 @@ func TestUpsertPreservesAnExistingEnvironmentBinding(t *testing.T) { |
| t.Fatalf("Environment = %q after an explicit clear, want it cleared", got.Environment) |
| } |
| } |
| + |
| +// A registry file written BEFORE the caps column existed must still open, and |
| +// its rows must read as "nothing granted" — the additive-migration contract |
| +// applied to the column that decides whether a unit gets box authority. The |
| +// pre-change file is simulated by dropping the column from a fresh registry |
| +// and writing a row through raw SQL, which is exactly the shape an older |
| +// binary left behind. |
| +func TestPreCapsRegistryOpensAndReadsUngranted(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + reg, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + old, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := old.Exec(`ALTER TABLE routes DROP COLUMN caps`); err != nil { |
| + t.Fatalf("simulate a pre-caps registry: %v", err) |
| + } |
| + if _, err := old.Exec(`INSERT INTO routes (host, kind, status, created_at) |
| +VALUES ('legacy.example.com', 'instance', 'ready', 1700000000)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := old.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + reg, err = Open(path) |
| + if err != nil { |
| + t.Fatalf("a pre-caps registry must still open: %v", err) |
| + } |
| + got, err := reg.Get("legacy.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil { |
| + t.Fatal("the pre-existing row vanished across the migration") |
| + } |
| + if got.Caps != "" { |
| + t.Fatalf("Caps = %q on a row written before the column existed, want %q — a migrated row must mean 'nothing granted'", got.Caps, "") |
| + } |
| + if got.Host != "legacy.example.com" || got.Kind != "instance" || got.CreatedAt != 1700000000 { |
| + t.Fatalf("the migrated row shifted columns: %+v", *got) |
| + } |
| + // And the new column is writable on that same row. |
| + if err := reg.SetCaps("legacy.example.com", "port:25"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.Get("legacy.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Caps != "port:25" { |
| + t.Fatalf("Caps = %q after SetCaps on a migrated row", got.Caps) |
| + } |
| +} |
| + |
| +// SetCaps is narrow (it touches no other column) and refuses an unknown host |
| +// rather than silently doing nothing — `carlos route -grant` is a hostname a |
| +// human types, and a typo that reports success while granting nothing is the |
| +// worst possible outcome for this particular column. |
| +func TestSetCapsIsNarrowAndErrorsOnAnUnknownHost(t *testing.T) { |
| + reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + base := Route{ |
| + Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit, |
| + Channel: "canary/keymail", Environment: "prod", Version: "abc1234", |
| + Status: "ready", NextDue: 99, DBPath: "/var/lib/carlos-app/keymail.dev/registry.db", |
| + } |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetCaps("keymail.dev", "port:25"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := base |
| + want.Caps = "port:25" |
| + want.CreatedAt = got.CreatedAt |
| + if *got != want { |
| + t.Fatalf("SetCaps was not narrow:\n got %+v\n want %+v", *got, want) |
| + } |
| + if err := reg.SetCaps("nope.example.com", "port:25"); err == nil { |
| + t.Fatal("SetCaps on an unknown host must error, not silently write nothing") |
| + } |
| +} |
| + |
| +// SetDBPath is the converge pass's narrow stamp (it teaches ops.FleetDBs |
| +// where a platform-replicated app's databases live), with the same |
| +// unknown-host contract. |
| +func TestSetDBPathIsNarrowAndErrorsOnAnUnknownHost(t *testing.T) { |
| + reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + base := Route{ |
| + Host: "console.example.com", Kind: "instance", App: "console", |
| + Backing: BackingUnit, Channel: "canary/console", Status: "ready", |
| + } |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetCaps("console.example.com", "host-role"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetDBPath("console.example.com", "/var/lib/carlos-app/console.example.com/console.db"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("console.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := base |
| + want.Caps = "host-role" |
| + want.DBPath = "/var/lib/carlos-app/console.example.com/console.db" |
| + want.CreatedAt = got.CreatedAt |
| + if *got != want { |
| + t.Fatalf("SetDBPath was not narrow:\n got %+v\n want %+v", *got, want) |
| + } |
| + if err := reg.SetDBPath("nope.example.com", "/x"); err == nil { |
| + t.Fatal("SetDBPath on an unknown host must error") |
| + } |
| +} |
| + |
| +// The named finding, as a test: `carlos add` re-runs and the domains |
| +// reconciler re-mints, and neither knows anything about grants. A plain |
| +// excluded.caps would silently REVOKE port:25 on a re-add — a capability |
| +// disappearing hours later, on the next converge pass, with nothing in the |
| +// operator's shell history connecting the two. |
| +func TestUpsertPreservesAnExistingGrant(t *testing.T) { |
| + reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + base := Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit, Status: "ready"} |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetCaps("keymail.dev", "host-role,port:25"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The re-add / the re-mint: same row, no grants in hand. |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Caps != "host-role,port:25" { |
| + t.Fatalf("Caps = %q after a re-Upsert, want the grant preserved", got.Caps) |
| + } |
| + // Preservation is a floor, not a freeze: an explicit non-empty value from |
| + // a caller that genuinely knows the grants still wins. |
| + base.Caps = "port:25" |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.Get("keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Caps != "port:25" { |
| + t.Fatalf("Caps = %q, want an explicit Upsert value to win", got.Caps) |
| + } |
| + // Revoking to nothing has exactly one door, and it is not Upsert. |
| + if err := reg.SetCaps("keymail.dev", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.Get("keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Caps != "" { |
| + t.Fatalf("Caps = %q after an explicit revoke-to-empty, want it cleared", got.Caps) |
| + } |
| +} |
| + |
| +// A grant changes unit shape, not routing: the edge has no reason to reload |
| +// for one, so the fingerprint must NOT move when caps does. (The inverse is |
| +// pinned by TestFingerprintChangesOnWrite for the columns that do matter.) |
| +func TestFingerprintIgnoresAGrant(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + reg, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit}); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetCaps("keymail.dev", "host-role,port:25"); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after != before { |
| + t.Fatalf("Fingerprint moved on a grant (%s -> %s) — the edge would reload for a change that does not touch routing", before, after) |
| + } |
| +} |