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 "errors"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/hostclaim"
14 "github.com/carlosframework/platform/internal/provision"
15 "github.com/carlosframework/platform/internal/registry"
16 "github.com/carlosframework/platform/internal/release"
17 "github.com/carlosframework/platform/internal/schedule"
18)
19
20// statusDeliverer is a schedule.Deliverer answering one status to every tick.
21type statusDeliverer int
22
23func (s statusDeliverer) Deliver(context.Context, registry.Route, *http.Request) (int, error) {
24 return int(s), nil
25}
26
27const (
28 mhHost = "a.go.example.com"
29 mhID = "mv_abcdefghijklmnopqrstuvwxyz"
30 mhRbID = "rb_abcdefghijklmnopqrstuvwxyz"
31)
32
33// mhRecord is a Phase 1 record mid-move from ie to uk, in state with the
34// given phases; region is the record's region at that state.
35func mhRecord(region, state, cutPhase string) provision.Record {
36 return provision.Record{Schema: 1, Host: mhHost, App: "app", Account: "bab", Region: region, Channel: "stable",
37 Move: &provision.MoveState{ID: mhID, From: "ie", To: "uk", State: state, CutPhase: cutPhase,
38 Rollback: &provision.MoveRollback{ID: mhRbID}}}
39}
40
41// mhClaims is a claim reader answering kind with participants ie-box and
42// uk-box for mhHost; err makes every read fail.
43func mhClaims(kind string, err error) func(context.Context, release.Store, string) (hostclaim.Observed, error) {
44 return func(_ context.Context, _ release.Store, host string) (hostclaim.Observed, error) {
45 if err != nil {
46 return hostclaim.Observed{}, err
47 }
48 return hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host,
49 Op: hostclaim.Op{Kind: kind, ID: mhID, Participants: []string{"ie-box", "uk-box"}}}}, nil
50 }
51}
52
53func mhView(box, region, kind string, err error, recs ...provision.Record) *movesHoldView {
54 v := newMovesHoldView(box, region, mhClaims(kind, err), nil)
55 v.refresh(context.Background(), nil, recs)
56 return v
57}
58
59// hold's table (moves spec §5.2, §9.6, §19.1): the target holds its row
60// landing after the flip, the source moving from stopping, restoring in a
61// revert or a rollback, and the target releasing in a rollback. A box the
62// claim does not name, and any state with nothing held, answers no hold.
63func TestMovesHoldViewHold(t *testing.T) {
64 cases := []struct {
65 name string
66 box, region, kind string
67 rec provision.Record
68 wantStatus, wantOp string
69 wantOK bool
70 }{
71 {"source preparing", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MovePreparing, ""), "", "", false},
72 {"source cut requested", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveCutting, provision.CutRequested), "", "", false},
73 {"source cut admitted", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveCutting, provision.CutAdmitted), "", "", false},
74 {"source stopping", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveCutting, provision.CutStopping), "moving", mhID, true},
75 {"source archived", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveCutting, provision.CutArchived), "moving", mhID, true},
76 {"source after the flip", "ie-box", "ie", hostclaim.KindLanding, mhRecord("uk", provision.MoveLanding, provision.CutFlipped), "moving", mhID, true},
77 {"target after the flip", "uk-box", "uk", hostclaim.KindLanding, mhRecord("uk", provision.MoveLanding, provision.CutFlipped), "landing", mhID, true},
78 {"target committed", "uk-box", "uk", hostclaim.KindLanding, mhRecord("uk", provision.MoveCommitted, provision.CutFlipped), "", "", false},
79 {"source reverting", "ie-box", "ie", hostclaim.KindReverting, mhRecord("ie", provision.MoveReverting, provision.CutStopped), "restoring", mhID, true},
80 {"source aborting a revert", "ie-box", "ie", hostclaim.KindReverting, mhRecord("ie", provision.MoveAborting, ""), "restoring", mhID, true},
81 {"source aborting before the cut", "ie-box", "ie", hostclaim.KindMoving, mhRecord("ie", provision.MoveAborting, ""), "", "", false},
82 {"source rolling back", "ie-box", "ie", hostclaim.KindRestoring, mhRecord("ie", provision.MoveRollingBack, provision.CutFlipped), "restoring", mhRbID, true},
83 {"target rolling back", "uk-box", "uk", hostclaim.KindRestoring, mhRecord("ie", provision.MoveRollingBack, provision.CutFlipped), "releasing", mhRbID, true},
84 {"target stuck", "uk-box", "uk", hostclaim.KindRestoring, mhRecord("ie", provision.MoveStuck, provision.CutFlipped), "releasing", mhRbID, true},
85 {"not a participant", "uk-box-2", "uk", hostclaim.KindLanding, mhRecord("uk", provision.MoveLanding, provision.CutFlipped), "", "", false},
86 }
87 for _, c := range cases {
88 t.Run(c.name, func(t *testing.T) {
89 v := mhView(c.box, c.region, c.kind, nil, c.rec)
90 status, op, ok := v.hold(c.rec)
91 if status != c.wantStatus || op != c.wantOp || ok != c.wantOK {
92 t.Fatalf("hold = (%q, %q, %v), want (%q, %q, %v)", status, op, ok, c.wantStatus, c.wantOp, c.wantOK)
93 }
94 })
95 }
96 // No move, or a legacy one: nothing held.
97 v := mhView("uk-box", "uk", hostclaim.KindLanding, nil)
98 if _, _, ok := v.hold(provision.Record{Host: mhHost}); ok {
99 t.Fatal("a record at rest is held")
100 }
101 if _, _, ok := v.hold(provision.Record{Host: mhHost, Move: &provision.MoveState{To: "uk", State: provision.MoveCutting}}); ok {
102 t.Fatal("a legacy move is held")
103 }
104}
105
106// Without a claim answer for the host (the view was built before the move,
107// or its claim would not read) hold falls back to the record's regions, and
108// never answers "no hold" for a state that holds: the row is never minted
109// ready. A rollback with no rollback id holds under the move id.
110func TestMovesHoldViewHoldFailsClosedWithoutTheClaim(t *testing.T) {
111 stale := mhView("uk-box", "uk", hostclaim.KindLanding, nil) // built from a list with no move in it
112 rec := mhRecord("uk", provision.MoveLanding, provision.CutFlipped)
113 if status, op, ok := stale.hold(rec); status != "landing" || op != mhID || !ok {
114 t.Fatalf("stale view: hold = (%q, %q, %v), want landing by the move", status, op, ok)
115 }
116 unread := mhView("ie-box", "ie", "", errors.New("store down"), mhRecord("ie", provision.MoveCutting, provision.CutStopping))
117 if status, _, ok := unread.hold(mhRecord("ie", provision.MoveCutting, provision.CutStopping)); status != "moving" || !ok {
118 t.Fatalf("unreadable claim: hold = (%q, %v), want moving", status, ok)
119 }
120 noRb := mhRecord("ie", provision.MoveRollingBack, provision.CutFlipped)
121 noRb.Move.Rollback = nil
122 if status, op, ok := stale.hold(noRb); status != "releasing" || op != mhID || !ok {
123 t.Fatalf("rollback with no id: hold = (%q, %q, %v), want releasing by the move", status, op, ok)
124 }
125 never := newMovesHoldView("uk-box", "uk", mhClaims(hostclaim.KindLanding, nil), nil) // never built
126 if status, _, ok := never.hold(rec); status != "landing" || !ok {
127 t.Fatalf("unbuilt view: hold = (%q, %v), want landing", status, ok)
128 }
129}
130
131// keep and frozen: a move-kind op freezes the host, and keeps it on a box
132// the op names as a participant. A claim that is serving does neither. Fail
133// closed: before the first build, and for a host whose claim would not read,
134// both answer true.
135func TestMovesHoldViewKeepAndFrozen(t *testing.T) {
136 rec := mhRecord("uk", provision.MoveLanding, provision.CutFlipped)
137 cases := []struct {
138 name, box, kind string
139 err error
140 keep, frozen bool
141 }{
142 {"moving, source", "ie-box", hostclaim.KindMoving, nil, true, true},
143 {"landing, source", "ie-box", hostclaim.KindLanding, nil, true, true},
144 {"landing, target", "uk-box", hostclaim.KindLanding, nil, true, true},
145 {"reverting, target", "uk-box", hostclaim.KindReverting, nil, true, true},
146 {"restoring, target", "uk-box", hostclaim.KindRestoring, nil, true, true},
147 {"landing, not a participant", "uk-box-2", hostclaim.KindLanding, nil, false, true},
148 {"serving", "ie-box", hostclaim.KindServing, nil, false, false},
149 {"relocating is region HA's", "ie-box", hostclaim.KindRelocating, nil, false, false},
150 {"unreadable claim", "ie-box", "", errors.New("store down"), true, true},
151 }
152 for _, c := range cases {
153 t.Run(c.name, func(t *testing.T) {
154 v := mhView(c.box, "", c.kind, c.err, rec)
155 if got := v.keep(mhHost); got != c.keep {
156 t.Errorf("keep = %v, want %v", got, c.keep)
157 }
158 if got := v.frozen(mhHost); got != c.frozen {
159 t.Errorf("frozen = %v, want %v", got, c.frozen)
160 }
161 if v.keep("other.go.example.com") || v.frozen("other.go.example.com") {
162 t.Error("a host with no move is kept or frozen")
163 }
164 })
165 }
166 never := newMovesHoldView("ie-box", "ie", mhClaims(hostclaim.KindServing, nil), nil)
167 if !never.keep("any.go.example.com") || !never.frozen("any.go.example.com") {
168 t.Fatal("an unbuilt view does not fail closed")
169 }
170 // A later build that cannot list keeps the last answer: refresh is only
171 // called with a list, so a failed list is no refresh at all.
172 built := mhView("ie-box", "ie", hostclaim.KindMoving, nil, mhRecord("ie", provision.MoveCutting, provision.CutStopping))
173 if !built.keep(mhHost) {
174 t.Fatal("the participant is not kept")
175 }
176 built.refresh(context.Background(), nil, nil)
177 if built.keep(mhHost) || built.frozen(mhHost) {
178 t.Fatal("a host whose move ended is still kept or frozen")
179 }
180}
181
182// The view against a real claim in a store, through hostclaim.Read: the
183// door's moving claim names ie-box and uk-box.
184func TestMovesHoldViewReadsTheClaim(t *testing.T) {
185 ctx := context.Background()
186 s := release.NewDirStore(t.TempDir())
187 created := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
188 inc, err := hostclaim.Incarnation("bab", "app", mhHost, created)
189 if err != nil {
190 t.Fatal(err)
191 }
192 obs, err := hostclaim.Update(ctx, s, hostclaim.Observed{Host: mhHost, Absent: true}, func(c *hostclaim.Claim) error {
193 c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "app", mhHost), Account: "bab", App: "app",
194 Incarnation: inc, CreatedAt: created, Location: "instances/" + mhHost + "/" + inc, Generation: "cur", Stamp: 1}
195 c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "ie-box"}
196 c.Touched = hostclaim.Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}}
197 return nil
198 })
199 if err != nil {
200 t.Fatal(err)
201 }
202 if _, err := hostclaim.Update(ctx, s, obs, hostclaim.BeginMove(mhID, "op", "ie-box", "uk-box", "uk", created)); err != nil {
203 t.Fatal(err)
204 }
205 rec := mhRecord("ie", provision.MoveCutting, provision.CutStopping)
206 ie := newMovesHoldView("ie-box", "ie", hostclaim.Read, nil)
207 ie.refresh(ctx, s, []provision.Record{rec})
208 if !ie.keep(mhHost) || !ie.frozen(mhHost) {
209 t.Fatal("the source is not kept and frozen under the door's moving claim")
210 }
211 if status, op, ok := ie.hold(rec); status != "moving" || op != mhID || !ok {
212 t.Fatalf("hold = (%q, %q, %v), want moving by the move", status, op, ok)
213 }
214 other := newMovesHoldView("ie-box-2", "ie", hostclaim.Read, nil)
215 other.refresh(ctx, s, []provision.Record{rec})
216 if other.keep(mhHost) {
217 t.Fatal("a box the claim does not name is kept")
218 }
219}
220
221// The drain reaches the schedule wiring's gate once armScheduleHolds has
222// run, and is a no-op before (no scheduled work armed).
223func TestMovesHoldViewDrainsTheScheduleGate(t *testing.T) {
224 v := newMovesHoldView("ie-box", "ie", mhClaims(hostclaim.KindMoving, nil), nil)
225 if err := v.drainTimers(context.Background(), mhHost); err != nil {
226 t.Fatalf("drain with no schedule armed = %v", err)
227 }
228 sd := &scheduleDeps{runner: &schedule.Runner{}, conv: &schedule.Converger{}}
229 armScheduleHolds(sd, v)
230 if sd.runner.Gate == nil || sd.conv.Gate != sd.runner.Gate || sd.conv.Frozen == nil {
231 t.Fatalf("the schedule halves do not share one gate and the freeze: %+v %+v", sd.runner, sd.conv)
232 }
233 if v.gate.Load() != sd.runner.Gate {
234 t.Fatal("the view does not drain the runner's gate")
235 }
236 if err := v.drainTimers(context.Background(), mhHost); err != nil {
237 t.Fatalf("drain = %v", err)
238 }
239}
240
241// The switch off (moves.Enabled false) is main: no hold view, so reconcile
242// gets no Hold, KeepParticipant or HoldWritten, and the schedule halves no
243// gate and no freeze.
244func TestMovesHoldsAreMainWithTheSwitchOff(t *testing.T) {
245 prev := armedMovesWorker.Load()
246 t.Cleanup(func() { armedMovesWorker.Store(prev) })
247 off := &movesWorker{cfg: moveCfg{Enabled: false}, holds: newMovesHoldView("ie-box", "ie", hostclaim.Read, nil)}
248 armedMovesWorker.Store(off)
249 if armedHoldView() != nil {
250 t.Fatal("a hold view with the switch off")
251 }
252 cfg := withMovesHolds(provision.ReconcileConfig{})
253 if cfg.Hold != nil || cfg.KeepParticipant != nil || cfg.HoldWritten != nil {
254 t.Fatal("reconcile hooks with the switch off")
255 }
256
257 // The schedule halves, wired as the agent wires them with the switch
258 // off, against a row whose status is a hold: the runner delivers its
259 // due timer and records the outcome, and the converger converges its
260 // declaration, exactly as main does (fix round 1: behaviour, not the
261 // absence of hooks).
262 ctx := context.Background()
263 reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db"))
264 if err != nil {
265 t.Fatal(err)
266 }
267 t.Cleanup(func() { reg.Close() })
268 const host = "held.bab.oncarlos.com"
269 if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "bab", App: "app", Status: "ready"}); err != nil {
270 t.Fatal(err)
271 }
272 if err := reg.UpsertTimer(registry.Timer{Host: host, Name: "remind", Kind: schedule.KindOnce, Spec: "at 1", Path: "/r", NextAt: 1, DueAt: 1}); err != nil {
273 t.Fatal(err)
274 }
275 if err := reg.SetHold(host, "moving", mhID); err != nil {
276 t.Fatal(err)
277 }
278 now := time.Date(2026, 8, 23, 13, 7, 0, 0, time.UTC)
279 st := release.NewDirStore(t.TempDir())
280 if _, err := schedule.UpsertSchedule(ctx, st, "bab", "app", schedule.Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now); err != nil {
281 t.Fatal(err)
282 }
283 runner := &schedule.Runner{Reg: reg, Deliver: statusDeliverer(503), Now: func() time.Time { return now }, Timeout: time.Minute,
284 Box: "ie-box", Build: "test", Token: func(string) (string, error) { return "tok", nil }}
285 conv := schedule.NewConverger(reg, st, "ie-box", nil)
286 conv.Now = func() time.Time { return now }
287 sd := &scheduleDeps{runner: runner, conv: conv}
288 armScheduleHolds(sd, armedHoldView())
289 if runner.Gate != nil || conv.Gate != nil || conv.Frozen != nil {
290 t.Fatal("schedule hooks with the switch off")
291 }
292 runner.Tick(ctx)
293 // Tick launches the delivery and returns: wait for the record, then
294 // for the host's goroutine to let go.
295 var tm *registry.Timer
296 for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
297 if tm, _ = reg.GetTimer(host, "remind"); tm != nil && tm.Attempts == 1 && !runner.Busy(host) {
298 break
299 }
300 }
301 if tm == nil || tm.Attempts != 1 || tm.LastStatus != "503" {
302 t.Fatalf("with the switch off a held row's due timer was not delivered and recorded as main does: %+v", tm)
303 }
304 if err := conv.Pass(ctx); err != nil {
305 t.Fatal(err)
306 }
307 if _, err := reg.GetTimer(host, "sync"); err != nil {
308 t.Fatalf("with the switch off the held host's declaration did not converge: %v", err)
309 }
310
311 armedMovesWorker.Store(nil)
312 if cfg := withMovesHolds(provision.ReconcileConfig{}); cfg.Hold != nil {
313 t.Fatal("reconcile hooks with no worker armed")
314 }
315
316 on := &movesWorker{cfg: moveCfg{Enabled: true}, holds: newMovesHoldView("ie-box", "ie", hostclaim.Read, func(string) {})}
317 armedMovesWorker.Store(on)
318 cfg = withMovesHolds(provision.ReconcileConfig{})
319 if cfg.Hold == nil || cfg.KeepParticipant == nil || cfg.HoldWritten == nil {
320 t.Fatal("no reconcile hooks with the switch on")
321 }
322}
323
324// Ruling J3 (fix round 1): the hand-over of a move's hold to its rollback
325// touches the stage-requested marker when it lands, and only then.
326func TestHandHoldTouchesTheStageMarkerOnlyWhenItLands(t *testing.T) {
327 const rb = "rb_abcdefghijklmnopqrstuvwxyz"
328 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
329 if err != nil {
330 t.Fatal(err)
331 }
332 defer reg.Close()
333 if err := reg.Upsert(registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "ready", Provisioned: true}); err != nil {
334 t.Fatal(err)
335 }
336 if err := reg.SetHold(p1Host, "landing", p1ID); err != nil {
337 t.Fatal(err)
338 }
339 marker := func(dd string) bool {
340 _, err := os.Stat(filepath.Join(dd, stageRequestMarker))
341 return err == nil
342 }
343
344 // A refusal: the row is held by p1ID, not p1ID2. Nothing moves, no marker.
345 refused := t.TempDir()
346 if ok, err := handHold(reg, holdStageRequester(refused))(p1Host, "releasing", rb, p1ID2, nil); err != nil || ok || marker(refused) {
347 t.Fatalf("refused hand-over: %v %v, marker %v", ok, err, marker(refused))
348 }
349 if rt, _ := reg.Get(p1Host); rt.Status != "landing" || rt.HoldOp != p1ID {
350 t.Fatalf("row changed by a refusal: %+v", rt)
351 }
352 // An absent row: no marker.
353 if ok, err := handHold(reg, holdStageRequester(refused))("absent.example.com", "releasing", rb, p1ID, nil); err != nil || ok || marker(refused) {
354 t.Fatalf("absent row: %v %v, marker %v", ok, err, marker(refused))
355 }
356
357 // The hand-over: the row is the rollback's, and the marker is written.
358 landed := t.TempDir()
359 if ok, err := handHold(reg, holdStageRequester(landed))(p1Host, "releasing", rb, p1ID, nil); err != nil || !ok || !marker(landed) {
360 t.Fatalf("hand-over: %v %v, marker %v", ok, err, marker(landed))
361 }
362 if rt, _ := reg.Get(p1Host); rt.Status != "releasing" || rt.HoldOp != rb {
363 t.Fatalf("after the hand-over: %+v", rt)
364 }
365
366 // A replay finds the row already the rollback's: done, nothing written.
367 var touched []string
368 if ok, err := handHold(reg, func(h string) { touched = append(touched, h) })(p1Host, "releasing", rb, p1ID, nil); err != nil || !ok || len(touched) != 0 {
369 t.Fatalf("replay: %v %v, marker touched %d times", ok, err, len(touched))
370 }
371}
372
373// Task 4 fix round 1, ruling 1: the hand-over's caller validates its own
374// record under the hold lock, after the row is re-read and before the hold
375// moves. A validation that refuses moves nothing and writes no marker; one
376// that passes is asked once, and the hold moves. The lock is the wrapper's
377// alone: the callback runs inside it and must not take it again.
378func TestHandHoldValidatesUnderTheLock(t *testing.T) {
379 const rb = "rb_abcdefghijklmnopqrstuvwxyz"
380 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
381 if err != nil {
382 t.Fatal(err)
383 }
384 defer reg.Close()
385 if err := reg.Upsert(registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Status: "ready", Provisioned: true}); err != nil {
386 t.Fatal(err)
387 }
388 if err := reg.SetHold(p1Host, "landing", p1ID); err != nil {
389 t.Fatal(err)
390 }
391 changed := errors.New("the record is no longer this rollback's release")
392 var seen []string
393 refused := t.TempDir()
394 ok, err := handHold(reg, holdStageRequester(refused))(p1Host, "releasing", rb, p1ID, func() error {
395 rt, _ := reg.Get(p1Host)
396 seen = append(seen, rt.Status+"/"+rt.HoldOp)
397 return changed
398 })
399 if !errors.Is(err, changed) || ok {
400 t.Fatalf("a refused validation: %v %v", ok, err)
401 }
402 if _, err := os.Stat(filepath.Join(refused, stageRequestMarker)); err == nil {
403 t.Fatal("a refused validation wrote the marker")
404 }
405 if rt, _ := reg.Get(p1Host); rt.Status != "landing" || rt.HoldOp != p1ID {
406 t.Fatalf("the hold moved under a refused validation: %+v", rt)
407 }
408 landed := t.TempDir()
409 if ok, err := handHold(reg, holdStageRequester(landed))(p1Host, "releasing", rb, p1ID, func() error {
410 rt, _ := reg.Get(p1Host)
411 seen = append(seen, rt.Status+"/"+rt.HoldOp)
412 return nil
413 }); err != nil || !ok {
414 t.Fatalf("a passing validation: %v %v", ok, err)
415 }
416 if rt, _ := reg.Get(p1Host); rt.Status != "releasing" || rt.HoldOp != rb {
417 t.Fatalf("after the hand-over: %+v", rt)
418 }
419 if strings.Join(seen, " ") != "landing/"+p1ID+" landing/"+p1ID {
420 t.Fatalf("validations saw %v, want the row before the hand-over, once each", seen)
421 }
422}
423