carlos / platform Public

registry: backing column — exec child vs carlos-app@ unit tenants

Additive migration; '' stays the exec default so existing rows are
untouched. Fingerprint covers backing so a flip reloads the edge (the
activator's backend choice rides the route captured at reload).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9addab95dd9d337a338e943de35c2ed015f9b4a8 parent 17bcc6a
2 files changed, +40 −7
  • internal/registry/registry.go +22 −6
  • internal/registry/registry_test.go +18 −1
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index d6ab09c33..6c572811a 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -50,9 +50,20 @@ type Route struct {
Replication string // "" | "stream" | "batched" | "daily"
NextDue int64
+ // Backing is who owns the instance's process: "" = an exec child of the
+ // agent (the activator spawns live/<host> itself — the Eleven shape), or
+ // BackingUnit = a carlos-app@<host> systemd unit (whole-app tenants:
+ // DynamicUser, StateDirectory) that the activator drives via systemctl.
+ Backing string
+
CreatedAt int64
}
+// BackingUnit marks a route whose process is a carlos-app@<host> systemd unit
+// rather than an exec child of the agent. The empty string stays the exec
+// default so pre-existing rows keep today's behavior byte-identical.
+const BackingUnit = "unit"
+
func Open(path string) (*Registry, error) {
d, err := sql.Open("sqlite", path)
if err != nil {
@@ -111,6 +122,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN canary INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN replication TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN next_due INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE routes ADD COLUMN backing TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -121,14 +133,15 @@ CREATE TABLE IF NOT EXISTS routes (
const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
version, prev_version, app, pool, region, channel, hibernate, canary,
-replication, next_due, created_at`
+replication, next_due, backing, created_at`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
var hib, can int
err := scan(&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, &hib, &can, &t.Replication, &t.NextDue, &t.CreatedAt)
+ &t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing,
+ &t.CreatedAt)
t.Hibernate = hib != 0
t.Canary = can != 0
return t, err
@@ -205,7 +218,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,
@@ -213,10 +226,12 @@ ON CONFLICT(host) DO UPDATE SET
prev_version=excluded.prev_version, app=excluded.app, pool=excluded.pool,
region=excluded.region, channel=excluded.channel,
hibernate=excluded.hibernate, canary=excluded.canary,
- replication=excluded.replication, next_due=excluded.next_due`,
+ replication=excluded.replication, next_due=excluded.next_due,
+ backing=excluded.backing`,
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.CreatedAt)
+ b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing,
+ t.CreatedAt)
return err
}
@@ -282,7 +297,8 @@ func (r *Registry) Fingerprint() (string, error) {
err = db.QueryRow(`
SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' ||
COALESCE(SUM(rowid*17 + LENGTH(status)*3 + LENGTH(socket) + LENGTH(addr)
- + LENGTH(version)*5 + hibernate*7 + LENGTH(channel)),0)
+ + LENGTH(version)*5 + hibernate*7 + LENGTH(channel)
+ + LENGTH(backing)*11),0)
FROM routes`).Scan(&fp)
return fp, err
}
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index 90dbd84c2..3ac555555 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -63,7 +63,7 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) {
DBPath: "/data/b.db", Version: "abc1234", PrevVersion: "def5678",
App: "kass", Pool: "shared", Region: "ie", Channel: "stable",
Hibernate: true, Canary: true, Replication: "batched",
- NextDue: 4200, CreatedAt: 1700000000,
+ NextDue: 4200, Backing: BackingUnit, CreatedAt: 1700000000,
}
if err := reg.Upsert(want); err != nil {
t.Fatal(err)
@@ -149,6 +149,23 @@ func TestFingerprintChangesOnWrite(t *testing.T) {
if final == after {
t.Fatal("fingerprint blind to a cross-handle column write")
}
+ // Backing flips must reload the edge too: a woken host's backend choice
+ // (exec child vs carlos-app@ unit) rides the route the reload captured.
+ rt, err := reg.Get("c.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rt.Backing = BackingUnit
+ if err := reg.Upsert(*rt); err != nil {
+ t.Fatal(err)
+ }
+ withBacking, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if withBacking == final {
+ t.Fatal("fingerprint blind to a backing flip")
+ }
}
func TestSetVersionsAndHibernateAreNarrow(t *testing.T) {