settings-3 task 3: the registry's update_settings column
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+98
−7
internal/registry/coldhandover.go+1 −1internal/registry/hold_test.go+1 −1internal/registry/registry.go+38 −5internal/registry/registry_test.go+1 −0internal/registry/remote_dispatch.go+3 −0internal/registry/updatesettings_test.go+54 −0
diff --git a/internal/registry/coldhandover.go b/internal/registry/coldhandover.go| index 4ca2b9292..4d82e992d 100644 |
| --- a/internal/registry/coldhandover.go |
| +++ b/internal/registry/coldhandover.go |
| @@ -135,7 +135,7 @@ func (r *Registry) RestoreClaimRows(ctx context.Context, id, retirement string, |
| return ErrClaimOwnership |
| } |
| t := next.Route |
| - values := []any{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, b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing, b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt, t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion} |
| + values := []any{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, b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing, b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt, t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings} |
| if _, err = tx.ExecContext(ctx, `INSERT INTO routes (`+routeCols+`) VALUES (`+strings.TrimSuffix(strings.Repeat("?,", len(values)), ",")+`)`, values...); err != nil { |
| return err |
| } |
diff --git a/internal/registry/hold_test.go b/internal/registry/hold_test.go| index 473a98762..496f9b2cb 100644 |
| --- a/internal/registry/hold_test.go |
| +++ b/internal/registry/hold_test.go |
| @@ -424,7 +424,7 @@ func TestFingerprintsUnchangedForUnheldRows(t *testing.T) { |
| t.Fatal(err) |
| } |
| const wantF = "2:f1013971d496e9401cd6a9928a1f4e18669deda40089d2c2a8b84263b32f34c6" |
| - const wantS = "2:149e8f668771c7493bdb7d4245fdabe03e957b0909c4daa1d037484b11064b80" |
| + const wantS = "2:178f6dc7e16d6e4226bf8f24acf88396dee2a2faf85b0306853d1c9ebf81a1b4" |
| if got, _ := u.Fingerprint(); got != wantF { |
| t.Fatalf("Fingerprint = %s, want %s", got, wantF) |
| } |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 091d2a804..a92bbcebe 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -292,6 +292,13 @@ type Route struct { |
| // different questions (SnapshotFingerprint's own doc comment). |
| Steering string |
| + // UpdateSettings is the instance's effective update settings |
| + // (release.UpdateSettings' canonical JSON, "" = today's adoption), |
| + // converged by provision.Reconcile from the record and the app policy. |
| + // Root reads it (plan 4). Deliberately NOT in Fingerprint(): it |
| + // changes what root installs, not what the edge serves. |
| + UpdateSettings string |
| + |
| // Replicas is the account's replica placements, comma-joined placement |
| // codes in canonical (sorted, deduplicated) order — the registry half of |
| // #162. "" on every row that predates the column = no replicas = |
| @@ -574,6 +581,7 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN good_digest TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN good_demand TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN serving_version TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN update_settings TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -851,7 +859,7 @@ replication, next_due, backing, status_named, platform, dns_status, |
| dns_checked_at, cert_expires_at, account, environment, caps, tunnel, |
| provisioned, seed, unit, fleet_box, addrs, health, steering, gate, created_at, |
| args, idle_seconds, max_run_seconds, replicas, replica, claim_incarnation, claim_stamp, claim_owner, claim_row_id, hold_op, |
| -good_version, good_digest, good_demand, serving_version` |
| +good_version, good_digest, good_demand, serving_version, update_settings` |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| @@ -863,7 +871,7 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| &t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.Unit, &t.FleetBox, |
| &t.Addrs, &t.Health, &t.Steering, &t.Gate, &t.CreatedAt, |
| &t.Args, &t.IdleSeconds, &t.MaxRunSeconds, &t.Replicas, &rep, &t.ClaimIncarnation, &t.ClaimStamp, &t.ClaimOwner, &t.ClaimRowID, &t.HoldOp, |
| - &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion) |
| + &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion, &t.UpdateSettings) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -1109,7 +1117,7 @@ func (r *Registry) Upsert(t Route) error { |
| } |
| res, 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, |
| @@ -1137,7 +1145,8 @@ ON CONFLICT(host) DO UPDATE SET |
| gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END, |
| args=CASE WHEN excluded.args = '' THEN routes.args ELSE excluded.args END, |
| idle_seconds=CASE WHEN excluded.idle_seconds = 0 THEN routes.idle_seconds ELSE excluded.idle_seconds END, |
| - max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END |
| + max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END, |
| + update_settings=CASE WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END |
| WHERE routes.claim_incarnation=excluded.claim_incarnation AND routes.claim_stamp=excluded.claim_stamp |
| AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.claim_row_id`, |
| t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, |
| @@ -1148,7 +1157,7 @@ AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.cla |
| b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, |
| t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, |
| t.Replicas, b2i(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, |
| - t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion) |
| + t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings) |
| if err != nil { |
| return err |
| } |
| @@ -1836,6 +1845,30 @@ func (r *Registry) SetSteering(host, steering string) error { |
| return nil |
| } |
| +// SetUpdateSettings repoints a route's effective update settings as a narrow |
| +// UPDATE: reconcile converges it from the record every pass, in both |
| +// directions, including down to "". Errors on an unmatched host, like |
| +// SetSteering. |
| +func (r *Registry) SetUpdateSettings(host, col string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetUpdateSettings", Host: host, UpdateSettings: col}, nil) |
| + return err |
| + } |
| + |
| + res, err := r.sql.Exec(`UPDATE routes SET update_settings = ? WHERE host = ?`, col, 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 |
| +} |
| + |
| // SetReplicas repoints a route's replica placement set (#162) as a narrow |
| // UPDATE: the provision pass converges this column every tick from the |
| // record, and the record's value is authoritative in BOTH directions — |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index b162bcc8b..f8ed1e8c1 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -83,6 +83,7 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) { |
| CreatedAt: 1700000000, |
| GoodVersion: "good789", GoodDigest: "sha256:good", GoodDemand: "swap-demand", |
| ServingVersion: "serve012", |
| + UpdateSettings: "", |
| } |
| if err := reg.Upsert(want); err != nil { |
| t.Fatal(err) |
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go| index fc085286a..9d84778b3 100644 |
| --- a/internal/registry/remote_dispatch.go |
| +++ b/internal/registry/remote_dispatch.go |
| @@ -52,6 +52,7 @@ type remoteRequest struct { |
| Addrs string |
| Health string |
| Steering string |
| + UpdateSettings string |
| Replicas string |
| Replica bool |
| Hash string |
| @@ -209,6 +210,8 @@ func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback |
| err = r.SetUpstreams(q.Host, q.Addr, q.Addrs, q.Health) |
| case "SetSteering": |
| err = r.SetSteering(q.Host, q.Steering) |
| + case "SetUpdateSettings": |
| + err = r.SetUpdateSettings(q.Host, q.UpdateSettings) |
| case "SetReplicas": |
| err = r.SetReplicas(q.Host, q.Replicas) |
| case "SetReplica": |
diff --git a/internal/registry/updatesettings_test.go b/internal/registry/updatesettings_test.go| new file mode 100644 |
| index 000000000..ec49b9a98 |
| --- /dev/null |
| +++ b/internal/registry/updatesettings_test.go |
| @@ -0,0 +1,54 @@ |
| +package registry |
| + |
| +import ( |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestUpdateSettingsColumn(t *testing.T) { |
| + reg, err := Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, _ := reg.Get("a.example.com") |
| + if rt.UpdateSettings != "" { |
| + t.Fatalf("a new row has settings %q", rt.UpdateSettings) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + col := `{"updates":"manual","channel":"stable"}` |
| + if err := reg.SetUpdateSettings("a.example.com", col); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get("a.example.com"); rt.UpdateSettings != col { |
| + t.Fatalf("column = %q", rt.UpdateSettings) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after != before { |
| + t.Fatal("update settings changed the edge's fingerprint") |
| + } |
| + // A full-row Upsert that does not carry the column keeps it. |
| + if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get("a.example.com"); rt.UpdateSettings != col { |
| + t.Fatalf("Upsert cleared the column: %q", rt.UpdateSettings) |
| + } |
| + if err := reg.SetUpdateSettings("a.example.com", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get("a.example.com"); rt.UpdateSettings != "" { |
| + t.Fatal("the narrow setter could not clear the column") |
| + } |
| + if err := reg.SetUpdateSettings("nobody.example.com", col); err == nil { |
| + t.Fatal("an unknown host was not an error") |
| + } |
| +} |