carlos / platform Public

settings-3 task 4: reconcile resolves update settings into the registry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 4c415b1701c806bb0206cbb036ea6bf44d128439 parent 09a40e5
4 files changed, +240 −27
  • internal/provision/plan9_b8_census_test.go +2 −2
  • internal/provision/reconcile.go +71 −13
  • internal/provision/reconcile_test.go +12 −12
  • internal/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)
+ }
+}