registry: additive Tunnel column, preserved on Upsert, set via carlos route -tunnel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+177
−21
cli.go+78 −5internal/registry/registry.go+46 −16internal/registry/registry_test.go+53 −0
diff --git a/cli.go b/cli.go| index a95989041..fdaa915f9 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -217,7 +217,19 @@ func runInstances(args []string) { |
| // back to the app's default bundle and needs no bucket check — there is |
| // nothing to prove exists. |
| // |
| -// When BOTH flags are given, both rails must resolve cleanly before EITHER |
| +// A third, independent axis, -tunnel, names the reverse channel a route is |
| +// bound to at the edge (registry.Route.Tunnel — backing for later work in |
| +// the remote-host-agent feature, issue #53). It is simpler still: no bucket |
| +// to check existence against (a tunnel only becomes real once a host-agent |
| +// dials in under that name), and -host only — there is no bulk sibling to |
| +// SetTunnel, so -app + -tunnel is refused outright rather than silently |
| +// doing nothing. Same "-tunnel -" clear sentinel and the same |
| +// platformCanaryOwnedRow refusal as -channel/-environment, for the same |
| +// structural reason. It writes through its own SetTunnel call, independent |
| +// of the -channel/-environment switch below — with only ever one candidate, |
| +// there is no combined transaction for it to join. |
| +// |
| +// When BOTH -channel and -environment are given, both rails must resolve cleanly before EITHER |
| // axis writes: a single combined transaction (SetChannelsAndEnvironments) |
| // commits both columns for every candidate together, so a combined move can |
| // never leave a route repointed on one axis but not the other. |
| @@ -270,23 +282,28 @@ 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`) |
| + tunnel := fs.String("tunnel", "", |
| + `name of the reverse channel backing this route at the edge; "-" clears to a Socket/Addr-routed upstream (host only, no -app)`) |
| 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) |
| - const usage = "usage: carlos route -host <host> [-channel <channel>] [-environment <name>] [-registry <db>] [-allow-version-change] [-dry-run]\n" + |
| + const usage = "usage: carlos route -host <host> [-channel <channel>] [-environment <name>] [-tunnel <name>] [-registry <db>] [-allow-version-change] [-dry-run]\n" + |
| " or: carlos route -app <app> [-channel <channel>] [-environment <name>] [-account <sqid|name|all>] [-account-id <sqid>] [-registry <db>] [-allow-version-change] [-dry-run]\n" + |
| - " (-account-id names ONE account by sqid and supersedes -account, \"all\" included)" |
| + " (-account-id names ONE account by sqid and supersedes -account, \"all\" included; -tunnel is -host only)" |
| 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) — ` + |
| + if *tunnel != "" && *app != "" { |
| + log.Fatal("route: -tunnel is only supported with -host — bulk -app tunnel repointing is not implemented\n\n" + usage) |
| + } |
| + if *channel == "" && *environment == "" && *tunnel == "" { |
| + log.Fatal(`route: at least one of -channel, -environment, or -tunnel 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) { |
| @@ -321,6 +338,16 @@ func runRoute(args []string) { |
| if envClear { |
| resolvedEnv = "" |
| } |
| + // tunnelClear is the same "-" clear sentinel as -environment's. Unlike |
| + // -environment there is no reserved name and no bucket to validate |
| + // against here — a tunnel name only becomes real once the host-agent |
| + // dials in under it (later work in this feature), so there is nothing to |
| + // check existence of yet. |
| + tunnelClear := *tunnel == "-" |
| + resolvedTunnel := *tunnel |
| + if tunnelClear { |
| + resolvedTunnel = "" |
| + } |
| r, err := registry.Open(*regPath) |
| if err != nil { |
| @@ -594,6 +621,30 @@ func runRoute(args []string) { |
| "%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note)) |
| } |
| } |
| + // The tunnel rail: -host only (the -app refusal above guarantees exactly |
| + // one candidate whenever -tunnel is set), and unlike -channel/-environment |
| + // there is no existence check — a tunnel only becomes real once the |
| + // host-agent dials in under that name, so there is no bucket store to |
| + // consult yet. Same Platform+Canary refusal as the other two axes, for |
| + // the same structural reason (platformCanaryOwnedRow's doc comment covers |
| + // all three columns it guards). |
| + var tunnelFrom string |
| + tunnelNoop, tunnelWillWrite := false, false |
| + if *tunnel != "" { |
| + rt := candidates[0] |
| + tunnelFrom = rt.Tunnel |
| + switch { |
| + case tunnelFrom == resolvedTunnel: |
| + tunnelNoop = true |
| + case platformCanaryOwnedRow(rt): |
| + refusals = append(refusals, fmt.Sprintf( |
| + "%s: Platform+Canary route — its whole row is rebuilt by the domains reconciler's canary-minting "+ |
| + "pass (internal/domains/reconcile.go) — this verb does not own this row, no flag overrides that", |
| + rt.Host)) |
| + default: |
| + tunnelWillWrite = true |
| + } |
| + } |
| if len(refusals) > 0 { |
| for _, msg := range refusals { |
| fmt.Fprintln(os.Stderr, msg) |
| @@ -631,6 +682,15 @@ func runRoute(args []string) { |
| } |
| } |
| } |
| + // The tunnel axis writes independently of the channel/environment switch |
| + // above — it has no bulk sibling to combine into their transactions (it |
| + // is always exactly one candidate; see the -app refusal above), so there |
| + // is nothing for it to be atomic WITH. |
| + if tunnelWillWrite && !*dryRun { |
| + if err := r.SetTunnel(candidates[0].Host, resolvedTunnel); err != nil { |
| + log.Fatalf("route: write: %v", err) |
| + } |
| + } |
| prefix := "" |
| if *dryRun { |
| @@ -642,6 +702,12 @@ func runRoute(args []string) { |
| } |
| return e |
| } |
| + tunnelLabel := func(t string) string { |
| + if t == "" { |
| + return "none" |
| + } |
| + return t |
| + } |
| for i := range candidates { |
| if chanVerdicts != nil { |
| v := chanVerdicts[i] |
| @@ -659,6 +725,13 @@ func runRoute(args []string) { |
| fmt.Printf("%s%s: environment %s -> %s\n", prefix, v.rt.Host, envLabel(v.from), envLabel(resolvedEnv)) |
| } |
| } |
| + if *tunnel != "" && i == 0 { |
| + if tunnelNoop { |
| + fmt.Printf("%s: tunnel already %s — nothing to do\n", candidates[0].Host, tunnelLabel(resolvedTunnel)) |
| + } else { |
| + fmt.Printf("%s%s: tunnel %s -> %s\n", prefix, candidates[0].Host, tunnelLabel(tunnelFrom), tunnelLabel(resolvedTunnel)) |
| + } |
| + } |
| } |
| } |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index d3431f19e..e04138c8a 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -68,6 +68,11 @@ type Route struct { |
| // that survivable rather than silent, but a column the reconciler |
| // rebuilds is not one this verb should be handing an operator. |
| Environment string |
| + // Tunnel is the name of the reverse channel backing this route at the |
| + // edge ("" = not tunnel-backed — routed to a Socket/Addr upstream as |
| + // today). Written only by SetTunnel / carlos route -tunnel; Upsert |
| + // preserves non-empty values — see Environment's comment for why. |
| + Tunnel string |
| Hibernate bool |
| Canary bool |
| @@ -179,6 +184,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 tunnel TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -344,7 +350,7 @@ func (r *Registry) Accounts() ([]Account, error) { |
| 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, tunnel, created_at` |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| @@ -353,7 +359,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.Tunnel, &t.CreatedAt) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -413,17 +419,18 @@ 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). |
| -// 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 |
| -// route on any re-add or re-mint, and the next config sync then rendered the |
| -// default bundle over that route's env file and restarted its unit. An |
| -// 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. |
| +// Upsert inserts or updates a route by host. Two columns are deliberately not |
| +// a plain overwrite: `environment` and `tunnel` keep the existing row's value |
| +// when the incoming Route carries the zero value (2026-08-02 review, |
| +// IMPORTANT 3, and the same rationale extended to Tunnel). Every full-row |
| +// Upsert caller — `carlos add` and the domains reconciler's two mint paths — |
| +// builds its Route without knowing anything about environment or tunnel |
| +// bindings, so a plain excluded.environment/excluded.tunnel silently unbound |
| +// a route on any re-add or re-mint, and (for environment) the next config |
| +// sync then rendered the default bundle over that route's env file and |
| +// restarted its unit. An explicit non-empty value still wins, and clearing |
| +// goes through SetEnvironment / SetTunnel (what `carlos route -environment -` |
| +// / `-tunnel -` call), which is the only place a clear is ever intended. |
| func (r *Registry) Upsert(t Route) error { |
| if t.CreatedAt == 0 { |
| t.CreatedAt = time.Now().Unix() |
| @@ -442,7 +449,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 +462,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, |
| + tunnel=CASE WHEN excluded.tunnel = '' THEN routes.tunnel ELSE excluded.tunnel 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.Tunnel, t.CreatedAt) |
| return err |
| } |
| @@ -562,6 +570,28 @@ func (r *Registry) SetEnvironment(host, env string) error { |
| return nil |
| } |
| +// SetTunnel repoints a route's reverse-channel binding as a narrow UPDATE — |
| +// same rationale and same "error on an unmatched host" contract as |
| +// SetEnvironment (a human types a hostname into `carlos route -tunnel` |
| +// directly, and a silent no-op on a typo would be worse than a refusal). |
| +// tunnel == "" clears the binding back to a Socket/Addr-routed upstream; the |
| +// CLI's "-tunnel -" sentinel resolves to that before calling here, so this |
| +// function itself has no notion of "clear" beyond writing "". |
| +func (r *Registry) SetTunnel(host, tunnel string) error { |
| + res, err := r.sql.Exec(`UPDATE routes SET tunnel = ? WHERE host = ?`, tunnel, 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 |
| +} |
| + |
| // SetEnvironments is SetEnvironment's bulk sibling, mirroring SetChannels: |
| // one transaction across every host in hosts, so `carlos route -app |
| // -environment` can never leave an app split across two environments the |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index aba9dc0b4..66aaac68d 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -778,3 +778,56 @@ func TestUpsertPreservesAnExistingEnvironmentBinding(t *testing.T) { |
| t.Fatalf("Environment = %q after an explicit clear, want it cleared", got.Environment) |
| } |
| } |
| + |
| +func TestTunnelColumnRoundTrips(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetTunnel("demo.example", "pi-box"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Tunnel != "pi-box" { |
| + t.Fatalf("Tunnel = %q, want pi-box", got.Tunnel) |
| + } |
| +} |
| + |
| +// Same rationale as TestUpsertPreservesAnExistingEnvironmentBinding: a |
| +// full-row Upsert caller that does not know about tunnel bindings must not |
| +// silently unbind one by re-writing the zero value. |
| +func TestUpsertPreservesTunnel(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + base := Route{Host: "demo.example", Kind: "instance", Status: "ready"} |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetTunnel("demo.example", "pi-box"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A full-row re-Upsert with zero-value Tunnel must not unbind. |
| + if err := reg.Upsert(base); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Tunnel != "pi-box" { |
| + t.Fatalf("Upsert clobbered Tunnel: %q", got.Tunnel) |
| + } |
| + // Clearing has exactly one door: SetTunnel(host, ""). |
| + if err := reg.SetTunnel("demo.example", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = reg.Get("demo.example") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Tunnel != "" { |
| + t.Fatalf("SetTunnel clear failed: %q", got.Tunnel) |
| + } |
| +} |