settings-3 task 4: reconcile resolves update settings into the registry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+240
−27
internal/provision/plan9_b8_census_test.go+2 −2internal/provision/reconcile.go+71 −13internal/provision/reconcile_test.go+12 −12internal/provision/updates_reconcile_test.go+155 −0
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 258957971..e432903d3 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -832,8 +832,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/provision/provision.go", "InstanceDir", "key-site", "lit:instances", "4defc7f996c83afe8d2d2efddd31f4de79d94a5c84e7ee99e8d639ca03d7bf81"}, |
| {"internal/provision/provision.go", "InstanceRunDir", "key-site", "lit:instances", "c0e58b35ea6c773c71524a7d9f5ab22c9a7bec46d0e0ab230784b935f77186c9"}, |
| {"internal/provision/provision.go", "NestedPaths", "key-site", "lit:instances", "64a91261fe43412aab90175f5af2333c35a5fef7232ce20c64fd678a27130826"}, |
| - {"internal/provision/reconcile.go", "Reconcile", "key-site", "key:ParseRecordKey,key:RecordKey,lit:instances", "5d066d1045533a400b2d1c70e919507c2311cf7b633810bfbe67b3f6ce535180"}, |
| - {"internal/provision/reconcile.go", "mintHeld", "key-site", "key:RecordKey", "71e106094dc5e7aa28c82c94d41a735a1599d95e10ac422528bdf01c2a13ba3b"}, |
| + {"internal/provision/reconcile.go", "Reconcile", "key-site", "key:ParseRecordKey,key:RecordKey,lit:instances", "38ba4b9f32239df40fbbfa23afaa9176f98a6309e5cf674ed24858f731580bb2"}, |
| + {"internal/provision/reconcile.go", "mintHeld", "key-site", "key:RecordKey", "7f8df930a87b69a7c5e995b1f29b5d1fa71a37d843393ba67e634430b8db2d29"}, |
| {"internal/provision/reconcile.go", "ListAllRecords", "key-site", "key:ParseRecordKey,lit:instances", "2396d277161244603476c6700c71d7bf6d89e0f8ccfd8eae12f8a05fa446e75d"}, |
| {"internal/provision/record_sequence.go", "IsRecordKey", "key-site", "key:IsRecordKey,lit:instances,lit:sidecars", "71ee3cc5e6c1399befa39612262a7a6656f2464c760419c1a87438d8c600b6cb"}, |
| {"internal/provision/record_sequence.go", "type preparedRecordKey", "key-site", "key:preparedRecordKey", "76c4a83eb76398bbe14e31fe82f9999b863e030082c240389bcd990e2d0475dd"}, |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 70b3b0509..bf86614b6 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -422,7 +422,7 @@ func (cfg ReconcileConfig) inScope(region, pool string, everywhere bool) bool { |
| // move a flat row to. Only the mint branch passes true, from |
| // ReconcileConfig.Layout; the drift and adoption comparisons derive flat |
| // and judge a nested row by NestedPaths. |
| -func routeFor(rec Record, dataDir, seed string, nested bool) registry.Route { |
| +func routeFor(rec Record, dataDir, seed string, nested bool, policy *UpdatePolicy) registry.Route { |
| rt := registry.Route{ |
| Host: rec.Host, Slug: rec.Slug, Kind: rec.KindOrDefault(), Unit: rec.Unit, |
| App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region, |
| @@ -433,6 +433,10 @@ func routeFor(rec Record, dataDir, seed string, nested bool) registry.Route { |
| Replica: rec.Role == "replica", |
| Gate: rec.Gate, |
| Provisioned: true, Seed: seed, Status: "ready", |
| + // The record's own settings over the app's policy (settings spec |
| + // §3.2); "" for default settings, so an untouched fleet mints the |
| + // row it always did. |
| + UpdateSettings: EffectiveUpdates(rec, policy).Column(), |
| } |
| if rec.DerivesPaths() { |
| rt.Socket = DefaultSocket(rec.Host) |
| @@ -687,7 +691,7 @@ func shapeMismatch(existing, want registry.Route) string { |
| // 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) { |
| +func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, policy *UpdatePolicy, policyKnown bool, dryRun bool) { |
| if rec.Channel != "" && existing.Channel != rec.Channel { |
| if dryRun { |
| log.Printf("provision: dry-run: would converge %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| @@ -774,6 +778,20 @@ func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, |
| log.Printf("provision: converged %s replica mark %v -> %v", rec.Host, existing.Replica, want) |
| } |
| } |
| + // Update settings (settings spec §4): the record over the app policy, |
| + // both directions, including back to "". Left alone when the policy |
| + // could not be read this pass. |
| + if policyKnown { |
| + if want := EffectiveUpdates(rec, policy).Column(); existing.UpdateSettings != want { |
| + if dryRun { |
| + log.Printf("provision: dry-run: would converge %s update settings %q -> %q", rec.Host, existing.UpdateSettings, want) |
| + } else if err := reg.SetUpdateSettings(rec.Host, want); err != nil { |
| + log.Printf("provision: converge update settings on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s update settings %q -> %q", rec.Host, existing.UpdateSettings, want) |
| + } |
| + } |
| + } |
| } |
| // Reconcile materializes bucket instance records into registry routes for |
| @@ -928,8 +946,9 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // The manifest is kept only as the seed it renders to, never whole: the |
| // lookup below has always reduced it to that at once. |
| type manifestRead struct { |
| - seed string |
| - err error |
| + seed string |
| + policy *UpdatePolicy |
| + err error |
| } |
| type tombRead struct { |
| tomb *release.AppTombstone |
| @@ -965,6 +984,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| r := manifestRead{err: merr} |
| if merr == nil { |
| r.seed = seedJSON(m) |
| + r.policy = m.Updates |
| } |
| prefetchMu.Lock() |
| manifests[k] = r |
| @@ -1007,6 +1027,29 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| return seedCache[k], seedKnown[k] |
| } |
| + // policyFor is the app's update policy for this pass (settings spec |
| + // §3.2). known is false when the manifest could not be read: the caller |
| + // then leaves every row's update_settings as it is, because a read |
| + // failure is not evidence the policy went away. An absent manifest is |
| + // known: no policy. |
| + policyFor := func(acct, app string) (*UpdatePolicy, bool) { |
| + k := acctApp{acct, app} |
| + if r, ok := manifests[k]; ok { |
| + if r.err != nil { |
| + return nil, errors.Is(r.err, os.ErrNotExist) |
| + } |
| + return r.policy, true |
| + } |
| + m, err := GetManifest(ctx, s, acct, app) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + return nil, true |
| + case err != nil: |
| + return nil, false |
| + } |
| + return m.Updates, true |
| + } |
| + |
| // --- trash cache, per (acct, app), lazily --------------------------------- |
| // An app in the trash provisions nothing, and its existing routes are |
| // RETIRED — both halves are this pass's job (#340). |
| @@ -1089,7 +1132,8 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| if cfg.Hold != nil { |
| if status, op, held := cfg.Hold(rec); held { |
| if !ok { |
| - mintHeld(ctx, reg, cfg, rec, seed, status, op) |
| + p, known := policyFor(rec.Account, rec.App) |
| + mintHeld(ctx, reg, cfg, rec, seed, status, op, p, known) |
| continue |
| } |
| holdExisting(ctx, s, reg, cfg, rec, existing, status, op) |
| @@ -1137,7 +1181,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // exact route this branch hardcoded before they existed — that is |
| // the additivity rule, pinned by TestRecordWireShapeIsAdditive and |
| // TestReconcileMints. |
| - nr := routeFor(rec, cfg.DataDir, seed, nested) |
| + p, pKnown := policyFor(rec.Account, rec.App) |
| + if !ok && !pKnown { |
| + // A read failure is not "no policy": minting now would mint an |
| + // instance that inherits manual as auto, and root would adopt |
| + // the head over its data's build. Absent is known. |
| + log.Printf("provision: %s: not minted this pass: the app's update policy could not be read", rec.Host) |
| + continue |
| + } |
| + nr := routeFor(rec, cfg.DataDir, seed, nested, p) |
| // A box that cannot provide this instance's config must not |
| // admit it: it would start without its secrets, and a keyed |
| // sibling would be left with a route that lost the race to one |
| @@ -1181,7 +1233,7 @@ 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, cfg.DryRun) |
| + convergeOwned(reg, existing, rec, p, pKnown, cfg.DryRun) |
| // Absent means "this record does not speak to hibernation" — |
| // never a derivation stamped over an operator's own setting. |
| // |
| @@ -1255,7 +1307,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // by design (the layout pass moved them), so when that is the |
| // only difference the line says so, rather than pointing an |
| // operator at `carlos remove`. |
| - want := routeFor(rec, cfg.DataDir, seed, false) |
| + want := routeFor(rec, cfg.DataDir, seed, false, p) |
| unitDrift := rec.Unit != "" && want.Unit != existing.Unit |
| if want.Kind != existing.Kind || unitDrift || |
| want.Socket != existing.Socket || want.DBPath != existing.DBPath { |
| @@ -1282,7 +1334,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // A hand-added row on a nested box (`carlos add` there mints |
| // nested too) matches the record at the nested paths; the row's |
| // own shape says which derivation to compare against. |
| - want := routeFor(rec, cfg.DataDir, seed, NestedPaths(cfg.DataDir, existing)) |
| + want := routeFor(rec, cfg.DataDir, seed, NestedPaths(cfg.DataDir, existing), p) |
| if reason := shapeMismatch(existing, want); reason != "" { |
| log.Printf("provision: %s exists but is not the route this record describes (%s) — leaving it alone", |
| rec.Host, reason) |
| @@ -1299,7 +1351,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // already on the record's channel. Every write inside |
| // convergeOwned is dry-run guarded there, so this call |
| // writes nothing. |
| - convergeOwned(reg, existing, rec, true) |
| + convergeOwned(reg, existing, rec, p, pKnown, true) |
| return nil |
| } |
| if err := reg.SetProvisioned(rec.Host, true); err != nil { |
| @@ -1314,7 +1366,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, cfg.DryRun) |
| + convergeOwned(reg, existing, rec, p, pKnown, cfg.DryRun) |
| } |
| return nil |
| }); err != nil { |
| @@ -1440,7 +1492,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // a mint that is going to land. With claims off the hook is a pass-through. |
| // A status that is not a hold, or an empty op, mints nothing: a |
| // participant's row is never minted ready. |
| -func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record, seed, status, op string) { |
| +func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record, seed, status, op string, policy *UpdatePolicy, policyKnown bool) { |
| if !registry.IsHold(status) || op == "" { |
| log.Printf("provision: %s: not minting: the move's hold %q by %q is not a hold", rec.Host, status, op) |
| return |
| @@ -1450,7 +1502,13 @@ func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, |
| "CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic", rec.Host, rec.App, rec.Account) |
| return |
| } |
| - nr := routeFor(rec, cfg.DataDir, seed, false) |
| + if !policyKnown { |
| + // The same wait as the ordinary mint: an instance that inherits |
| + // manual from its app must not land held as auto. |
| + log.Printf("provision: %s: not minted this pass: the app's update policy could not be read", rec.Host) |
| + return |
| + } |
| + nr := routeFor(rec, cfg.DataDir, seed, false, policy) |
| nr.Status, nr.HoldOp = status, op |
| if cfg.ConfigGate != nil { |
| if err := cfg.ConfigGate(ctx, nr); err != nil { |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index 51e53e7c6..f95ce302b 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -1072,7 +1072,7 @@ func TestReconcileConvergesLegacyAddrToAddrs(t *testing.T) { |
| // (shapeMismatch is the adoption gate AND the drift test — the two failures |
| // the un-cleared addr caused, asserted at their source). |
| seed := seedJSON(&Manifest{Seed: []string{"seed"}}) // setup()'s manifest |
| - if reason := shapeMismatch(after, routeFor(rec, cfg.DataDir, seed, false)); reason != "" { |
| + if reason := shapeMismatch(after, routeFor(rec, cfg.DataDir, seed, false, nil)); reason != "" { |
| t.Errorf("converged row still diverges from its record (%s) — the pass would log this every tick and refuse re-adoption", reason) |
| } |
| @@ -2655,7 +2655,7 @@ func TestEverywhereClassAgreesAcrossRecordAndRow(t *testing.T) { |
| t.Fatalf("fixture is not a shape this pass would ever see: %v", err) |
| } |
| fromRecord := everywhereClass(f.rec) |
| - fromRow := everywhereRoute(routeFor(f.rec, "/data", `["seed"]`, false)) |
| + fromRow := everywhereRoute(routeFor(f.rec, "/data", `["seed"]`, false, nil)) |
| if fromRecord != fromRow { |
| t.Fatalf("class disagrees across the mint and removal gates: everywhereClass = %v, "+ |
| "everywhereRoute(routeFor(...)) = %v — a record this box mints must be a row it can retire", |
| @@ -2674,7 +2674,7 @@ func TestEverywhereClassAgreesAcrossRecordAndRow(t *testing.T) { |
| func TestRouteForCarriesAddrsAndHealth(t *testing.T) { |
| rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", |
| Kind: "service", Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"}} |
| - rt := routeFor(rec, "/data", "seed", false) |
| + rt := routeFor(rec, "/data", "seed", false, nil) |
| if rt.Addrs != "10.0.0.1:3000,10.0.0.2:3000" { |
| t.Fatalf("Addrs = %q", rt.Addrs) |
| } |
| @@ -2687,7 +2687,7 @@ func TestRouteForCarriesAddrsAndHealth(t *testing.T) { |
| } |
| // Legacy single-addr record: byte-identical route to before this change. |
| old := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable", Kind: "service", Addr: "10.0.0.9:3000"} |
| - ort := routeFor(old, "/data", "seed", false) |
| + ort := routeFor(old, "/data", "seed", false, nil) |
| if ort.Addrs != "" || ort.Health != "" || ort.Addr != "10.0.0.9:3000" { |
| t.Fatalf("legacy record's route changed shape: %+v", ort) |
| } |
| @@ -2703,7 +2703,7 @@ func TestRouteForMaterializesPartialHealthDefaults(t *testing.T) { |
| rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", |
| Kind: "service", Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"}, |
| Health: &Health{Path: "/healthz"}} |
| - rt := routeFor(rec, "/data", "seed", false) |
| + rt := routeFor(rec, "/data", "seed", false, nil) |
| var h Health |
| if err := json.Unmarshal([]byte(rt.Health), &h); err != nil { |
| t.Fatalf("health JSON = %q (%v)", rt.Health, err) |
| @@ -2851,11 +2851,11 @@ func TestReconcileUnsetInboundMintsAsBefore(t *testing.T) { |
| // onto the route, byte for byte — no derivation, unlike Hibernate/StatusNamed. |
| func TestRouteForCarriesSteering(t *testing.T) { |
| rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", Steering: "latency"} |
| - if got := routeFor(rec, "/data", "seed", false).Steering; got != "latency" { |
| + if got := routeFor(rec, "/data", "seed", false, nil).Steering; got != "latency" { |
| t.Fatalf("Steering = %q, want latency", got) |
| } |
| unsteered := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable"} |
| - if got := routeFor(unsteered, "/data", "seed", false).Steering; got != "" { |
| + if got := routeFor(unsteered, "/data", "seed", false, nil).Steering; got != "" { |
| t.Fatalf("Steering = %q, want empty for a record that never mentioned it", got) |
| } |
| } |
| @@ -3207,7 +3207,7 @@ func TestReconcileHoldsParticipantRows(t *testing.T) { |
| } |
| // The ie box: the source's moving row, held by the move. |
| - ieRow := routeFor(acmeRecord(sqid), "/data", `["seed"]`, false) |
| + ieRow := routeFor(acmeRecord(sqid), "/data", `["seed"]`, false, nil) |
| ieRow.Status, ieRow.HoldOp = "moving", "mv_acme" |
| ie := regWith(t, ieRow) |
| keep := true |
| @@ -3307,7 +3307,7 @@ func TestReconcileHoldsAnExistingRow(t *testing.T) { |
| if err := CreateRecord(ctx, s, rec); err != nil { |
| t.Fatal(err) |
| } |
| - owned := routeFor(rec, "/data", `["seed"]`, false) |
| + owned := routeFor(rec, "/data", `["seed"]`, false, nil) |
| cases := []struct { |
| name string |
| row registry.Route |
| @@ -3408,7 +3408,7 @@ func TestReconcileHoldRereadsTheRecordBeforeHoldingARow(t *testing.T) { |
| m.State = MoveCommitted |
| committed.Move = &m |
| cs := &changingStore{Store: s, key: RecordKey(rec.Account, rec.App, rec.Host), first: mustJSON(t, rec), later: mustJSON(t, committed)} |
| - reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) // the target's row, opened |
| + reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false, nil)) // the target's row, opened |
| var wrote holdLog |
| cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written, |
| Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }} |
| @@ -3434,7 +3434,7 @@ func TestReconcileHoldWriteIsRefusedWhenAnotherOpTookTheRow(t *testing.T) { |
| if err := CreateRecord(ctx, s, rec); err != nil { |
| t.Fatal(err) |
| } |
| - reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) // unheld at the listing |
| + reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false, nil)) // unheld at the listing |
| var wrote holdLog |
| cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written, |
| Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }} |
| @@ -3475,7 +3475,7 @@ func TestReconcileHoldWriteIsSerialisedWithTheMoveOpening(t *testing.T) { |
| if err := CreateRecord(ctx, s, rec); err != nil { |
| t.Fatal(err) |
| } |
| - reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) |
| + reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false, nil)) |
| var wrote holdLog |
| cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written, |
| Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }} |
diff --git a/internal/provision/updates_reconcile_test.go b/internal/provision/updates_reconcile_test.go| new file mode 100644 |
| index 000000000..b35c6c10e |
| --- /dev/null |
| +++ b/internal/provision/updates_reconcile_test.go |
| @@ -0,0 +1,155 @@ |
| +package provision |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// updatesFixture is reconcile_test.go's own setup (an account, the "tito" |
| +// app's instances manifest, a DirStore, a temp registry) with the three |
| +// moves these tests make: write the app's manifest with a given update |
| +// policy, run a pass, read a row. |
| +type updatesFixture struct { |
| + t *testing.T |
| + s *release.DirStore |
| + sqid string |
| + reg *registry.Registry |
| + cfg ReconcileConfig |
| +} |
| + |
| +func newUpdatesFixture(t *testing.T) *updatesFixture { |
| + t.Helper() |
| + s, sqid := setup(t) |
| + return &updatesFixture{t: t, s: s, sqid: sqid, reg: regWith(t), |
| + cfg: ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}} |
| +} |
| + |
| +// putManifest replaces the app's instances manifest with one carrying p. |
| +func (fx *updatesFixture) putManifest(p *UpdatePolicy) { |
| + fx.t.Helper() |
| + m := Manifest{Schema: 1, App: "tito", Domain: "go.tito.io", Pool: "tito", Seed: []string{"seed"}, Updates: p} |
| + putReplace(fx.t, fx.s, ManifestKey(fx.sqid, "tito"), mustJSON(fx.t, m)) |
| +} |
| + |
| +// corruptManifest leaves unparseable bytes at the manifest's key: a read |
| +// failure that is not absence. |
| +func (fx *updatesFixture) corruptManifest() { |
| + fx.t.Helper() |
| + putReplace(fx.t, fx.s, ManifestKey(fx.sqid, "tito"), []byte("{")) |
| +} |
| + |
| +func (fx *updatesFixture) putRecord(rec Record) { |
| + fx.t.Helper() |
| + if err := CreateRecord(ctx, fx.s, rec); err != nil { |
| + fx.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (fx *updatesFixture) run() { |
| + fx.t.Helper() |
| + if err := Reconcile(ctx, fx.s, fx.reg, fx.cfg); err != nil { |
| + fx.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (fx *updatesFixture) row(host string) registry.Route { return *mustGet(fx.t, fx.reg, host) } |
| + |
| +func (fx *updatesFixture) hasRow(host string) bool { |
| + fx.t.Helper() |
| + rt, err := fx.reg.Get(host) |
| + if err != nil { |
| + fx.t.Fatal(err) |
| + } |
| + return rt != nil |
| +} |
| + |
| +func TestReconcileCarriesSettingsToTheRow(t *testing.T) { |
| + fx := newUpdatesFixture(t) |
| + fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual}) |
| + fx.putRecord(acmeRecord(fx.sqid)) |
| + fx.run() |
| + rt := fx.row("acme.go.tito.io") |
| + u, err := release.ParseUpdateSettings(rt.UpdateSettings) |
| + if err != nil || u.Updates != release.UpdatesManual { |
| + t.Fatalf("minted row settings %q: %+v %v", rt.UpdateSettings, u, err) |
| + } |
| + // The default changes: the row follows on the next pass. |
| + fx.putManifest(nil) |
| + fx.run() |
| + if rt = fx.row("acme.go.tito.io"); rt.UpdateSettings != "" { |
| + t.Fatalf("after the policy was removed: %q", rt.UpdateSettings) |
| + } |
| +} |
| + |
| +func TestAnUntouchedFleetConvergesNothing(t *testing.T) { |
| + fx := newUpdatesFixture(t) |
| + fx.putRecord(acmeRecord(fx.sqid)) |
| + fx.run() |
| + if fx.row("acme.go.tito.io").UpdateSettings != "" { |
| + t.Fatal("a record with no settings wrote the column") |
| + } |
| + fx.run() |
| + if fx.row("acme.go.tito.io").UpdateSettings != "" { |
| + t.Fatal("a second pass wrote the column") |
| + } |
| +} |
| + |
| +// Review Focus 2. |
| +func TestUnreadablePolicyLeavesTheColumnAlone(t *testing.T) { |
| + fx := newUpdatesFixture(t) |
| + fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual}) |
| + fx.putRecord(acmeRecord(fx.sqid)) |
| + fx.run() |
| + fx.corruptManifest() |
| + fx.run() |
| + if u, _ := release.ParseUpdateSettings(fx.row("acme.go.tito.io").UpdateSettings); u.Updates != release.UpdatesManual { |
| + t.Fatal("an unreadable policy reset the instance to auto") |
| + } |
| +} |
| + |
| +// Astra I1 #7: an instance that inherits manual from its app, minted (on a |
| +// fresh box, or after a move) while the app's manifest cannot be read, must |
| +// not be minted as auto: it would adopt the head instead of recovering |
| +// last_executed. The mint waits until the policy reads; a genuinely absent |
| +// manifest is known (no policy) and mints at once. |
| +func TestMintWaitsForAReadablePolicy(t *testing.T) { |
| + fx := newUpdatesFixture(t) |
| + fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual}) |
| + fx.corruptManifest() |
| + fx.putRecord(acmeRecord(fx.sqid)) |
| + fx.run() |
| + if fx.hasRow("acme.go.tito.io") { |
| + t.Fatal("minted while the app's update policy was unreadable") |
| + } |
| + fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual}) |
| + fx.run() |
| + if u, _ := release.ParseUpdateSettings(fx.row("acme.go.tito.io").UpdateSettings); u.Updates != release.UpdatesManual { |
| + t.Fatalf("minted with %q once the policy read", fx.row("acme.go.tito.io").UpdateSettings) |
| + } |
| +} |
| + |
| +// Ruling R5: the moves held mint is a mint like any other. It waits while the |
| +// policy is unreadable, then lands held with the inherited settings. |
| +func TestHeldMintWaitsForAReadablePolicy(t *testing.T) { |
| + fx := newUpdatesFixture(t) |
| + fx.cfg.Region = "uk" |
| + fx.cfg.Hold = func(Record) (string, string, bool) { return "landing", "mv_acme", true } |
| + fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual}) |
| + fx.corruptManifest() |
| + fx.putRecord(movingRecord(fx.sqid)) |
| + fx.run() |
| + if fx.hasRow("acme.go.tito.io") { |
| + t.Fatal("held mint landed while the app's update policy was unreadable") |
| + } |
| + fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual}) |
| + fx.run() |
| + rt := fx.row("acme.go.tito.io") |
| + if rt.Status != "landing" || rt.HoldOp != "mv_acme" { |
| + t.Fatalf("row is %q held by %q, want landing by mv_acme", rt.Status, rt.HoldOp) |
| + } |
| + if u, _ := release.ParseUpdateSettings(rt.UpdateSettings); u.Updates != release.UpdatesManual { |
| + t.Fatalf("held mint carries %q once the policy read", rt.UpdateSettings) |
| + } |
| +} |