settings-5 task 8: a host that changes tenant resolves its update settings fresh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+457
−11
internal/host/buildstate.go+22 −5internal/host/buildstate_test.go+7 −0internal/host/tenantchange_test.go+125 −0internal/registry/registry.go+11 −2internal/registry/updatesettings_test.go+50 −0internal/release/adopt.go+14 −2internal/release/buildstate.go+13 −0internal/release/tenantchange_test.go+209 −0internal/release/updatetarget.go+6 −2
diff --git a/internal/host/buildstate.go b/internal/host/buildstate.go| index 1634b42e5..f81f46d92 100644 |
| --- a/internal/host/buildstate.go |
| +++ b/internal/host/buildstate.go |
| @@ -507,7 +507,8 @@ func (b *buildStates) write(ctx context.Context, host string, merge func(cur *re |
| // The one exception is the zero-downtime planner's checked selection of |
| // build in place of that target, which the activator carries on ctx for this |
| // exec only (activator.PlannedSelection; ruling R-PLANNED-RECOVERY): it is |
| -// admitted, and still recorded. |
| +// admitted, and still recorded. A consumed file another tenant of the |
| +// hostname left (Consumed.ForeignTo) is read as none (ruling R26). |
| func (b *buildStates) beforeExec(ctx context.Context, host, build string) error { |
| if build == "" { |
| return fmt.Errorf("no build to record for %s", host) |
| @@ -520,6 +521,12 @@ func (b *buildStates) beforeExec(ctx context.Context, host, build string) error |
| // (Astra round 1 finding 1): a wake that decided on a stale copy is |
| // refused here, before anything is written or run. |
| rt, _ := b.reg.Get(host) |
| + // A file root wrote for another tenant of this hostname carries nothing |
| + // into this tenant's state (ruling R26). An untagged one is read as the |
| + // route's own, as before. |
| + if rt != nil && consumed.ForeignTo(*rt) { |
| + consumed = nil |
| + } |
| held := rt != nil && settingsApply(*rt) |
| inPlaceOf := "" |
| if sel, ok := activator.PlannedSelectionFrom(ctx); ok && sel.Build == build { |
| @@ -540,7 +547,8 @@ func (b *buildStates) beforeExec(ctx context.Context, host, build string) error |
| // finding 3): before a cut seals host's build state at bucket/prefix (the |
| // lease's location: the hibernate prefix for a legacy cut, the claim |
| // holder's location for a Phase 1 cut or a return), root's consumed record |
| -// on this box is merged into it with release.CarryRequest: a resolution and |
| +// on this box for the route's tenant (not one Consumed.ForeignTo it, ruling |
| +// R26) is merged into it with release.CarryRequest: a resolution and |
| // a failed completion, never a successful completion, never last_executed. |
| // The object is written only when that changes its request state, under the |
| // host's lock and against the etag it read (write), so it never overwrites a |
| @@ -573,7 +581,9 @@ func (b *buildStates) carryRequest(ctx context.Context, host, bucket, prefix str |
| if err != nil { |
| return fmt.Errorf("read root's consumed file for %s: %w", host, err) |
| } |
| - if consumed == nil { |
| + // Another tenant's file is no request record of this route's (ruling |
| + // R26): nothing to carry. An untagged one is carried as before. |
| + if consumed == nil || (rt != nil && consumed.ForeignTo(*rt)) { |
| return nil |
| } |
| ctx = activator.WithBuildStateLocation(ctx, activator.BuildStateLocation{Bucket: bucket, Prefix: prefix}) |
| @@ -651,10 +661,17 @@ func (b *buildStates) heldTarget(host string) activator.HeldWake { |
| // heldWakeInput reads DecideHeldWake's input for rt. state, when set, is |
| // the build state just read under the lease (beforeExec's revalidation), in |
| // place of the registry copy. A consumed file or refusal record that does |
| -// not read is no word from root (nil); a copy that does not parse is unread. |
| +// not read is no word from root (nil), and so is a consumed file not |
| +// decided for rt's tenant (Consumed.DecidedFor); a copy that does not parse |
| +// is unread. |
| func (b *buildStates) heldWakeInput(rt registry.Route, state *release.BuildState) release.HeldWakeInput { |
| in := release.HeldWakeInput{Settings: rt.UpdateSettings} |
| - in.Consumed, _ = release.ReadConsumed(b.optDir, rt.Host) |
| + // Only a decision root made for this route's tenant counts (ruling R26): |
| + // another tenant's file, or an untagged one from before ruling R22, is |
| + // no word from root, so a wake that could be held holds and asks root. |
| + if c, _ := release.ReadConsumed(b.optDir, rt.Host); c.DecidedFor(rt) { |
| + in.Consumed = c |
| + } |
| in.Refusal, _ = release.ReadRefusal(b.bakeDir, rt.Host) |
| in.Installed, _ = release.LiveVersion(b.optDir, rt.Host) |
| if state != nil { |
diff --git a/internal/host/buildstate_test.go b/internal/host/buildstate_test.go| index 67da7fbab..5eaeab15f 100644 |
| --- a/internal/host/buildstate_test.go |
| +++ b/internal/host/buildstate_test.go |
| @@ -661,9 +661,16 @@ func TestClaimsOnRegistersOnlyTheClaimBucket(t *testing.T) { |
| // --- the pre-exec write (Service.BeforeExec, settings spec §4.2) --- |
| +// writeConsumed writes root's consumed file for the test host. An untagged |
| +// c is tagged with the route's tenant, as root writes every file since |
| +// ruling R22; the held wake check reads no other (ruling R26). Tests of an |
| +// untagged or foreign file use writeConsumedAsIs. |
| func writeConsumed(t *testing.T, b *buildStates, c release.Consumed) { |
| t.Helper() |
| c.Host = "a.example.com" |
| + if rt, err := b.reg.Get(c.Host); err == nil && rt != nil && c.Account == "" && c.App == "" { |
| + c.Account, c.App = release.RouteAccount(*rt), rt.App |
| + } |
| if err := release.WriteConsumed(b.optDir, c); err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/host/tenantchange_test.go b/internal/host/tenantchange_test.go| new file mode 100644 |
| index 000000000..edc505e79 |
| --- /dev/null |
| +++ b/internal/host/tenantchange_test.go |
| @@ -0,0 +1,125 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// settings-5 task 8 (ruling R26), the agent's half: the consumed file is |
| +// keyed by hostname, and a hostname can move to another tenant on one box. |
| +// A file tagged for another tenant is absent to the pre-exec write and to a |
| +// move's carry; an untagged file is read there as today. The held wake check |
| +// trusts only a file decided for the route (DecidedFor): with a foreign or an |
| +// untagged file it holds and asks root. |
| + |
| +// foreignRecord is tenant A's (cdf/blog) record for the test host: a |
| +// resolved request, a failed completion watermark and a manual decision. |
| +func foreignRecord() release.Consumed { |
| + return release.Consumed{Account: "cdf", App: "blog", |
| + Resolved: &release.ResolvedRequest{ID: 7, Target: "v2"}, Completed: 3, CompletedFailed: true, |
| + Reason: string(release.ReasonManual), Desired: "v1", Detail: "v1"} |
| +} |
| + |
| +// writeConsumedAsIs writes c for the test host exactly as given, its tenant |
| +// tag included or left empty (writeConsumed tags it as root writes now). |
| +func writeConsumedAsIs(t *testing.T, b *buildStates, c release.Consumed) { |
| + t.Helper() |
| + c.Host = astraHost |
| + if err := release.WriteConsumed(b.optDir, c); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestBeforeExecIgnoresAnotherTenantsFile(t *testing.T) { |
| + b, _, _ := testBuildStates(t) |
| + ctx := context.Background() |
| + writeConsumedAsIs(t, b, foreignRecord()) |
| + if err := b.beforeExec(ctx, astraHost, "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _, err := b.read(ctx, astraHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.LastExecuted != "v1" || st.Resolved != nil || st.Completed != 0 || st.CompletedFailed { |
| + t.Fatalf("state = %+v (resolved %+v); want v1 and none of A's request state", st, st.Resolved) |
| + } |
| + |
| + // An untagged file is the route's own, read as today: its resolution is |
| + // carried and its failed completion kept. |
| + b, _, _ = testBuildStates(t) |
| + legacy := foreignRecord() |
| + legacy.Account, legacy.App = "", "" |
| + writeConsumedAsIs(t, b, legacy) |
| + if err := b.beforeExec(ctx, astraHost, "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, _, _ = b.read(ctx, astraHost); st.Resolved == nil || st.Resolved.ID != 7 || st.Completed != 3 || !st.CompletedFailed { |
| + t.Fatalf("state = %+v (resolved %+v); want the untagged file's request state", st, st.Resolved) |
| + } |
| +} |
| + |
| +func TestACarryIgnoresAnotherTenantsFile(t *testing.T) { |
| + b, s, reg := testBuildStates(t) |
| + ctx := context.Background() |
| + if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "A", Completed: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetStatus(astraHost, "moving"); err != nil { |
| + t.Fatal(err) |
| + } |
| + key := release.BuildStateKey("inst/" + astraHost) |
| + etag := mustEtag(t, s, key) |
| + writeConsumedAsIs(t, b, foreignRecord()) |
| + if err := b.carryRequest(ctx, astraHost, "hib", "inst/"+astraHost); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mustEtag(t, s, key) != etag { |
| + st, _, _ := getState(ctx, s, key, astraHost) |
| + t.Fatalf("A's record was carried into B's state: %+v", st) |
| + } |
| + |
| + // An untagged file is carried as today. |
| + legacy := foreignRecord() |
| + legacy.Account, legacy.App = "", "" |
| + writeConsumedAsIs(t, b, legacy) |
| + if err := b.carryRequest(ctx, astraHost, "hib", "inst/"+astraHost); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _, err := getState(ctx, s, key, astraHost) |
| + if err != nil || st == nil || st.LastExecuted != "A" || st.Completed != 3 || !st.CompletedFailed || st.Resolved == nil || st.Resolved.ID != 7 { |
| + t.Fatalf("state %+v, %v; want A with R3 completed failed and R7's resolution carried", st, err) |
| + } |
| +} |
| + |
| +func TestTheHeldWakeTrustsOnlyAFileDecidedForTheRoute(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + u := release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", InstallRequest: 2} |
| + setSettings(t, reg, u) |
| + setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2", Completed: 1}}) |
| + record := release.Consumed{Completed: 1, Resolved: &release.ResolvedRequest{ID: 2, Target: "v4"}, |
| + Reason: string(release.ReasonRequest), Desired: "v4", Detail: "v4", Settings: release.SettingsDigest(u.Column())} |
| + |
| + for _, tc := range []struct{ name, account, app string }{ |
| + {"another tenant's file", "cdf", "blog"}, |
| + {"another app's file on the same account", release.OpsAccount, "blog"}, |
| + {"an untagged file", "", ""}, |
| + } { |
| + c := record |
| + c.Account, c.App = tc.account, tc.app |
| + writeConsumedAsIs(t, b, c) |
| + if h := b.heldTarget(astraHost); !h.Held || !h.AskRoot || h.Target != "v2" { |
| + t.Fatalf("%s: heldTarget = %+v; want held on v2, asking root (no decision for this route)", tc.name, h) |
| + } |
| + } |
| + |
| + // The route's own file is root's word: the request's T. |
| + c := record |
| + c.Account, c.App = release.OpsAccount, "km" |
| + writeConsumedAsIs(t, b, c) |
| + if h := b.heldTarget(astraHost); !h.Held || h.AskRoot || h.Target != "v4" { |
| + t.Fatalf("the route's own file: heldTarget = %+v; want the request's v4", h) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 384d5c1ad..3867c14ab 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1084,6 +1084,13 @@ func (r *Registry) Delete(host string) error { |
| // steering housekeeper duty (Task 6) is armed would silently clear a route's |
| // Route53 latency records — a real incident this repo caught in review before |
| // it shipped, not a hypothetical. |
| +// `update_settings` and `build_state` keep an existing value only while the |
| +// row's tenant (account, app) stays the same. An Upsert that moves the |
| +// hostname to another tenant takes the incoming values, empty included: |
| +// one tenant's manual or major hold, or its build state, is never another |
| +// tenant's (settings-5 task 8). SQLite evaluates every SET expression |
| +// against the row as it was, so `routes.account` and `routes.app` there are |
| +// still the old tenant's. |
| // `good_version`, `good_digest`, `good_demand` and `serving_version` |
| // (zero-downtime spec §8.5) are absent from the ON CONFLICT clause on |
| // purpose: an existing row keeps them whatever the incoming Route carries. |
| @@ -1159,8 +1166,10 @@ ON CONFLICT(host) DO UPDATE SET |
| args=CASE WHEN excluded.args = '' THEN routes.args ELSE excluded.args END, |
| idle_seconds=CASE WHEN excluded.idle_seconds = 0 THEN routes.idle_seconds ELSE excluded.idle_seconds END, |
| max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END, |
| - update_settings=CASE WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END, |
| - build_state=CASE WHEN excluded.build_state = '' THEN routes.build_state ELSE excluded.build_state END |
| + update_settings=CASE WHEN routes.account != excluded.account OR routes.app != excluded.app THEN excluded.update_settings |
| + WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END, |
| + build_state=CASE WHEN routes.account != excluded.account OR routes.app != excluded.app THEN excluded.build_state |
| + WHEN excluded.build_state = '' THEN routes.build_state ELSE excluded.build_state END |
| WHERE routes.claim_incarnation=excluded.claim_incarnation AND routes.claim_stamp=excluded.claim_stamp |
| AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.claim_row_id`, |
| t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, |
diff --git a/internal/registry/updatesettings_test.go b/internal/registry/updatesettings_test.go| index 4f2571d55..c164f368d 100644 |
| --- a/internal/registry/updatesettings_test.go |
| +++ b/internal/registry/updatesettings_test.go |
| @@ -53,6 +53,56 @@ func TestUpdateSettingsColumn(t *testing.T) { |
| } |
| } |
| +// A hostname that changes tenant on an Upsert takes the new tenant's update |
| +// settings and build state, empty included: A's manual hold or major hold, |
| +// and A's build state, never carry into B's row (task 8, ruling R25). A |
| +// same-tenant Upsert that does not carry the columns keeps them. |
| +func TestATenantChangeUpsertTakesTheNewTenantsSettings(t *testing.T) { |
| + reg, err := Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + const host = "a.example.com" |
| + settings := `{"updates":"manual","channel":"stable"}` |
| + state := `{"known":true,"replica":true}` |
| + tenantA := Route{Host: host, Kind: "instance", Account: "bab", App: "km", UpdateSettings: settings, BuildState: state} |
| + if err := reg.Upsert(tenantA); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, _ := reg.Get(host) |
| + if rt.UpdateSettings != settings || rt.BuildState != state { |
| + t.Fatalf("A's row: settings %q, build state %q", rt.UpdateSettings, rt.BuildState) |
| + } |
| + |
| + // The same tenant, columns not carried: kept (today's behaviour). |
| + if err := reg.Upsert(Route{Host: host, Kind: "instance", Account: "bab", App: "km"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get(host); rt.UpdateSettings != settings || rt.BuildState != state { |
| + t.Fatalf("a same-tenant Upsert lost the columns: settings %q, build state %q", rt.UpdateSettings, rt.BuildState) |
| + } |
| + |
| + // Another app on the same account: B's empty columns win. |
| + if err := reg.Upsert(Route{Host: host, Kind: "instance", Account: "bab", App: "other"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get(host); rt.UpdateSettings != "" || rt.BuildState != "" { |
| + t.Fatalf("another app kept A's columns: settings %q, build state %q", rt.UpdateSettings, rt.BuildState) |
| + } |
| + |
| + // Another account with the same app name, and B's own values: B's win. |
| + if err := reg.Upsert(tenantA); err != nil { |
| + t.Fatal(err) |
| + } |
| + bSettings := `{"updates":"auto","major":2}` |
| + if err := reg.Upsert(Route{Host: host, Kind: "instance", Account: "bes", App: "km", UpdateSettings: bSettings}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get(host); rt.UpdateSettings != bSettings || rt.BuildState != "" { |
| + t.Fatalf("another account: settings %q, build state %q", rt.UpdateSettings, rt.BuildState) |
| + } |
| +} |
| + |
| func TestUpdateSettingsMovesSnapshotFingerprint(t *testing.T) { |
| reg, err := Open(filepath.Join(t.TempDir(), "r.db")) |
| if err != nil { |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 6c7d515a7..8a497e4e7 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -2160,7 +2160,9 @@ func backfillManifestDigest(ctx context.Context, s Store, optDir, acct, app, ver |
| // A route with default settings and no consumed file is today's adoption: |
| // the head, with bake, on the pointer's evidence, and no book, no Target and |
| // no consumed write. Only an unreadable failure book on a managed route |
| -// changes that (the zero-downtime plans' refuseManaged posture). |
| +// changes that (the zero-downtime plans' refuseManaged posture). A consumed |
| +// file another tenant of the hostname left (Consumed.ForeignTo) is read as |
| +// none, and root's write replaces it (ruling R26). |
| func settingsTarget(ctx context.Context, optDir string, bake *FirstSeen, rt registry.Route, ptr *ChannelPointer, cur string, now time.Time, |
| cfg passConfig, holds map[string]time.Duration, ignore bool) (string, TargetDecision, bool) { |
| stop := func(dec TargetDecision) (string, TargetDecision, bool) { |
| @@ -2172,7 +2174,17 @@ func settingsTarget(ctx context.Context, optDir string, bake *FirstSeen, rt regi |
| plain := uerr == nil && u.Default() |
| managed := cfg.managed != nil && cfg.managed(rt) |
| consumed, cerr := ReadConsumed(optDir, rt.Host) |
| - if plain && consumed == nil && (cerr == nil || !controlname.ValidHostShape(rt.Host)) { |
| + // A file root wrote for another tenant of this hostname is no record of |
| + // this route's (ruling R26): every rule below decides as if there were |
| + // none, and NextConsumed starts the route's record empty. A default route |
| + // still takes the Target path over one, so root's write replaces the old |
| + // tenant's file with this route's own (the decision is today's adoption |
| + // either way). An untagged file is read as the route's own, as before. |
| + foreign := consumed.ForeignTo(rt) |
| + if foreign { |
| + consumed = nil |
| + } |
| + if plain && consumed == nil && !foreign && (cerr == nil || !controlname.ValidHostShape(rt.Host)) { |
| if ptr == nil { |
| return "", TargetDecision{}, true // today's quiet skip |
| } |
diff --git a/internal/release/buildstate.go b/internal/release/buildstate.go| index 8c32008ed..c280b9a56 100644 |
| --- a/internal/release/buildstate.go |
| +++ b/internal/release/buildstate.go |
| @@ -123,6 +123,19 @@ func (c *Consumed) DecidedFor(rt registry.Route) bool { |
| return c != nil && c.Account != "" && c.App != "" && c.Account == RouteAccount(rt) && c.App == rt.App |
| } |
| +// ForeignTo is whether c was decided for another tenant than rt's: it names |
| +// a tenant (Account or App set) and that tenant is not (RouteAccount(rt), |
| +// rt.App). A hostname can move between tenants on one box, and the file is |
| +// keyed by hostname, so root and the agent treat a foreign file as absent |
| +// wherever they decide from it (ruling R26): the adoption read, NextConsumed, |
| +// the pre-exec write and a move's carry. An untagged file (from before |
| +// ruling R22) is not foreign: those readers take it as the route's own, so a |
| +// completion or resolution only it holds still counts. The held wake check |
| +// and the update report are stricter and trust only DecidedFor. |
| +func (c *Consumed) ForeignTo(rt registry.Route) bool { |
| + return c != nil && (c.Account != "" || c.App != "") && (c.Account != RouteAccount(rt) || c.App != rt.App) |
| +} |
| + |
| // SettingsDigest names one update_settings column exactly (the registry's |
| // text, as root and the agent both read it), for Consumed.Settings. |
| func SettingsDigest(col string) string { |
diff --git a/internal/release/tenantchange_test.go b/internal/release/tenantchange_test.go| new file mode 100644 |
| index 000000000..f1bcdcf62 |
| --- /dev/null |
| +++ b/internal/release/tenantchange_test.go |
| @@ -0,0 +1,209 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "reflect" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// settings-5 task 8 (ruling R26): a hostname that moves to another tenant on |
| +// one box meets the old tenant's consumed file, keyed by hostname only. A |
| +// file tagged for another tenant is absent to root's decision and to the |
| +// next file root writes; an untagged file from before the tag is read as |
| +// today, so its completions and resolutions still count for the tenant that |
| +// has the route. |
| + |
| +// writeForeignConsumed is tenant A's record for host: a manual decision, a |
| +// resolved request, a failed completion watermark and a baking head. |
| +func writeForeignConsumed(t *testing.T, opt, host string) { |
| + t.Helper() |
| + a := Consumed{ |
| + Host: host, Account: "cdf", App: "km", |
| + Resolved: &ResolvedRequest{ID: 7, Target: "v0"}, Completed: 6, CompletedFailed: true, |
| + Desired: "v0", Reason: string(ReasonManual), Detail: "v0", Baking: "v9", |
| + Settings: SettingsDigest(UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"}.Column()), |
| + } |
| + if err := WriteConsumed(opt, a); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestAForeignConsumedFileIsAbsentForTheNewTenant(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + for _, ta := range [][2]string{{"cdf", "km"}, {"dee", "blog"}} { |
| + shipRelease(t, s, ta[0], ta[1], "v1", adoptPlatform, []byte("v1")) |
| + if err := Promote(ctx, s, priv, ta[0], ta[1], "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + t.Run("default settings: a fresh rule 4 decision with B's identity", func(t *testing.T) { |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + const host = "a.example.com" |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog", Channel: "canary/x", Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + writeForeignConsumed(t, opt, host) |
| + passWithSettings(t, reg, s, pub, opt) |
| + if v, _ := LiveVersion(opt, host); v != "v1" { |
| + t.Fatalf("live %q, want B's head v1 (today's adoption)", v) |
| + } |
| + c, err := ReadConsumed(opt, host) |
| + if err != nil || c == nil { |
| + t.Fatalf("consumed = %+v, %v", c, err) |
| + } |
| + want := Consumed{Schema: 1, Host: host, Account: "dee", App: "blog", |
| + Desired: "v1", Reason: string(ReasonAdopt), Detail: "v1", Settings: SettingsDigest(""), At: c.At} |
| + if !reflect.DeepEqual(*c, want) { |
| + t.Fatalf("B's consumed file:\n got %+v\nwant %+v (B's identity, none of A's bookkeeping)", *c, want) |
| + } |
| + }) |
| + |
| + t.Run("an install request: A's watermark never swallows B's id", func(t *testing.T) { |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + const host = "b.example.com" |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog", Channel: "canary/x", Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetUpdateSettings(host, UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 3}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetBuildState(host, BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v0"}}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + writeForeignConsumed(t, opt, host) |
| + passWithSettings(t, reg, s, pub, opt) |
| + if v, _ := LiveVersion(opt, host); v != "v1" { |
| + t.Fatalf("live %q, want v1: B's request 3 resolved onto B's head", v) |
| + } |
| + c, _ := ReadConsumed(opt, host) |
| + if c == nil || c.Resolved == nil || *c.Resolved != (ResolvedRequest{ID: 3, Target: "v1"}) || |
| + c.Completed != 0 || c.CompletedFailed || c.Account != "dee" || c.App != "blog" { |
| + t.Fatalf("B's consumed file = %+v (resolved %+v)", c, c.Resolved) |
| + } |
| + }) |
| +} |
| + |
| +// An untagged file (root wrote it before ruling R22) is the route's own |
| +// record: read as today, it keeps a failed completion and a resolution that |
| +// only it holds, and root's next write tags it with the route's tenant. |
| +func TestAnUntaggedConsumedFileKeepsItsBookkeepingForTheSameTenant(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + for _, v := range []string{"v1", "v2", "v3"} { |
| + shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v)) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + manual5 := UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 5} |
| + state := BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v1", Completed: 4}} |
| + |
| + t.Run("a failed completion only the file holds is not re-resolved", func(t *testing.T) { |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + const host = "a.example.com" |
| + settingsRoute(t, reg, host, "km", "canary/x", manual5, state) |
| + legacy := Consumed{Host: host, Resolved: &ResolvedRequest{ID: 5, Target: "vbad"}, Completed: 5, CompletedFailed: true, |
| + Desired: "v1", Reason: string(ReasonManual), Detail: "v1"} |
| + if err := WriteConsumed(opt, legacy); err != nil { |
| + t.Fatal(err) |
| + } |
| + passWithSettings(t, reg, s, pub, opt) |
| + if v, _ := LiveVersion(opt, host); v != "v1" { |
| + t.Fatalf("live %q, want v1: request 5 is completed (failed), the instance is manual", v) |
| + } |
| + c, _ := ReadConsumed(opt, host) |
| + if c == nil || c.Completed != 5 || !c.CompletedFailed || c.Resolved == nil || *c.Resolved != (ResolvedRequest{ID: 5, Target: "vbad"}) { |
| + t.Fatalf("consumed = %+v (resolved %+v): the file's completion was lost", c, c.Resolved) |
| + } |
| + if c.Account != OpsAccount || c.App != "km" || c.Reason != string(ReasonManual) { |
| + t.Fatalf("consumed names %q/%q, reason %q", c.Account, c.App, c.Reason) |
| + } |
| + }) |
| + |
| + t.Run("a resolution only the file holds is followed", func(t *testing.T) { |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + const host = "b.example.com" |
| + settingsRoute(t, reg, host, "km", "canary/x", manual5, state) |
| + legacy := Consumed{Host: host, Resolved: &ResolvedRequest{ID: 5, Target: "v2"}, Completed: 4, |
| + Desired: "v2", Reason: string(ReasonRequest), Detail: "v2"} |
| + if err := WriteConsumed(opt, legacy); err != nil { |
| + t.Fatal(err) |
| + } |
| + passWithSettings(t, reg, s, pub, opt) |
| + if v, _ := LiveVersion(opt, host); v != "v2" { |
| + t.Fatalf("live %q, want v2: request 5 was resolved to v2, not re-resolved onto the head", v) |
| + } |
| + c, _ := ReadConsumed(opt, host) |
| + if c == nil || c.Resolved == nil || *c.Resolved != (ResolvedRequest{ID: 5, Target: "v2"}) { |
| + t.Fatalf("consumed = %+v: the resolution was lost", c) |
| + } |
| + }) |
| +} |
| + |
| +// NextConsumed itself never copies another tenant's bookkeeping, whatever |
| +// its caller read: a tagged foreign file starts the next record empty, and |
| +// an untagged file is carried as today. |
| +func TestNextConsumedStartsEmptyOverAForeignFile(t *testing.T) { |
| + const host = "a.example.com" |
| + route := registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog"} |
| + known := BuildStateCopy{Known: true, Replica: true} |
| + dec := TargetDecision{Version: "v1", Reason: ReasonAdopt, Detail: "v1"} |
| + bookkeeping := Consumed{Host: host, Resolved: &ResolvedRequest{ID: 7, Target: "v0"}, Completed: 6, CompletedFailed: true, Baking: "v9"} |
| + |
| + foreign := bookkeeping |
| + foreign.Account, foreign.App = "cdf", "km" |
| + got := NextConsumed(TargetInput{Route: route, Build: known, Consumed: &foreign}, dec) |
| + want := Consumed{Host: host, Account: "dee", App: "blog", Desired: "v1", Reason: "adopt", Detail: "v1", Settings: SettingsDigest("")} |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("over A's file:\n got %+v\nwant %+v", got, want) |
| + } |
| + |
| + sameAccount := bookkeeping |
| + sameAccount.Account, sameAccount.App = "dee", "km" |
| + if got := NextConsumed(TargetInput{Route: route, Build: known, Consumed: &sameAccount}, dec); got.Completed != 0 || got.Resolved != nil { |
| + t.Fatalf("another app on the same account passed its bookkeeping on: %+v", got) |
| + } |
| + |
| + legacy := bookkeeping |
| + got = NextConsumed(TargetInput{Route: route, Build: known, Consumed: &legacy}, dec) |
| + if got.Completed != 6 || !got.CompletedFailed || got.Resolved == nil || *got.Resolved != *legacy.Resolved { |
| + t.Fatalf("an untagged file lost its bookkeeping: %+v", got) |
| + } |
| + if got.Account != "dee" || got.App != "blog" { |
| + t.Fatalf("the untagged file was not tagged with the route's tenant: %q/%q", got.Account, got.App) |
| + } |
| +} |
| + |
| +// ForeignTo is the rule every reader shares: tagged, and for another tenant. |
| +func TestConsumedForeignTo(t *testing.T) { |
| + rt := registry.Route{Host: "a.example.com", Account: "dee", App: "blog"} |
| + cases := []struct { |
| + name string |
| + c *Consumed |
| + want bool |
| + }{ |
| + {"no file", nil, false}, |
| + {"untagged", &Consumed{}, false}, |
| + {"the route's tenant", &Consumed{Account: "dee", App: "blog"}, false}, |
| + {"another app", &Consumed{Account: "dee", App: "km"}, true}, |
| + {"another account", &Consumed{Account: "cdf", App: "blog"}, true}, |
| + {"half tagged, another account", &Consumed{Account: "cdf"}, true}, |
| + {"half tagged, the route's app", &Consumed{App: "blog"}, true}, |
| + } |
| + for _, c := range cases { |
| + if got := c.c.ForeignTo(rt); got != c.want { |
| + t.Errorf("%s: ForeignTo = %v, want %v", c.name, got, c.want) |
| + } |
| + } |
| + // A route with no valid account is the ops account's, as root tags it. |
| + ops := registry.Route{Host: "a.example.com", App: "km"} |
| + if (&Consumed{Account: OpsAccount, App: "km"}).ForeignTo(ops) { |
| + t.Error("the ops account's own file is foreign to its route") |
| + } |
| +} |
diff --git a/internal/release/updatetarget.go b/internal/release/updatetarget.go| index 9d14aa6c0..007737b92 100644 |
| --- a/internal/release/updatetarget.go |
| +++ b/internal/release/updatetarget.go |
| @@ -216,7 +216,9 @@ func tagInMajor(tag string, major int) bool { |
| // NextConsumed is root's consumed-file update for one decision (settings |
| // spec §4.3 rule 2; ruling R-MERGE): in is what Target decided on, and |
| // in.Consumed the file as read. root's pass writes the result before it |
| -// stages anything; the simulation calls the same function. |
| +// stages anything; the simulation calls the same function. A file |
| +// ForeignTo in.Route (another tenant's) is not carried: the record starts |
| +// empty (ruling R26). |
| // |
| // - dec.Fail completes that request as failed: above the file's watermark |
| // it becomes the new watermark; equal to it (T failed after it first |
| @@ -236,7 +238,9 @@ func tagInMajor(tag string, major int) bool { |
| // R22); At is the writer's. |
| func NextConsumed(in TargetInput, dec TargetDecision) Consumed { |
| next := Consumed{Host: in.Route.Host} |
| - if in.Consumed != nil { |
| + // Another tenant's file passes on nothing (ruling R26): the record starts |
| + // empty, whatever the caller read. An untagged file is carried as today. |
| + if in.Consumed != nil && !in.Consumed.ForeignTo(in.Route) { |
| next = *in.Consumed |
| next.Host = in.Route.Host |
| next.Resolved = copyResolved(in.Consumed.Resolved) |