carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "reflect"
8 "strings"
9 "testing"
10
11 "github.com/carlosframework/platform/internal/hostclaim"
12 "github.com/carlosframework/platform/internal/provision"
13)
14
15// The box's side of `move resolve --retry` (moves spec §7.8 stuck, §7.10;
16// Plan 4 Task 7). The console door writes provision.MoveRetry and nothing
17// else (h.retry is the same transition through the same record writer); the
18// box then resumes the source's recovery at the phase the stuck rollback
19// recorded as StuckAt, with the stuck reason gone and the rollback's own
20// reason kept. A verification runs again and is never bypassed (INV-4); a
21// probe that ran out of attempts is called again with the whole budget;
22// nothing earlier than StuckAt runs again. The fixture is Task 5's.
23
24// verifications counts how many times the source ran its baseline check.
25func (h *recoverHarness) verifications() int {
26 n := 0
27 for _, p := range h.rcHits {
28 if p == "verify" {
29 n++
30 }
31 }
32 return n
33}
34
35// retried asserts what MoveRetry leaves for the box: rolling back at the
36// phase the stuck mark named, no stuck reason, no stuck-at, the cause kept.
37func (h *recoverHarness) retried(t *testing.T, at, cause string) {
38 t.Helper()
39 m := h.read(t, p1Host).Move
40 if m.State != provision.MoveRollingBack || m.StuckReason != "" || m.Rollback == nil || m.Rollback.SourcePhase != at || m.Rollback.StuckAt != "" {
41 t.Fatalf("after the retry: %+v %+v, want rolling_back at %s", m, m.Rollback, at)
42 }
43 if m.Rollback.Reason != cause {
44 t.Fatalf("the retry changed the rollback's cause: %q, want %q", m.Rollback.Reason, cause)
45 }
46}
47
48// TestRetryResumesTheReturnsVerification (J20, INV-4): a clean return whose
49// files no longer match the source's stopped baseline is stuck at verified.
50// A retry returns the record to verified, and the box verifies again, once
51// per retry; unrepaired it is stuck again with the same sentence and nothing
52// after the check has run; repaired it recovers. The retry never skips the
53// check, and never runs the hand-over, the claim write or anything before
54// verified a second time.
55func TestRetryResumesTheReturnsVerification(t *testing.T) {
56 t.Parallel()
57 h := newRecoverHarness(t, false)
58 rb := h.released(t)
59 cause := h.read(t, p1Host).Move.Rollback.Reason
60 upload := filepath.Join(h.sdir, p1Host, "upload.txt")
61 if err := os.WriteFile(upload, []byte("changed on the source"), 0o644); err != nil {
62 t.Fatal(err)
63 }
64 stuck := func(verified int) {
65 t.Helper()
66 m := h.read(t, p1Host).Move
67 if m.State != provision.MoveStuck || m.StuckReason != provision.StuckBaselineMismatch || m.Rollback.StuckAt != provision.RbSourceVerified {
68 t.Fatalf("not stuck at verified: %+v %+v", m, m.Rollback)
69 }
70 if n := h.verifications(); n != verified {
71 t.Fatalf("verified %d times, want %d (%v)", n, verified, h.rcHits)
72 }
73 if len(h.probes) != 0 {
74 t.Fatalf("probed past a mismatch: %+v", h.probes)
75 }
76 }
77 h.srcPass(t)
78 stuck(1)
79 handed := len(h.srcHanded)
80 if handed != 1 {
81 t.Fatalf("hand-overs before the retry: %v", h.srcHanded)
82 }
83 // Stuck is not driven on: a pass between the stuck mark and the retry
84 // verifies nothing.
85 h.srcPass(t)
86 stuck(1)
87
88 // The retry alone verifies nothing: it only moves the record.
89 h.retry(t)
90 h.retried(t, provision.RbSourceVerified, cause)
91 if n := h.verifications(); n != 1 {
92 t.Fatalf("the retry verified by itself: %d", n)
93 }
94 h.srcPass(t)
95 stuck(2)
96 h.retry(t)
97 h.srcPass(t)
98 stuck(3)
99
100 // The operator repairs the source; one more retry reaches opened.
101 if err := os.WriteFile(upload, []byte("hello"), 0o644); err != nil {
102 t.Fatal(err)
103 }
104 h.retry(t)
105 h.retried(t, provision.RbSourceVerified, cause)
106 h.srcPass(t)
107 got := h.opened(t)
108 if got.ID != rb.ID || got.StuckAt != "" || h.read(t, p1Host).Move.StuckReason != "" {
109 t.Fatalf("opened: %+v", got)
110 }
111 if n := h.verifications(); n != 4 {
112 t.Fatalf("verified %d times, want 4 (one per attempt)", n)
113 }
114 if len(h.srcHanded) != handed {
115 t.Fatalf("the retry handed the row over again: %v", h.srcHanded)
116 }
117 if len(h.probes) != 1 {
118 t.Fatalf("probes: %d, want the one after the repair", len(h.probes))
119 }
120}
121
122// TestRetryGivesTheProbeItsWholeBudgetAgain (J20): a recovery probe that ran
123// out of attempts is stuck at probing. A retry resumes at probing, so the
124// box calls the probe again with the full nine attempts (the counter starts
125// again at 0) and runs nothing before it: no verification, no install, no
126// claim step. The probe keeps its own count (Task 9 builds it on claim
127// Plan 10's attempts); the box's journal holds none to reset.
128func TestRetryGivesTheProbeItsWholeBudgetAgain(t *testing.T) {
129 t.Parallel()
130 h := newRecoverHarness(t, false)
131 h.released(t)
132 h.probe = func(recoveryProbeRequest) (recoveryProbeOutcome, error) { return recoveryProbeExhausted, nil }
133 h.srcPass(t)
134 cause := h.read(t, p1Host).Move.Rollback.Reason
135 stuck := func(probes int) {
136 t.Helper()
137 m := h.read(t, p1Host).Move
138 if m.State != provision.MoveStuck || m.StuckReason != provision.StuckRecoveryProbeAttempts || m.Rollback.StuckAt != provision.RbSourceProbing {
139 t.Fatalf("not stuck at probing: %+v %+v", m, m.Rollback)
140 }
141 if len(h.probes) != probes {
142 t.Fatalf("probe calls: %d, want %d", len(h.probes), probes)
143 }
144 }
145 stuck(1)
146 h.srcPass(t)
147 stuck(1) // stuck is not driven on
148 // The source's files change under the stuck probe, as a live app's would.
149 if err := os.WriteFile(filepath.Join(h.sdir, p1Host, "upload.txt"), []byte("written by the recovered app"), 0o644); err != nil {
150 t.Fatal(err)
151 }
152
153 h.retry(t)
154 h.retried(t, provision.RbSourceProbing, cause)
155 h.srcPass(t)
156 stuck(2)
157 h.retry(t)
158 h.probe = nil
159 h.srcPass(t)
160 h.opened(t)
161 if len(h.probes) != 3 {
162 t.Fatalf("probe calls: %d, want 3", len(h.probes))
163 }
164 for i, req := range h.probes {
165 if req.Attempts != 9 {
166 t.Fatalf("probe call %d carries a budget of %d, want the whole nine", i, req.Attempts)
167 }
168 if !req.Clean || req.Coverage != recoveryCoverageClean || req.SourceGeneration != "cur" {
169 t.Fatalf("probe call %d: %+v", i, req)
170 }
171 }
172 if n := h.verifications(); n != 1 {
173 t.Fatalf("verified %d times, want 1: a retry at probing never verifies again (INV-2)", n)
174 }
175 if len(h.srcHanded) != 1 {
176 t.Fatalf("hand-overs: %v", h.srcHanded)
177 }
178}
179
180// TestRetryResumesADirtyInstallAtLocal: a dirty return's install that found
181// the source's files occupied is stuck at local (the three install reasons
182// are legal at local and configured). A retry resumes the shared installer
183// at local from its journal, not from the start: the pre-move park, run
184// once, is not run again, and once the operator has cleared the files the
185// recovery reaches opened.
186func TestRetryResumesADirtyInstallAtLocal(t *testing.T) {
187 t.Parallel()
188 h := newRecoverHarness(t, true)
189 h.released(t)
190 sentinel := filepath.Join(h.sdir, p1Host, "sentinel.txt")
191 h.landFault = func(p string) error {
192 if p == "after-"+landStepRetired {
193 if err := os.MkdirAll(filepath.Dir(sentinel), 0o755); err != nil {
194 t.Fatal(err)
195 }
196 return os.WriteFile(sentinel, []byte("not the return's"), 0o644)
197 }
198 return nil
199 }
200 h.srcPass(t)
201 cause := h.read(t, p1Host).Move.Rollback.Reason
202 m := h.read(t, p1Host).Move
203 if m.State != provision.MoveStuck || m.StuckReason != provision.StuckReturnFilesOccupied || m.Rollback.StuckAt != provision.RbSourceLocal {
204 t.Fatalf("not stuck at local: %+v %+v", m, m.Rollback)
205 }
206 if len(h.probes) != 0 {
207 t.Fatalf("probed past a refused install: %+v", h.probes)
208 }
209 // Stuck, retried with the sentinel still there: stuck again, the same
210 // sentence, the sentinel untouched.
211 h.retry(t)
212 h.retried(t, provision.RbSourceLocal, cause)
213 h.srcPass(t)
214 if m := h.read(t, p1Host).Move; m.State != provision.MoveStuck || m.StuckReason != provision.StuckReturnFilesOccupied || m.Rollback.StuckAt != provision.RbSourceLocal {
215 t.Fatalf("an unrepaired retry: %+v %+v", m, m.Rollback)
216 }
217 if body, err := os.ReadFile(sentinel); err != nil || string(body) != "not the return's" {
218 t.Fatalf("the sentinel: %q %v", body, err)
219 }
220 // Repaired: the operator removes what is not the return's.
221 if err := os.Remove(sentinel); err != nil {
222 t.Fatal(err)
223 }
224 h.landFault = nil
225 h.retry(t)
226 h.srcPass(t)
227 rb := h.opened(t)
228 if body, err := os.ReadFile(provision.DefaultDBPath(h.sdir, p1Host) + ".gen"); err != nil || strings.TrimSpace(string(body)) != rb.ID {
229 t.Fatalf("the installed generation marker: %q %v", body, err)
230 }
231 if n := h.verifications(); n != 0 {
232 t.Fatalf("a dirty return verified the baseline %d times; it installs instead", n)
233 }
234 if len(h.probes) != 1 || h.probes[0].Clean || h.probes[0].Coverage != recoveryCoverageSealed || h.probes[0].Attempts != recoveryProbeAttempts {
235 t.Fatalf("probes: %+v", h.probes)
236 }
237}
238
239// TestRetryLeavesTheClaimAndTheRowAlone: the retry is a record write. It
240// does not touch the host claim or the registry row; the box's next pass
241// does what the phase asks.
242func TestRetryLeavesTheClaimAndTheRowAlone(t *testing.T) {
243 t.Parallel()
244 h := newRecoverHarness(t, false)
245 h.released(t)
246 if err := os.WriteFile(filepath.Join(h.sdir, p1Host, "upload.txt"), []byte("changed"), 0o644); err != nil {
247 t.Fatal(err)
248 }
249 h.srcPass(t)
250 claim, row := h.claim(t), h.srcRoute
251 h.retry(t)
252 after := h.claim(t)
253 if after.Op.Phase != hostclaim.PhaseClaimed || after.Op.Phase != claim.Op.Phase || after.Op.Exec != claim.Op.Exec {
254 t.Fatalf("the retry changed the claim: %+v -> %+v", claim.Op, after.Op)
255 }
256 if !reflect.DeepEqual(h.srcRoute, row) {
257 t.Fatalf("the retry changed the row: %+v -> %+v", row, h.srcRoute)
258 }
259 // A second retry, now that the move is not stuck, is the provision
260 // refusal the door speaks as its 409.
261 g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
262 if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveRetry(g)); err == nil || !strings.Contains(err.Error(), provision.RefusedNotStuck) {
263 t.Fatalf("a retry of a move that is not stuck: %v", err)
264 }
265}
266
267// countEvent counts event in events.
268func countEvent(events []string, event string) int {
269 n := 0
270 for _, e := range events {
271 if e == event {
272 n++
273 }
274 }
275 return n
276}
277
278// Final review I3: a retry's event comes from the box's own pass. After
279// `move resolve --retry` (the claim-backed MoveRetryRecovery, which advances
280// the epoch) the record is rolling_back or reverting, which movesPass sends
281// to the source's recovery or revert before phase1MoveRound; the pass still
282// logs retry-<epoch>, once, however many passes see it.
283func TestRetryEventIsLoggedByThePass(t *testing.T) {
284 t.Parallel()
285 t.Run("a rollback's source recovery", func(t *testing.T) {
286 h := newRecoverHarness(t, false)
287 h.released(t)
288 if err := os.WriteFile(filepath.Join(h.sdir, p1Host, "upload.txt"), []byte("changed on the source"), 0o644); err != nil {
289 t.Fatal(err)
290 }
291 h.srcPass(t)
292 if m := h.read(t, p1Host).Move; m.State != provision.MoveStuck {
293 t.Fatalf("not stuck: %+v", m)
294 }
295 h.retry(t)
296 if m := h.read(t, p1Host).Move; m.State != provision.MoveRollingBack || m.RecoveryRetryEpoch != 1 {
297 t.Fatalf("after the retry: %+v", m)
298 }
299 h.srcPass(t)
300 h.srcPass(t)
301 if n := countEvent(h.sEvents, "retry"); n != 1 {
302 t.Fatalf("retry events %d, want one: %v", n, h.sEvents)
303 }
304 })
305
306 t.Run("a revert", func(t *testing.T) {
307 h := newRevertHarness(t)
308 h.fromStopped(t)
309 h.arm(t)
310 if err := os.WriteFile(filepath.Join(h.cfg.DataDir, p1Host, "upload.txt"), []byte("changed while stopped"), 0o644); err != nil {
311 t.Fatal(err)
312 }
313 h.pass(t)
314 if m := h.move(t); m.State != provision.MoveStuck {
315 t.Fatalf("not stuck: %+v", m)
316 }
317 g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
318 if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, provision.MoveRetryRecovery(g)); err != nil {
319 t.Fatal(err)
320 }
321 if m := h.move(t); m.State != provision.MoveReverting || m.RecoveryRetryEpoch != 1 {
322 t.Fatalf("after the retry: %+v", m)
323 }
324 h.pass(t)
325 h.pass(t)
326 if n := countEvent(h.events, "retry"); n != 1 {
327 t.Fatalf("retry events %d, want one: %v", n, h.events)
328 }
329 })
330}
331