host,registry: snapshot fingerprint covers all columns; DR-visibility minors (final review)
registrySnapshotPass gated on registry.Fingerprint, which deliberately hashes only edge-reload-relevant columns — caps, unit, replication, environment, account, canary and provisioned are excluded. Those are exactly the box-scoped rows nothing but the snapshot can rebuild, so a caps grant or a console unit stamp never triggered a snapshot at all. Adds registry.SnapshotFingerprint: fresh connection, hashes every column of routes (SELECT rowid, routeCols — a future column is covered with no fourth place to update) plus accounts, fleets and fleet_boxes, each under its own section marker. Hashing the VACUUM INTO page image instead was rejected as nondeterministic. The pass and the loop's seed now use it; the sidecar's fingerprint field carries this digest, which is safe because it is only ever compared against itself. Also: the pass logs loudly when it overwrites a sidecar written by a different box (the cold-standby clobber the pinfra runbook's new Step 0 prevents — a rehearsal will see the line); the generation-collision comment now says plainly that a clock step-back with a registry change leaves the retained generation holding OLDER content than current + sidecar, losing one intermediate rung of the tail rather than the restore target; and enable_auto_recovery is documented in the region-host variable table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+416
−10
infra/modules/README.md+1 −0internal/host/snapshot_loop.go+57 −10internal/host/snapshot_loop_test.go+101 −0internal/registry/registry.go+134 −0internal/registry/registry_test.go+123 −0
diff --git a/infra/modules/README.md b/infra/modules/README.md| index 857b58d23..8d43b6a22 100644 |
| --- a/infra/modules/README.md |
| +++ b/infra/modules/README.md |
| @@ -67,6 +67,7 @@ bucket, so a wrong-region wake fails loudly at the IAM layer. |
| | `ssm_parameter_arn` | SSM parameter ARN pattern the host may read, e.g. `arn:aws:ssm:<region>:<account>:parameter/carlos/*`. | |
| | `ami_name_pattern` | AMI name filter (default `debian-13-arm64-*`). | |
| | `ami_owner` | AMI owner account (default `136693071363`, Debian). | |
| +| `enable_auto_recovery` | Arm the EC2 auto-recovery alarm (HA ladder rung 0) — a system-status-check failure recovers the instance onto healthy hardware, same instance id, EIP and volumes. Default `true`; set `false` only where an external supervisor owns recovery. | |
| Outputs: `eip_public_ip` (point your DNS here), `instance_id`, |
| `security_group_id` (for sibling services that admit fleet traffic |
diff --git a/internal/host/snapshot_loop.go b/internal/host/snapshot_loop.go| index 486ca01df..f09390829 100644 |
| --- a/internal/host/snapshot_loop.go |
| +++ b/internal/host/snapshot_loop.go |
| @@ -28,6 +28,14 @@ const ( |
| snapshotStamp = "20060102T150405Z" |
| ) |
| +// snapshotMeta describes the object at snapshotKey. `Box` is the writing |
| +// box's label (per-instance hashed machine id) — a restore must check it |
| +// names the DEAD box, not the standby, or it is restoring the standby's own |
| +// empty registry. `Fingerprint` carries registry.SnapshotFingerprint (ALL |
| +// columns), not registry.Fingerprint (edge-reload columns only): it is only |
| +// ever compared against itself — the loop seeds lastFP from it and skips an |
| +// unchanged registry — so no reader outside this file depends on which |
| +// digest it is. |
| type snapshotMeta struct { |
| V int `json:"v"` |
| Box string `json:"box"` |
| @@ -37,11 +45,17 @@ type snapshotMeta struct { |
| } |
| // registrySnapshotPass snapshots the registry into the bucket when its |
| -// fingerprint moved since *lastFP. One pass; the loop owns cadence. *lastFP |
| -// is updated only after every write (including pruning) succeeded, so a |
| -// partial failure is retried whole next tick. |
| +// snapshot fingerprint moved since *lastFP. One pass; the loop owns cadence. |
| +// *lastFP is updated only after every write (including pruning) succeeded, so |
| +// a partial failure is retried whole next tick. |
| +// |
| +// The gate is SnapshotFingerprint, NOT Fingerprint: Fingerprint deliberately |
| +// omits caps, unit, replication, environment, account, canary and |
| +// provisioned, which are precisely the box-scoped columns only this snapshot |
| +// can rebuild — gating on it meant a caps grant or a console unit stamp never |
| +// produced a snapshot at all. |
| func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Registry, box string, now time.Time, lastFP *string) error { |
| - fp, err := reg.Fingerprint() |
| + fp, err := reg.SnapshotFingerprint() |
| if err != nil { |
| return fmt.Errorf("snapshot: fingerprint: %w", err) |
| } |
| @@ -65,14 +79,35 @@ func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Re |
| // Generation first (create-only — a same-second re-run or a clock |
| // step-back can collide on the stamp, in which case Put's ErrCASConflict |
| - // is deliberately swallowed: the existing generation for that second is |
| - // as good as the one we'd have written, so there is nothing to retry), |
| - // then current + sidecar (replace with the CAS-retry recipe |
| + // is deliberately swallowed and the retained generation is KEPT rather |
| + // than replaced). Be precise about what that costs: after a clock |
| + // step-back WITH a registry change in between, the retained generation |
| + // holds OLDER content while the current key and its sidecar hold the |
| + // newer — the two are not equivalent, and we are choosing to keep the |
| + // older one under that stamp. What is lost is one intermediate rung of |
| + // the generation tail, never the restore target itself: current + sidecar |
| + // are written unconditionally below, so a restore still gets the newest |
| + // registry. Retrying under a bumped stamp would buy a slightly denser |
| + // tail at the cost of a write loop against a lying clock. |
| + // |
| + // Then current + sidecar (replace with the CAS-retry recipe |
| // usage.UpsertEdgeDay uses), then prune. |
| genKey := snapshotGenPrefix + now.UTC().Format(snapshotStamp) + ".db" |
| if _, err := s.Put(ctx, genKey, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| return fmt.Errorf("snapshot: put %s: %w", genKey, err) |
| } |
| + // Overwriting a sidecar another box wrote is the cold-standby clobber |
| + // (runbook step 0): a freshly bootstrapped standby whose agent starts |
| + // before the operator restores will replace the dead box's snapshot with |
| + // its own empty registry, and every self-consistency check downstream |
| + // (bytes, created_at) still passes. Nothing here can safely decide whose |
| + // registry is authoritative — a legitimate promotion looks identical — |
| + // so this does not refuse the write; it makes the moment loud, in the |
| + // agent log, where a rehearsal will see it. |
| + if prior := readSnapshotMeta(ctx, s); prior != nil && prior.Box != "" && prior.Box != box { |
| + log.Printf("agent: registry snapshot: OVERWRITING a snapshot written by box %q (this box is %q, prior created_at %s) — if this box is a standby that has not been restored yet, STOP and preserve the prior snapshot", |
| + prior.Box, box, prior.CreatedAt.Format(time.RFC3339)) |
| + } |
| if err := putReplacing(ctx, s, snapshotKey, body); err != nil { |
| return err |
| } |
| @@ -111,15 +146,27 @@ func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Re |
| // "": the loop then snapshots on its first tick, the same behavior a box |
| // that has never snapshotted before gets. This must never fail the loop. |
| func seedSnapshotFingerprint(ctx context.Context, s release.Store) string { |
| + meta := readSnapshotMeta(ctx, s) |
| + if meta == nil { |
| + return "" |
| + } |
| + return meta.Fingerprint |
| +} |
| + |
| +// readSnapshotMeta returns the sidecar currently in the bucket, or nil when |
| +// there is none, it cannot be read, or it does not parse. Advisory only: both |
| +// callers treat nil as "no prior snapshot" and carry on, so a store outage |
| +// must never fail a pass. |
| +func readSnapshotMeta(ctx context.Context, s release.Store) *snapshotMeta { |
| raw, _, err := s.Get(ctx, snapshotMetaKey) |
| if err != nil { |
| - return "" |
| + return nil |
| } |
| var meta snapshotMeta |
| if err := json.Unmarshal(raw, &meta); err != nil { |
| - return "" |
| + return nil |
| } |
| - return meta.Fingerprint |
| + return &meta |
| } |
| // registrySnapshotLoop snapshots the registry to the deployment bucket every |
diff --git a/internal/host/snapshot_loop_test.go b/internal/host/snapshot_loop_test.go| index e212e0bd9..f93f87dd9 100644 |
| --- a/internal/host/snapshot_loop_test.go |
| +++ b/internal/host/snapshot_loop_test.go |
| @@ -102,6 +102,107 @@ func TestSnapshotPassSkipsWhenFingerprintUnchanged(t *testing.T) { |
| if len(gens) != 2 { |
| t.Fatalf("changed registry did not write a new generation: %v", gens) |
| } |
| + |
| + // A caps grant touches no edge-reload column, so registry.Fingerprint |
| + // sits still for it — which is why this pass gates on |
| + // SnapshotFingerprint instead. Caps and unit stamps are exactly the |
| + // box-scoped rows only the snapshot can rebuild; if they did not trigger |
| + // a pass, the standby would restore a registry missing them. |
| + if err := reg.SetCaps("a.example.com", "", "net_bind_service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(15*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + gens, err = s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != 3 { |
| + t.Fatalf("caps-only change did not snapshot: generations = %v (want 3) — a caps grant would never reach the bucket", gens) |
| + } |
| + |
| + // Same for a console unit stamp. |
| + if err := reg.SetUnit("a.example.com", "console.service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(20*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + gens, err = s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != 4 { |
| + t.Fatalf("unit-only change did not snapshot: generations = %v (want 4)", gens) |
| + } |
| +} |
| + |
| +// The sidecar's fingerprint field must be the digest the pass actually gates |
| +// on, or seedSnapshotFingerprint would seed lastFP with a value the next |
| +// pass can never match and every restart would re-snapshot. |
| +func TestSnapshotSidecarCarriesSnapshotFingerprint(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := snapTestRegistry(t) |
| + |
| + var lastFP string |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", time.Date(2026, 8, 12, 3, 0, 0, 0, time.UTC), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + want, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + metaRaw, _, err := s.Get(ctx, snapshotMetaKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var meta snapshotMeta |
| + if err := json.Unmarshal(metaRaw, &meta); err != nil { |
| + t.Fatal(err) |
| + } |
| + if meta.Fingerprint != want { |
| + t.Fatalf("sidecar fingerprint = %q, want SnapshotFingerprint %q", meta.Fingerprint, want) |
| + } |
| +} |
| + |
| +// readSnapshotMeta is what makes the foreign-box overwrite loud (the |
| +// cold-standby clobber the runbook's step 0 exists to prevent). It must |
| +// report the prior writer's box, and must degrade to nil rather than |
| +// erroring when there is no sidecar at all. |
| +func TestReadSnapshotMetaReportsPriorWriter(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := snapTestRegistry(t) |
| + at := time.Date(2026, 8, 12, 3, 0, 0, 0, time.UTC) |
| + |
| + if got := readSnapshotMeta(ctx, s); got != nil { |
| + t.Fatalf("readSnapshotMeta with no sidecar = %+v, want nil", got) |
| + } |
| + |
| + var lastFP string |
| + if err := registrySnapshotPass(ctx, s, reg, "dead-box", at, &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := readSnapshotMeta(ctx, s) |
| + if got == nil || got.Box != "dead-box" { |
| + t.Fatalf("readSnapshotMeta = %+v, want Box \"dead-box\"", got) |
| + } |
| + |
| + // The standby case: a different box overwrites. The pass does NOT refuse |
| + // it (a legitimate promotion is indistinguishable) — it logs — so the |
| + // write must still land, now naming the standby. |
| + if err := reg.Upsert(registry.Route{Host: "c.example.com", Kind: "service", Addr: "10.0.0.3:3000", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := registrySnapshotPass(ctx, s, reg, "standby-box", at.Add(5*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + got = readSnapshotMeta(ctx, s) |
| + if got == nil || got.Box != "standby-box" { |
| + t.Fatalf("after a standby write readSnapshotMeta = %+v, want Box \"standby-box\"", got) |
| + } |
| } |
| func TestSnapshotPassPrunesGenerationsBeyondKeep(t *testing.T) { |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index e62115a8a..0277d5247 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1297,6 +1297,140 @@ FROM fleet_boxes ORDER BY fleet, label`) |
| return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil |
| } |
| +// SnapshotFingerprint summarizes the registry for the SNAPSHOT pass, where |
| +// Fingerprint summarizes it for the edge's reload poll. The two answer |
| +// different questions and must not be conflated: |
| +// |
| +// Fingerprint hashes only the columns a reload cares about, and deliberately |
| +// EXCLUDES caps, unit, replication, environment, account, canary and |
| +// provisioned — they change who restarts a process or what a tenant may do, |
| +// not what the edge serves. That exclusion is correct for reloads and wrong |
| +// for snapshots: those box-scoped columns are exactly the rows nothing else |
| +// in the bucket can rebuild, so a cold-standby restore that misses them |
| +// silently drops caps grants and console unit stamps. Gating the snapshot on |
| +// Fingerprint meant a caps grant or a unit stamp never triggered a write at |
| +// all. So SnapshotFingerprint hashes EVERY column of routes, plus accounts, |
| +// fleets and fleet_boxes — anything the restore has to reproduce. |
| +// |
| +// The routes query is `SELECT rowid, ` + routeCols, so a column added to |
| +// routeCols is covered here automatically (no fourth place to update); the |
| +// values are scanned generically and written in column order, which is why |
| +// this needs no per-column typing the way Fingerprint does. |
| +// |
| +// Hashing the VACUUM INTO output instead was rejected: SQLite's page image |
| +// is not byte-deterministic across runs of identical content (freelist and |
| +// page-ordering effects), so it would report a change on every pass and |
| +// snapshot forever. |
| +// |
| +// Fresh connection, same reason as Fingerprint and List: a long-lived |
| +// modernc connection can serve a stale WAL snapshot of another process's |
| +// commits. |
| +func (r *Registry) SnapshotFingerprint() (string, error) { |
| + db, err := sql.Open("sqlite", r.path) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer db.Close() |
| + if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil { |
| + return "", err |
| + } |
| + |
| + rows, err := db.Query(`SELECT rowid, ` + routeCols + ` FROM routes ORDER BY rowid`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer rows.Close() |
| + cols, err := rows.Columns() |
| + if err != nil { |
| + return "", err |
| + } |
| + h := sha256.New() |
| + n := 0 |
| + vals := make([]sql.NullString, len(cols)) |
| + dest := make([]any, len(cols)) |
| + for i := range vals { |
| + dest[i] = &vals[i] |
| + } |
| + for rows.Next() { |
| + if err := rows.Scan(dest...); err != nil { |
| + return "", err |
| + } |
| + for _, v := range vals { |
| + // NULL and "" must not hash alike: a column that has never been |
| + // written is a different restore than one explicitly cleared. |
| + if v.Valid { |
| + fmt.Fprintf(h, "%s\x1f", v.String) |
| + } else { |
| + fmt.Fprint(h, "\x00\x1f") |
| + } |
| + } |
| + fmt.Fprint(h, "\x1e") |
| + n++ |
| + } |
| + if err := rows.Err(); err != nil { |
| + return "", err |
| + } |
| + |
| + // Section markers, same discipline as Fingerprint: a value can never |
| + // migrate across sections and cancel another change out. |
| + fmt.Fprint(h, "\x1daccounts\x1e") |
| + arows, err := db.Query(`SELECT id, sqid, name FROM accounts ORDER BY id`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer arows.Close() |
| + for arows.Next() { |
| + var id int64 |
| + var sqid, name string |
| + if err := arows.Scan(&id, &sqid, &name); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1e", id, sqid, name) |
| + } |
| + if err := arows.Err(); err != nil { |
| + return "", err |
| + } |
| + |
| + fmt.Fprint(h, "\x1dfleets\x1e") |
| + frows, err := db.Query(`SELECT name, account, bucket, rev FROM fleets ORDER BY name`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer frows.Close() |
| + for frows.Next() { |
| + var name, account, bucket string |
| + var rev int64 |
| + if err := frows.Scan(&name, &account, &bucket, &rev); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%d\x1e", name, account, bucket, rev) |
| + } |
| + if err := frows.Err(); err != nil { |
| + return "", err |
| + } |
| + |
| + fmt.Fprint(h, "\x1dfleet_boxes\x1e") |
| + brows, err := db.Query(`SELECT fleet, label, token_hash, status, rev |
| +FROM fleet_boxes ORDER BY fleet, label`) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer brows.Close() |
| + for brows.Next() { |
| + var fleet, label, tokenHash, status string |
| + var rev int64 |
| + if err := brows.Scan(&fleet, &label, &tokenHash, &status, &rev); err != nil { |
| + return "", err |
| + } |
| + fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1f%d\x1e", fleet, label, tokenHash, status, rev) |
| + } |
| + if err := brows.Err(); err != nil { |
| + return "", err |
| + } |
| + |
| + return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil |
| +} |
| + |
| // SnapshotTo writes a consistent single-file copy of the registry to path |
| // via VACUUM INTO on a fresh connection — same fresh-connection recipe as |
| // Fingerprint (modernc WAL visibility). path must not already exist: |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index c537e3415..4fa70f2fb 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1626,6 +1626,129 @@ func TestFingerprintIgnoresAUnitStamp(t *testing.T) { |
| } |
| } |
| +// The other half of TestFingerprintIgnoresAGrant and |
| +// TestFingerprintIgnoresAUnitStamp: what the edge is right to ignore, the |
| +// cold-standby snapshot must NOT ignore. caps and unit are box-scoped rows |
| +// nothing else in the bucket can rebuild, so if SnapshotFingerprint sat |
| +// still for them the snapshot pass would never fire and a restore would come |
| +// back without them. These two tests pin the divergence in both directions |
| +// at once — Fingerprint still still, SnapshotFingerprint moved. |
| +func TestSnapshotFingerprintSeesACapsGrant(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{ |
| + Host: "mail.example.com", Kind: "instance", App: "mail", |
| + Addr: "127.0.0.1:8091", Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + beforeSnap, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + beforeEdge, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetCaps("mail.example.com", "", "net_bind_service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + afterSnap, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + afterEdge, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if afterSnap == beforeSnap { |
| + t.Fatalf("SnapshotFingerprint unchanged across a caps grant (%s) — the snapshot pass would never fire, and a cold-standby restore would silently drop the grant", beforeSnap) |
| + } |
| + if afterEdge != beforeEdge { |
| + t.Fatalf("Fingerprint moved on a caps grant (%s -> %s) — the edge-reload digest must stay indifferent to it", beforeEdge, afterEdge) |
| + } |
| +} |
| + |
| +func TestSnapshotFingerprintSeesAUnitStamp(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{ |
| + Host: "console.example.com", Kind: "instance", App: "console", |
| + Addr: "127.0.0.1:8090", Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + beforeSnap, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + beforeEdge, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetUnit("console.example.com", "console.service"); err != nil { |
| + t.Fatal(err) |
| + } |
| + afterSnap, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + afterEdge, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if afterSnap == beforeSnap { |
| + t.Fatalf("SnapshotFingerprint unchanged across a unit stamp (%s) — a restored console route would come back unstamped and stop auto-cycling", beforeSnap) |
| + } |
| + if afterEdge != beforeEdge { |
| + t.Fatalf("Fingerprint moved on a unit stamp (%s -> %s)", beforeEdge, afterEdge) |
| + } |
| +} |
| + |
| +// Determinism: repeated calls over an unchanged registry must agree, or the |
| +// snapshot pass writes a generation every 5 minutes forever. (This is the |
| +// property that ruled out hashing the VACUUM INTO page image.) |
| +func TestSnapshotFingerprintStableAcrossCalls(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{ |
| + Host: "a.example.com", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + first, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := 0; i < 3; i++ { |
| + again, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if again != first { |
| + t.Fatalf("SnapshotFingerprint call %d = %s, want %s — an unstable digest snapshots on every pass", i+2, again, first) |
| + } |
| + } |
| +} |
| + |
| +// An account rename has no route column behind it, and only the snapshot can |
| +// restore the accounts table — so it has to move the snapshot digest even |
| +// though no route changed at all. |
| +func TestSnapshotFingerprintSeesAnAccountWrite(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + before, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := reg.sql.Exec(`INSERT INTO accounts (id, sqid, name) VALUES (?, ?, ?)`, 4242, "zzz", "later"); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after == before { |
| + t.Fatalf("SnapshotFingerprint unchanged across an accounts write (%s)", before) |
| + } |
| +} |
| + |
| func TestValidTunnelNameLengthBoundary(t *testing.T) { |
| ok32 := "01234567890123456789012345678901" // 32 chars |
| if len(ok32) != 32 { |