package host import ( "context" "errors" "os" "strings" "testing" "github.com/carlosframework/platform/internal/release" ) // Settings plan 4, Astra round 1 findings 1, 4 and 5: the held wake's answer // (heldTarget, wakeTarget) and the pre-exec write's revalidation. const astraHost = "a.example.com" // decided writes root's consumed file as a decision made for the route's // current update_settings column (Consumed.Settings, ruling R-HELD-PURE). func decided(t *testing.T, b *buildStates, c release.Consumed) { t.Helper() rt, err := b.reg.Get(astraHost) if err != nil || rt == nil { t.Fatalf("route: %v", err) } c.Settings = release.SettingsDigest(rt.UpdateSettings) writeConsumed(t, b, c) } // installLive stages ver of km and points the host's live link at it, as // root's install leaves a box. func installLive(t *testing.T, b *buildStates, ver string) { t.Helper() if err := release.StageArtifact(b.optDir, "bab", ver, "km", []byte(ver)); err != nil { t.Fatal(err) } if err := release.PointLive(b.optDir, astraHost, "bab", ver, "km"); err != nil { t.Fatal(err) } if v, _ := release.LiveVersion(b.optDir, astraHost); v != ver { t.Fatalf("live = %q, want %s", v, ver) } } // Finding 4 (§4.3 rule 3; ruling R-HELD-PURE): a sleeper last ran B (major // 1); under auto, root cold-installed A (major 2). The member sets major 1. // Before root decides for the new settings, its consumed file still says // adopt, for the old column. The wake must not read that as "not held" and // run A against B's data: it holds on B and asks root. Root's decision for // the new column then decides: a major hold holds on B; an adoption (the head // is in major 1) is not held. No consumed file at all holds and asks too. func TestAMajorHoldIsNotBypassedBeforeRootDecidesForTheSettings(t *testing.T) { t.Parallel() b, _, reg := testBuildStates(t) installLive(t, b, "A") setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "B"}}) setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable"}) decided(t, b, release.Consumed{Reason: string(release.ReasonAdopt), Desired: "A", Detail: "A"}) if h := b.heldTarget(astraHost); h.Held { t.Fatalf("auto, root decided adoption for these settings: %+v; want today's wake", h) } one := 1 setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &one}) if h := b.heldTarget(astraHost); !h.Held || !h.AskRoot || h.Target != "B" || h.Staged != nil { t.Fatalf("major 1, root's decision is for the old settings: %+v; want held on B, asking root, never A", h) } decided(t, b, release.Consumed{Reason: string(release.ReasonMajor), Desired: "B", Detail: "B"}) if h := b.heldTarget(astraHost); !h.Held || h.AskRoot || h.Target != "B" { t.Fatalf("root's major hold for these settings: %+v; want held on B", h) } decided(t, b, release.Consumed{Reason: string(release.ReasonAdopt), Desired: "C", Detail: "C"}) if h := b.heldTarget(astraHost); h.Held { t.Fatalf("root's adoption for these settings: %+v; want today's wake", h) } if err := os.Remove(release.ConsumedPath(b.optDir, astraHost)); err != nil { t.Fatal(err) } if h := b.heldTarget(astraHost); !h.Held || !h.AskRoot || h.Target != "B" { t.Fatalf("no decision from root at all: %+v; want held on B, asking root", h) } } // Finding 5 (§4.3 failure precedence, §6): a legacy manual instance with its // build state read and absent, T installed, and root's decision that T // failed. The installed-build fallback is failure-checked like // last_executed: held on T and failed, never "not held" (which ran T). // // It runs twice: with root's file tagged for the route's tenant, as root // writes it now, and with the file left untagged, as a settings-4 build // wrote it (ruling R28: an untagged file is read as before task 8). func TestAFailedInstalledBuildWithNoBuildStateIsHeldFailed(t *testing.T) { t.Parallel() for _, untagged := range []bool{false, true} { b, _, reg := testBuildStates(t) installLive(t, b, "T") setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true}) write := func(c release.Consumed) { if !untagged { decided(t, b, c) return } rt, _ := reg.Get(astraHost) c.Settings = release.SettingsDigest(rt.UpdateSettings) writeConsumedAsIs(t, b, c) } write(release.Consumed{Reason: string(release.ReasonFailed), Detail: "T"}) if h := b.heldTarget(astraHost); !h.Held || !h.Failed || h.Target != "T" || h.Staged != nil { t.Fatalf("untagged %v: heldTarget = %+v; want T held and failed", untagged, h) } // Not failed: held on the installed build, as root's Target holds it. write(release.Consumed{Reason: string(release.ReasonManual), Desired: "T", Detail: "T"}) if h := b.heldTarget(astraHost); !h.Held || h.Failed || h.Target != "T" { t.Fatalf("untagged %v: heldTarget = %+v; want held on the installed T", untagged, h) } } } // stateGetFails fails every Get of a build state, as a bucket blip. type stateGetFails struct { release.Store err error } func (f stateGetFails) Get(ctx context.Context, key string) ([]byte, string, error) { if strings.HasSuffix(key, "/build-state.json") { return nil, "", f.err } return f.Store.Get(ctx, key) } // Finding 1, the wake's half (§4.3 stale-link refusal): a recovered // instance's copy says last_executed A, and A is live, while its durable // build state names B. The wake's refresh fails: the wake is refused, never // decided from the old copy (which runs A). func TestAHeldWakeWhoseRefreshFailedIsRefused(t *testing.T) { b, s, reg := testBuildStates(t) ctx := context.Background() installLive(t, b, "A") if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "B"}); err != nil { t.Fatal(err) } setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "A"}}) setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) decided(t, b, release.Consumed{Reason: string(release.ReasonManual), Desired: "A", Detail: "A"}) b.storeFor = storesOf(map[string]release.Store{"hib": stateGetFails{Store: s, err: errors.New("s3: 503")}}) if h := b.wakeTarget(ctx, astraHost); !h.Held || !h.Unread || h.Target == "A" { t.Fatalf("wakeTarget after a failed refresh = %+v; want refused as unread, never A", h) } // The registry keeps the failure, so the answer read again says the same. if h := b.heldTarget(astraHost); !h.Unread { t.Fatalf("heldTarget after the failed refresh = %+v; want unread", h) } // With the bucket back the wake targets the durable B. b.storeFor = storesOf(map[string]release.Store{"hib": s}) if h := b.wakeTarget(ctx, astraHost); !h.Held || h.Unread || h.Target != "B" { t.Fatalf("wakeTarget with the bucket back = %+v; want held on B", h) } } // Finding 1, the write's half: the wake decided on a stale copy (A), and the // pre-exec write reads the durable state under the lease (B). It names a // held target other than the build about to run, so the record is refused // as stale and the object is not touched; nothing runs. B itself records, // and a route with default settings records whatever runs, as today. func TestThePreExecWriteRevalidatesTheHeldTarget(t *testing.T) { b, s, reg := testBuildStates(t) ctx := context.Background() installLive(t, b, "A") if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "B"}); err != nil { t.Fatal(err) } setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "A"}}) setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) decided(t, b, release.Consumed{Reason: string(release.ReasonManual), Desired: "A", Detail: "A"}) err := b.beforeExec(ctx, astraHost, "A") if err == nil || !strings.Contains(err.Error(), "B") { t.Fatalf("beforeExec(A) over a durable B = %v; want a stale refusal naming B", err) } st, _, _ := getState(ctx, s, release.BuildStateKey("inst/"+astraHost), astraHost) if st == nil || st.LastExecuted != "B" { t.Fatalf("state after the refusal = %+v; want B untouched", st) } if err := b.beforeExec(ctx, astraHost, "B"); err != nil { t.Fatalf("beforeExec(B) = %v; the held target records", err) } setSettings(t, reg, release.UpdateSettings{}) if err := b.beforeExec(ctx, astraHost, "A"); err != nil { t.Fatalf("default settings: beforeExec(A) = %v; want today's record", err) } }