carlos / platform Public

provision: ReconcileConfig.DryRun reports the pass and writes nothing

The backfill mitigation from design §4.3: a production route is adopted
only after someone has read what adopting it would mean, because adoption
is what grants the removal phase authority over that row.

convergeOwned takes the dry-run flag itself rather than trusting each of
its two callers to guard around it — a guard living in only one caller is
exactly the single-unguarded-write failure mode this pass exists to
prevent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell 24609620d539284551134f6f181845babe0bb237 parent e6a1a3a
2 files changed, +114 −7
  • internal/provision/reconcile.go +41 −7
  • internal/provision/reconcile_test.go +73 −0
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 3c564fd16..649636d7d 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -38,6 +38,12 @@ type ReconcileConfig struct {
DataDir string
// Now is the clock seam; nil = time.Now.
Now func() time.Time
+ // DryRun reports what the pass would do and writes nothing — not to the
+ // registry, not anywhere. The backfill mitigation (design §4.3): a
+ // production route is adopted only after someone has read what adopting
+ // it would mean, because adoption is what grants the removal phase
+ // authority over that row.
+ DryRun bool
}
// acctApp identifies one app within one account — the unit both the record
@@ -167,16 +173,26 @@ func shapeMismatch(existing, want registry.Route) string {
// by an earlier pass. Both the owned-route converge branch and the
// adoption branch call this, so there is exactly one place these two
// columns get written from a record, never two copies drifting apart.
-func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record) {
+//
+// dryRun is a parameter here, not left to callers, deliberately: this
+// function is reached from two branches, and a dry-run guard living in only
+// one of the two callers is exactly the single-unguarded-write failure mode
+// the pass exists to prevent (design §4.3). Guarding here means both call
+// sites are safe by construction, not by each one remembering to check.
+func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, dryRun bool) {
if rec.Channel != "" && existing.Channel != rec.Channel {
- if err := reg.SetChannel(rec.Host, rec.Channel); err != nil {
+ if dryRun {
+ log.Printf("provision: dry-run: would converge %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel)
+ } else if err := reg.SetChannel(rec.Host, rec.Channel); err != nil {
log.Printf("provision: converge channel on %s: %v", rec.Host, err)
} else {
log.Printf("provision: converged %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel)
}
}
if existing.Environment != rec.Environment {
- if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil {
+ if dryRun {
+ log.Printf("provision: dry-run: would converge %s environment %q -> %q", rec.Host, existing.Environment, rec.Environment)
+ } else if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil {
log.Printf("provision: converge environment on %s: %v", rec.Host, err)
}
}
@@ -387,6 +403,11 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// Hibernate and the path derivation now come from the record
// (routeFor); everything else about this branch is unchanged.
nr := routeFor(rec, cfg.DataDir, seed)
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would mint %s route %s (app %q acct %q channel %q)",
+ nr.Kind, rec.Host, rec.App, rec.Account, rec.Channel)
+ continue
+ }
if err := reg.Upsert(nr); err != nil {
log.Printf("provision: mint %s: %v", rec.Host, err)
continue
@@ -398,18 +419,22 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// deploy's SetVersions or the activator's SetNextDue would
// clobber those, and Upsert cannot express "clear Environment"
// at all (it preserves a non-empty value by design).
- convergeOwned(reg, existing, rec)
+ convergeOwned(reg, existing, rec, cfg.DryRun)
// Absent means "this record does not speak to hibernation" —
// never a derivation stamped over an operator's own setting.
if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate {
- if err := reg.SetHibernate(rec.Host, *rec.Hibernate); err != nil {
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would converge %s hibernate %v -> %v", rec.Host, existing.Hibernate, *rec.Hibernate)
+ } else if err := reg.SetHibernate(rec.Host, *rec.Hibernate); err != nil {
log.Printf("provision: converge hibernate on %s: %v", rec.Host, err)
}
}
// seedIsKnown guards against stamping a transient manifest-read
// failure's "" fallback over a working seed — see seedFor.
if seedIsKnown && existing.Seed != seed {
- if err := reg.SetSeed(rec.Host, seed); err != nil {
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would converge %s seed", rec.Host)
+ } else if err := reg.SetSeed(rec.Host, seed); err != nil {
log.Printf("provision: converge seed on %s: %v", rec.Host, err)
}
}
@@ -443,6 +468,11 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
rec.Host, reason)
continue
}
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would adopt existing route %s (app %q acct %q), converging channel %s -> %s",
+ rec.Host, rec.App, rec.Account, existing.Channel, rec.Channel)
+ continue
+ }
if err := reg.SetProvisioned(rec.Host, true); err != nil {
log.Printf("provision: adopt %s: %v", rec.Host, err)
continue
@@ -455,7 +485,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// live process is allowed to sleep. It converges on the next
// pass through the owned branch above, by which point the
// adoption is already visible in the log.
- convergeOwned(reg, existing, rec)
+ convergeOwned(reg, existing, rec, cfg.DryRun)
}
}
@@ -478,6 +508,10 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
log.Printf("provision: not removing %s: its record exists but could not be read cleanly this pass", rt.Host)
continue
}
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would remove retired instance route %s (record gone)", rt.Host)
+ continue
+ }
if err := reg.Delete(rt.Host); err != nil {
log.Printf("provision: remove retired instance route %s: %v", rt.Host, err)
continue
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 9af1a31c7..00e0965d2 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -1232,6 +1232,79 @@ func TestReconcileRemovesAdoptedRouteWhenRecordGoes(t *testing.T) {
}
}
+// A dry-run pass reports what it would do and writes nothing — the backfill
+// mitigation (design §4.3): a production route is adopted only after someone
+// has read what adopting it would mean, because adoption is what grants the
+// removal phase authority over that row. This exercises all four write
+// shapes in the same pass — mint (acme.go.tito.io has no existing route),
+// adopt (a hand-added route matching a record's shape but on the wrong
+// channel), converge (an already-provisioned route whose channel,
+// environment, hibernate and seed all disagree with its record), and remove
+// (a provisioned route whose record is gone) — because a test that only
+// covers minting would pass while a removal wrote through.
+func TestReconcileDryRunWritesNothing(t *testing.T) {
+ s, sqid := setup(t)
+
+ // Mint: no existing route for this host.
+ if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil {
+ t.Fatal(err)
+ }
+
+ // Adopt: a hand-added route matching the record's shape, on a
+ // different channel — adoption would converge it in the same pass.
+ adoptRec := Record{Schema: 1, Host: "adopt.go.tito.io", Slug: "adopt", App: "tito",
+ Account: sqid, Pool: "tito", Region: "ie", Channel: "edge", CreatedAt: time.Now()}
+ if err := CreateRecord(ctx, s, adoptRec); err != nil {
+ t.Fatal(err)
+ }
+ adoptable := registry.Route{
+ Host: "adopt.go.tito.io", Slug: "adopt", Kind: "instance",
+ Socket: "/run/carlos/adopt.go.tito.io.sock", DBPath: "/data/adopt.go.tito.io.db",
+ App: "tito", Account: sqid, Pool: "tito", Region: "ie",
+ Channel: "stable", Hibernate: true, Status: "ready",
+ }
+
+ // Converge: already provisioned (this pass or an earlier one owns it),
+ // but channel, environment, hibernate and seed all disagree.
+ onHibernate := true
+ ownedRec := Record{Schema: 1, Host: "owned.go.tito.io", Slug: "owned", App: "tito",
+ Account: sqid, Pool: "tito", Region: "ie", Channel: "edge", Environment: "staging",
+ Hibernate: &onHibernate, CreatedAt: time.Now()}
+ if err := CreateRecord(ctx, s, ownedRec); err != nil {
+ t.Fatal(err)
+ }
+ owned := registry.Route{
+ Host: "owned.go.tito.io", Slug: "owned", Kind: "instance",
+ Socket: "/run/carlos/owned.go.tito.io.sock", DBPath: "/data/owned.go.tito.io.db",
+ App: "tito", Account: sqid, Pool: "tito", Region: "ie",
+ Channel: "stable", Provisioned: true, Status: "ready",
+ }
+
+ // Remove: provisioned, in scope, and its record is gone.
+ retired := registry.Route{Host: "gone.go.tito.io", Kind: "instance", App: "tito", Account: sqid,
+ Pool: "tito", Region: "ie", Status: "ready", Provisioned: true}
+
+ reg := regWith(t, adoptable, owned, retired)
+ before, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data", DryRun: true}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(before, after) {
+ t.Errorf("dry-run wrote to the registry:\nbefore %+v\nafter %+v", before, after)
+ }
+ if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil {
+ t.Errorf("dry-run minted a route: %+v %v", rt, err)
+ }
+}
+
// The other side of that coin, and the one that matters more: a route no
// record ever described is never removed, no matter how many passes run.
// Adoption is the ONLY way this pass acquires removal authority, and this