edge asset cache task 4: record fields, route columns and convergence
Record gains Assets (*bool) and AssetGen with AssetsOn and NewAssetGen. The registry gains assets_off and asset_gen route columns, SetAssets, and Fingerprint coverage (folded in only when set). RouteFor carries both and convergeOwned converges them as one pair.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
7 files changed,
+443
−10
internal/provision/provision.go+33 −0internal/provision/provision_test.go+52 −1internal/provision/reconcile.go+16 −0internal/provision/reconcile_test.go+98 −0internal/registry/registry.go+93 −9internal/registry/registry_test.go+147 −0internal/registry/remote_dispatch.go+4 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index a5657b0cb..7fa7a23be 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -8,6 +8,7 @@ package provision |
| import ( |
| "context" |
| + "crypto/rand" |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| @@ -156,6 +157,20 @@ type Record struct { |
| // even then only once a deployment arms CARLOS_STEERING_ZONE_ID. |
| Steering string `json:"steering,omitempty"` |
| + // --- asset cache fields (2026-10-24, edge asset cache design 3.2, 6) --- |
| + |
| + // Assets is the instance's asset-cache opt-out: absent means "follow |
| + // the release", false means "catch nothing for this instance". A |
| + // POINTER for Hibernate's reason: this column is CONVERGED, and absent |
| + // must not read as an explicit false. AssetsOn is the accessor. |
| + Assets *bool `json:"assets,omitempty"` |
| + // AssetGen is the instance's asset generation: 16 lowercase hex from |
| + // NewAssetGen, drawn at create and redrawn by a purge. Every edge cache |
| + // key carries it, so a new one orphans the old entries at once. Empty |
| + // (a record from before the field) publishes nothing. Converged onto |
| + // the route row beside Assets. |
| + AssetGen string `json:"asset_gen,omitempty"` |
| + |
| // Replica is NOT a separate record field: Role carries the role |
| // vocabulary alone (ValidRole's door), and every reader derives the |
| // boolean from it. A second spelling of the same fact is two chances |
| @@ -462,6 +477,24 @@ func (r Record) StatusNamedOrDefault() bool { |
| return false |
| } |
| +// AssetsOn reports whether this instance follows the release's asset |
| +// declaration: true unless Assets is present and false. |
| +func (r Record) AssetsOn() bool { |
| + return r.Assets == nil || *r.Assets |
| +} |
| + |
| +// NewAssetGen draws an asset generation: 64 random bits as 16 lowercase hex. |
| +// Random rather than a counter, so a hostname deleted and recreated can never |
| +// land back on a generation an edge still holds entries for (design section |
| +// 6). |
| +func NewAssetGen() (string, error) { |
| + var b [8]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + return "", fmt.Errorf("draw asset generation: %w", err) |
| + } |
| + return hex.EncodeToString(b[:]), nil |
| +} |
| + |
| // ValidKind pins the record's route-class vocabulary to the set `carlos |
| // add` documents. "status" is deliberately absent: the fleet status page's |
| // route is the platform's own, not an app's, and nothing should be able to |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 596506be4..f3e8300d2 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| + "regexp" |
| "strings" |
| "testing" |
| "time" |
| @@ -49,6 +50,34 @@ func TestRecordWireShapeIsAdditive(t *testing.T) { |
| if rec.Hibernate != nil { |
| t.Errorf("Hibernate = %v, want nil — absent and false are different facts here", rec.Hibernate) |
| } |
| + if rec.Assets != nil { |
| + t.Errorf("Assets = %v, want nil — absent and false are different facts here", rec.Assets) |
| + } |
| + if rec.AssetGen != "" { |
| + t.Errorf("AssetGen = %q, want empty — a record from before the field names no generation", rec.AssetGen) |
| + } |
| + if !rec.AssetsOn() { |
| + t.Error("AssetsOn() = false, want true — absent means follow the release") |
| + } |
| + off := false |
| + if (Record{Assets: &off}).AssetsOn() { |
| + t.Error("AssetsOn() = true for an explicit false, want false") |
| + } |
| + on := true |
| + if !(Record{Assets: &on}).AssetsOn() { |
| + t.Error("AssetsOn() = false for an explicit true, want true") |
| + } |
| + // A legacy record round-tripped through the new struct marshals without |
| + // either new key. |
| + body, err := json.Marshal(rec) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range []string{"assets", "asset_gen"} { |
| + if strings.Contains(string(body), `"`+key+`"`) { |
| + t.Errorf("legacy record marshals %q when it was never set: %s", key, body) |
| + } |
| + } |
| if got := rec.KindOrDefault(); got != "instance" { |
| t.Errorf("KindOrDefault() = %q, want instance", got) |
| } |
| @@ -72,7 +101,7 @@ func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named", "steering"} { |
| + for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named", "steering", "assets", "asset_gen"} { |
| if strings.Contains(string(body), `"`+key+`"`) { |
| t.Errorf("marshalled record carries %q when it was never set: %s", key, body) |
| } |
| @@ -1420,3 +1449,25 @@ func TestListRecordsStrictFailsOnAnyReadFailure(t *testing.T) { |
| t.Fatalf("ListRecordsStrict over a failed listing = %+v, %v, %v; want an error", recs, unreadable, err) |
| } |
| } |
| + |
| +// TestNewAssetGenShape pins the asset generation token (edge asset cache |
| +// design section 6): 64 random bits as 16 lowercase hex, a fresh one per call. |
| +func TestNewAssetGenShape(t *testing.T) { |
| + re := regexp.MustCompile(`^[0-9a-f]{16}$`) |
| + a, err := NewAssetGen() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := NewAssetGen() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, g := range []string{a, b} { |
| + if !re.MatchString(g) { |
| + t.Errorf("NewAssetGen() = %q, want 16 lowercase hex", g) |
| + } |
| + } |
| + if a == b { |
| + t.Errorf("two NewAssetGen() calls returned %q", a) |
| + } |
| +} |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 31945adc1..35d871fac 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -440,6 +440,8 @@ func RouteFor(rec Record, dataDir, seed string, nested bool, policy *UpdatePolic |
| Channel: rec.Channel, Environment: rec.Environment, |
| Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamedOrDefault(), |
| Steering: rec.Steering, |
| + AssetsOff: !rec.AssetsOn(), |
| + AssetGen: rec.AssetGen, |
| Replicas: replicaSet(rec.Replicas), |
| Replica: rec.Role == "replica", |
| Gate: rec.Gate, |
| @@ -760,6 +762,20 @@ func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, |
| log.Printf("provision: converged %s steering %q -> %q", rec.Host, existing.Steering, rec.Steering) |
| } |
| } |
| + // The asset opt-out and generation converge as one pair, both ways, |
| + // from the record: SetAssets writes the two columns in one UPDATE, so |
| + // the row never carries a new generation under the old opt-out. |
| + if want := !rec.AssetsOn(); existing.AssetsOff != want || existing.AssetGen != rec.AssetGen { |
| + if dryRun { |
| + log.Printf("provision: dry-run: would converge %s assets off %v -> %v, generation %q -> %q", |
| + rec.Host, existing.AssetsOff, want, existing.AssetGen, rec.AssetGen) |
| + } else if err := reg.SetAssets(rec.Host, want, rec.AssetGen); err != nil { |
| + log.Printf("provision: converge assets on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s assets off %v -> %v, generation %q -> %q", |
| + rec.Host, existing.AssetsOff, want, existing.AssetGen, rec.AssetGen) |
| + } |
| + } |
| // Replicas CONVERGES, where steering merely repoints: the bucket's value |
| // is authoritative in BOTH directions, including down to none — a set |
| // that shrank must actually shrink on the row, or the edges file goes on |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index 6c148437d..53e01a789 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -3680,3 +3680,101 @@ func TestListAllRecordsStrictKeepsFailingClosed(t *testing.T) { |
| t.Fatalf("a record that will not Get = %+v, %v, %v; want an error and nothing else", recs, unreadable, err) |
| } |
| } |
| + |
| +// TestRouteForCarriesAssets: the mint renders the record's opt-out and |
| +// generation onto the route. An absent Assets follows the release (AssetsOff |
| +// false); only an explicit false switches the route off. |
| +func TestRouteForCarriesAssets(t *testing.T) { |
| + off := false |
| + on := true |
| + rec := Record{Host: "shop.example.com", App: "web", Account: "abc", Channel: "stable", Assets: &off, AssetGen: "0123456789abcdef"} |
| + got := RouteFor(rec, "/data", "seed", false, nil) |
| + if !got.AssetsOff || got.AssetGen != "0123456789abcdef" { |
| + t.Fatalf("AssetsOff = %v, AssetGen = %q, want true and the record's token", got.AssetsOff, got.AssetGen) |
| + } |
| + for name, a := range map[string]*bool{"absent": nil, "true": &on} { |
| + rec := Record{Host: "shop.example.com", App: "web", Account: "abc", Channel: "stable", Assets: a} |
| + got := RouteFor(rec, "/data", "seed", false, nil) |
| + if got.AssetsOff || got.AssetGen != "" { |
| + t.Errorf("%s: AssetsOff = %v, AssetGen = %q, want false and empty", name, got.AssetsOff, got.AssetGen) |
| + } |
| + } |
| +} |
| + |
| +// TestReconcileConvergesAssets: a record whose AssetGen or opt-out changes |
| +// after mint converges onto the owned row within one pass through |
| +// convergeOwned, touching only the two asset columns, in both directions. |
| +func TestReconcileConvergesAssets(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + if before.AssetsOff || before.AssetGen != "" { |
| + t.Fatalf("minted AssetsOff = %v, AssetGen = %q, want false and empty", before.AssetsOff, before.AssetGen) |
| + } |
| + |
| + rec.AssetGen = "0123456789abcdef" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.AssetGen != "0123456789abcdef" || after.AssetsOff { |
| + t.Errorf("AssetGen = %q, AssetsOff = %v, want the new token and false", after.AssetGen, after.AssetsOff) |
| + } |
| + after.AssetGen = before.AssetGen |
| + if after != before { |
| + t.Errorf("converging the asset generation touched another column:\n before %+v\n after %+v", before, after) |
| + } |
| + |
| + // A purge: a fresh token replaces the old one. |
| + rec.AssetGen = "fedcba9876543210" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").AssetGen; got != "fedcba9876543210" { |
| + t.Errorf("AssetGen = %q after a purge, want the fresh token", got) |
| + } |
| + |
| + // The opt-out reaches the row, and clearing it reaches the row too. |
| + off := false |
| + rec.Assets = &off |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io"); !got.AssetsOff || got.AssetGen != "fedcba9876543210" { |
| + t.Errorf("AssetsOff = %v, AssetGen = %q, want true and the token kept", got.AssetsOff, got.AssetGen) |
| + } |
| + rec.Assets = nil |
| + rec.AssetGen = "" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io"); got.AssetsOff || got.AssetGen != "" { |
| + t.Errorf("AssetsOff = %v, AssetGen = %q, want both cleared", got.AssetsOff, got.AssetGen) |
| + } |
| +} |
| + |
| +// TestShapeMismatchIgnoresAssets: the asset columns are record-owned, so a |
| +// hand-added route that disagrees on them is still adopted, and |
| +// convergeOwned stamps the record's values on afterwards. |
| +func TestShapeMismatchIgnoresAssets(t *testing.T) { |
| + a := registry.Route{Kind: "instance", AssetsOff: true, AssetGen: "0123456789abcdef"} |
| + b := registry.Route{Kind: "instance"} |
| + if shapeMismatch(a, b) != "" { |
| + t.Fatalf("differing asset columns blocked adoption: %q, want a match", shapeMismatch(a, b)) |
| + } |
| + if shapeMismatch(b, a) != "" { |
| + t.Fatalf("differing asset columns blocked adoption: %q, want a match", shapeMismatch(b, a)) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 4447cc982..ffa2cf304 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -87,6 +87,23 @@ type Route struct { |
| // In Fingerprint (the edge must reload on a change), folded only when |
| // non-empty. |
| ImportGate string |
| + // AssetsOff is the instance's asset-cache opt-out (edge asset cache |
| + // design section 3.2): true means the home box publishes no asset |
| + // prefixes for this host. Inverted from the record's Assets *bool so |
| + // the zero value is the default, "follow the release". Converged from |
| + // provision.Record.Assets by RouteFor and convergeOwned, together with |
| + // AssetGen through SetAssets. IN Fingerprint (unlike Steering): it |
| + // changes what the home box publishes, so the edge must reload on it, |
| + // folded in only when set so a registry that never used it digests as |
| + // it did before the column existed. |
| + AssetsOff bool |
| + // AssetGen is the instance's asset generation, 16 lowercase hex |
| + // (provision.NewAssetGen), "" for a record from before the field. A |
| + // purge draws a fresh one; every cache key under the old one stops |
| + // matching. Empty publishes nothing. Converged from |
| + // provision.Record.AssetGen. IN Fingerprint, folded in only when set, |
| + // for AssetsOff's reason. |
| + AssetGen string |
| LastError string |
| DBPath string |
| @@ -609,6 +626,8 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN build_state TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN seed_source TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN import_gate TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN assets_off INTEGER NOT NULL DEFAULT 0`, |
| + `ALTER TABLE routes ADD COLUMN asset_gen TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -897,10 +916,10 @@ 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, update_settings, build_state, |
| -seed_source, import_gate` |
| +seed_source, import_gate, assets_off, asset_gen` |
| // routeColCount is the number of names in routeCols. |
| -const routeColCount = 55 |
| +const routeColCount = 57 |
| // placeholders is n comma-joined bind parameters. |
| func placeholders(n int) string { |
| @@ -909,7 +928,7 @@ func placeholders(n int) string { |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| - var hib, can, sn, plat, prov, rep int |
| + var hib, can, sn, plat, prov, rep, assetsOff 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.Backing, |
| @@ -918,7 +937,8 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| &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.UpdateSettings, &t.BuildState, |
| - &t.SeedSource, &t.ImportGate) |
| + &t.SeedSource, &t.ImportGate, &assetsOff, &t.AssetGen) |
| + t.AssetsOff = assetsOff != 0 |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -1118,6 +1138,10 @@ func (r *Registry) Delete(host string) error { |
| // steering housekeeper duty (Task 6) is armed would silently clear a route's |
| // Route53 latency records — a real incident this repo caught in review before |
| // it shipped, not a hypothetical. |
| +// `assets_off` and `asset_gen` travel as one pair: an incoming literal that |
| +// names a generation replaces both, one that names none (a bare re-add that |
| +// knows nothing of assets) keeps both, so it cannot silently switch an |
| +// opted-out instance back on. SetAssets is the door that can clear them. |
| // `update_settings` and `build_state` keep an existing value only while the |
| // row's tenant (effective account, app) stays the same. An Upsert that moves |
| // the hostname to another tenant takes the incoming values, empty included: |
| @@ -1208,7 +1232,9 @@ ON CONFLICT(host) DO UPDATE SET |
| WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END, |
| build_state=CASE WHEN `+tenantChangedSQL+` THEN excluded.build_state |
| WHEN excluded.build_state = '' THEN routes.build_state ELSE excluded.build_state END, |
| - seed_source=CASE WHEN excluded.seed_source = '' THEN routes.seed_source ELSE excluded.seed_source END |
| + seed_source=CASE WHEN excluded.seed_source = '' THEN routes.seed_source ELSE excluded.seed_source END, |
| + assets_off=CASE WHEN excluded.asset_gen = '' THEN routes.assets_off ELSE excluded.assets_off END, |
| + asset_gen=CASE WHEN excluded.asset_gen = '' THEN routes.asset_gen ELSE excluded.asset_gen 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`, |
| routeArgs(normalizeRoute(t))...) |
| @@ -1269,7 +1295,7 @@ func routeArgs(t Route) []any { |
| 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, t.BuildState, |
| - t.SeedSource, t.ImportGate, |
| + t.SeedSource, t.ImportGate, b2iDB(t.AssetsOff), t.AssetGen, |
| } |
| } |
| @@ -1969,6 +1995,32 @@ func (r *Registry) SetSteering(host, steering string) error { |
| return nil |
| } |
| +// SetAssets converges a route's asset-cache columns as one narrow UPDATE: |
| +// off is the instance opt-out (true = publish no prefixes), gen the asset |
| +// generation ("" for none). They are one fact from one record, so they move |
| +// together, in both directions, including down to false and "". Value |
| +// validation lives at the provision door (NewAssetGen mints the only shape). |
| +// Errors on an unmatched host, like SetSteering. |
| +func (r *Registry) SetAssets(host string, off bool, gen string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetAssets", Host: host, AssetsOff: off, AssetGen: gen}, nil) |
| + return err |
| + } |
| + |
| + res, err := r.sql.Exec(`UPDATE routes SET assets_off = ?, asset_gen = ? WHERE host = ?`, b2iDB(off), gen, 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 |
| +} |
| + |
| // 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 |
| @@ -3009,6 +3061,11 @@ func (r *Registry) DeletePath(host, prefix string) error { |
| // "box-a"/"box-b"), which is precisely the blind spot hashing the values |
| // closes. |
| // |
| +// `assets_off` and `asset_gen` (edge asset cache design sections 3.2 and 6) |
| +// ARE hashed, folded in only when set: unlike steering they change what the |
| +// home box publishes to the edge, and an unset registry's digest is |
| +// byte-identical to the one before the columns existed. |
| +// |
| // `steering` (multi-region ingress design Part 4) is deliberately NOT hashed |
| // here — see Route.Steering's own doc comment for the exclusion, stated in |
| // the same place `unit`'s exclusion is (registry.go:93's rule, restated): |
| @@ -3069,7 +3126,8 @@ func (r *Registry) Fingerprint() (string, error) { |
| rows, err := db.Query(` |
| SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel, |
| db_path, region, fleet_box, addrs, health, gate, hold_op, import_gate, |
| - claim_incarnation, claim_stamp, claim_owner, claim_row_id |
| + claim_incarnation, claim_stamp, claim_owner, claim_row_id, |
| + assets_off, asset_gen |
| FROM routes ORDER BY rowid`) |
| if err != nil { |
| return "", err |
| @@ -3078,12 +3136,14 @@ FROM routes ORDER BY rowid`) |
| h := sha256.New() |
| n := 0 |
| for rows.Next() { |
| - var rowid, hibernate int64 |
| + var rowid, hibernate, assetsOff int64 |
| var claim ClaimOwnership |
| + var assetGen string |
| var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health, gate, holdOp, importGate string |
| if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate, |
| &channel, &backing, &tunnel, &dbPath, ®ion, &fleetBox, &addrs, &health, &gate, &holdOp, &importGate, |
| - &claim.ClaimIncarnation, &claim.ClaimStamp, &claim.ClaimOwner, &claim.ClaimRowID); err != nil { |
| + &claim.ClaimIncarnation, &claim.ClaimStamp, &claim.ClaimOwner, &claim.ClaimRowID, |
| + &assetsOff, &assetGen); err != nil { |
| return "", err |
| } |
| fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s", |
| @@ -3102,6 +3162,15 @@ FROM routes ORDER BY rowid`) |
| if importGate != "" { |
| fmt.Fprintf(h, "\x1fimport_gate=%q", importGate) |
| } |
| + // The asset columns follow the same rule: the edge must reload when |
| + // the opt-out or the generation changes, and a registry that never |
| + // set either digests as it did before the columns existed. |
| + if assetsOff != 0 { |
| + fmt.Fprint(h, "\x1fassets_off=1") |
| + } |
| + if assetGen != "" { |
| + fmt.Fprintf(h, "\x1fasset_gen=%q", assetGen) |
| + } |
| // Ownership follows the same rule: the all-default value is omitted, |
| // and each non-default field is tagged and quoted so a partial value |
| // cannot alias another field's. |
| @@ -3374,6 +3443,21 @@ func (r *Registry) SnapshotFingerprint() (string, error) { |
| } |
| continue |
| } |
| + // assets_off and asset_gen follow the same rule: a registry that |
| + // never set either digests exactly as it did before the columns |
| + // existed, so upgrading does not force a snapshot on every box. |
| + if cols[i] == "assets_off" { |
| + if v.Valid && v.String != "" && v.String != "0" { |
| + fmt.Fprintf(h, "assets_off=%s\x1f", v.String) |
| + } |
| + continue |
| + } |
| + if cols[i] == "asset_gen" { |
| + if v.Valid && v.String != "" { |
| + fmt.Fprintf(h, "asset_gen=%q\x1f", v.String) |
| + } |
| + continue |
| + } |
| // Claim ownership columns follow the hold_op rule: default |
| // values are omitted, anything else is tagged and quoted. |
| if snapshotClaimCols[cols[i]] { |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index ab3b7b1fd..5ec2d1103 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -87,6 +87,8 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) { |
| BuildState: `{"known":true,"state":{"schema":1,"last_executed":"bs345"}}`, |
| SeedSource: `{"id":"sd_aaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"replica","state":"ready"}`, |
| ImportGate: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", |
| + AssetsOff: true, |
| + AssetGen: "0123456789abcdef", |
| } |
| if err := reg.Upsert(want); err != nil { |
| t.Fatal(err) |
| @@ -2949,3 +2951,148 @@ func TestGoodBuildMovesTheSnapshotFingerprint(t *testing.T) { |
| t.Fatal("a new good build did not move the snapshot fingerprint") |
| } |
| } |
| + |
| +func TestSetAssetsRoundTrips(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetAssets("shop.example.com", true, "0123456789abcdef"); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("shop.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.AssetsOff || got.AssetGen != "0123456789abcdef" { |
| + t.Fatalf("AssetsOff = %v, AssetGen = %q, want true and the token", got.AssetsOff, got.AssetGen) |
| + } |
| + // Both columns move together and clear together. |
| + if err := reg.SetAssets("shop.example.com", false, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err = reg.Get("shop.example.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.AssetsOff || got.AssetGen != "" { |
| + t.Fatalf("AssetsOff = %v, AssetGen = %q after clearing, want false and empty", got.AssetsOff, got.AssetGen) |
| + } |
| + if err := reg.SetAssets("nope.example.com", true, "x"); err == nil { |
| + t.Fatal("SetAssets on an unmatched host: want error, got nil") |
| + } |
| +} |
| + |
| +// TestUpsertKeepsAssetColumnsWhenIncomingNamesNone: an Upsert whose literal |
| +// carries no asset generation (a bare `carlos add` over an existing host) |
| +// must not clear the opt-out or the generation. |
| +func TestUpsertKeepsAssetColumnsWhenIncomingNamesNone(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready", AssetsOff: true, AssetGen: "0123456789abcdef"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := reg.Get("shop.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.AssetsOff || got.AssetGen != "0123456789abcdef" { |
| + t.Fatalf("AssetsOff = %v, AssetGen = %q, want the stored values kept", got.AssetsOff, got.AssetGen) |
| + } |
| + // A literal that does name a generation replaces both. |
| + if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready", AssetGen: "fedcba9876543210"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err = reg.Get("shop.example.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.AssetsOff || got.AssetGen != "fedcba9876543210" { |
| + t.Fatalf("AssetsOff = %v, AssetGen = %q, want false and the new token", got.AssetsOff, got.AssetGen) |
| + } |
| +} |
| + |
| +// TestFingerprintSeesAssetColumns: unlike steering, the asset opt-out and |
| +// generation change what the edge publishes and serves, so each moves the |
| +// reload fingerprint. A registry that never set either digests exactly as it |
| +// did before the columns existed. |
| +func TestFingerprintSeesAssetColumns(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp := func() string { |
| + t.Helper() |
| + v, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return v |
| + } |
| + base := fp() |
| + if err := reg.SetAssets("shop.example.com", false, "0123456789abcdef"); err != nil { |
| + t.Fatal(err) |
| + } |
| + withGen := fp() |
| + if withGen == base { |
| + t.Fatal("Fingerprint did not move on an asset generation change") |
| + } |
| + if err := reg.SetAssets("shop.example.com", false, "fedcba9876543210"); err != nil { |
| + t.Fatal(err) |
| + } |
| + withOther := fp() |
| + if withOther == withGen { |
| + t.Fatal("Fingerprint did not move on a changed asset generation") |
| + } |
| + if err := reg.SetAssets("shop.example.com", true, "fedcba9876543210"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp() == withOther { |
| + t.Fatal("Fingerprint did not move on an opt-out change") |
| + } |
| + if err := reg.SetAssets("shop.example.com", false, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := fp(); got != base { |
| + t.Fatalf("Fingerprint after clearing = %s, want the original %s: unset columns must not change the digest", got, base) |
| + } |
| +} |
| + |
| +// TestSnapshotFingerprintSeesAssetColumns: the restore surface reproduces the |
| +// asset columns, and a registry that never set them digests as before. |
| +func TestSnapshotFingerprintSeesAssetColumns(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + base, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetAssets("shop.example.com", true, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + off, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if off == base { |
| + t.Fatal("SnapshotFingerprint did not move on an opt-out") |
| + } |
| + if err := reg.SetAssets("shop.example.com", false, "0123456789abcdef"); err != nil { |
| + t.Fatal(err) |
| + } |
| + gen, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if gen == base || gen == off { |
| + t.Fatal("SnapshotFingerprint did not move on an asset generation") |
| + } |
| + if err := reg.SetAssets("shop.example.com", false, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := reg.SnapshotFingerprint(); got != base { |
| + t.Fatal("SnapshotFingerprint after clearing differs from the original") |
| + } |
| +} |
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go| index 23adccb5e..4750f4254 100644 |
| --- a/internal/registry/remote_dispatch.go |
| +++ b/internal/registry/remote_dispatch.go |
| @@ -55,6 +55,8 @@ type remoteRequest struct { |
| Addrs string |
| Health string |
| Steering string |
| + AssetsOff bool |
| + AssetGen string |
| UpdateSettings string |
| BuildState string |
| Replicas string |
| @@ -241,6 +243,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 "SetAssets": |
| + err = r.SetAssets(q.Host, q.AssetsOff, q.AssetGen) |
| case "SetUpdateSettings": |
| err = r.SetUpdateSettings(q.Host, q.UpdateSettings) |
| case "SetBuildState": |