| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io/fs" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "sort" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/park" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/lease" |
| 20 | "github.com/carlosframework/platform/internal/moves" |
| 21 | "github.com/carlosframework/platform/internal/provision" |
| 22 | "github.com/carlosframework/platform/internal/release" |
| 23 | ) |
| 24 | |
| 25 | const mvEvMoveID = "mv_abcdefghijklmnopqrstuvwxyz" |
| 26 | |
| 27 | // evRec is a record whose move is in the given state, edited by mut. |
| 28 | func mvEvRec(state string, mut func(*provision.MoveState)) provision.Record { |
| 29 | m := &provision.MoveState{ID: mvEvMoveID, Incarnation: strings.Repeat("ab", 32), From: "ie", To: "uk", Cut: "auto", State: state, RequestedAt: time.Unix(1_800_000_000, 0).UTC()} |
| 30 | if mut != nil { |
| 31 | mut(m) |
| 32 | } |
| 33 | return provision.Record{Account: "bab", App: "titogo", Host: "acme.go.tito.io", Region: "ie", Move: m} |
| 34 | } |
| 35 | |
| 36 | func mvEvAt(sec int64) *time.Time { |
| 37 | t := time.Unix(1_800_000_000+sec, 0).UTC() |
| 38 | return &t |
| 39 | } |
| 40 | |
| 41 | // The events a full move that ends done goes through, each record the one a |
| 42 | // writer leaves, in order. The console's request is seen by the source's |
| 43 | // pass (requested); every later record is a box writer's. |
| 44 | func doneFlow() []provision.Record { |
| 45 | cut := func(phase string) provision.Record { |
| 46 | return mvEvRec(provision.MoveCutting, func(m *provision.MoveState) { |
| 47 | m.CutPhase, m.CutAt, m.CutBudgetS = phase, mvEvAt(10), 45 |
| 48 | }) |
| 49 | } |
| 50 | land := func(phase string, attempts int) provision.Record { |
| 51 | r := mvEvRec(provision.MoveLanding, func(m *provision.MoveState) { |
| 52 | m.CutPhase, m.CutAt, m.CutBudgetS, m.FlippedAt, m.LandingPhase = provision.CutFlipped, mvEvAt(10), 45, mvEvAt(20), phase |
| 53 | if attempts > 0 { |
| 54 | m.Landing = &provision.MoveLandingJournal{Attempts: attempts} |
| 55 | } |
| 56 | }) |
| 57 | r.Region = "uk" |
| 58 | return r |
| 59 | } |
| 60 | committed := func(mut func(*provision.MoveState)) provision.Record { |
| 61 | r := mvEvRec(provision.MoveCommitted, func(m *provision.MoveState) { |
| 62 | m.CutPhase, m.CutAt, m.FlippedAt, m.CommittedAt = provision.CutFlipped, mvEvAt(10), mvEvAt(20), mvEvAt(40) |
| 63 | m.Landing = &provision.MoveLandingJournal{Attempts: 1} |
| 64 | if mut != nil { |
| 65 | mut(m) |
| 66 | } |
| 67 | }) |
| 68 | r.Region = "uk" |
| 69 | return r |
| 70 | } |
| 71 | closed := provision.Record{Account: "bab", App: "titogo", Host: "acme.go.tito.io", Region: "uk", |
| 72 | LastMove: &provision.MoveOutcome{ID: mvEvMoveID, Outcome: "done", At: *mvEvAt(60), OfflineS: 41.2}} |
| 73 | return []provision.Record{ |
| 74 | mvEvRec(provision.MovePreparing, nil), |
| 75 | mvEvRec(provision.MoveReady, nil), |
| 76 | cut(provision.CutRequested), |
| 77 | cut(provision.CutAdmitted), cut(provision.CutStopping), cut(provision.CutStopped), cut(provision.CutStaged), cut(provision.CutArchived), |
| 78 | land(provision.LandChecking, 0), |
| 79 | land(provision.LandParking, 0), land(provision.LandBuilding, 0), land(provision.LandPublished, 0), land(provision.LandLocal, 0), |
| 80 | land(provision.LandConfigured, 0), land(provision.LandRestored, 0), land(provision.LandProbing, 0), land(provision.LandProbing, 1), |
| 81 | land(provision.LandProbed, 1), |
| 82 | committed(nil), |
| 83 | committed(func(m *provision.MoveState) { m.OpenedAt = mvEvAt(51) }), |
| 84 | committed(func(m *provision.MoveState) { m.OpenedAt, m.SourceParkedAt = mvEvAt(51), mvEvAt(55) }), |
| 85 | closed, |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | var doneFlowEvents = []string{ |
| 90 | "requested", "ready", "cut_requested", "admitted", "stopping", "stopped", "staged", "archived", "flipped", |
| 91 | "parking", "building", "published", "local", "configured", "restored", "probing", "probe_attempt", "probed", |
| 92 | "committed", "opened", "source_parked", "done", |
| 93 | } |
| 94 | |
| 95 | // eventWorker is a worker whose LogEvent hook records every event it is |
| 96 | // handed. |
| 97 | func eventWorker(got *[]moves.LogEvent) *movesWorker { |
| 98 | w := &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| 99 | w.cfg.Box = "box-test" |
| 100 | w.cfg.Now = func() time.Time { return time.Unix(1_800_000_100, 0).UTC() } |
| 101 | w.cfg.LogEvent = func(_ context.Context, _ release.Store, _ provision.Record, ev moves.LogEvent) error { |
| 102 | *got = append(*got, ev) |
| 103 | return nil |
| 104 | } |
| 105 | return w |
| 106 | } |
| 107 | |
| 108 | // Review Focus 3: across a full done flow, every transition gives one event, |
| 109 | // in order, with its identity; the record seen again (a status poll, a pass |
| 110 | // that changes nothing, a replay of the same write) gives none. |
| 111 | func TestEveryTransitionEmitsOneEvent(t *testing.T) { |
| 112 | ctx := context.Background() |
| 113 | var got []moves.LogEvent |
| 114 | w := eventWorker(&got) |
| 115 | flow := doneFlow() |
| 116 | w.observeMove(ctx, nil, flow[0]) |
| 117 | for i := 1; i < len(flow); i++ { |
| 118 | w.logTransition(ctx, nil, flow[i-1], flow[i]) |
| 119 | // A status poll, a pass that changes nothing, a replayed write. A |
| 120 | // closed move's identities are forgotten (create-only storage refuses |
| 121 | // a replay of the close), so the close is not replayed here. |
| 122 | w.observeMove(ctx, nil, flow[i]) |
| 123 | w.logTransition(ctx, nil, flow[i], flow[i]) |
| 124 | if i < len(flow)-1 { |
| 125 | w.logTransition(ctx, nil, flow[i-1], flow[i]) |
| 126 | } |
| 127 | } |
| 128 | var events []string |
| 129 | for _, ev := range got { |
| 130 | events = append(events, ev.Event) |
| 131 | if ev.Op != mvEvMoveID || ev.Host != "acme.go.tito.io" || ev.Actor != "box-test" || ev.V != 1 || ev.At.IsZero() { |
| 132 | t.Errorf("event %+v", ev) |
| 133 | } |
| 134 | } |
| 135 | if strings.Join(events, " ") != strings.Join(doneFlowEvents, " ") { |
| 136 | t.Fatalf("events\n got %v\nwant %v", events, doneFlowEvents) |
| 137 | } |
| 138 | ids := map[string]bool{} |
| 139 | for _, ev := range got { |
| 140 | if ids[ev.Identity] { |
| 141 | t.Fatalf("identity %q twice", ev.Identity) |
| 142 | } |
| 143 | ids[ev.Identity] = true |
| 144 | } |
| 145 | for _, want := range []string{"requested", "ready", "cutting-requested", "cutting-stopped", "cutting-flipped", "landing-probing", "probe_attempt-1", "committed", "committed-opened", "done"} { |
| 146 | if !ids[want] { |
| 147 | t.Errorf("no event with identity %q in %v", want, ids) |
| 148 | } |
| 149 | } |
| 150 | if f := got[len(got)-1].Fields; f["offline_s"] != "41.2" { |
| 151 | t.Errorf("done carries %v, want offline_s", f) |
| 152 | } |
| 153 | if f := got[19].Fields; got[19].Event != "opened" || f["offline_s"] != "41" { |
| 154 | t.Errorf("opened carries %v, want offline_s from cut_at to the open", f) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // A rollback, a stuck and a retry: the rollback's phases are named for the |
| 159 | // half that moved; stuck and retry are keyed by the recovery retry epoch |
| 160 | // (claim Plan 10's RecoveryRetryEpoch, preflight L11). |
| 161 | func TestRollbackStuckAndRetryEvents(t *testing.T) { |
| 162 | ctx := context.Background() |
| 163 | var got []moves.LogEvent |
| 164 | w := eventWorker(&got) |
| 165 | rb := func(target, source string, mut func(*provision.MoveState)) provision.Record { |
| 166 | return mvEvRec(provision.MoveRollingBack, func(m *provision.MoveState) { |
| 167 | m.CutAt = mvEvAt(10) |
| 168 | m.Rollback = &provision.MoveRollback{ID: "rb_x", Reason: provision.RollbackLandingDeadline, TargetPhase: target, SourcePhase: source} |
| 169 | if mut != nil { |
| 170 | mut(m) |
| 171 | } |
| 172 | }) |
| 173 | } |
| 174 | stuck := func(epoch uint64) provision.Record { |
| 175 | return mvEvRec(provision.MoveStuck, func(m *provision.MoveState) { |
| 176 | m.CutAt = mvEvAt(10) |
| 177 | m.Rollback = &provision.MoveRollback{ID: "rb_x", TargetPhase: provision.RbTargetCleaned, SourcePhase: provision.RbSourceVerified, StuckAt: provision.RbSourceVerified} |
| 178 | m.StuckReason, m.RecoveryRetryEpoch = provision.StuckBaselineMismatch, epoch |
| 179 | }) |
| 180 | } |
| 181 | landing := mvEvRec(provision.MoveLanding, func(m *provision.MoveState) { m.LandingPhase = provision.LandProbing }) |
| 182 | seq := []provision.Record{ |
| 183 | landing, |
| 184 | rb(provision.RbTargetReleasing, provision.RbSourceWaiting, nil), |
| 185 | rb(provision.RbTargetReleased, provision.RbSourceWaiting, nil), |
| 186 | rb(provision.RbTargetReleased, provision.RbSourceClaimed, nil), |
| 187 | rb(provision.RbTargetCleaned, provision.RbSourceVerified, nil), |
| 188 | stuck(0), |
| 189 | } |
| 190 | for i := 1; i < len(seq); i++ { |
| 191 | w.logTransition(ctx, nil, seq[i-1], seq[i]) |
| 192 | } |
| 193 | // The operator's retry (the console's write, seen by the box) advances |
| 194 | // the epoch and leaves stuck; the second stuck is a new entry. |
| 195 | retried := rb(provision.RbTargetCleaned, provision.RbSourceVerified, func(m *provision.MoveState) { m.RecoveryRetryEpoch = 1 }) |
| 196 | w.observeMove(ctx, nil, retried) |
| 197 | w.observeMove(ctx, nil, retried) |
| 198 | w.logTransition(ctx, nil, retried, stuck(1)) |
| 199 | w.logTransition(ctx, nil, stuck(1), provision.Record{Account: "bab", App: "titogo", Host: "acme.go.tito.io", Region: "ie", |
| 200 | LastMove: &provision.MoveOutcome{ID: mvEvMoveID, Outcome: "rolled_back", At: *mvEvAt(90), OfflineS: 80}}) |
| 201 | var events, ids []string |
| 202 | for _, ev := range got { |
| 203 | events = append(events, ev.Event) |
| 204 | ids = append(ids, ev.Identity) |
| 205 | } |
| 206 | want := "rolling_back released claimed cleaned stuck retry stuck rolled_back" |
| 207 | if strings.Join(events, " ") != want { |
| 208 | t.Fatalf("events %v, want %s", events, want) |
| 209 | } |
| 210 | wantIDs := "rolling_back rolling_back-target-released rolling_back-source-claimed rolling_back-target-cleaned stuck-0 retry-1 stuck-1 rolled_back" |
| 211 | if strings.Join(ids, " ") != wantIDs { |
| 212 | t.Fatalf("identities %v, want %s", ids, wantIDs) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | type memberSink struct{ events []string } |
| 217 | |
| 218 | func (s *memberSink) App(acct, app, host, line string) {} |
| 219 | func (s *memberSink) Event(acct, app, host, level, msg string) { |
| 220 | s.events = append(s.events, acct+"/"+app+"/"+host+" "+level+" "+msg) |
| 221 | } |
| 222 | |
| 223 | // Preflight L9 and L27: the production LogEvent writes the object |
| 224 | // create-only, and only the writer that created it tells members; a second |
| 225 | // writer, or a replay, does neither. Steps members do not need (stopping) |
| 226 | // are logged and not shown to them. |
| 227 | func TestMoveLogWriterTellsMembersOnce(t *testing.T) { |
| 228 | ctx := context.Background() |
| 229 | s := release.NewDirStore(t.TempDir()) |
| 230 | sink := &memberSink{} |
| 231 | write := moveLogWriter(sink) |
| 232 | flow := doneFlow() |
| 233 | ev := func(event, identity string, fields map[string]string) moves.LogEvent { |
| 234 | return moves.LogEvent{V: 1, Op: mvEvMoveID, Host: "acme.go.tito.io", Event: event, Identity: identity, Actor: "box-uk", At: time.Now().UTC(), Fields: fields} |
| 235 | } |
| 236 | done := flow[len(flow)-1] |
| 237 | write(ctx, s, done, ev("done", "done", map[string]string{"offline_s": "41.2", "from": "ie", "to": "uk"})) |
| 238 | write(ctx, s, done, ev("done", "done", map[string]string{"offline_s": "41.2", "from": "ie", "to": "uk"})) |
| 239 | write(ctx, s, flow[4], ev("stopping", "cutting-stopping", map[string]string{"from": "ie", "to": "uk"})) |
| 240 | if len(sink.events) != 1 || sink.events[0] != "bab/titogo/acme.go.tito.io info Moved. acme.go.tito.io is served from uk. It was offline for 41 seconds." { |
| 241 | t.Fatalf("member events %q", sink.events) |
| 242 | } |
| 243 | prefix := moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", mvEvMoveID) |
| 244 | logged, err := moves.ReadLog(ctx, s, prefix) |
| 245 | if err != nil || len(logged) != 2 || logged[0].Actor != "box-uk" { |
| 246 | t.Fatalf("log %+v %v", logged, err) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // The member lines (preflight L9, Paul's choice): M-C1, M-C5, M-C6, M-C7, |
| 251 | // M-C8, M-C9, and for stuck a line with no operator command in it. |
| 252 | func TestMemberLines(t *testing.T) { |
| 253 | flow := doneFlow() |
| 254 | rolling := mvEvRec(provision.MoveRollingBack, func(m *provision.MoveState) { |
| 255 | m.Rollback = &provision.MoveRollback{ID: "rb_x", Reason: provision.RollbackLandingDeadline} |
| 256 | }) |
| 257 | stuck := mvEvRec(provision.MoveStuck, nil) |
| 258 | rolledBack := provision.Record{Account: "bab", App: "titogo", Host: "acme.go.tito.io", Region: "ie", |
| 259 | LastMove: &provision.MoveOutcome{ID: mvEvMoveID, Outcome: "rolled_back"}} |
| 260 | for _, c := range []struct { |
| 261 | event string |
| 262 | rec provision.Record |
| 263 | level string |
| 264 | line string |
| 265 | }{ |
| 266 | {"requested", flow[0], "info", "Moving acme.go.tito.io from ie to uk."}, |
| 267 | {"admitted", flow[3], "info", "uk is ready. Starting the cut. acme.go.tito.io will be offline for about 45 seconds."}, |
| 268 | {"flipped", flow[8], "info", "Stopped in ie. Starting in uk."}, |
| 269 | {"rolling_back", rolling, "warn", "uk could not start acme.go.tito.io: " + provision.RollbackLandingDeadline + ". Moving it back to ie."}, |
| 270 | {"rolled_back", rolledBack, "warn", "acme.go.tito.io is back in ie and serving. Nothing was lost."}, |
| 271 | {"stuck", stuck, "error", "acme.go.tito.io could not start in uk or in ie. It is offline. An operator has been paged."}, |
| 272 | {"stopping", flow[4], "", ""}, |
| 273 | } { |
| 274 | // The entry's own fields, as the writer names the transition (the |
| 275 | // admitted cut's budget, the rollback's reason): the line needs |
| 276 | // nothing else, so an entry the outbox writes later says the same. |
| 277 | ev := moves.LogEvent{Event: c.event, Fields: map[string]string{"from": "ie", "to": "uk"}} |
| 278 | if before := (provision.Record{Host: c.rec.Host}); c.rec.Move != nil { |
| 279 | before.Move = &provision.MoveState{ID: c.rec.Move.ID, From: "ie", To: "uk", State: provision.MoveLanding} |
| 280 | if c.event == "admitted" { |
| 281 | before = flow[2] |
| 282 | } |
| 283 | if tr, ok := moves.TransitionOf(before, c.rec); ok && tr.Event == c.event { |
| 284 | ev.Fields = tr.Fields |
| 285 | } |
| 286 | } |
| 287 | line, ok := memberLine(ev, c.rec.Host) |
| 288 | level := "" |
| 289 | if ok { |
| 290 | level = memberLevel(c.event) |
| 291 | } |
| 292 | if ok != (c.line != "") || level != c.level || line != c.line { |
| 293 | t.Errorf("%s: %q %q %v, want %q %q", c.event, level, line, ok, c.level, c.line) |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | // ---- Task 3 fix round 1 ---- |
| 299 | |
| 300 | // Ruling 4: the box knows the home region only as "", so the member line |
| 301 | // names it with the reviewed phrase (MV-N381), never dropping the line. |
| 302 | func TestHomeRegionMemberLines(t *testing.T) { |
| 303 | fromHome := mvEvRec(provision.MovePreparing, func(m *provision.MoveState) { m.From = "" }) |
| 304 | toHome := mvEvRec(provision.MovePreparing, func(m *provision.MoveState) { m.To = "" }) |
| 305 | for _, c := range []struct { |
| 306 | rec provision.Record |
| 307 | event string |
| 308 | want string |
| 309 | }{ |
| 310 | {fromHome, "requested", "Moving acme.go.tito.io from the home region to uk."}, |
| 311 | {toHome, "requested", "Moving acme.go.tito.io from ie to the home region."}, |
| 312 | {toHome, "flipped", "Stopped in ie. Starting in the home region."}, |
| 313 | } { |
| 314 | ev := moves.LogEvent{Event: c.event, Fields: map[string]string{"from": c.rec.Move.From, "to": c.rec.Move.To}} |
| 315 | if line, ok := memberLine(ev, c.rec.Host); !ok || line != c.want { |
| 316 | t.Errorf("%s: %q %v, want %q", c.event, line, ok, c.want) |
| 317 | } |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | // Ruling 5: the events carry the transition's evidence. The cut's stopped |
| 322 | // carries the source's digests, the landing's probed the installed |
| 323 | // database, a stuck its role, and the source's recovered the recovery's |
| 324 | // timing. |
| 325 | func TestEventsCarryTheirEvidence(t *testing.T) { |
| 326 | ctx := context.Background() |
| 327 | var got []moves.LogEvent |
| 328 | w := eventWorker(&got) |
| 329 | cut := func(phase string, src *provision.MoveSource) provision.Record { |
| 330 | return mvEvRec(provision.MoveCutting, func(m *provision.MoveState) { m.CutPhase, m.Source = phase, src }) |
| 331 | } |
| 332 | src := &provision.MoveSource{FinalTXID: 42, DBSHA256: strings.Repeat("d", 64), TimersSHA256: strings.Repeat("e", 64), |
| 333 | Files: &provision.MoveFilesDigest{Plain: strings.Repeat("f", 64)}, Evidence: "moves/x/stopped-source.json"} |
| 334 | w.logTransition(ctx, nil, cut(provision.CutStopping, nil), cut(provision.CutStopped, src)) |
| 335 | land := func(phase, db string) provision.Record { |
| 336 | return mvEvRec(provision.MoveLanding, func(m *provision.MoveState) { |
| 337 | m.LandingPhase = phase |
| 338 | m.Landing = &provision.MoveLandingJournal{InstalledDBSHA256: db, Attempts: 1} |
| 339 | }) |
| 340 | } |
| 341 | w.logTransition(ctx, nil, land(provision.LandProbing, strings.Repeat("a", 64)), land(provision.LandProbed, strings.Repeat("a", 64))) |
| 342 | rb := func(target, source string, stuck bool) provision.Record { |
| 343 | state := provision.MoveRollingBack |
| 344 | if stuck { |
| 345 | state = provision.MoveStuck |
| 346 | } |
| 347 | return mvEvRec(state, func(m *provision.MoveState) { |
| 348 | m.CutAt = mvEvAt(10) |
| 349 | m.Rollback = &provision.MoveRollback{ID: "rb_x", RequestedAt: mvEvAt(30), TargetPhase: target, SourcePhase: source} |
| 350 | if source == provision.RbSourceRecovered { |
| 351 | m.Rollback.RecoveredAt = mvEvAt(70) |
| 352 | } |
| 353 | }) |
| 354 | } |
| 355 | w.logTransition(ctx, nil, rb(provision.RbTargetReturning, provision.RbSourceWaiting, false), rb(provision.RbTargetReturning, provision.RbSourceWaiting, true)) |
| 356 | w.logTransition(ctx, nil, rb(provision.RbTargetCleaned, provision.RbSourceProbed, false), rb(provision.RbTargetCleaned, provision.RbSourceRecovered, false)) |
| 357 | // The source's stuck, on another box's worker (the target's stuck above |
| 358 | // is stuck-0 in this one). |
| 359 | eventWorker(&got).logTransition(ctx, nil, rb(provision.RbTargetCleaned, provision.RbSourceVerified, false), rb(provision.RbTargetCleaned, provision.RbSourceVerified, true)) |
| 360 | by := map[string]map[string]string{} |
| 361 | for _, ev := range got { |
| 362 | by[ev.Identity] = ev.Fields |
| 363 | } |
| 364 | if f := by["cutting-stopped"]; f["db_sha256"] != src.DBSHA256 || f["final_txid"] != "42" || f["files_plain"] != src.Files.Plain || f["timers_sha256"] != src.TimersSHA256 { |
| 365 | t.Errorf("stopped carries %v", f) |
| 366 | } |
| 367 | if f := by["landing-probed"]; f["installed_db_sha256"] != strings.Repeat("a", 64) { |
| 368 | t.Errorf("probed carries %v", f) |
| 369 | } |
| 370 | if f := by["rolling_back-source-recovered"]; f["recovery_s"] != "40" || f["offline_s"] != "60" { |
| 371 | t.Errorf("recovered carries %v, want recovery_s 40 (rollback request to recovered) and offline_s 60 (cut to recovered)", f) |
| 372 | } |
| 373 | var roles []string |
| 374 | for _, ev := range got { |
| 375 | if ev.Event == "stuck" { |
| 376 | roles = append(roles, ev.Fields["role"]) |
| 377 | } |
| 378 | } |
| 379 | if strings.Join(roles, " ") != "target source" { |
| 380 | t.Errorf("stuck roles %v, want the target's (at returning), then the source's", roles) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | // Ruling 7: the real writers log, through the move harness. The landing |
| 385 | // harness drives the source's readiness, the console's cut, the cut and the |
| 386 | // flip, then the landing to installed; the close harness the probe, the |
| 387 | // commit, the open, the source's park and the close. A writer whose log |
| 388 | // call went missing drops out of the list. |
| 389 | func TestRealWritersLogEveryTransition(t *testing.T) { |
| 390 | t.Parallel() |
| 391 | t.Run("readiness to installed", func(t *testing.T) { |
| 392 | h := newLandingHarness(t) |
| 393 | var got []moves.LogEvent |
| 394 | h.cfg.LogEvent = func(_ context.Context, _ release.Store, _ provision.Record, ev moves.LogEvent) error { |
| 395 | got = append(got, ev) |
| 396 | return nil |
| 397 | } |
| 398 | h.flip(t) |
| 399 | h.land(t) |
| 400 | h.installed(t) |
| 401 | wantSubsequence(t, got, "ready", "cut_requested", "admitted", "stopping", "stopped", "staged", "archived", "flipped", "parking", "building", "published", "local", "configured", "restored") |
| 402 | }) |
| 403 | t.Run("probe to done", func(t *testing.T) { |
| 404 | h := newCloseHarness(t) |
| 405 | var got []moves.LogEvent |
| 406 | h.cfg.LogEvent = func(_ context.Context, _ release.Store, _ provision.Record, ev moves.LogEvent) error { |
| 407 | got = append(got, ev) |
| 408 | return nil |
| 409 | } |
| 410 | h.land(t) |
| 411 | h.committedAndOpen(t) |
| 412 | h.sourcePass(t) |
| 413 | h.land(t) |
| 414 | lane := h.chore(t, "uk", true) |
| 415 | h.clockPast(lane.Park.EvidenceAt.Add(park.SignatureAge + lane.Park.RequestLimit)) |
| 416 | h.land(t) |
| 417 | src := h.chore(t, "ie", false) |
| 418 | h.clockPast(src.Park.EvidenceAt.Add(park.SignatureAge + src.Park.RequestLimit)) |
| 419 | if err := h.regionalAck(t, src); err != nil { |
| 420 | t.Fatal(err) |
| 421 | } |
| 422 | h.land(t) |
| 423 | h.done(t) |
| 424 | wantSubsequence(t, got, "probing", "probe_attempt", "probed", "committed", "opened", "source_parked", "done") |
| 425 | }) |
| 426 | } |
| 427 | |
| 428 | // wantSubsequence requires want's events, in order, among got's, and no |
| 429 | // identity twice. |
| 430 | func wantSubsequence(t *testing.T, got []moves.LogEvent, want ...string) { |
| 431 | t.Helper() |
| 432 | var events []string |
| 433 | seen := map[string]bool{} |
| 434 | for _, ev := range got { |
| 435 | events = append(events, ev.Event) |
| 436 | if seen[ev.Identity] { |
| 437 | t.Errorf("identity %q twice", ev.Identity) |
| 438 | } |
| 439 | seen[ev.Identity] = true |
| 440 | } |
| 441 | i := 0 |
| 442 | for _, e := range events { |
| 443 | if i < len(want) && e == want[i] { |
| 444 | i++ |
| 445 | } |
| 446 | } |
| 447 | if i != len(want) { |
| 448 | t.Fatalf("events %v lack %v in order (matched %d)", events, want, i) |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | // flakyLogStore fails every write under a move's log while fail is set. |
| 453 | type flakyLogStore struct { |
| 454 | release.Store |
| 455 | fail bool |
| 456 | } |
| 457 | |
| 458 | func (f *flakyLogStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 459 | if f.fail && strings.Contains(key, "/log/") { |
| 460 | return "", errors.New("the bucket is down") |
| 461 | } |
| 462 | return f.Store.Put(ctx, key, body, ifMatch) |
| 463 | } |
| 464 | |
| 465 | // outboxWorker is a worker with the production log writer over s and an |
| 466 | // outbox under dataDir. |
| 467 | func outboxWorker(dataDir string) *movesWorker { |
| 468 | w := &movesWorker{mirrors: map[string]lease.MirrorStore{}} |
| 469 | w.cfg.Box = "box-test" |
| 470 | w.cfg.DataDir = dataDir |
| 471 | w.cfg.Now = func() time.Time { return time.Unix(1_800_000_100, 0).UTC() } |
| 472 | w.cfg.LogEvent = moveLogWriter(nil) |
| 473 | return w |
| 474 | } |
| 475 | |
| 476 | func outboxFiles(t *testing.T, dataDir string) []string { |
| 477 | t.Helper() |
| 478 | var out []string |
| 479 | root := moveLogOutboxDir(dataDir) |
| 480 | err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { |
| 481 | if errors.Is(err, fs.ErrNotExist) { |
| 482 | return fs.SkipAll |
| 483 | } |
| 484 | if err != nil { |
| 485 | return err |
| 486 | } |
| 487 | if !d.IsDir() { |
| 488 | rel, _ := filepath.Rel(root, p) |
| 489 | out = append(out, filepath.ToSlash(rel)) |
| 490 | } |
| 491 | return nil |
| 492 | }) |
| 493 | if err != nil { |
| 494 | t.Fatal(err) |
| 495 | } |
| 496 | return out |
| 497 | } |
| 498 | |
| 499 | // Fix round 2, ruling 1: a log write that fails is kept, with the entry's |
| 500 | // bytes, in the worker's outbox on disk, and every pass retries the outbox, |
| 501 | // whatever the record now says. Here the write fails at the request and |
| 502 | // again at the close; the move is gone from the record by the time the |
| 503 | // bucket answers, and a fresh worker (an agent restart) writes both. |
| 504 | func TestLogOutboxOutlivesTheMove(t *testing.T) { |
| 505 | ctx := context.Background() |
| 506 | dataDir := t.TempDir() |
| 507 | s := &flakyLogStore{Store: release.NewDirStore(t.TempDir()), fail: true} |
| 508 | w := outboxWorker(dataDir) |
| 509 | flow := doneFlow() |
| 510 | w.observeMove(ctx, s, flow[0]) |
| 511 | last := flow[len(flow)-1] |
| 512 | w.logTransition(ctx, s, flow[len(flow)-2], last) |
| 513 | if last.Move != nil { |
| 514 | t.Fatal("the fixture's last record still has its move") |
| 515 | } |
| 516 | files := outboxFiles(t, dataDir) |
| 517 | if strings.Join(files, " ") != "acme.go.tito.io/"+mvEvMoveID+"/done.json acme.go.tito.io/"+mvEvMoveID+"/requested.json" { |
| 518 | t.Fatalf("outbox %v", files) |
| 519 | } |
| 520 | w.flushLogOutbox(ctx, s) // still failing: kept |
| 521 | if len(outboxFiles(t, dataDir)) != 2 { |
| 522 | t.Fatal("a failed retry dropped an entry") |
| 523 | } |
| 524 | pending, err := os.ReadFile(filepath.Join(moveLogOutboxDir(dataDir), "acme.go.tito.io", mvEvMoveID, "requested.json")) |
| 525 | if err != nil { |
| 526 | t.Fatal(err) |
| 527 | } |
| 528 | var kept moveLogPending |
| 529 | if err := json.Unmarshal(pending, &kept); err != nil { |
| 530 | t.Fatal(err) |
| 531 | } |
| 532 | s.fail = false |
| 533 | outboxWorker(dataDir).flushLogOutbox(ctx, s) |
| 534 | if f := outboxFiles(t, dataDir); len(f) != 0 { |
| 535 | t.Fatalf("written entries left in the outbox: %v", f) |
| 536 | } |
| 537 | prefix := moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", mvEvMoveID) |
| 538 | got, err := moves.ReadLog(ctx, s, prefix) |
| 539 | if err != nil || len(got) != 2 { |
| 540 | t.Fatalf("log %+v %v", got, err) |
| 541 | } |
| 542 | body, _, err := s.Get(ctx, moves.LogKeyFor(prefix, "requested")) |
| 543 | if err != nil || string(body) != string(kept.Entry) { |
| 544 | t.Fatalf("the object is not the kept bytes:\n%s\n%s", body, kept.Entry) |
| 545 | } |
| 546 | // Another sighting of the closed move writes nothing more. |
| 547 | w.observeMove(ctx, s, last) |
| 548 | if f := outboxFiles(t, dataDir); len(f) != 0 { |
| 549 | t.Fatalf("outbox %v", f) |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | // Fix round 2, ruling 2: recovery probe starts are logged through the |
| 554 | // journal as a box builds it (movesWorker.probeAttempts), and a start whose |
| 555 | // log write fails is kept in the outbox. |
| 556 | func TestRecoveryProbeAttemptsAreLogged(t *testing.T) { |
| 557 | ctx := context.Background() |
| 558 | f := recoveryPAFixture(t, hostclaim.KindRestoring) |
| 559 | s := &flakyLogStore{Store: f.claims, fail: true} |
| 560 | w := outboxWorker(f.dataDir) |
| 561 | ids := 0 |
| 562 | ops := w.probeAttempts(s, func(context.Context, string, string, string) (*provision.Record, error) { return f.rec, nil }, |
| 563 | func() string { ids++; return "id-" + strings.Repeat("x", ids) }) |
| 564 | for n := 0; n < 3; n++ { |
| 565 | if _, err := ops.OpenProbe(ctx, f.start, time.Now()); err != nil { |
| 566 | t.Fatal(err) |
| 567 | } |
| 568 | if err := ops.Reserve(ctx, f.start, n); err != nil { |
| 569 | t.Fatalf("start %d: %v", n, err) |
| 570 | } |
| 571 | if err := ops.Failed(ctx, f.start, n); err != nil { |
| 572 | t.Fatal(err) |
| 573 | } |
| 574 | s.fail = false // only the first start's write fails |
| 575 | } |
| 576 | if files := outboxFiles(t, f.dataDir); len(files) != 1 || !strings.HasSuffix(files[0], "/probe_attempt-0-1.json") { |
| 577 | t.Fatalf("outbox %v, want the first start's entry", files) |
| 578 | } |
| 579 | w.flushLogOutbox(ctx, s) |
| 580 | got, err := moves.ReadLog(ctx, s, moves.ReportPrefix(f.rec.Account, f.rec.App, f.rec.Host, f.rec.Move.ID)) |
| 581 | if err != nil { |
| 582 | t.Fatal(err) |
| 583 | } |
| 584 | var names []string |
| 585 | for _, ev := range got { |
| 586 | if ev.Event != "probe_attempt" { |
| 587 | t.Fatalf("event %+v", ev) |
| 588 | } |
| 589 | names = append(names, ev.Identity) |
| 590 | } |
| 591 | sort.Strings(names) |
| 592 | if strings.Join(names, " ") != "probe_attempt-0-1 probe_attempt-0-2 probe_attempt-0-3" { |
| 593 | t.Fatalf("logged %v", names) |
| 594 | } |
| 595 | } |
| 596 | |
| 597 | // Fix round 2, ruling 3: the memo forgets a move as soon as a pass sees its |
| 598 | // record with no move or another move: here a source box whose move was |
| 599 | // closed on the target, then the same box when the next move replaced it. |
| 600 | // The memo is also bounded, dropping its oldest identities. |
| 601 | func TestLogMemoRetiresAMoveClosedElsewhere(t *testing.T) { |
| 602 | ctx := context.Background() |
| 603 | var got []moves.LogEvent |
| 604 | w := eventWorker(&got) |
| 605 | flow := doneFlow() |
| 606 | w.observeMove(ctx, nil, flow[0]) |
| 607 | w.logTransition(ctx, nil, flow[0], flow[1]) |
| 608 | if w.logged.len() == 0 { |
| 609 | t.Fatal("nothing remembered") |
| 610 | } |
| 611 | w.cfg.Enabled = true // the pass forgets only with the switch on (I1) |
| 612 | s := release.NewDirStore(t.TempDir()) |
| 613 | closed := flow[len(flow)-1] |
| 614 | if err := provision.CreateRecord(ctx, s, closed); err != nil { |
| 615 | t.Fatal(err) |
| 616 | } |
| 617 | if err := movesPass(ctx, s, w); err != nil { |
| 618 | t.Fatal(err) |
| 619 | } |
| 620 | if n := w.logged.len(); n != 0 { |
| 621 | t.Fatalf("%d identities remembered after the pass saw the move closed elsewhere", n) |
| 622 | } |
| 623 | w.observeMove(ctx, nil, flow[0]) |
| 624 | next := mvEvRec(provision.MovePreparing, func(m *provision.MoveState) { m.ID = "mv_next" }) |
| 625 | s = release.NewDirStore(t.TempDir()) |
| 626 | if err := provision.CreateRecord(ctx, s, next); err != nil { |
| 627 | t.Fatal(err) |
| 628 | } |
| 629 | if err := movesPass(ctx, s, w); err != nil { |
| 630 | t.Fatal(err) |
| 631 | } |
| 632 | if w.logged.seen(next.Host + "/" + mvEvMoveID + "/requested") { |
| 633 | t.Fatal("the replaced move is still remembered") |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | func TestLogMemoIsBounded(t *testing.T) { |
| 638 | var m moveLogMemo |
| 639 | for i := 0; i < moveLogMemoCap+10; i++ { |
| 640 | m.add("h", "op", "h/op/"+strconv.Itoa(i)) |
| 641 | } |
| 642 | if m.len() != moveLogMemoCap { |
| 643 | t.Fatalf("%d entries, want %d", m.len(), moveLogMemoCap) |
| 644 | } |
| 645 | if m.seen("h/op/0") || !m.seen("h/op/"+strconv.Itoa(moveLogMemoCap+9)) { |
| 646 | t.Fatal("not the oldest dropped") |
| 647 | } |
| 648 | m.forget("h", "op") |
| 649 | if m.len() != 0 { |
| 650 | t.Fatalf("%d entries after forget", m.len()) |
| 651 | } |
| 652 | } |
| 653 | |
| 654 | // Fix round 3, ruling 4: the outbox keeps the first entry queued for an |
| 655 | // identity. A restart during the outage forgets what was handed off, so the |
| 656 | // next pass sees the transition again and builds it again, later; that never |
| 657 | // replaces the kept bytes or their time. |
| 658 | func TestLogOutboxKeepsTheFirstEntry(t *testing.T) { |
| 659 | ctx := context.Background() |
| 660 | dataDir := t.TempDir() |
| 661 | s := &flakyLogStore{Store: release.NewDirStore(t.TempDir()), fail: true} |
| 662 | rec := doneFlow()[0] |
| 663 | w := outboxWorker(dataDir) |
| 664 | w.observeMove(ctx, s, rec) |
| 665 | kept := filepath.Join(moveLogOutboxDir(dataDir), rec.Host, mvEvMoveID, "requested.json") |
| 666 | first, err := os.ReadFile(kept) |
| 667 | if err != nil { |
| 668 | t.Fatal(err) |
| 669 | } |
| 670 | restarted := outboxWorker(dataDir) |
| 671 | restarted.cfg.Now = func() time.Time { return time.Unix(1_800_000_900, 0).UTC() } |
| 672 | restarted.flushLogOutbox(ctx, s) // still failing |
| 673 | restarted.observeMove(ctx, s, rec) |
| 674 | again, err := os.ReadFile(kept) |
| 675 | if err != nil { |
| 676 | t.Fatal(err) |
| 677 | } |
| 678 | if string(again) != string(first) { |
| 679 | t.Fatalf("the kept entry was replaced:\n%s\n%s", first, again) |
| 680 | } |
| 681 | s.fail = false |
| 682 | restarted.flushLogOutbox(ctx, s) |
| 683 | got, err := moves.ReadLog(ctx, s, moves.ReportPrefix(rec.Account, rec.App, rec.Host, mvEvMoveID)) |
| 684 | if err != nil || len(got) != 1 || !got[0].At.Equal(time.Unix(1_800_000_100, 0)) { |
| 685 | t.Fatalf("log %+v %v, want the first entry's time", got, err) |
| 686 | } |
| 687 | } |
| 688 | |
| 689 | // syncSpy stands in for outboxSync: it records every path synced, and |
| 690 | // fails the paths in fail. |
| 691 | type syncSpy struct { |
| 692 | fail map[string]bool |
| 693 | synced map[string]int |
| 694 | } |
| 695 | |
| 696 | func spyOutboxSync(t *testing.T) *syncSpy { |
| 697 | t.Helper() |
| 698 | spy := &syncSpy{fail: map[string]bool{}, synced: map[string]int{}} |
| 699 | saved := outboxSync |
| 700 | outboxSync = func(f *os.File) error { |
| 701 | // Keyed by the absolute path, so a relative data directory's |
| 702 | // syncs are named the same way. |
| 703 | name := f.Name() |
| 704 | if abs, err := filepath.Abs(name); err == nil { |
| 705 | name = abs |
| 706 | } |
| 707 | if spy.fail[name] { |
| 708 | return errors.New("injected sync failure") |
| 709 | } |
| 710 | spy.synced[name]++ |
| 711 | return f.Sync() |
| 712 | } |
| 713 | t.Cleanup(func() { outboxSync = saved }) |
| 714 | return spy |
| 715 | } |
| 716 | |
| 717 | // reset forgets what was synced and fails nothing. |
| 718 | func (s *syncSpy) reset() { |
| 719 | s.fail, s.synced = map[string]bool{}, map[string]int{} |
| 720 | } |
| 721 | |
| 722 | // Final review I2: the log outbox acknowledges an entry only once it is |
| 723 | // durable. Every directory the write created is synced into its parent, a |
| 724 | // failed directory sync is an error, and an entry already there (an earlier |
| 725 | // try whose sync failed) is synced, file and directory, before it counts. |
| 726 | // The caller does not remember an entry the outbox could not make durable, |
| 727 | // so the next sighting tries again. |
| 728 | func TestLogOutboxIsDurableBeforeItAcknowledges(t *testing.T) { |
| 729 | ctx := context.Background() |
| 730 | spy := spyOutboxSync(t) |
| 731 | rec := doneFlow()[0] |
| 732 | ev := moves.LogEvent{V: 1, Op: mvEvMoveID, Host: rec.Host, Event: "requested", Identity: "requested"} |
| 733 | |
| 734 | t.Run("new directories are synced into their parents", func(t *testing.T) { |
| 735 | spy.reset() |
| 736 | dd := t.TempDir() |
| 737 | if err := outboxWorker(dd).queueLog(rec, ev); err != nil { |
| 738 | t.Fatal(err) |
| 739 | } |
| 740 | root := moveLogOutboxDir(dd) |
| 741 | for _, d := range []string{dd, filepath.Join(dd, ".moves"), root, filepath.Join(root, rec.Host), filepath.Join(root, rec.Host, mvEvMoveID)} { |
| 742 | if spy.synced[d] == 0 { |
| 743 | t.Errorf("%s was not synced; synced %v", d, spy.synced) |
| 744 | } |
| 745 | } |
| 746 | }) |
| 747 | |
| 748 | t.Run("a failed directory sync is an error, and the retry syncs the existing entry", func(t *testing.T) { |
| 749 | spy.reset() |
| 750 | dd := t.TempDir() |
| 751 | dir := filepath.Join(moveLogOutboxDir(dd), rec.Host, mvEvMoveID) |
| 752 | spy.fail[dir] = true |
| 753 | if err := outboxWorker(dd).queueLog(rec, ev); err == nil { |
| 754 | t.Fatal("an entry whose directory sync failed was acknowledged") |
| 755 | } |
| 756 | file := filepath.Join(dir, "requested.json") |
| 757 | if _, err := os.Lstat(file); err != nil { |
| 758 | t.Fatalf("the fixture wants the entry linked before the failed sync: %v", err) |
| 759 | } |
| 760 | spy.reset() |
| 761 | spy.fail[file] = true |
| 762 | if err := outboxWorker(dd).queueLog(rec, ev); err == nil { |
| 763 | t.Fatal("an existing entry whose file sync failed was acknowledged") |
| 764 | } |
| 765 | spy.reset() |
| 766 | if err := outboxWorker(dd).queueLog(rec, ev); err != nil { |
| 767 | t.Fatal(err) |
| 768 | } |
| 769 | if spy.synced[file] == 0 || spy.synced[dir] == 0 { |
| 770 | t.Fatalf("an existing entry was acknowledged without syncing it and its directory: %v", spy.synced) |
| 771 | } |
| 772 | }) |
| 773 | |
| 774 | t.Run("the caller retries an entry the outbox could not make durable", func(t *testing.T) { |
| 775 | spy.reset() |
| 776 | dd := t.TempDir() |
| 777 | s := &flakyLogStore{Store: release.NewDirStore(t.TempDir()), fail: true} |
| 778 | w := outboxWorker(dd) |
| 779 | spy.fail[filepath.Join(moveLogOutboxDir(dd), rec.Host, mvEvMoveID)] = true |
| 780 | w.observeMove(ctx, s, rec) |
| 781 | key := rec.Host + "/" + mvEvMoveID + "/requested" |
| 782 | if w.logged.seen(key) { |
| 783 | t.Fatal("an entry the outbox could not make durable counts as handed off") |
| 784 | } |
| 785 | spy.reset() |
| 786 | w.observeMove(ctx, s, rec) |
| 787 | if !w.logged.seen(key) { |
| 788 | t.Fatal("the next sighting did not hand the entry off") |
| 789 | } |
| 790 | }) |
| 791 | } |
| 792 | |
| 793 | // Final re-review 1, I2: the worker is built with its data directory made |
| 794 | // absolute (movesDataDir), so the outbox's ancestor walk always ends at that |
| 795 | // root. With CARLOS_DATA_DIR=".", an ancestor sync that fails leaves the new |
| 796 | // directories behind; the retry finds them there and still syncs every one |
| 797 | // of them, up to the data directory, before it acknowledges the entry. |
| 798 | func TestLogOutboxUnderARelativeDataDir(t *testing.T) { |
| 799 | spy := spyOutboxSync(t) |
| 800 | t.Chdir(t.TempDir()) |
| 801 | t.Setenv("CARLOS_DATA_DIR", ".") |
| 802 | root, err := filepath.Abs(".") |
| 803 | if err != nil { |
| 804 | t.Fatal(err) |
| 805 | } |
| 806 | w := outboxWorker(movesDataDir(dataDir())) |
| 807 | rec := doneFlow()[0] |
| 808 | ev := moves.LogEvent{V: 1, Op: mvEvMoveID, Host: rec.Host, Event: "requested", Identity: "requested"} |
| 809 | spy.fail[filepath.Join(root, ".moves")] = true |
| 810 | if err := w.queueLog(rec, ev); err == nil { |
| 811 | t.Fatal("an entry whose new directory's sync failed was acknowledged") |
| 812 | } |
| 813 | spy.reset() |
| 814 | if err := w.queueLog(rec, ev); err != nil { |
| 815 | t.Fatal(err) |
| 816 | } |
| 817 | out := moveLogOutboxDir(root) |
| 818 | for _, d := range []string{root, filepath.Join(root, ".moves"), out, filepath.Join(out, rec.Host), filepath.Join(out, rec.Host, mvEvMoveID)} { |
| 819 | if spy.synced[d] == 0 { |
| 820 | t.Errorf("the retry acknowledged the entry without syncing %s; synced %v", d, spy.synced) |
| 821 | } |
| 822 | } |
| 823 | } |
| 824 | |