Merge pull request #156 from carlosframework/ha-snapshot
host: registry snapshot to bucket + auto-recovery alarm (HA rung 0/1)
9 files changed,
+867
−0
infra/modules/README.md+1 −0infra/modules/region-host/main.tf+22 −0infra/modules/region-host/variables.tf+6 −0internal/host/agent.go+1 −0internal/host/snapshot_loop.go+222 −0internal/host/snapshot_loop_test.go+280 −0internal/registry/registry.go+159 −0internal/registry/registry_test.go+123 −0internal/registry/snapshot_test.go+53 −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/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf| index fb939d063..1f5187efe 100644 |
| --- a/infra/modules/region-host/main.tf |
| +++ b/infra/modules/region-host/main.tf |
| @@ -9,6 +9,8 @@ |
| # not include this region's bucket — a wrong-region wake fails loudly at |
| # the IAM layer. |
| +data "aws_region" "current" {} |
| + |
| data "aws_ami" "debian_arm64" { |
| most_recent = true |
| owners = [var.ami_owner] |
| @@ -200,3 +202,23 @@ resource "aws_eip" "host" { |
| instance = aws_instance.host.id |
| tags = { Name = var.name } |
| } |
| + |
| +# HA ladder rung 0 (2026-08-12 spec): system-status failure recovers the |
| +# instance onto healthy hardware, keeping instance id, EIP and volumes. |
| +resource "aws_cloudwatch_metric_alarm" "host_auto_recover" { |
| + count = var.enable_auto_recovery ? 1 : 0 |
| + alarm_name = "${var.name}-auto-recover" |
| + alarm_description = "EC2 system-status-check failure — auto-recovers ${var.name} onto healthy hardware." |
| + namespace = "AWS/EC2" |
| + metric_name = "StatusCheckFailed_System" |
| + statistic = "Maximum" |
| + period = 60 |
| + evaluation_periods = 2 |
| + threshold = 1 |
| + comparison_operator = "GreaterThanOrEqualToThreshold" |
| + alarm_actions = ["arn:aws:automate:${data.aws_region.current.region}:ec2:recover"] |
| + dimensions = { |
| + InstanceId = aws_instance.host.id |
| + } |
| + tags = { Name = "${var.name}-auto-recover" } |
| +} |
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf| index 1570628d8..23e1156e1 100644 |
| --- a/infra/modules/region-host/variables.tf |
| +++ b/infra/modules/region-host/variables.tf |
| @@ -77,6 +77,12 @@ variable "route53_zone_ids" { |
| } |
| } |
| +variable "enable_auto_recovery" { |
| + description = "Arm an EC2 auto-recovery alarm on the host (HA ladder rung 0)." |
| + type = bool |
| + default = true |
| +} |
| + |
| variable "enable_cost_explorer" { |
| description = "Grant ce:GetCostAndUsage so a console on this box can pull the monthly bill for the economics dashboard" |
| type = bool |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index df18b9cb1..09c483b4f 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -304,6 +304,7 @@ func RunAgent(args []string) { |
| go domainsLoop(reg, peekAcmeCache(args)) |
| go instancesLoop(reg) |
| go retentionLoop(reg) |
| + go registrySnapshotLoop(reg) |
| // The sweep reads the registry only through this closure — the stamped |
| // bespoke units to sample beside the carlos- ones, re-read each tick. |
| go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) }) |
diff --git a/internal/host/snapshot_loop.go b/internal/host/snapshot_loop.go| new file mode 100644 |
| index 000000000..f09390829 |
| --- /dev/null |
| +++ b/internal/host/snapshot_loop.go |
| @@ -0,0 +1,222 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "math/rand/v2" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The cold-standby spec (2026-08-12 HA posture, Part I rung 1): the current |
| +// snapshot at a fixed key a runbook can name, a sidecar describing it, and a |
| +// short tail of prior generations. All under control/ beside control/serial; |
| +// platform boxes write direct S3, so no store-door ACL is involved. |
| +const ( |
| + snapshotKey = "control/registry-snapshot.db" |
| + snapshotMetaKey = "control/registry-snapshot.json" |
| + snapshotGenPrefix = "control/registry-snapshots/" |
| + snapshotKeep = 8 |
| + 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"` |
| + Fingerprint string `json:"fingerprint"` |
| + CreatedAt time.Time `json:"created_at"` |
| + Bytes int `json:"bytes"` |
| +} |
| + |
| +// registrySnapshotPass snapshots the registry into the bucket when its |
| +// 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.SnapshotFingerprint() |
| + if err != nil { |
| + return fmt.Errorf("snapshot: fingerprint: %w", err) |
| + } |
| + if fp == *lastFP { |
| + return nil |
| + } |
| + |
| + dir, err := os.MkdirTemp("", "carlos-registry-snapshot") |
| + if err != nil { |
| + return fmt.Errorf("snapshot: %w", err) |
| + } |
| + defer os.RemoveAll(dir) |
| + file := filepath.Join(dir, "registry.db") |
| + if err := reg.SnapshotTo(file); err != nil { |
| + return fmt.Errorf("snapshot: vacuum: %w", err) |
| + } |
| + body, err := os.ReadFile(file) |
| + if err != nil { |
| + return fmt.Errorf("snapshot: %w", err) |
| + } |
| + |
| + // 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 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 |
| + } |
| + meta := snapshotMeta{V: 1, Box: box, Fingerprint: fp, CreatedAt: now.UTC(), Bytes: len(body)} |
| + metaRaw, err := json.Marshal(meta) |
| + if err != nil { |
| + return fmt.Errorf("snapshot: %w", err) |
| + } |
| + if err := putReplacing(ctx, s, snapshotMetaKey, metaRaw); err != nil { |
| + return err |
| + } |
| + |
| + gens, err := s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + return fmt.Errorf("snapshot: list generations: %w", err) |
| + } |
| + sort.Strings(gens) // stamps are lexically chronological |
| + for len(gens) > snapshotKeep { |
| + if err := s.Delete(ctx, gens[0]); err != nil { |
| + return fmt.Errorf("snapshot: prune %s: %w", gens[0], err) |
| + } |
| + gens = gens[1:] |
| + } |
| + |
| + *lastFP = fp |
| + return nil |
| +} |
| + |
| +// seedSnapshotFingerprint reads the sidecar a previous run (or a previous |
| +// process on this same box) left at snapshotMetaKey and returns its |
| +// fingerprint, so a freshly started loop's in-memory lastFP starts where the |
| +// bucket already is rather than empty. Without this, a crash-looping agent |
| +// would re-snapshot an unchanged registry on every restart and burn through |
| +// all snapshotKeep generations with identical copies. Any read or parse |
| +// failure — no sidecar yet, a corrupt object, a store that is down — returns |
| +// "": 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 nil |
| + } |
| + var meta snapshotMeta |
| + if err := json.Unmarshal(raw, &meta); err != nil { |
| + return nil |
| + } |
| + return &meta |
| +} |
| + |
| +// registrySnapshotLoop snapshots the registry to the deployment bucket every |
| +// 5 minutes + jitter when it changed — rung 1 of the HA ladder (cold |
| +// standby restores this file). Platform-box job, retentionLoop's rules: |
| +// skipped on fleet boxes, disabled with a log line when there is no bucket. |
| +// The fixed control/ keys this writes assume one platform box per deployment |
| +// bucket (true for every deployment today); a second concurrent writer would |
| +// interleave current/sidecar writes from two registries. |
| +func registrySnapshotLoop(reg *registry.Registry) { |
| + ctx := context.Background() |
| + if shouldSkipPublisherElection(os.Getenv) { |
| + log.Printf("agent: registry snapshot loop is a platform-box job — skipped on a fleet box") |
| + return |
| + } |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Printf("agent: registry snapshot loop disabled: %v", err) |
| + return |
| + } |
| + box := metricsBoxLabel(os.Getenv, readMachineID()) |
| + log.Printf("agent: registry snapshot loop armed (5min + jitter, %s)", snapshotKey) |
| + lastFP := seedSnapshotFingerprint(ctx, store) |
| + delay := time.Minute |
| + for { |
| + time.Sleep(delay) |
| + delay = 5*time.Minute + rand.N(time.Minute) |
| + if err := registrySnapshotPass(ctx, store, reg, box, time.Now(), &lastFP); err != nil { |
| + log.Printf("agent: registry snapshot pass: %v", err) |
| + } |
| + } |
| +} |
| + |
| +// putReplacing writes key regardless of prior state: create-only when |
| +// absent, etag-replace when present, re-reading on a lost race. Three |
| +// attempts, like usage.UpsertEdgeDay. |
| +func putReplacing(ctx context.Context, s release.Store, key string, body []byte) error { |
| + for attempt := 0; attempt < 3; attempt++ { |
| + _, etag, err := s.Get(ctx, key) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + case err != nil: |
| + return fmt.Errorf("snapshot: get %s: %w", key, err) |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err == nil { |
| + return nil |
| + } else if !errors.Is(err, release.ErrCASConflict) { |
| + return fmt.Errorf("snapshot: put %s: %w", key, err) |
| + } |
| + } |
| + return fmt.Errorf("snapshot: %s: lost CAS race three times", key) |
| +} |
diff --git a/internal/host/snapshot_loop_test.go b/internal/host/snapshot_loop_test.go| new file mode 100644 |
| index 000000000..f93f87dd9 |
| --- /dev/null |
| +++ b/internal/host/snapshot_loop_test.go |
| @@ -0,0 +1,280 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func snapTestRegistry(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + return reg |
| +} |
| + |
| +func TestSnapshotPassWritesCurrentSidecarAndGeneration(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) |
| + |
| + var lastFP string |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at, &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + raw, _, err := s.Get(ctx, snapshotKey) |
| + if err != nil { |
| + t.Fatalf("current snapshot missing: %v", err) |
| + } |
| + if len(raw) == 0 { |
| + t.Fatal("current snapshot is empty") |
| + } |
| + |
| + metaRaw, _, err := s.Get(ctx, snapshotMetaKey) |
| + if err != nil { |
| + t.Fatalf("sidecar missing: %v", err) |
| + } |
| + var meta snapshotMeta |
| + if err := json.Unmarshal(metaRaw, &meta); err != nil { |
| + t.Fatal(err) |
| + } |
| + if meta.V != 1 || meta.Box != "box1" || meta.Fingerprint == "" || meta.Bytes != len(raw) { |
| + t.Fatalf("sidecar = %+v (snapshot %d bytes)", meta, len(raw)) |
| + } |
| + if lastFP != meta.Fingerprint { |
| + t.Fatalf("lastFP %q not updated to %q", lastFP, meta.Fingerprint) |
| + } |
| + |
| + gens, err := s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != 1 || gens[0] != snapshotGenPrefix+"20260812T030000Z.db" { |
| + t.Fatalf("generations = %v", gens) |
| + } |
| +} |
| + |
| +func TestSnapshotPassSkipsWhenFingerprintUnchanged(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) |
| + |
| + var lastFP string |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at, &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(5*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + gens, err := s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != 1 { |
| + t.Fatalf("unchanged registry grew generations: %v", gens) |
| + } |
| + |
| + // A registry change makes the next pass write again. |
| + if err := reg.Upsert(registry.Route{Host: "b.example.com", Kind: "service", Addr: "10.0.0.2:3000", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(10*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + gens, err = s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + 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) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := snapTestRegistry(t) |
| + at := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC) |
| + |
| + var lastFP string |
| + for i := 0; i < snapshotKeep+3; i++ { |
| + // Change the registry each round so no pass skips. |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Version: string(rune('a' + i))}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(time.Duration(i)*5*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + gens, err := s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != snapshotKeep { |
| + t.Fatalf("kept %d generations, want %d: %v", len(gens), snapshotKeep, gens) |
| + } |
| + // The survivors must be the newest ones: the earliest kept stamp is round 3's. |
| + if gens[0] != snapshotGenPrefix+"20260812T001500Z.db" { |
| + t.Fatalf("oldest kept generation = %q, want the round-3 stamp", gens[0]) |
| + } |
| +} |
| + |
| +func TestSeedSnapshotFingerprintEmptyWhenNoSidecar(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + if got := seedSnapshotFingerprint(context.Background(), s); got != "" { |
| + t.Fatalf("seedSnapshotFingerprint = %q, want empty with no sidecar written yet", got) |
| + } |
| +} |
| + |
| +// TestSeedSnapshotFingerprintMatchingSidecarSkipsFirstPass proves the |
| +// registrySnapshotLoop wiring this backs: reading lastFP from the bucket |
| +// sidecar at loop start (rather than starting it empty in memory) makes the |
| +// very first pass after a restart a no-op when the registry has not changed |
| +// since the last snapshot — the crash-loop case addition 1 of the Task 2 |
| +// review exists to fix. |
| +func TestSeedSnapshotFingerprintMatchingSidecarSkipsFirstPass(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) |
| + |
| + // A prior process ran a pass and wrote the sidecar, then exited (or |
| + // crashed) — its in-memory lastFP is gone. |
| + var priorFP string |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at, &priorFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // A fresh process seeds lastFP from the bucket instead of starting empty. |
| + seeded := seedSnapshotFingerprint(ctx, s) |
| + if seeded == "" || seeded != priorFP { |
| + t.Fatalf("seeded fingerprint = %q, want %q", seeded, priorFP) |
| + } |
| + |
| + lastFP := seeded |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(5*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + gens, err := s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != 1 { |
| + t.Fatalf("seeded lastFP did not skip the unchanged registry: generations = %v (want 1, no new write)", gens) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index d051d5195..14b7be821 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -13,6 +13,7 @@ import ( |
| "crypto/sha256" |
| "database/sql" |
| "fmt" |
| + "os" |
| "regexp" |
| "strings" |
| "time" |
| @@ -1670,3 +1671,161 @@ 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: |
| +// modernc's VACUUM INTO silently overwrites an existing file rather than |
| +// refusing it (unlike upstream SQLite's CLI), so we check first — callers |
| +// stage into a temp dir. The bound-parameter form (not fmt.Sprintf/%q) is |
| +// required: %q is Go string escaping, not SQL escaping, so a path |
| +// containing a backslash gets VACUUM INTO writing to a different on-disk |
| +// path than the one os.Stat just checked, silently defeating the guard. |
| +func (r *Registry) SnapshotTo(path string) error { |
| + if _, err := os.Stat(path); err == nil { |
| + return fmt.Errorf("registry: snapshot target already exists: %s", path) |
| + } else if !os.IsNotExist(err) { |
| + return err |
| + } |
| + db, err := sql.Open("sqlite", r.path) |
| + if err != nil { |
| + return err |
| + } |
| + defer db.Close() |
| + _, err = db.Exec("VACUUM INTO ?", path) |
| + return err |
| +} |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index b2b9fe8a6..fdef66c1a 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1740,6 +1740,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 { |
diff --git a/internal/registry/snapshot_test.go b/internal/registry/snapshot_test.go| new file mode 100644 |
| index 000000000..65d73c1e2 |
| --- /dev/null |
| +++ b/internal/registry/snapshot_test.go |
| @@ -0,0 +1,53 @@ |
| +package registry |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestSnapshotToProducesOpenableCopy(t *testing.T) { |
| + dir := t.TempDir() |
| + reg, err := Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + if err := reg.Upsert(Route{Host: "a.example.com", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + out := filepath.Join(dir, "snap.db") |
| + if err := reg.SnapshotTo(out); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + copyReg, err := Open(out) |
| + if err != nil { |
| + t.Fatalf("open snapshot: %v", err) |
| + } |
| + defer copyReg.Close() |
| + rt, err := copyReg.Get("a.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt.Addr != "10.0.0.1:3000" { |
| + t.Fatalf("snapshot route addr = %q, want 10.0.0.1:3000", rt.Addr) |
| + } |
| +} |
| + |
| +func TestSnapshotToRefusesExistingTarget(t *testing.T) { |
| + dir := t.TempDir() |
| + reg, err := Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + out := filepath.Join(dir, "snap.db") |
| + if err := os.WriteFile(out, []byte("x"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SnapshotTo(out); err == nil { |
| + t.Fatal("SnapshotTo over an existing file: want error, got nil") |
| + } |
| +} |