| index 95bcf388f..d94fc7378 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -85,6 +85,15 @@ func putReplace(t *testing.T, s release.Store, key string, body []byte) { |
| } |
| } |
| |
| +func mustJSON(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| // setup writes a valid account manifest (id acctID) and that account's |
| // "tito" app instances manifest (domain go.tito.io, pool tito, seed |
| // ["seed"]) — the fixture every test case below builds records on top of. |
| @@ -289,6 +298,122 @@ func TestReconcileConvergesSeed(t *testing.T) { |
| } |
| } |
| |
| +// Issue #130, as it happened in production on 2026-08-09: a record created |
| +// on stable, deleted and recreated on edge, and the route row left on |
| +// stable — a route pointing at a channel with no pointer, so every wake |
| +// failed "no adoptable head" while carlos channels cheerfully showed edge |
| +// promoted. Reconcile created from a record and never converged to one. |
| +func TestReconcileConvergesChannel(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") |
| + |
| + rec.Channel = "edge" |
| + 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.Channel != "edge" { |
| + t.Errorf("channel = %q, want edge — the record moved and the row did not", after.Channel) |
| + } |
| + after.Channel = before.Channel |
| + if after != before { |
| + t.Errorf("converge touched more than Channel: before %+v after %+v", before, after) |
| + } |
| + |
| + // The fleet runs on canary/* channels today (pinned in |
| + // internal/release/laddercheck_test.go); a converge that could not carry |
| + // one would stop deploys. |
| + rec.Channel = "canary/rehearsal" |
| + 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").Channel; got != "canary/rehearsal" { |
| + t.Errorf("channel = %q, want canary/rehearsal", got) |
| + } |
| +} |
| + |
| +func TestReconcileConvergesEnvironment(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) |
| + } |
| + |
| + rec.Environment = "staging" |
| + 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").Environment; got != "staging" { |
| + t.Errorf("environment = %q, want staging", got) |
| + } |
| + |
| + // Clearing must reach the row too. Upsert deliberately PRESERVES a |
| + // non-empty environment (it cannot tell "untouched" from "cleared"), so |
| + // the clear has to go through the narrow setter — which is exactly why |
| + // this converge does not re-Upsert. |
| + rec.Environment = "" |
| + 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").Environment; got != "" { |
| + t.Errorf("environment = %q, want it cleared", got) |
| + } |
| +} |
| + |
| +// An explicit Hibernate converges; an absent one never does. Absent means |
| +// "the record does not speak to this", and a pass that stamped its |
| +// derivation over an operator's deliberate SetHibernate would be the silent |
| +// stomp this design exists to end. |
| +func TestReconcileConvergesHibernateOnlyWhenExplicit(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) |
| + } |
| + if err := reg.SetHibernate("acme.go.tito.io", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mustGet(t, reg, "acme.go.tito.io").Hibernate { |
| + t.Error("an absent Hibernate re-asserted its derivation over an operator's setting") |
| + } |
| + |
| + on := true |
| + rec.Hibernate = &on |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !mustGet(t, reg, "acme.go.tito.io").Hibernate { |
| + t.Error("an explicit Hibernate did not converge") |
| + } |
| +} |
| + |
| // 5. Conflict. |
| func TestReconcileLeavesConflictAlone(t *testing.T) { |
| s, sqid := setup(t) |