| index 9212d2ea6..088bc4796 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -390,3 +390,91 @@ func TestReconcileSkipsMalformedRecord(t *testing.T) { |
| t.Errorf("minted from a malformed record's body host: %+v %v", rt, err) |
| } |
| } |
| + |
| +// CRITICAL regression (code review finding): an existing route's record |
| +// going unparseable — not gone, just unreadable this pass — must never read |
| +// as "the bucket retired it". Before the fix, json.Unmarshal failure and |
| +// path-disagreement both log-and-continue without setting readFailed and |
| +// without the host entering recs, so the removal phase deleted the route. |
| +// A single non-additive change to Record's wire shape reaching an older |
| +// binary would make EVERY existing record fail exactly this way, turning |
| +// one tick into a mass fleet retirement. The fix (protect map, keyed on the |
| +// record's KEY-derived host) is deliberately narrower than readFailed: it |
| +// holds back only the specific corrupted host, so a genuinely retired |
| +// neighbor is still removed in the very same pass — proven below. |
| +func TestReconcileProtectsRouteWithCorruptedRecordBody(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); 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) |
| + } |
| + mustGet(t, reg, "acme.go.tito.io") // minted while the record was good |
| + |
| + // Corrupt the record body IN PLACE (a replace, not a delete) — the |
| + // shape a non-additive encoding change would produce on an old binary. |
| + putReplace(t, s, RecordKey(sqid, "tito", "acme.go.tito.io"), []byte("not json")) |
| + |
| + // A second, genuinely retired route: its record is deleted outright |
| + // (not corrupted), so it must still be removed in the SAME pass — this |
| + // is what proves protect is narrow, not a readFailed-style pass-wide |
| + // veto. |
| + ghost := registry.Route{Host: "ghost.go.tito.io", Kind: "instance", App: "tito", |
| + Status: "ready", Provisioned: true, Region: "ie", Pool: "tito", Account: sqid} |
| + if err := reg.Upsert(ghost); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "acme.go.tito.io") // protected: an unparseable record is not "gone" |
| + if rt, err := reg.Get("ghost.go.tito.io"); err != nil || rt != nil { |
| + t.Errorf("a genuinely record-less route was not removed alongside a protected one: %+v %v", rt, err) |
| + } |
| +} |
| + |
| +// IMPORTANT regression (code review finding): two accounts holding a record |
| +// for the same host must not resolve by silent last-writer-wins map |
| +// overwrite (the losing tenant's record would vanish from the pass with no |
| +// diagnostic). First-seen (bucket listing order) wins; the loser is skipped |
| +// loudly and produces no second route, no crash. |
| +func TestReconcileDuplicateHostAcrossAccountsFirstSeenWins(t *testing.T) { |
| + s := dirStore(t) |
| + sqid1 := writeAccountManifest(t, s, 2, "Tito") |
| + sqid2 := writeAccountManifest(t, s, 3, "Other") |
| + rec1 := Record{Schema: 1, Host: "dup.go.tito.io", Slug: "dup", App: "tito", |
| + Account: sqid1, Pool: "tito", Region: "ie", Channel: "stable", CreatedAt: time.Now()} |
| + rec2 := Record{Schema: 1, Host: "dup.go.tito.io", Slug: "dup", App: "tito", |
| + Account: sqid2, Pool: "tito", Region: "ie", Channel: "stable", CreatedAt: time.Now()} |
| + if err := CreateRecord(ctx, s, rec1); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := CreateRecord(ctx, s, rec2); 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) |
| + } |
| + |
| + // The winner is whichever record's KEY sorts first — that is the order |
| + // s.List("apps/") actually hands the reconcile loop, so this must be |
| + // derived, never assumed. |
| + key1, key2 := RecordKey(sqid1, "tito", rec1.Host), RecordKey(sqid2, "tito", rec2.Host) |
| + winner := sqid1 |
| + if key2 < key1 { |
| + winner = sqid2 |
| + } |
| + got := mustGet(t, reg, "dup.go.tito.io") |
| + if got.Account != winner { |
| + t.Errorf("duplicate host resolved to account %q, want first-seen %q", got.Account, winner) |
| + } |
| + if routes, err := reg.List(); err != nil || len(routes) != 1 { |
| + t.Errorf("duplicate host produced %d routes, want exactly 1: %+v %v", len(routes), routes, err) |
| + } |
| +} |