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 "go/ast"
8 "go/parser"
9 "go/token"
10 "io/fs"
11 "os/exec"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/hostclaim"
17 "github.com/carlosframework/platform/internal/hostguard"
18 "github.com/carlosframework/platform/internal/hostwrite"
19 "github.com/carlosframework/platform/internal/registry"
20)
21
22// claimedStartFor is a ClaimStart for obs with every hook a no-op, version
23// v. It is the one ClaimStart these tests build: main's claims-on launch
24// needs a RoleLauncher and CurrentAttempt for valid() (claimwindow_test.go),
25// and on this base RoleLaunch.Start refuses every shape before its before
26// hook runs (index P2-9).
27func claimedStartFor(obs hostclaim.Observed, work hostwrite.Work, v string) ClaimStart {
28 return ClaimStart{RoleLauncher: &RoleLaunch{}, CurrentAttempt: func() hostclaim.WakeRetryAttempt { return hostclaim.WakeRetryAttempt{} },
29 Host: obs.Host, Work: work, Identity: hostwrite.Identity{Config: "c", Release: "r"}, Version: v,
30 Restore: func(context.Context) error { return nil },
31 Materialize: func(context.Context) error { return nil }, Build: func() *exec.Cmd { return nil },
32 Ready: func(context.Context, *exec.Cmd) error { return nil }, Reserve: func(context.Context, int) error { return nil },
33 Failed: func(context.Context, int) error { return nil }, Healthy: func(context.Context, int, *exec.Cmd) error { return nil },
34 Replicate: func(context.Context, string) (Rep, error) { return nil, nil },
35 Publish: func(context.Context, hostclaim.Observed) (hostclaim.Observed, error) { return obs, nil },
36 Retain: func(Proc, Rep, error) {}}
37}
38
39func claimedObs(host string) hostclaim.Observed {
40 return hostclaim.Observed{Host: host, ETag: "one", Claim: &hostclaim.Claim{
41 Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Session: 1, Generation: "one"},
42 Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"},
43 }}
44}
45
46// Without a runtime that builds per-generation starts, a claims-on swap is
47// refused before anything is raised, stopped or spawned.
48func TestClaimsOnSwapNeedsGenerations(t *testing.T) {
49 t.Parallel()
50 for _, mode := range []SwapMode{ModeOverlap, ModeHold} {
51 f := newSwapFixture(t)
52 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: &swapWork{}}}
53 f.a.cfg.Claims = &ClaimRuntime{Pins: true} // Generations false
54 res := f.a.Swap(context.Background(), f.attempt(mode), f.newest)
55 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errClaimsNoGenerations.Error()) {
56 t.Fatalf("%s: Swap = %+v", mode, res)
57 }
58 if gens, raises, _ := f.b.calls(); gens != 0 || raises != 0 {
59 t.Fatalf("%s: spawned %d, raised %d", mode, gens, raises)
60 }
61 if f.b.lastProc().isStopped() {
62 t.Fatalf("%s: the incumbent was stopped", mode)
63 }
64 }
65}
66
67// A provider that binds anything but the generation the activator chose is
68// refused before Restore touches the database. With generations off (a
69// zero Gen) nothing is checked, as today.
70func TestStartAdmittedRefusesAGenerationMismatch(t *testing.T) {
71 obs := claimedObs("x.example")
72 work, _ := (hostwrite.Config{}).Select(context.Background(), obs.Host, "spawn")
73 stop := errors.New("stop at restore")
74 want := Gen{N: 9, Socket: "/run/carlos/g/abc.9.sock", Control: "/run/carlos/g/abc.9.sock.control"}
75 for _, tc := range []struct {
76 name string
77 policy Gen
78 bound Gen
79 restored bool
80 }{
81 {"matching", want, want, true},
82 {"another socket", want, Gen{N: 9, Socket: "/run/carlos/x.example.sock", Control: want.Control}, false},
83 {"another control socket", want, Gen{N: 9, Socket: want.Socket, Control: "/run/carlos/x.example.sock.control"}, false},
84 {"another number", want, Gen{N: 10, Socket: want.Socket, Control: want.Control}, false},
85 {"generations off", Gen{}, Gen{}, true},
86 } {
87 restored := false
88 a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box",
89 Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil },
90 Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
91 s := claimedStartFor(obs, work, "v1")
92 s.Gen = tc.bound
93 s.Restore = func(context.Context) error { restored = true; return stop }
94 return s, nil
95 }}}}
96 err := a.startAdmitted(context.Background(), &actEntry{host: obs.Host}, obs, startPolicy{MaxStarts: 1, Window: time.Second, Gen: tc.policy})
97 if restored != tc.restored {
98 t.Fatalf("%s: restored = %v, want %v (err %v)", tc.name, restored, tc.restored, err)
99 }
100 if !tc.restored && !errors.Is(err, hostwrite.ErrUnavailable) {
101 t.Fatalf("%s: %v, want hostwrite.ErrUnavailable", tc.name, err)
102 }
103 if tc.restored && !errors.Is(err, stop) {
104 t.Fatalf("%s: %v, want the restore's own error", tc.name, err)
105 }
106 }
107}
108
109// The activator chooses claims-on generation sockets the way plan 1 lays
110// them out, and a canonical handle with a real number where there is no
111// generation path.
112func TestClaimGenAt(t *testing.T) {
113 b := testExecBackend(t)
114 b.RunDirOverride = t.TempDir()
115 a := New(Config{}, b, nil)
116 flat := registry.Route{Host: "c.example.com", Kind: "instance", DBPath: b.DataDir + "/c.example.com.db", Socket: b.RunDirOverride + "/c.example.com.sock"}
117 if g := a.claimGenAt(flat, 4); g.N != 4 || g.Socket != b.flatGenSocket(flat.Host, 4) || g.Control != g.Socket+".control" {
118 t.Fatalf("flat: %+v", g)
119 }
120 foreign := registry.Route{Host: "f.example.com", Kind: "instance", Socket: t.TempDir() + "/odd.sock"}
121 if g := a.claimGenAt(foreign, 5); g.N != 5 || g.Socket != foreign.Socket || g.Control != foreign.Socket+".control" {
122 t.Fatalf("foreign: %+v", g)
123 }
124 // The split backend asks its exec half; a unit-backed row has no
125 // generation path of its own.
126 split := New(Config{}, &SplitBackend{Exec: b}, nil)
127 if g := split.claimGenAt(flat, 6); g.N != 6 || g.Socket != b.flatGenSocket(flat.Host, 6) {
128 t.Fatalf("split, flat: %+v", g)
129 }
130 unit := flat
131 unit.Backing = registry.BackingUnit
132 if g := split.claimGenAt(unit, 7); g.N != 7 || g.Socket != unit.Socket {
133 t.Fatalf("split, unit: %+v", g)
134 }
135}
136
137// Spec §7.4 (index P2-9): every claims-on launch is closed on this base, and
138// the work's own validation refuses before the exec. Nothing ran, so the
139// attempt is abandoned, never SwapFailed: a failure record would halt the
140// cohort for a build that never executed.
141func TestAClaimsRefusalAtTheExecIsNoFailure(t *testing.T) {
142 t.Parallel()
143 for _, refusal := range []error{hostguard.ErrStampMismatch, hostguard.ErrFenced, hostguard.ErrNoClaim} {
144 f := newSwapFixture(t)
145 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: &swapWork{}}}
146 f.a.cfg.Claims = &ClaimRuntime{Pins: true, Generations: true}
147 f.a.claimSpawn = func(context.Context, *actEntry, registry.Route, hostwrite.Work, Gen) (Gen, Proc, error) {
148 return Gen{}, nil, fmt.Errorf("work: %w", refusal)
149 }
150 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapAbandoned {
151 t.Fatalf("%v at the exec: Swap = %+v, want abandoned", refusal, res)
152 }
153 }
154 // No seam: the closed launch refuses the swap before anything runs
155 // (claimsSwapRefusal), as the admission's refusal (claimAdmission), and
156 // the attempt is abandoned. The runtime is never read or prepared:
157 // there is no launch to prepare for.
158 f := newSwapFixture(t)
159 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: &swapWork{}}}
160 f.a.cfg.Claims = &ClaimRuntime{Pins: true, Generations: true,
161 Read: func(context.Context, string) (hostclaim.Observed, error) {
162 return hostclaim.Observed{}, errors.New("s3: GetObject: request timeout")
163 },
164 Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
165 t.Fatal("prepared a successor on a closed launch")
166 return ClaimStart{}, nil
167 }}
168 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapAbandoned {
169 t.Fatalf("a closed launch: Swap = %+v, want abandoned", res)
170 }
171}
172
173// Index P2-9, final review F9: a claims-on overlap whose runtime has
174// Generations and Pins, with no test seam standing in for the launch, is
175// refused by the closed launch before its admission and its raise. It is
176// abandoned with nothing raised or spawned, the incumbent still serving and
177// no failure recorded (SwapFailed would halt the cohort).
178func TestAClaimsOnOverlapOnAClosedLaunchIsAbandoned(t *testing.T) {
179 f := newSwapFixture(t)
180 w := &swapWork{}
181 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: w}}
182 f.a.cfg.Claims = &ClaimRuntime{Pins: true, Generations: true}
183 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
184 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, ErrClaimIsolationUnavailable.Error()) {
185 t.Fatalf("Swap = %+v, want abandoned on the closed launch", res)
186 }
187 if !strings.Contains(res.Reason, errClaimsAdmission.Error()) {
188 t.Fatalf("Reason %q: the closed launch is the admission's refusal", res.Reason)
189 }
190 gens, raises, restores := f.b.calls()
191 if gens != 0 || raises != 0 || restores != 0 {
192 t.Fatalf("StartStaged %d, raises %d, restores %d: want nothing raised or spawned", gens, raises, restores)
193 }
194 if n := w.withCalls(); n != 0 {
195 t.Fatalf("With calls = %d: the refusal comes before the admission", n)
196 }
197 if f.b.lastProc().isStopped() {
198 t.Fatal("the incumbent was stopped")
199 }
200 if got := f.appSocket(t); got != f.incumbent.Socket {
201 t.Fatalf("AppSocket = %q, want the incumbent", got)
202 }
203 f.a.mu.Lock()
204 e := f.a.entries[f.host]
205 state, pinFailed := e.state, e.pinFailed
206 f.a.mu.Unlock()
207 if state != actAwake || pinFailed != nil {
208 t.Fatalf("entry state %v, pinFailed %+v: the incumbent should serve on, with nothing recorded", state, pinFailed)
209 }
210}
211
212// Index P2-9: a claims-on hold whose runtime cannot start a generation of
213// its own is abandoned before the incumbent stops. A hold is stop-then-start
214// (spec §7.8): refused after the stop, it would only cost the instance its
215// running process, since the closed launch could never start the pin.
216func TestAClaimsOnHoldWithoutGenerationsIsAbandonedBeforeTheStop(t *testing.T) {
217 f := newSwapFixture(t)
218 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: &swapWork{}}}
219 touched := false
220 f.a.cfg.Claims = &ClaimRuntime{Box: "box", Pins: true, // Generations false
221 Read: func(context.Context, string) (hostclaim.Observed, error) {
222 touched = true
223 return claimedObs(f.host), nil
224 },
225 Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
226 touched = true
227 return ClaimStart{}, nil
228 }}
229 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
230 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errClaimsNoGenerations.Error()) {
231 t.Fatalf("Swap = %+v, want abandoned: %v", res, errClaimsNoGenerations)
232 }
233 if f.b.lastProc().isStopped() {
234 t.Fatal("the incumbent was stopped by a hold that could never start its pin")
235 }
236 if ev := f.b.events(); indexPrefix(ev, "stop:") >= 0 {
237 t.Fatalf("events %v: the hold began a stop", ev)
238 }
239 if touched {
240 t.Fatal("the claim runtime was read or prepared for a refused hold")
241 }
242 f.a.mu.Lock()
243 e := f.a.entries[f.host]
244 state, held := e.state, e.holdNext
245 f.a.mu.Unlock()
246 if state != actAwake || held != nil {
247 t.Fatalf("entry state %v, holdNext %v: want awake, no hold", state, held)
248 }
249}
250
251// Spec §6.4, §7.4 (index P2-9): a claimed planned wake on this base meets
252// the closed launch, which refuses before its exec hook. That refusal is the
253// admission's (claimAdmission), so it is stale, never a failed build: no
254// pinFailed, no fallback. Prepare and Restore ran once.
255func TestAClaimedPlannedWakeOnAClosedLaunchRecordsNoFailure(t *testing.T) {
256 obs := claimedObs("x.example")
257 work, _ := (hostwrite.Config{}).Select(context.Background(), obs.Host, "spawn")
258 prepares, restores, failed := 0, 0, 0
259 a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box",
260 Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil },
261 Prepare: func(_ context.Context, _ registry.Route, _ hostclaim.Observed, p startPolicy) (ClaimStart, error) {
262 prepares++
263 s := claimedStartFor(obs, work, p.Pin.Build)
264 s.Restore = func(context.Context) error { restores++; return nil }
265 s.Failed = func(context.Context, int) error { failed++; return nil }
266 return s, nil
267 }}}}
268 e := &actEntry{host: obs.Host}
269 gateCalls := 0
270 policy := startPolicy{MaxStarts: 1, Window: time.Second, Pin: &Gen{Build: "v2"}, Fallback: &Gen{Build: "v1"},
271 HealthBy: time.Now().Add(time.Second),
272 Gate: func() error { gateCalls++; return nil },
273 PinCheck: func(Gen) error { gateCalls++; return nil }}
274 err := a.startAdmitted(context.Background(), e, obs, policy)
275 if !errors.Is(err, ErrClaimIsolationUnavailable) || !errors.Is(err, errClaimsAdmission) {
276 t.Fatalf("startAdmitted = %v, want the closed launch as the admission's refusal", err)
277 }
278 if got := classifyStep(nil, err); got != stepStale {
279 t.Fatalf("classifyStep = %v, want stepStale", got)
280 }
281 if prepares != 1 || restores != 1 {
282 t.Fatalf("Prepare ran %d times, Restore %d: want once each, and no fallback", prepares, restores)
283 }
284 if failed != 1 {
285 t.Fatalf("Failed ran %d times: main's retry accounting is unchanged", failed)
286 }
287 if e.pinFailed != nil || e.pinFailReason != "" {
288 t.Fatalf("pinFailed = %+v (%q): a refused launch was recorded as a failed build", e.pinFailed, e.pinFailReason)
289 }
290 if gateCalls != 0 {
291 t.Fatalf("Gate/PinCheck ran %d times: the closed launch never reaches its exec hook", gateCalls)
292 }
293 if e.pinExec {
294 t.Fatal("pinExec set: nothing reached the exec")
295 }
296}
297
298// Spec §7.4 (Task 2): a failed wake whose generation is not proven stopped
299// still holds the host's lease. A claims-on wake is refused at its head, as
300// doWake refuses one, before the claim is even read.
301func TestAClaimedWakeRefusesWhileTheLastFailedWakeIsUnproven(t *testing.T) {
302 read := false
303 a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box",
304 Read: func(context.Context, string) (hostclaim.Observed, error) {
305 read = true
306 return claimedObs("x.example"), nil
307 },
308 StopForeign: func(context.Context, string) error { return nil }}}}
309 a.unprovenWakes = map[string]*unprovenWake{"x.example": {}}
310 e := &actEntry{host: "x.example", route: registry.Route{Host: "x.example", Kind: "instance"}}
311 if _, err := a.doClaimWake(context.Background(), e); !errors.Is(err, errWakeUnproven) {
312 t.Fatalf("doClaimWake = %v, want errWakeUnproven", err)
313 }
314 if read {
315 t.Fatal("the claim was read for a host whose last failed wake is unproven")
316 }
317}
318
319// orderContainment records whether the leader had already exited when
320// KillAndEmpty ran, then kills and waits like the real one.
321type orderContainment struct {
322 p *claimProcess
323 exitedAtKill bool
324}
325
326func (c *orderContainment) KillAndEmpty(context.Context) error {
327 c.exitedAtKill = c.p.Exited()
328 if c.p.cmd.Process != nil {
329 _ = c.p.cmd.Process.Kill()
330 }
331 <-c.p.done
332 return nil
333}
334
335func (c *orderContainment) Close() error { return nil }
336
337// Spec §7.5 step 6 (index P6 #4): a retiring claims-on generation gets
338// SIGTERM and its drain before KillAndEmpty, so its in-flight requests
339// finish. An unready candidate is still killed at once. Stop alone
340// (cgroup.kill first) killed the retiring generation's requests.
341func TestARetiringClaimedGenerationDrainsBeforeItsKill(t *testing.T) {
342 if _, err := exec.LookPath("sleep"); err != nil {
343 t.Skip("no sleep")
344 }
345 for _, tc := range []struct {
346 role genRole
347 drained bool
348 }{{roleRetiring, true}, {roleSuccessor, false}} {
349 cmd := exec.Command("sleep", "30")
350 if err := cmd.Start(); err != nil {
351 t.Fatal(err)
352 }
353 p := newClaimProcess(cmd, nil)
354 c := &orderContainment{p: p}
355 p.prepared = c
356 a := &Service{}
357 e := &actEntry{host: "claims.example.com"}
358 if !a.stopMember(e, &genMember{gen: Gen{N: 2}, role: tc.role, proc: p}) {
359 t.Fatalf("%s: stop not proven", tc.role)
360 }
361 if c.exitedAtKill != tc.drained {
362 t.Fatalf("%s: leader exited before KillAndEmpty = %v, want %v", tc.role, c.exitedAtKill, tc.drained)
363 }
364 }
365}
366
367// Final review F9: on this base every claims-backed swap (work that is not
368// hostwrite.Legacy) is refused before its side effects, whatever the
369// runtime's Generations and Pins say: no admission inside the work, no
370// parent raise, no stop of the incumbent, no spawn. The guard opens only
371// with a working isolated launch, which this base does not have.
372func TestEveryClaimsBackedSwapIsRefusedBeforeItsSideEffects(t *testing.T) {
373 t.Parallel()
374 runtimes := []struct {
375 name string
376 r *ClaimRuntime
377 }{
378 {"no runtime", nil},
379 {"neither", &ClaimRuntime{}},
380 {"generations only", &ClaimRuntime{Generations: true}},
381 {"pins only", &ClaimRuntime{Pins: true}},
382 {"generations and pins", &ClaimRuntime{Generations: true, Pins: true}},
383 }
384 for _, mode := range []SwapMode{ModeOverlap, ModeHold} {
385 for _, rc := range runtimes {
386 t.Run(string(mode)+"/"+rc.name, func(t *testing.T) {
387 f := newSwapFixture(t)
388 w := &swapWork{}
389 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: w}}
390 f.a.cfg.Claims = rc.r
391 res := f.a.Swap(context.Background(), f.attempt(mode), f.newest)
392 if res.Outcome != SwapAbandoned {
393 t.Fatalf("Swap = %+v, want abandoned", res)
394 }
395 if !strings.Contains(res.Reason, errClaimsNoGenerations.Error()) && !strings.Contains(res.Reason, ErrClaimIsolationUnavailable.Error()) {
396 t.Fatalf("Reason %q: want the claims guard's refusal", res.Reason)
397 }
398 gens, raises, restores := f.b.calls()
399 if gens != 0 || raises != 0 || restores != 0 {
400 t.Fatalf("StartStaged %d, raises %d, restores %d: want no side effect", gens, raises, restores)
401 }
402 if f.b.lastProc().isStopped() {
403 t.Fatal("the incumbent was stopped")
404 }
405 if ev := f.b.events(); indexPrefix(ev, "stop:") >= 0 || indexPrefix(ev, "raise:") >= 0 {
406 t.Fatalf("events %v: a refused claims-backed swap acted", ev)
407 }
408 if n := w.withCalls(); n != 0 {
409 t.Fatalf("With calls = %d: a refused swap entered the selected work", n)
410 }
411 f.a.mu.Lock()
412 e := f.a.entries[f.host]
413 state, held, members := e.state, e.holdNext, len(e.ops.members)
414 f.a.mu.Unlock()
415 if state != actAwake || held != nil || members != 0 {
416 t.Fatalf("entry state %v, holdNext %v, members %d: want awake, untouched", state, held, members)
417 }
418 })
419 }
420 }
421}
422
423// claimSpawn stands in for an open isolated launch, and only tests may set
424// it: production must always meet claimsSwapRefusal's refusal. No
425// non-test file of the package assigns the field.
426func TestClaimSpawnIsATestSeam(t *testing.T) {
427 t.Parallel()
428 fset := token.NewFileSet()
429 pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool { return !strings.HasSuffix(fi.Name(), "_test.go") }, 0)
430 if err != nil {
431 t.Fatal(err)
432 }
433 for _, pkg := range pkgs {
434 for name, file := range pkg.Files {
435 ast.Inspect(file, func(n ast.Node) bool {
436 switch x := n.(type) {
437 case *ast.AssignStmt:
438 for _, l := range x.Lhs {
439 if sel, ok := l.(*ast.SelectorExpr); ok && sel.Sel.Name == "claimSpawn" {
440 t.Errorf("%s: %s assigns claimSpawn", name, fset.Position(x.Pos()))
441 }
442 }
443 case *ast.KeyValueExpr:
444 if id, ok := x.Key.(*ast.Ident); ok && id.Name == "claimSpawn" {
445 t.Errorf("%s: %s sets claimSpawn in a literal", name, fset.Position(x.Pos()))
446 }
447 }
448 return true
449 })
450 }
451 }
452}
453