carlos / platform Public

registry: additive Tunnel column, preserved on Upsert, set via carlos route -tunnel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bbb07339589d62950a422224f686a26163a1f498 parent 1f6193f
3 files changed, +177 −21
  • cli.go +78 −5
  • internal/registry/registry.go +46 −16
  • internal/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)
+ }
+}