| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | ) |
| 12 | |
| 13 | // Settings plan 4, Astra round 1 findings 1, 4 and 5: the held wake's answer |
| 14 | // (heldTarget, wakeTarget) and the pre-exec write's revalidation. |
| 15 | |
| 16 | const astraHost = "a.example.com" |
| 17 | |
| 18 | // decided writes root's consumed file as a decision made for the route's |
| 19 | // current update_settings column (Consumed.Settings, ruling R-HELD-PURE). |
| 20 | func decided(t *testing.T, b *buildStates, c release.Consumed) { |
| 21 | t.Helper() |
| 22 | rt, err := b.reg.Get(astraHost) |
| 23 | if err != nil || rt == nil { |
| 24 | t.Fatalf("route: %v", err) |
| 25 | } |
| 26 | c.Settings = release.SettingsDigest(rt.UpdateSettings) |
| 27 | writeConsumed(t, b, c) |
| 28 | } |
| 29 | |
| 30 | // installLive stages ver of km and points the host's live link at it, as |
| 31 | // root's install leaves a box. |
| 32 | func installLive(t *testing.T, b *buildStates, ver string) { |
| 33 | t.Helper() |
| 34 | if err := release.StageArtifact(b.optDir, "bab", ver, "km", []byte(ver)); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | if err := release.PointLive(b.optDir, astraHost, "bab", ver, "km"); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | if v, _ := release.LiveVersion(b.optDir, astraHost); v != ver { |
| 41 | t.Fatalf("live = %q, want %s", v, ver) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // Finding 4 (§4.3 rule 3; ruling R-HELD-PURE): a sleeper last ran B (major |
| 46 | // 1); under auto, root cold-installed A (major 2). The member sets major 1. |
| 47 | // Before root decides for the new settings, its consumed file still says |
| 48 | // adopt, for the old column. The wake must not read that as "not held" and |
| 49 | // run A against B's data: it holds on B and asks root. Root's decision for |
| 50 | // the new column then decides: a major hold holds on B; an adoption (the head |
| 51 | // is in major 1) is not held. No consumed file at all holds and asks too. |
| 52 | func TestAMajorHoldIsNotBypassedBeforeRootDecidesForTheSettings(t *testing.T) { |
| 53 | t.Parallel() |
| 54 | b, _, reg := testBuildStates(t) |
| 55 | installLive(t, b, "A") |
| 56 | setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "B"}}) |
| 57 | setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable"}) |
| 58 | decided(t, b, release.Consumed{Reason: string(release.ReasonAdopt), Desired: "A", Detail: "A"}) |
| 59 | if h := b.heldTarget(astraHost); h.Held { |
| 60 | t.Fatalf("auto, root decided adoption for these settings: %+v; want today's wake", h) |
| 61 | } |
| 62 | one := 1 |
| 63 | setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &one}) |
| 64 | if h := b.heldTarget(astraHost); !h.Held || !h.AskRoot || h.Target != "B" || h.Staged != nil { |
| 65 | t.Fatalf("major 1, root's decision is for the old settings: %+v; want held on B, asking root, never A", h) |
| 66 | } |
| 67 | decided(t, b, release.Consumed{Reason: string(release.ReasonMajor), Desired: "B", Detail: "B"}) |
| 68 | if h := b.heldTarget(astraHost); !h.Held || h.AskRoot || h.Target != "B" { |
| 69 | t.Fatalf("root's major hold for these settings: %+v; want held on B", h) |
| 70 | } |
| 71 | decided(t, b, release.Consumed{Reason: string(release.ReasonAdopt), Desired: "C", Detail: "C"}) |
| 72 | if h := b.heldTarget(astraHost); h.Held { |
| 73 | t.Fatalf("root's adoption for these settings: %+v; want today's wake", h) |
| 74 | } |
| 75 | if err := os.Remove(release.ConsumedPath(b.optDir, astraHost)); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | if h := b.heldTarget(astraHost); !h.Held || !h.AskRoot || h.Target != "B" { |
| 79 | t.Fatalf("no decision from root at all: %+v; want held on B, asking root", h) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // Finding 5 (§4.3 failure precedence, §6): a legacy manual instance with its |
| 84 | // build state read and absent, T installed, and root's decision that T |
| 85 | // failed. The installed-build fallback is failure-checked like |
| 86 | // last_executed: held on T and failed, never "not held" (which ran T). |
| 87 | // |
| 88 | // It runs twice: with root's file tagged for the route's tenant, as root |
| 89 | // writes it now, and with the file left untagged, as a settings-4 build |
| 90 | // wrote it (ruling R28: an untagged file is read as before task 8). |
| 91 | func TestAFailedInstalledBuildWithNoBuildStateIsHeldFailed(t *testing.T) { |
| 92 | t.Parallel() |
| 93 | for _, untagged := range []bool{false, true} { |
| 94 | b, _, reg := testBuildStates(t) |
| 95 | installLive(t, b, "T") |
| 96 | setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) |
| 97 | setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true}) |
| 98 | write := func(c release.Consumed) { |
| 99 | if !untagged { |
| 100 | decided(t, b, c) |
| 101 | return |
| 102 | } |
| 103 | rt, _ := reg.Get(astraHost) |
| 104 | c.Settings = release.SettingsDigest(rt.UpdateSettings) |
| 105 | writeConsumedAsIs(t, b, c) |
| 106 | } |
| 107 | write(release.Consumed{Reason: string(release.ReasonFailed), Detail: "T"}) |
| 108 | if h := b.heldTarget(astraHost); !h.Held || !h.Failed || h.Target != "T" || h.Staged != nil { |
| 109 | t.Fatalf("untagged %v: heldTarget = %+v; want T held and failed", untagged, h) |
| 110 | } |
| 111 | // Not failed: held on the installed build, as root's Target holds it. |
| 112 | write(release.Consumed{Reason: string(release.ReasonManual), Desired: "T", Detail: "T"}) |
| 113 | if h := b.heldTarget(astraHost); !h.Held || h.Failed || h.Target != "T" { |
| 114 | t.Fatalf("untagged %v: heldTarget = %+v; want held on the installed T", untagged, h) |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // stateGetFails fails every Get of a build state, as a bucket blip. |
| 120 | type stateGetFails struct { |
| 121 | release.Store |
| 122 | err error |
| 123 | } |
| 124 | |
| 125 | func (f stateGetFails) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 126 | if strings.HasSuffix(key, "/build-state.json") { |
| 127 | return nil, "", f.err |
| 128 | } |
| 129 | return f.Store.Get(ctx, key) |
| 130 | } |
| 131 | |
| 132 | // Finding 1, the wake's half (§4.3 stale-link refusal): a recovered |
| 133 | // instance's copy says last_executed A, and A is live, while its durable |
| 134 | // build state names B. The wake's refresh fails: the wake is refused, never |
| 135 | // decided from the old copy (which runs A). |
| 136 | func TestAHeldWakeWhoseRefreshFailedIsRefused(t *testing.T) { |
| 137 | b, s, reg := testBuildStates(t) |
| 138 | ctx := context.Background() |
| 139 | installLive(t, b, "A") |
| 140 | if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "B"}); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "A"}}) |
| 144 | setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) |
| 145 | decided(t, b, release.Consumed{Reason: string(release.ReasonManual), Desired: "A", Detail: "A"}) |
| 146 | b.storeFor = storesOf(map[string]release.Store{"hib": stateGetFails{Store: s, err: errors.New("s3: 503")}}) |
| 147 | if h := b.wakeTarget(ctx, astraHost); !h.Held || !h.Unread || h.Target == "A" { |
| 148 | t.Fatalf("wakeTarget after a failed refresh = %+v; want refused as unread, never A", h) |
| 149 | } |
| 150 | // The registry keeps the failure, so the answer read again says the same. |
| 151 | if h := b.heldTarget(astraHost); !h.Unread { |
| 152 | t.Fatalf("heldTarget after the failed refresh = %+v; want unread", h) |
| 153 | } |
| 154 | // With the bucket back the wake targets the durable B. |
| 155 | b.storeFor = storesOf(map[string]release.Store{"hib": s}) |
| 156 | if h := b.wakeTarget(ctx, astraHost); !h.Held || h.Unread || h.Target != "B" { |
| 157 | t.Fatalf("wakeTarget with the bucket back = %+v; want held on B", h) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // Finding 1, the write's half: the wake decided on a stale copy (A), and the |
| 162 | // pre-exec write reads the durable state under the lease (B). It names a |
| 163 | // held target other than the build about to run, so the record is refused |
| 164 | // as stale and the object is not touched; nothing runs. B itself records, |
| 165 | // and a route with default settings records whatever runs, as today. |
| 166 | func TestThePreExecWriteRevalidatesTheHeldTarget(t *testing.T) { |
| 167 | b, s, reg := testBuildStates(t) |
| 168 | ctx := context.Background() |
| 169 | installLive(t, b, "A") |
| 170 | if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "B"}); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "A"}}) |
| 174 | setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) |
| 175 | decided(t, b, release.Consumed{Reason: string(release.ReasonManual), Desired: "A", Detail: "A"}) |
| 176 | err := b.beforeExec(ctx, astraHost, "A") |
| 177 | if err == nil || !strings.Contains(err.Error(), "B") { |
| 178 | t.Fatalf("beforeExec(A) over a durable B = %v; want a stale refusal naming B", err) |
| 179 | } |
| 180 | st, _, _ := getState(ctx, s, release.BuildStateKey("inst/"+astraHost), astraHost) |
| 181 | if st == nil || st.LastExecuted != "B" { |
| 182 | t.Fatalf("state after the refusal = %+v; want B untouched", st) |
| 183 | } |
| 184 | if err := b.beforeExec(ctx, astraHost, "B"); err != nil { |
| 185 | t.Fatalf("beforeExec(B) = %v; the held target records", err) |
| 186 | } |
| 187 | setSettings(t, reg, release.UpdateSettings{}) |
| 188 | if err := b.beforeExec(ctx, astraHost, "A"); err != nil { |
| 189 | t.Fatalf("default settings: beforeExec(A) = %v; want today's record", err) |
| 190 | } |
| 191 | } |
| 192 | |