carlos / platform Public

settings-5 task 8: a host that changes tenant resolves its update settings fresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1b603228c533045f6935cb80c21c19505b85ce01 parent 5c57c95
9 files changed, +457 −11
  • internal/host/buildstate.go +22 −5
  • internal/host/buildstate_test.go +7 −0
  • internal/host/tenantchange_test.go +125 −0
  • internal/registry/registry.go +11 −2
  • internal/registry/updatesettings_test.go +50 −0
  • internal/release/adopt.go +14 −2
  • internal/release/buildstate.go +13 −0
  • internal/release/tenantchange_test.go +209 −0
  • internal/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)