| 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 |