| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | ) |
| 6 | |
| 7 | func ip(n int) *int { return &n } |
| 8 | |
| 9 | type tcase struct { |
| 10 | name string |
| 11 | in TargetInput |
| 12 | want TargetDecision |
| 13 | } |
| 14 | |
| 15 | func head(v string) *ChannelPointer { return &ChannelPointer{Version: v} } |
| 16 | |
| 17 | func known(replica bool, b *BuildState) BuildStateCopy { |
| 18 | return BuildStateCopy{Known: true, Replica: replica, State: b} |
| 19 | } |
| 20 | |
| 21 | func runTargetCases(t *testing.T, cases []tcase) { |
| 22 | t.Helper() |
| 23 | for _, c := range cases { |
| 24 | got := Target(c.in) |
| 25 | w := c.want |
| 26 | if got.Version != w.Version || got.Reason != w.Reason || got.Evidence != w.Evidence || got.Bake != w.Bake || |
| 27 | got.Fail != w.Fail || (got.Resolve == nil) != (w.Resolve == nil) || |
| 28 | (got.Resolve != nil && *got.Resolve != *w.Resolve) { |
| 29 | t.Errorf("%s:\n got %+v\nwant %+v", c.name, got, w) |
| 30 | } |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func auto() UpdateSettings { return UpdateSettings{Updates: UpdatesAuto, Channel: "stable"} } |
| 35 | func manual() UpdateSettings { return UpdateSettings{Updates: UpdatesManual, Channel: "stable"} } |
| 36 | |
| 37 | func TestTargetRules(t *testing.T) { |
| 38 | failedV9 := func(v string) bool { return v == "v9" } |
| 39 | runTargetCases(t, []tcase{ |
| 40 | {"auto: today's adoption", TargetInput{Pointer: head("v3"), Settings: auto(), Installed: "v2"}, |
| 41 | TargetDecision{Version: "v3", Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true}}, |
| 42 | {"pin: the pinned build, a record, no bake", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1"}, Installed: "v3"}, |
| 43 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| 44 | // Astra I3 #6: the request is still resolved at the start of the pass, |
| 45 | // pin or no pin, onto the head eligible now; the pin decides what runs. |
| 46 | {"pin outranks manual and a request, which still resolves", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, Pin: "v1", InstallRequest: 5}, Build: known(false, nil)}, |
| 47 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 5, Target: "v3"}}}, |
| 48 | {"pin, request already resolved to T: the pin runs, T is kept for later", TargetInput{Pointer: head("v4"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, |
| 49 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}}, |
| 50 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| 51 | {"pin, the request's T failed: completes failed under the pin", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, |
| 52 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}, Completed: 1}, Failed: failedV9, Build: known(false, nil)}, |
| 53 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Fail: 2}}, |
| 54 | {"pin, request waiting on bake: unresolved", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, Build: known(false, nil)}, |
| 55 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| 56 | {"pending request, head eligible: resolves to the head", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 57 | Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"}, |
| 58 | TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer, Resolve: &ResolvedRequest{ID: 2, Target: "v3"}}}, |
| 59 | {"pending request, head baking: unresolved, held instance recovers", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 60 | Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v0"}, |
| 61 | TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| 62 | {"pending request, head baking, FastTrack: resolves anyway", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2, InstallFastTrack: true}, |
| 63 | Build: known(true, &BuildState{Completed: 1}), Installed: "v1"}, |
| 64 | TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer, Resolve: &ResolvedRequest{ID: 2, Target: "v3"}}}, |
| 65 | {"resolved request, T no longer the head: a record", TargetInput{Pointer: head("v4"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 66 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}, Installed: "v1", Build: known(false, nil)}, |
| 67 | TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}}, |
| 68 | {"resolved request ignores manual and major", TargetInput{Pointer: head("v3"), HeadTag: "2.0.0", Settings: UpdateSettings{Updates: UpdatesManual, Major: ip(1), InstallRequest: 2}, |
| 69 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}, Build: known(false, nil)}, |
| 70 | TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer}}, |
| 71 | {"resolved request whose T failed: completes failed, installs nothing", TargetInput{Pointer: head("v9"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 72 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}}, Failed: failedV9, Build: known(true, &BuildState{LastExecuted: "v1"}), Installed: "v1"}, |
| 73 | TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord, Fail: 2}}, |
| 74 | {"manual, build state differs from installed: install last_executed", TargetInput{Pointer: head("v3"), Settings: manual(), |
| 75 | Build: known(true, &BuildState{LastExecuted: "v1"}), Installed: "v2"}, |
| 76 | TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| 77 | {"manual, no build state, something installed: keep it", TargetInput{Pointer: head("v3"), Settings: manual(), Build: known(true, nil), Installed: "v2"}, |
| 78 | TargetDecision{Version: "v2", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| 79 | {"manual, no build state, nothing installed, a replica: unknown", TargetInput{Pointer: head("v3"), Settings: manual(), Build: known(true, nil)}, |
| 80 | TargetDecision{Reason: ReasonUnknown}}, |
| 81 | {"manual, none of these: new, gets the eligible head", TargetInput{Pointer: head("v3"), Settings: manual(), Build: known(false, nil)}, |
| 82 | TargetDecision{Version: "v3", Reason: ReasonNew, Evidence: EvidencePointer, Bake: true}}, |
| 83 | {"major matching head tag: today's adoption", TargetInput{Pointer: head("v3"), HeadTag: "2.1.0", Settings: UpdateSettings{Updates: UpdatesAuto, Channel: "stable", Major: ip(2)}, Installed: "v2"}, |
| 84 | TargetDecision{Version: "v3", Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true}}, |
| 85 | {"major, head of another major: held", TargetInput{Pointer: head("v3"), HeadTag: "3.0.0", Settings: UpdateSettings{Updates: UpdatesAuto, Channel: "stable", Major: ip(2)}, |
| 86 | Build: known(true, &BuildState{LastExecuted: "v2"}), Installed: "v2"}, |
| 87 | TargetDecision{Version: "v2", Reason: ReasonMajor, Evidence: EvidenceRecord}}, |
| 88 | {"major, untagged head: held", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Channel: "stable", Major: ip(2)}, |
| 89 | Build: known(true, &BuildState{LastExecuted: "v2"}), Installed: "v2"}, |
| 90 | TargetDecision{Version: "v2", Reason: ReasonMajor, Evidence: EvidenceRecord}}, |
| 91 | {"auto onto a failed head: nothing new", TargetInput{Pointer: head("v9"), Settings: auto(), Installed: "v2", Failed: failedV9}, |
| 92 | TargetDecision{Reason: ReasonFailed}}, |
| 93 | }) |
| 94 | } |
| 95 | |
| 96 | // Review Focus 3. |
| 97 | func TestFailureRecordsOutrankAPin(t *testing.T) { |
| 98 | runTargetCases(t, []tcase{{"pin of a failed build", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v9"}, |
| 99 | Installed: "v2", Failed: func(v string) bool { return v == "v9" }}, TargetDecision{Reason: ReasonFailed}}}) |
| 100 | } |
| 101 | |
| 102 | // Review Focus 2. |
| 103 | func TestARequestBelowTheWatermarkNeverRuns(t *testing.T) { |
| 104 | runTargetCases(t, []tcase{{"completed in the build state, older local file", TargetInput{Pointer: head("v4"), |
| 105 | Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 3}, |
| 106 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2}, |
| 107 | Build: known(true, &BuildState{LastExecuted: "v3", Completed: 3}), Installed: "v3"}, |
| 108 | TargetDecision{Version: "v3", Reason: ReasonManual, Evidence: EvidenceRecord}}}) |
| 109 | } |
| 110 | |
| 111 | // Review Focus 4. |
| 112 | func TestAnUnreadBuildStateIsNeverNew(t *testing.T) { |
| 113 | runTargetCases(t, []tcase{{"held, build state never read", TargetInput{Pointer: head("v3"), Settings: manual(), Build: BuildStateCopy{}}, |
| 114 | TargetDecision{Reason: ReasonUnknown}}}) |
| 115 | } |
| 116 | |
| 117 | // Review Focus 5. |
| 118 | func TestAResolutionInTheBuildStateIsReused(t *testing.T) { |
| 119 | runTargetCases(t, []tcase{{"resolved on another box", TargetInput{Pointer: head("v5"), |
| 120 | Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 4}, |
| 121 | Build: known(true, &BuildState{LastExecuted: "v2", Resolved: &ResolvedRequest{ID: 4, Target: "v3"}, Completed: 3})}, |
| 122 | TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}}}) |
| 123 | } |
| 124 | |
| 125 | // Astra I1 #10: T ran (the request completed at the pre-exec write), then |
| 126 | // failed its health check. The completed request is marked failed once, |
| 127 | // and is not reopened. |
| 128 | func TestAFailureAfterCompletionMarksTheRequestFailed(t *testing.T) { |
| 129 | failedV3 := func(v string) bool { return v == "v3" } |
| 130 | runTargetCases(t, []tcase{ |
| 131 | {"completed, then T failed", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 132 | Build: known(true, &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 2}), |
| 133 | Installed: "v1", Failed: failedV3}, |
| 134 | TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord, Fail: 2}}, |
| 135 | {"already recorded as failed: not again", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 136 | Build: known(true, &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 2, CompletedFailed: true}), |
| 137 | Installed: "v1", Failed: failedV3}, |
| 138 | TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| 139 | }) |
| 140 | } |
| 141 | |
| 142 | // Astra I1 #9: rules 1-3 do not need a readable head. |
| 143 | func TestIndependentTargetsNeedNoPointer(t *testing.T) { |
| 144 | runTargetCases(t, []tcase{ |
| 145 | {"pin, no pointer", TargetInput{Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1"}}, |
| 146 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| 147 | {"resolved request, no pointer", TargetInput{Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 148 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}, Build: known(false, nil)}, |
| 149 | TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}}, |
| 150 | {"held recovery, no pointer", TargetInput{Settings: manual(), Build: known(true, &BuildState{LastExecuted: "v1"})}, |
| 151 | TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| 152 | {"new held instance, no pointer: nothing yet", TargetInput{Settings: manual(), Build: known(false, nil)}, |
| 153 | TargetDecision{Reason: ReasonNew}}, |
| 154 | {"auto, no pointer: nothing", TargetInput{Settings: auto()}, TargetDecision{}}, |
| 155 | }) |
| 156 | } |
| 157 | |
| 158 | // Spec §8: a held sleeper with a pending request wakes onto T, which the |
| 159 | // wake check reads from this same decision. |
| 160 | func TestAPendingTOutranksLastExecuted(t *testing.T) { |
| 161 | runTargetCases(t, []tcase{{"resolved T pending while last_executed is older", TargetInput{Pointer: head("v3"), |
| 162 | Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 163 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}, |
| 164 | Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v3"}, |
| 165 | TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidencePointer}}}) |
| 166 | } |
| 167 | |
| 168 | // R-TARGET-UNKNOWN: with the build state unread the watermark sees only the |
| 169 | // consumed file, so a request that already completed would look pending on a |
| 170 | // fresh box. Target then does no request bookkeeping at all. |
| 171 | func TestAnUnreadBuildStateDoesNoRequestBookkeeping(t *testing.T) { |
| 172 | runTargetCases(t, []tcase{ |
| 173 | {"held, completed request looks pending: unknown, nothing resolved", TargetInput{Pointer: head("v7"), |
| 174 | Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 5}, Build: BuildStateCopy{}}, |
| 175 | TargetDecision{Reason: ReasonUnknown}}, |
| 176 | {"auto, FastTrack, baking head: today's adoption, nothing resolved", TargetInput{Pointer: head("v7"), HeadHeld: "held 20h", |
| 177 | Settings: UpdateSettings{Updates: UpdatesAuto, InstallRequest: 5, InstallFastTrack: true}, Build: BuildStateCopy{}}, |
| 178 | TargetDecision{Version: "v7", Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true}}, |
| 179 | {"pin, pending request: the pin, nothing resolved", TargetInput{Pointer: head("v7"), |
| 180 | Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 5}, Build: BuildStateCopy{}}, |
| 181 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| 182 | {"resolved T failed, build state unread: no failure completion", TargetInput{Pointer: head("v7"), |
| 183 | Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 5}, |
| 184 | Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 5, Target: "v9"}}, Failed: func(v string) bool { return v == "v9" }}, |
| 185 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| 186 | }) |
| 187 | } |
| 188 | |
| 189 | // R-TARGET-FAILEDHEAD: a pending request whose eligible head this box holds |
| 190 | // as failed is resolved to that head and completed failed in the same pass. |
| 191 | // It installs nothing itself; the later rules decide what runs, and the |
| 192 | // failed head never becomes the Version. |
| 193 | func TestAPendingRequestOnAFailedHeadCompletesFailed(t *testing.T) { |
| 194 | failedV9 := func(v string) bool { return v == "v9" } |
| 195 | runTargetCases(t, []tcase{ |
| 196 | {"manual: the held build runs, request resolved and failed", TargetInput{Pointer: head("v9"), Failed: failedV9, |
| 197 | Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 198 | Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"}, |
| 199 | TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 2, Target: "v9"}, Fail: 2}}, |
| 200 | {"auto: nothing new, request resolved and failed", TargetInput{Pointer: head("v9"), Failed: failedV9, |
| 201 | Settings: UpdateSettings{Updates: UpdatesAuto, InstallRequest: 2}, Build: known(true, nil), Installed: "v2"}, |
| 202 | TargetDecision{Reason: ReasonFailed, Resolve: &ResolvedRequest{ID: 2, Target: "v9"}, Fail: 2}}, |
| 203 | {"pin: the pin runs, request resolved and failed", TargetInput{Pointer: head("v9"), Failed: failedV9, |
| 204 | Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, Build: known(true, nil)}, |
| 205 | TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 2, Target: "v9"}, Fail: 2}}, |
| 206 | {"baking failed head: still unresolved", TargetInput{Pointer: head("v9"), Failed: failedV9, HeadHeld: "held 20h", |
| 207 | Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| 208 | Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"}, |
| 209 | TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| 210 | }) |
| 211 | } |
| 212 | |
| 213 | // Ruling R-RESOLUTION-STATE-WINS (Task 11 sweep, seed 531): box A resolved R |
| 214 | // to v1 and lost the instance to a move before any wake; box B resolved R |
| 215 | // again, to v4, and a wake carried that into the build state; back on A, the |
| 216 | // file and the build state name different builds for the same R. The build |
| 217 | // state's T is followed, root's file is rewritten to it, and the wake that |
| 218 | // runs it completes R. Following the file ran v1 forever with R pending. |
| 219 | func TestTheBuildStatesResolutionOutranksTheBoxsFile(t *testing.T) { |
| 220 | in := TargetInput{Pointer: head("v5"), HeadTag: "1.5.0", |
| 221 | Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 1, InstallFastTrack: true}, |
| 222 | Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 1, Target: "v1"}}, |
| 223 | Build: known(true, &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 1, Target: "v4"}}), Installed: "v1"} |
| 224 | runTargetCases(t, []tcase{{"same request, two builds: the build state's T", in, |
| 225 | TargetDecision{Version: "v4", Reason: ReasonRequest, Evidence: EvidenceRecord}}}) |
| 226 | dec := Target(in) |
| 227 | c := NextConsumed(in, dec) |
| 228 | if c.Resolved == nil || c.Resolved.ID != 1 || c.Resolved.Target != "v4" { |
| 229 | t.Fatalf("consumed after the pass = %+v; want the build state's R1->v4", c.Resolved) |
| 230 | } |
| 231 | st := NextBuildState(in.Build.State, &c, dec.Version) |
| 232 | if st.LastExecuted != "v4" || st.Completed != 1 || st.CompletedFailed { |
| 233 | t.Fatalf("build state after the wake = %+v; want R1 completed by v4", st) |
| 234 | } |
| 235 | } |
| 236 | |