carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/cgroup"
12 "github.com/carlosframework/platform/internal/hostguard"
13 "github.com/carlosframework/platform/internal/hostwrite"
14 "github.com/carlosframework/platform/internal/instancectl"
15 "github.com/carlosframework/platform/internal/registry"
16)
17
18// The happy path, in spec order: raise, exec the staged path, health
19// through the handle, switch, then SIGTERM the retiring generation, then
20// restore the parent. The live link names v3 (root moved it) and makes no
21// difference: the successor execs the attempt's staged v2 (review focus 2).
22// The retiring generation is signalled only after AppSocket has moved
23// (review focus 1).
24func TestSwapOverlapSwitchesAfterHealthAndRetiresTheIncumbent(t *testing.T) {
25 f := newSwapFixture(t)
26 f.b.execVerFn = func() string { return "v3" }
27 incumbent := f.b.lastProc()
28 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
29 if res.Outcome != SwapDone || res.Gen.Build != "v2" || res.Gen.StagedPath != f.staged {
30 t.Fatalf("Swap = %+v", res)
31 }
32 if got := f.appSocket(t); got != res.Gen.Socket {
33 t.Fatalf("AppSocket = %q, want the successor's %q", got, res.Gen.Socket)
34 }
35 ev := f.b.events()
36 order := []int{
37 indexPrefix(ev, "raise"),
38 indexPrefix(ev, "startGen:"+f.staged),
39 indexPrefix(ev, fmt.Sprintf("switch:%d", res.Gen.N)),
40 indexPrefix(ev, "stop:instance"),
41 // The parent restore. A bare "restore" is the wake's data restore
42 // (fakeBackend.Restore), logged before any of this.
43 indexPrefix(ev, "restore:"),
44 }
45 for i := range order {
46 if order[i] < 0 || (i > 0 && order[i] < order[i-1]) {
47 t.Fatalf("event order %v (indexes %v): want raise, startGen, switch, stop:instance, restore", ev, order)
48 }
49 }
50 if !incumbent.isStopped() {
51 t.Fatal("the retiring generation was never stopped")
52 }
53 cur, members := f.a.genStateForTest(f.host)
54 if cur.N != res.Gen.N || len(members) != 0 {
55 t.Fatalf("after the swap: current %d, members %v", cur.N, members)
56 }
57 if _, raises, restores := f.b.calls(); raises != 1 || restores != 1 {
58 t.Fatalf("raises %d restores %d, want 1 and 1", raises, restores)
59 }
60 if got := f.b.startCount(); got != 1 {
61 t.Fatalf("Start (live link) calls = %d, want only the original wake", got)
62 }
63}
64
65// During the overlap the parent holds the incumbent's tier plus the
66// successor's, each captured from its own artifact (spec §7.3), and the
67// restore is to whichever generation is current afterwards. Here the
68// incumbent (v1) declares children (1 GiB) and the successor (v2) does
69// not (512 MiB). Summing one tier twice gives a 1 GiB parent (or 2 GiB),
70// and restoring to the live tier leaves the wrong limit after a failure.
71func TestSwapOverlapUsesEachArtifactsTier(t *testing.T) {
72 t.Parallel()
73 const gib, half = int64(1 << 30), int64(1 << 29)
74 t.Run("switched: restored to the successor's tier", func(t *testing.T) {
75 f := newSwapFixture(t)
76 f.b.tiers = map[string]cgroup.Limit{"v1": cgroup.TierChildren, "v2": cgroup.TierDefault}
77 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
78 if res.Outcome != SwapDone || res.Gen.Tier != cgroup.TierDefault {
79 t.Fatalf("Swap = %+v, want done with the successor's own tier", res)
80 }
81 ev := f.b.events()
82 if indexPrefix(ev, fmt.Sprintf("raise:%d+%d", gib, half)) < 0 || indexPrefix(ev, fmt.Sprintf("restore:%d", half)) < 0 {
83 t.Fatalf("events %v: want raise:%d+%d and restore:%d", ev, gib, half, half)
84 }
85 if g, _ := f.b.lastGen(); g.Tier != cgroup.TierDefault {
86 t.Fatalf("the successor spawned at %v, want its own 512 MiB tier", g.Tier)
87 }
88 })
89 t.Run("failed: restored to the incumbent's tier", func(t *testing.T) {
90 f := newSwapFixture(t)
91 f.b.tiers = map[string]cgroup.Limit{"v1": cgroup.TierChildren, "v2": cgroup.TierDefault}
92 f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 503") }
93 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapFailed {
94 t.Fatalf("Swap = %+v", res)
95 }
96 if ev := f.b.events(); indexPrefix(ev, fmt.Sprintf("restore:%d", gib)) < 0 {
97 t.Fatalf("events %v: want restore:%d (the incumbent is still current)", ev, gib)
98 }
99 })
100}
101
102// The "swapped" event carries the retiring generation's drain time (spec
103// §9.4), so the result measures it: from SIGTERM to the proven exit.
104func TestSwapOverlapReportsTheDrainTime(t *testing.T) {
105 t.Parallel()
106 f := newSwapFixture(t)
107 gate := make(chan struct{})
108 f.b.lastProc().setStopGate(gate)
109 time.AfterFunc(80*time.Millisecond, func() { close(gate) })
110 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
111 if res.Outcome != SwapDone {
112 t.Fatalf("Swap = %+v", res)
113 }
114 if res.Drain < 50*time.Millisecond || res.Drain > 5*time.Second {
115 t.Fatalf("Drain = %v, want the retiring generation's stop (about 80ms)", res.Drain)
116 }
117}
118
119// A successor that fails its health check, fails to spawn, or exits before
120// the switch leaves the incumbent current and untouched (spec §8.1).
121func TestSwapOverlapFailureKeepsTheIncumbent(t *testing.T) {
122 t.Parallel()
123 for _, tc := range []struct {
124 name string
125 arrange func(f *swapFixture)
126 reason string
127 }{
128 {"unhealthy", func(f *swapFixture) {
129 f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 503") }
130 }, "health"},
131 {"spawn fails", func(f *swapFixture) { f.b.genErr = errors.New("exec format error") }, "spawn"},
132 {"exits before the switch", func(f *swapFixture) {
133 f.a.genHealth = func(context.Context, Gen) error {
134 _, p := f.b.lastGen()
135 p.exit()
136 return nil
137 }
138 }, errSuccessorExited.Error()},
139 } {
140 t.Run(tc.name, func(t *testing.T) {
141 f := newSwapFixture(t)
142 tc.arrange(f)
143 incumbent := f.b.lastProc()
144 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
145 if res.Outcome != SwapFailed || !strings.Contains(res.Reason, tc.reason) {
146 t.Fatalf("Swap = %+v, want failed with %q", res, tc.reason)
147 }
148 if got := f.appSocket(t); got != f.incumbent.Socket {
149 t.Fatalf("AppSocket moved to %q after a failure", got)
150 }
151 if incumbent.isStopped() {
152 t.Fatal("the incumbent was stopped by a failed swap")
153 }
154 if _, p := f.b.lastGen(); p != nil && !p.isStopped() {
155 t.Fatal("the failed successor was left running")
156 }
157 if _, raises, restores := f.b.calls(); raises != restores {
158 t.Fatalf("raises %d restores %d: the parent limit was not restored", raises, restores)
159 }
160 })
161 }
162}
163
164// Every refusal spawns nothing. Swaps off is the flag-off test.
165func TestSwapRefusals(t *testing.T) {
166 t.Parallel()
167 for _, tc := range []struct {
168 name string
169 arrange func(t *testing.T, f *swapFixture, at *Attempt)
170 want error
171 }{
172 {"swap off", func(_ *testing.T, f *swapFixture, _ *Attempt) { f.a.gens = false }, errSwapOff},
173 {"asleep", func(_ *testing.T, _ *swapFixture, at *Attempt) { at.Host = "asleep.example.com" }, errSwapNotAwake},
174 {"worker", func(_ *testing.T, f *swapFixture, _ *Attempt) {
175 f.a.mu.Lock()
176 f.a.entries[f.host].worker = true
177 f.a.mu.Unlock()
178 }, errSwapKind},
179 {"busy", func(t *testing.T, f *swapFixture, _ *Attempt) {
180 plantMember(t, f.a, f.host, roleSuccessor, &fakeProc{}, 77)
181 }, errSwapBusy},
182 {"unknown mode", func(_ *testing.T, _ *swapFixture, at *Attempt) { at.Mode = "" }, errSwapMode},
183 {"superseded", func(_ *testing.T, f *swapFixture, _ *Attempt) { f.rev.Add(1) }, errSwapSuperseded},
184 {"fenced", func(_ *testing.T, f *swapFixture, _ *Attempt) {
185 f.a.mu.Lock()
186 f.a.fences = map[string]uint64{f.host: 3}
187 f.a.mu.Unlock()
188 }, errSwapFenced},
189 {"lease lost", func(_ *testing.T, f *swapFixture, _ *Attempt) { f.b.lastRep().setErr(errors.New("renewal failed")) }, errSwapLeaseLost},
190 } {
191 t.Run(tc.name, func(t *testing.T) {
192 f := newSwapFixture(t)
193 at := f.attempt(ModeOverlap)
194 tc.arrange(t, f, &at)
195 res := f.a.Swap(context.Background(), at, f.newest)
196 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, tc.want.Error()) {
197 t.Fatalf("Swap = %+v, want abandoned: %v", res, tc.want)
198 }
199 if gens, raises, _ := f.b.calls(); gens != 0 || raises != 0 {
200 t.Fatalf("a refused attempt spawned %d generation(s), raised %d time(s)", gens, raises)
201 }
202 })
203 }
204 t.Run("digest mismatch", func(t *testing.T) {
205 f := newSwapFixture(t)
206 at := f.attempt(ModeOverlap)
207 at.Digest = strings.Repeat("0", 64)
208 res := f.a.Swap(context.Background(), at, f.newest)
209 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, "staging") {
210 t.Fatalf("Swap = %+v, want abandoned as a staging error", res)
211 }
212 if gens, _, _ := f.b.calls(); gens != 0 {
213 t.Fatal("a mismatched artifact reached StartGen")
214 }
215 })
216 t.Run("backend without generations", func(t *testing.T) {
217 a, _, _, _ := testActivator(t, time.Hour)
218 a.gens = true
219 if res := a.Swap(context.Background(), Attempt{Host: "x.example.com", Mode: ModeOverlap}, func(string) uint64 { return 0 }); res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapNoGens.Error()) {
220 t.Fatalf("Swap = %+v", res)
221 }
222 })
223}
224
225// A stopping entry admits no successor (spec §7.4: "the entry is still
226// awake"). This is the witness for mutation M5.
227func TestSwapWitnessNoAdmissionOntoAStoppingEntry(t *testing.T) {
228 f := newSwapFixture(t)
229 gate := make(chan struct{})
230 f.b.lastProc().setStopGate(gate)
231 f.a.Bounce(f.host)
232 waitFor(t, "the entry to stop", func() bool {
233 f.a.mu.Lock()
234 defer f.a.mu.Unlock()
235 e := f.a.entries[f.host]
236 return e != nil && e.state == actStopping
237 })
238 // The refusal is immediate. A Swap that admits a successor instead
239 // switches and then drains the incumbent, whose Stop the gate holds, so
240 // it cannot return until the gate opens: bound the wait, so the
241 // mutation fails here by name rather than hanging the run.
242 results := make(chan SwapResult, 1)
243 go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
244 var res SwapResult
245 select {
246 case res = <-results:
247 case <-time.After(5 * time.Second):
248 close(gate)
249 select {
250 case res = <-results:
251 t.Fatalf("Swap waited on the stopping incumbent's drain, then returned %+v: it admitted a successor onto a stopping entry", res)
252 case <-time.After(5 * time.Second):
253 t.Fatal("Swap onto a stopping entry did not return")
254 }
255 }
256 close(gate)
257 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapNotAwake.Error()) {
258 t.Fatalf("Swap = %+v, want abandoned: not awake", res)
259 }
260 if gens, _, _ := f.b.calls(); gens != 0 {
261 t.Fatalf("StartGen ran %d time(s) on a stopping entry", gens)
262 }
263}
264
265// The one outcome classifier (spec §6.4, §7.4): only a failure of the build
266// is stepFailed. A cancelled lifecycle, an intentional stop and a refusal
267// by the attempt gate record nothing; a health deadline that expires is the
268// build's.
269func TestClassifyStep(t *testing.T) {
270 live, ended := context.Background(), func() context.Context {
271 ctx, cancel := context.WithCancel(context.Background())
272 cancel()
273 return ctx
274 }()
275 for _, tc := range []struct {
276 name string
277 ctx context.Context
278 err error
279 want stepOutcome
280 }{
281 {"ok", live, nil, stepOK},
282 {"the lifecycle ended", ended, errors.New("dial: connection refused"), stepCancelled},
283 {"cancelled", live, fmt.Errorf("health: %w", context.Canceled), stepCancelled},
284 {"fenced", live, fmt.Errorf("wake: %w", ErrFenced), stepCancelled},
285 {"the barrier", live, errSwapStopped, stepCancelled},
286 {"superseded", live, fmt.Errorf("pin: %w", errSwapSuperseded), stepStale},
287 {"lease lost", live, errSwapLeaseLost, stepStale},
288 // Claims validation at the exec (spawnClaimedGen's Work.With):
289 // nothing ran, so no failure of the build and no halted cohort.
290 {"a changed stamp at the exec", live, fmt.Errorf("with: %w", hostguard.ErrStampMismatch), stepStale},
291 {"a durable fence at the exec", live, fmt.Errorf("with: %w", hostguard.ErrFenced), stepStale},
292 // The strict-host gate refused a placed spawn (plan 2), and every
293 // claims-on launch is closed on this base: nothing of the build ran.
294 {"the strict gate", live, fmt.Errorf("x: %w: %w", ErrStrictRequired, ErrStrictGroupInUse), stepStale},
295 {"a closed claims launch", live, fmt.Errorf("launch: %w", ErrClaimIsolationUnavailable), stepStale},
296 // A row that cannot run a staged generation at all.
297 {"a unit row", live, errUnitNoGens, stepStale},
298 // Amadan #272: the generation start refused before any exec because
299 // the host's parent holds a process directly.
300 {"a populated legacy parent", live, fmt.Errorf("h: %w: holds 1 process(es)", cgroup.ErrLegacyParentPopulated), stepStale},
301 // Task 19: the start's killed cgroup could not be made fresh, so the
302 // manager refused before any exec.
303 {"a killed cgroup that would not go", live, fmt.Errorf("%w: carlos-exec@h/g2: device or resource busy", cgroup.ErrKilledCgroupKept), stepStale},
304 // Task 7 (readiness round 2): a generation whose limits could not be
305 // applied is refused before any exec: the box's condition.
306 {"a generation's limits not applied", live, fmt.Errorf("h: %w: g2: memory.max: is a directory", cgroup.ErrLimitNotApplied), stepStale},
307 // Root refused the nested staged executable before it mounted or
308 // started anything (Task 9): an abandonment, not a failed build.
309 {"root refused the staged exe", live, fmt.Errorf("start unit: %w: not blog's staged v2", instancectl.ErrStagedExe), stepStale},
310 {"a backend without generations", live, errSwapNoGens, stepStale},
311 {"a health deadline", live, fmt.Errorf("health: %w", context.DeadlineExceeded), stepFailed},
312 {"HTTP 500", live, errors.New("GET / answered 500"), stepFailed},
313 } {
314 if got := classifyStep(tc.ctx, tc.err); got != tc.want {
315 t.Errorf("%s: %d, want %d", tc.name, got, tc.want)
316 }
317 }
318}
319
320// The gate's verdict is typed (spec §6.3, §7.4): a supersession may restore
321// behind a fresh ownership check; a lost lease, a fence, shutdown or a
322// claims refusal stops every spawn.
323func TestVerdictOf(t *testing.T) {
324 for _, tc := range []struct {
325 err error
326 want gateVerdict
327 }{
328 {nil, gateProceed},
329 {errors.New("GET / answered 500"), gateProceed},
330 {fmt.Errorf("gate: %w", errSwapSuperseded), gateSuperseded},
331 {errSwapLeaseLost, gateNoOwnership},
332 {errSwapFenced, gateNoOwnership},
333 {errSwapShutdown, gateNoOwnership},
334 {errSwapNotAwake, gateNoOwnership},
335 // The barrier's cancel (memberCurrentLocked): the entry is stopping,
336 // so nothing may spawn for it, a restore included.
337 {errSwapStopped, gateNoOwnership},
338 {fmt.Errorf("with: %w", hostguard.ErrStampMismatch), gateNoOwnership},
339 {fmt.Errorf("with: %w", hostguard.ErrFenced), gateNoOwnership},
340 // P5 review partial new #3: an absent claim, and a claim read that
341 // failed before the exec, are no failure of the build either.
342 {fmt.Errorf("with: %w", hostguard.ErrNoClaim), gateNoOwnership},
343 {claimAdmission(errors.New("s3: GetObject: request timeout"), false), gateNoOwnership},
344 {claimAdmission(errors.New("GET / answered 500"), true), gateProceed},
345 // Plan 2's strict-host gate on placed spawns (index P2-8), as the
346 // exec backend wraps them.
347 {fmt.Errorf("h: %w", ErrStrictRequired), gateNoOwnership},
348 {fmt.Errorf("h: %w: %w", ErrStrictRequired, ErrStrictGroupInUse), gateNoOwnership},
349 {fmt.Errorf("h: %w", ErrStrictGroupInUse), gateNoOwnership},
350 {fmt.Errorf("h: %w", ErrStrictCleanupPending), gateNoOwnership},
351 // Every claims-on launch is closed on this base (index P2-9). It
352 // wraps hostbox.ErrUnsupported, and is still NoOwnership, raw or
353 // as a claims admission.
354 {ErrClaimIsolationUnavailable, gateNoOwnership},
355 {fmt.Errorf("start: %w", ErrClaimIsolationUnavailable), gateNoOwnership},
356 {claimAdmission(ErrClaimIsolationUnavailable, false), gateNoOwnership},
357 // Amadan #272: a populated legacy parent refuses the start before any
358 // exec, and no build is at fault.
359 {fmt.Errorf("h: %w: holds 1 process(es)", cgroup.ErrLegacyParentPopulated), gateNoOwnership},
360 // Task 19: a start whose killed cgroup could not be made fresh is
361 // refused before the command is built; no build is at fault.
362 {cgroup.ErrKilledCgroupKept, gateNoOwnership},
363 {fmt.Errorf("%w: carlos-exec@h/role-sidecar: device or resource busy", cgroup.ErrKilledCgroupKept), gateNoOwnership},
364 // Task 7: a generation start refused because its limits could not
365 // all be applied (or it could only be started then moved) is refused
366 // before the exec; no build is at fault.
367 {cgroup.ErrLimitNotApplied, gateNoOwnership},
368 {fmt.Errorf("h: %w: the placement shim is not available", cgroup.ErrLimitNotApplied), gateNoOwnership},
369 // A row that cannot run a staged generation: nothing staged spawns.
370 {errUnitNoGens, gateNoOwnership},
371 {instancectl.ErrStagedExe, gateNoOwnership},
372 {fmt.Errorf("start unit: %w: x", instancectl.ErrStagedExe), gateNoOwnership},
373 {errSwapNoGens, gateNoOwnership},
374 } {
375 if got := verdictOf(tc.err); got != tc.want {
376 t.Errorf("verdictOf(%v) = %d, want %d", tc.err, got, tc.want)
377 }
378 }
379}
380
381// Spec §6.3, §7.4: the checks run again at the exec, not only at the
382// admission. A newer attempt that lands while the parent is being raised
383// (or, with plan 5, while the pins are being published) means nothing
384// execs, and the raise is undone. Checked only at the admission, the
385// superseded build execs and is abandoned only at the switch.
386func TestSwapOverlapReChecksAtTheExec(t *testing.T) {
387 f := newSwapFixture(t)
388 f.b.onRaise = func() { f.rev.Add(1) }
389 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
390 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
391 t.Fatalf("Swap = %+v, want abandoned: superseded", res)
392 }
393 if gens, raises, restores := f.b.calls(); gens != 0 || raises != restores {
394 t.Fatalf("spawned %d, raises %d, restores %d: want nothing exec'd and the raise undone", gens, raises, restores)
395 }
396}
397
398// A newer attempt that lands during the health check is seen at the switch.
399// The successor is abandoned and stopped, and the incumbent keeps serving.
400func TestSwapOverlapSupersededAtTheSwitch(t *testing.T) {
401 f := newSwapFixture(t)
402 f.a.genHealth = func(context.Context, Gen) error { f.rev.Add(1); return nil }
403 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
404 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
405 t.Fatalf("Swap = %+v", res)
406 }
407 if got := f.appSocket(t); got != f.incumbent.Socket {
408 t.Fatalf("AppSocket = %q, want the incumbent", got)
409 }
410 if _, p := f.b.lastGen(); !p.isStopped() {
411 t.Fatal("the abandoned successor was left running")
412 }
413}
414
415// useClaimSpawn stands the claimSpawn seam in for the kernel: a claims-on
416// spawn needs a delegated cgroup tree and an open isolated launch, and
417// every claims-on launch is closed on this base.
418func useClaimSpawn(f *swapFixture) {
419 f.a.claimSpawn = func(ctx context.Context, _ *actEntry, rt registry.Route, _ hostwrite.Work, g Gen) (Gen, Proc, error) {
420 return f.b.StartStaged(ctx, rt, g)
421 }
422}
423
424// Claims on: a refused selection or a refused check, before the spawn or
425// at the switch, abandons the attempt and is never retried as claims off.
426func TestSwapClaimsOn(t *testing.T) {
427 t.Parallel()
428 for _, tc := range []struct {
429 name string
430 sel func() hostwrite.Selector
431 wantGens int
432 }{
433 {"selector refuses", func() hostwrite.Selector { return &swapSelector{err: hostwrite.ErrUnavailable} }, 0},
434 {"no selector", func() hostwrite.Selector { return nil }, 0},
435 {"refused before the spawn", func() hostwrite.Selector { return &swapSelector{work: &swapWork{denyAt: 1}} }, 0},
436 {"refused at the exec", func() hostwrite.Selector { return &swapSelector{work: &swapWork{denyAt: 2}} }, 0},
437 {"refused at the switch", func() hostwrite.Selector { return &swapSelector{work: &swapWork{denyAt: 3}} }, 1},
438 } {
439 t.Run(tc.name, func(t *testing.T) {
440 f := newSwapFixture(t)
441 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: tc.sel()}
442 // A runtime that can start a generation of its own, so each row
443 // is refused where it names, not by errClaimsNoGenerations.
444 f.a.cfg.Claims = &ClaimRuntime{Pins: true, Generations: true}
445 // The seam stands in for an open launch, so each row reaches the
446 // refusal it names; without it, claimsSwapRefusal refuses every
447 // row first (the "closed launch, no seam" case below).
448 useClaimSpawn(f)
449 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
450 if res.Outcome != SwapAbandoned {
451 t.Fatalf("Swap = %+v, want abandoned", res)
452 }
453 gens, raises, restores := f.b.calls()
454 if gens != tc.wantGens || raises != restores {
455 t.Fatalf("gens %d (want %d), raises %d, restores %d", gens, tc.wantGens, raises, restores)
456 }
457 if got := f.appSocket(t); got != f.incumbent.Socket {
458 t.Fatalf("AppSocket = %q, want the incumbent", got)
459 }
460 if _, p := f.b.lastGen(); p != nil && !p.isStopped() {
461 t.Fatal("a refused successor was left running")
462 }
463 })
464 }
465 t.Run("allowed", func(t *testing.T) {
466 f := newSwapFixture(t)
467 w := &swapWork{}
468 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: w}}
469 f.a.cfg.Claims = &ClaimRuntime{Pins: true, Generations: true}
470 useClaimSpawn(f)
471 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapDone {
472 t.Fatalf("Swap = %+v", res)
473 }
474 if w.withCalls() != 3 {
475 t.Fatalf("With calls = %d, want 3 (the admission, the exec and the switch)", w.withCalls())
476 }
477 })
478 // Every claims-on launch is closed on this base (index P2-9). Without
479 // the seam a claims-on swap is refused before its admission and its
480 // raise (claimsSwapRefusal, final review F9): abandoned (never failed,
481 // which would halt the cohort for a build that never ran), nothing
482 // raised or spawned, and never sent down the claims-off StartStaged.
483 t.Run("closed launch, no seam", func(t *testing.T) {
484 f := newSwapFixture(t)
485 w := &swapWork{}
486 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: w}}
487 f.a.cfg.Claims = &ClaimRuntime{Pins: true, Generations: true}
488 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
489 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, ErrClaimIsolationUnavailable.Error()) {
490 t.Fatalf("Swap = %+v, want abandoned: %v", res, ErrClaimIsolationUnavailable)
491 }
492 gens, raises, restores := f.b.calls()
493 if gens != 0 || raises != 0 || restores != 0 {
494 t.Fatalf("StartStaged %d, raises %d, restores %d: want nothing raised or spawned", gens, raises, restores)
495 }
496 if ev := f.b.events(); indexPrefix(ev, "startGen:") >= 0 {
497 t.Fatalf("events %v: a claims-on swap went down the claims-off spawn", ev)
498 }
499 if w.withCalls() != 0 {
500 t.Fatalf("With calls = %d, want 0 (refused before the admission)", w.withCalls())
501 }
502 if got := f.appSocket(t); got != f.incumbent.Socket {
503 t.Fatalf("AppSocket = %q, want the incumbent", got)
504 }
505 if _, members := f.a.genStateForTest(f.host); len(members) != 0 {
506 t.Fatalf("members %v left in the operation set", members)
507 }
508 })
509}
510