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 "bytes"
5 "context"
6 "log"
7 "net/http"
8 "net/http/httptest"
9 "reflect"
10 "runtime"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/activator"
17 "github.com/carlosframework/platform/internal/registry"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21// The stamp is the whole licence for skipping the sweep's grace: it is written
22// only after the adopting pass is finished with the host. A drift whose route
23// is still stamped with the old version is an adoption in flight, and one with
24// no row at all is not a route this box adopts.
25func TestConvergeAdoptedCyclesOnlyWhenTheStampAgrees(t *testing.T) {
26 rows := map[string]*registry.Route{
27 "done.test": {Host: "done.test", Version: "v2"},
28 "inflight.test": {Host: "inflight.test", Version: "v1"},
29 "named.test": {Host: "named.test", Version: "v2", Unit: "console.service"},
30 "unitback.test": {Host: "unitback.test", Version: "v2", Backing: registry.BackingUnit},
31 }
32 drifted := func() map[string]string {
33 return map[string]string{
34 "done.test": "v2", "inflight.test": "v2", "named.test": "v2",
35 "unitback.test": "v2", "norow.test": "v2",
36 }
37 }
38 var cycled []string
39 n := convergeAdopted(drifted,
40 func(h string) (*registry.Route, error) { return rows[h], nil },
41 func(h, ver string) bool { cycled = append(cycled, h+"@"+ver); return true }, 10)
42 if want := []string{"done.test@v2"}; !reflect.DeepEqual(cycled, want) {
43 t.Fatalf("cycled = %v, want %v", cycled, want)
44 }
45 if n != 1 {
46 t.Fatalf("convergeAdopted = %d, want 1", n)
47 }
48}
49
50// The ordinary tick, where nothing has shipped, must cost no registry read:
51// this leg runs every two seconds on every box.
52func TestConvergeAdoptedReadsNoRouteWhenNothingDrifted(t *testing.T) {
53 n := convergeAdopted(func() map[string]string { return nil },
54 func(h string) (*registry.Route, error) {
55 t.Fatalf("read the route for %s with nothing drifted", h)
56 return nil, nil
57 },
58 func(h, ver string) bool {
59 t.Fatalf("cycled %s with nothing drifted", h)
60 return false
61 }, 10)
62 if n != 0 {
63 t.Fatalf("convergeAdopted = %d, want 0", n)
64 }
65}
66
67// The leg runs on its own loop, never waiting on the fast tick: a tick that
68// takes a full reconcile and adopt pass can run tens of seconds, and a
69// stamped adoption must not wait behind it. Here the loop keeps calling the
70// leg on its own cadence, and stops when told.
71func TestConvergeLoopRunsTheLegOnItsOwnCadence(t *testing.T) {
72 t.Parallel()
73 calls := make(chan struct{}, 100)
74 stop := make(chan struct{})
75 done := make(chan struct{})
76 go func() {
77 runConvergeLoop(func() { calls <- struct{}{} }, 5*time.Millisecond, stop)
78 close(done)
79 }()
80 for i := 0; i < 3; i++ {
81 select {
82 case <-calls:
83 case <-time.After(10 * time.Second):
84 t.Fatalf("the loop ran the leg %d times, want it to keep running", i)
85 }
86 }
87 close(stop)
88 select {
89 case <-done:
90 case <-time.After(10 * time.Second):
91 t.Fatal("the loop did not stop")
92 }
93}
94
95// The production sequence behind discussion #231, with the real pass on both
96// sides. The agent cannot write the version store, so the ROOT pass adopts a
97// new release for a warm exec instance: it stages, repoints and stamps, and —
98// having no handle on the agent's children — cycles nothing. The agent's own
99// pass then finds the host up to date and never reaches its restart callback.
100// Before the converge leg, that left the child on the old build until the
101// activator sweep's 90s grace ran out.
102func TestRootAdoptionOfAWarmExecInstanceIsFinishedByTheAgent(t *testing.T) {
103 env := newFastFixture(t)
104 ctx := context.Background()
105 plat := runtime.GOOS + "-" + runtime.GOARCH
106 host := "warm.test"
107 ship := func(ver string) {
108 t.Helper()
109 m := release.Manifest{
110 App: "fastapp", Version: ver, Kind: "binary",
111 Artifacts: map[string]release.Artifact{plat: {}},
112 }
113 if err := release.Ship(ctx, env.store, "bab", m, map[string][]byte{plat: []byte("build " + ver)}); err != nil {
114 t.Fatalf("Ship %s: %v", ver, err)
115 }
116 if err := release.Promote(ctx, env.store, env.priv, "bab", "fastapp", "edge", ver); err != nil {
117 t.Fatalf("Promote %s: %v", ver, err)
118 }
119 }
120 // rootRestartRoute with systemctl stubbed out: same dispatch, bounce nil.
121 rootPass := func() {
122 t.Helper()
123 restart := makeRestartRoute(env.reg, func(string) {}, unitOnly(func(string) error { return nil }), nil)
124 if err := release.Pass(ctx, env.reg, env.store, env.pub, env.optDir, restart, release.BakeDir(env.dataDir)); err != nil {
125 t.Fatalf("root pass: %v", err)
126 }
127 }
128 if err := env.reg.Upsert(registry.Route{
129 Host: host, Kind: "instance", App: "fastapp", Channel: "edge",
130 Status: "ready", Hibernate: true,
131 }); err != nil {
132 t.Fatalf("upsert: %v", err)
133 }
134
135 ship("v1")
136 rootPass()
137 spawned := "v1" // the awake child's build: frozen at exec, as the activator records it
138
139 ship("v2")
140 rootPass()
141 if v, err := release.LiveVersion(env.optDir, host); err != nil || v != "v2" {
142 t.Fatalf("live = %q, %v after the root pass; want v2", v, err)
143 }
144 if rt, err := env.reg.Get(host); err != nil || rt == nil || rt.Version != "v2" {
145 t.Fatalf("route = %+v, %v after the root pass; want it stamped v2", rt, err)
146 }
147
148 // The agent's tick. Its pass has a bounce to offer and never gets to use
149 // it: the host is already up to date.
150 var bounced, cycled []string
151 restart := makeRestartRoute(env.reg, func(string) {}, unitOnly(func(string) error { return nil }),
152 func(h string) bool { bounced = append(bounced, h); return true })
153 d := env.deps(nil)
154 d.restart = restart
155 d.passOpts = fastPassOpts(env.dataDir, func(string, string, error) {}, restart, nil)
156 d.fullPass = func(ctx context.Context) {
157 if err := release.Pass(ctx, d.reg, d.store, d.pub, d.optDir, d.restart, d.passOpts...); err != nil {
158 t.Fatalf("agent pass: %v", err)
159 }
160 }
161 leg := func() {
162 convergeAdopted(
163 func() map[string]string {
164 live, _ := release.LiveVersion(env.optDir, host)
165 if live == "" || live == spawned {
166 return nil
167 }
168 return map[string]string{host: live}
169 },
170 env.reg.Get,
171 func(h, ver string) bool { cycled = append(cycled, h+"@"+ver); spawned = ver; return true },
172 adoptedCyclesPerTick)
173 }
174 now := time.Unix(1754640000, 0)
175 fastTick(ctx, d, now) // seeds the serial
176 if err := release.BumpSerial(ctx, env.store, now); err != nil {
177 t.Fatalf("BumpSerial: %v", err)
178 }
179 fastTick(ctx, d, now.Add(10*time.Second)) // the serial move: the agent's full pass
180 leg() // the converge loop's next run
181
182 if len(bounced) != 0 {
183 t.Fatalf("the agent's pass bounced %v — it found the host already adopted, so this test no longer models the gap", bounced)
184 }
185 if want := []string{host + "@v2"}; !reflect.DeepEqual(cycled, want) {
186 t.Fatalf("cycled = %v, want %v: the warm child is still on v1 under a symlink and a stamp that both say v2", cycled, want)
187 }
188 leg()
189 if len(cycled) != 1 {
190 t.Fatalf("cycled again on the next tick: %v", cycled)
191 }
192}
193
194// One pass can adopt every warm instance of an app inside a few seconds.
195// Stopping them all on one tick would blip every host of the app at the same
196// moment; the leg spends its budget in host order and leaves the rest for the
197// ticks that follow. A drifted host it has to wait on costs the others nothing.
198func TestConvergeAdoptedCyclesOnePerTickInHostOrder(t *testing.T) {
199 spawned := map[string]string{"a.test": "v1", "b.test": "v1", "c.test": "v1", "wait.test": "v1"}
200 stamp := map[string]string{"a.test": "v2", "b.test": "v2", "c.test": "v2", "wait.test": "v1"}
201 drifted := func() map[string]string {
202 out := map[string]string{}
203 for h, v := range spawned {
204 if v != "v2" {
205 out[h] = "v2"
206 }
207 }
208 return out
209 }
210 get := func(h string) (*registry.Route, error) { return &registry.Route{Host: h, Version: stamp[h]}, nil }
211 var cycled []string
212 converge := func(h, ver string) bool { cycled = append(cycled, h); spawned[h] = ver; return true }
213
214 for tick := 1; tick <= 4; tick++ {
215 if n := convergeAdopted(drifted, get, converge, adoptedCyclesPerTick); tick <= 3 && n != 1 {
216 t.Fatalf("tick %d cycled %d, want 1", tick, n)
217 }
218 }
219 if want := []string{"a.test", "b.test", "c.test"}; !reflect.DeepEqual(cycled, want) {
220 t.Fatalf("cycled = %v, want %v: one per tick, in host order, never the host whose stamp does not agree", cycled, want)
221 }
222}
223
224// versionedBackend is activator.Backend with the one thing convergence needs
225// and fakeWakeBackend does not model: a child that knows which build it was
226// exec'd from, read off the SAME live symlink the release pass writes. It
227// binds no socket — the caller supplies the proxy — so it runs anywhere.
228type versionedBackend struct {
229 fakeWakeBackend
230 optDir string
231
232 mu sync.Mutex
233 starts []string // the build each spawn exec'd, in order
234}
235
236type flagProc struct {
237 mu sync.Mutex
238 stopped bool
239}
240
241func (p *flagProc) Stop(context.Context) error {
242 p.mu.Lock()
243 p.stopped = true
244 p.mu.Unlock()
245 return nil
246}
247
248func (p *flagProc) Exited() bool {
249 p.mu.Lock()
250 defer p.mu.Unlock()
251 return p.stopped
252}
253
254func (b *versionedBackend) LiveVersion(host string) (string, error) {
255 return release.LiveVersion(b.optDir, host)
256}
257
258func (b *versionedBackend) Start(_ context.Context, rt registry.Route) (activator.Proc, string, error) {
259 ver, err := release.LiveVersion(b.optDir, rt.Host)
260 if err != nil {
261 return nil, "", err
262 }
263 b.mu.Lock()
264 b.starts = append(b.starts, ver)
265 b.mu.Unlock()
266 return &flagProc{}, ver, nil
267}
268
269func (b *versionedBackend) started() []string {
270 b.mu.Lock()
271 defer b.mu.Unlock()
272 return append([]string(nil), b.starts...)
273}
274
275// The whole of discussion #231 with nothing stood in for: the real release
276// pass as root runs it, a real activator owning the child, and the leg exactly
277// as fastConvergeLoop builds it (convergeLegFor). The sweeper is never
278// started, so the ONLY thing that can cycle the instance is the leg — a 90s
279// grace cannot rescue this test.
280func TestConvergeLegCyclesARootAdoptedInstanceOnARealActivator(t *testing.T) {
281 t.Parallel()
282 env := newFastFixture(t)
283 ctx := context.Background()
284 plat := runtime.GOOS + "-" + runtime.GOARCH
285 host := "warm.test"
286 rt := registry.Route{
287 Host: host, Kind: "instance", App: "fastapp", Channel: "edge",
288 Status: "ready", Hibernate: true,
289 }
290 if err := env.reg.Upsert(rt); err != nil {
291 t.Fatalf("upsert: %v", err)
292 }
293 adopt := func(ver string) {
294 t.Helper()
295 m := release.Manifest{
296 App: "fastapp", Version: ver, Kind: "binary",
297 Artifacts: map[string]release.Artifact{plat: {}},
298 }
299 if err := release.Ship(ctx, env.store, "bab", m, map[string][]byte{plat: []byte("build " + ver)}); err != nil {
300 t.Fatalf("Ship %s: %v", ver, err)
301 }
302 if err := release.Promote(ctx, env.store, env.priv, "bab", "fastapp", "edge", ver); err != nil {
303 t.Fatalf("Promote %s: %v", ver, err)
304 }
305 // The root pass: rootRestartRoute's dispatch (bounce nil), systemctl stubbed.
306 restart := makeRestartRoute(env.reg, func(string) {}, unitOnly(func(string) error { return nil }), nil)
307 if err := release.Pass(ctx, env.reg, env.store, env.pub, env.optDir, restart, release.BakeDir(env.dataDir)); err != nil {
308 t.Fatalf("root pass for %s: %v", ver, err)
309 }
310 }
311 backend := &versionedBackend{optDir: env.optDir}
312 svc := activator.New(activator.Config{Bucket: "test-bucket", Idle: time.Hour}, backend, env.reg)
313 t.Cleanup(func() {
314 sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
315 defer cancel()
316 svc.Shutdown(sctx)
317 })
318 request := func() {
319 t.Helper()
320 rec := httptest.NewRecorder()
321 proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
322 if !svc.ServeHTTP(rec, httptest.NewRequest("GET", "http://"+host+"/", nil), rt, proxy) {
323 t.Fatal("the activator did not take the request")
324 }
325 if rec.Code != http.StatusNoContent {
326 t.Fatalf("request = %d, want 204", rec.Code)
327 }
328 }
329 leg := convergeLegFor(svc, env.reg)
330 if leg == nil {
331 t.Fatal("convergeLegFor returned no leg for a box with an activator")
332 }
333
334 // "Was it stopped?" is asked of the spawn count, after a request: Owns
335 // stays true while an entry drains, so it cannot tell a spared child from
336 // one being stopped. A request against a stopped instance spawns again.
337 adopt("v1")
338 request() // wakes the instance onto v1
339 leg()
340 request()
341 if got, want := backend.started(), []string{"v1"}; !reflect.DeepEqual(got, want) {
342 t.Fatalf("spawned builds = %v, want %v: the leg stopped an instance whose build is the adopted one", got, want)
343 }
344
345 adopt("v2") // root: staged, repointed, stamped — and nothing cycled
346 if !svc.Owns(host) {
347 t.Fatal("the root pass stopped the agent's child — this test no longer models the gap")
348 }
349 leg()
350 deadline := time.Now().Add(10 * time.Second)
351 for svc.Owns(host) {
352 if time.Now().After(deadline) {
353 t.Fatal("the warm instance is still running v1 after the leg: it was adopted onto v2 and nothing cycled it")
354 }
355 time.Sleep(5 * time.Millisecond)
356 }
357
358 request() // the next request wakes it onto v2
359 if got, want := backend.started(), []string{"v1", "v2"}; !reflect.DeepEqual(got, want) {
360 t.Fatalf("spawned builds = %v, want %v", got, want)
361 }
362 leg()
363 request()
364 if got, want := backend.started(), []string{"v1", "v2"}; !reflect.DeepEqual(got, want) {
365 t.Fatalf("spawned builds = %v, want %v: the leg cycled the instance again after it had converged", got, want)
366 }
367}
368
369func TestConvergeLegForABoxWithNoActivator(t *testing.T) {
370 if convergeLegFor(nil, nil) != nil {
371 t.Fatal("convergeLegFor built a leg with no activator to ask")
372 }
373}
374
375// The adopt passes' restart dispatch must be built on adoptBounceFn, not
376// bounceFn. With the converge leg in place an instance can already be on the
377// new build when a slower pass reaches its own restart; the plain Bounce would
378// stop that replacement for nothing. A stale child still gets its cycle.
379func TestAdoptRestartDispatchSparesAConvergedChild(t *testing.T) {
380 env := newFastFixture(t)
381 host := "warm.test"
382 rt := registry.Route{
383 Host: host, Kind: "instance", App: "fastapp", Channel: "edge",
384 Status: "ready", Hibernate: true,
385 }
386 if err := env.reg.Upsert(rt); err != nil {
387 t.Fatalf("upsert: %v", err)
388 }
389 env.stageLive(t, host, "v1")
390 backend := &versionedBackend{optDir: env.optDir}
391 svc := activator.New(activator.Config{Bucket: "test-bucket", Idle: time.Hour}, backend, env.reg)
392 t.Cleanup(func() {
393 sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
394 defer cancel()
395 svc.Shutdown(sctx)
396 })
397 wake := func() {
398 t.Helper()
399 rec := httptest.NewRecorder()
400 proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
401 if !svc.ServeHTTP(rec, httptest.NewRequest("GET", "http://"+host+"/", nil), rt, proxy) || rec.Code != http.StatusNoContent {
402 t.Fatalf("wake request = %d", rec.Code)
403 }
404 }
405 restart := makeRestartRoute(env.reg, func(string) {}, unitOnly(func(u string) error {
406 t.Fatalf("named-unit arm fired for %q on a route that names no unit", u)
407 return nil
408 }), adoptBounceFn(svc))
409
410 // Asked of the spawn count, after a request — see the note in
411 // TestConvergeLegCyclesARootAdoptedInstanceOnARealActivator.
412 wake() // exec'd from v1, and live/<host> names v1
413 if err := restart(host); err != nil {
414 t.Fatalf("restart: %v", err)
415 }
416 wake()
417 if got, want := backend.started(), []string{"v1"}; !reflect.DeepEqual(got, want) {
418 t.Fatalf("spawned builds = %v, want %v: the adopt dispatch stopped a child already running the build live/<host> names", got, want)
419 }
420
421 env.stageLive(t, host, "v2") // the symlink moves under the running child
422 if err := restart(host); err != nil {
423 t.Fatalf("restart: %v", err)
424 }
425 wake()
426 if got, want := backend.started(), []string{"v1", "v2"}; !reflect.DeepEqual(got, want) {
427 t.Fatalf("spawned builds = %v, want %v: the adopt dispatch left a stale child running", got, want)
428 }
429}
430
431// A leg past slowLegAfter says so in the journal, with what it was doing; a
432// quick one says nothing, because the fast loop runs every two seconds.
433func TestLogSlowSpeaksOnlyWhenSlow(t *testing.T) {
434 var buf bytes.Buffer
435 prev, flags := log.Writer(), log.Flags()
436 log.SetOutput(&buf)
437 log.SetFlags(0)
438 t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) })
439
440 logSlow("fast sweep", time.Now(), "3 routes")
441 if buf.Len() != 0 {
442 t.Fatalf("a quick leg logged %q", buf.String())
443 }
444 logSlow("serial pass stages", time.Now().Add(-2*slowLegAfter), "reconcile 9.0s, adopt 1.0s")
445 got := buf.String()
446 for _, want := range []string{"serial pass stages took 10", "(reconcile 9.0s, adopt 1.0s)"} {
447 if !strings.Contains(got, want) {
448 t.Fatalf("slow-leg line %q lacks %q", got, want)
449 }
450 }
451}
452
453// The point of the leg's own loop: it acts while the fast tick is stuck in a
454// long full pass. The pass is held open here until the leg has cycled the
455// drifted instance, which it could never do if it still rode the tick.
456func TestConvergeLoopActsWhileTheFullPassIsBusy(t *testing.T) {
457 env := newFastFixture(t)
458 d := env.deps(nil)
459 inPass, unblock := make(chan struct{}), make(chan struct{})
460 d.fullPass = func(context.Context) { close(inPass); <-unblock }
461
462 cycled := make(chan string, 1)
463 leg := func() {
464 convergeAdopted(
465 func() map[string]string { return map[string]string{"warm.test": "v2"} },
466 func(h string) (*registry.Route, error) { return &registry.Route{Host: h, Version: "v2"}, nil },
467 func(h, ver string) bool {
468 select {
469 case cycled <- h + "@" + ver:
470 default:
471 }
472 return true
473 },
474 adoptedCyclesPerTick)
475 }
476 ctx := context.Background()
477 now := time.Unix(1754640000, 0)
478 fastTick(ctx, d, now) // seeds the serial
479 if err := release.BumpSerial(ctx, env.store, now); err != nil {
480 t.Fatalf("BumpSerial: %v", err)
481 }
482 tickDone := make(chan struct{})
483 go func() { fastTick(ctx, d, now.Add(10*time.Second)); close(tickDone) }()
484 <-inPass // the tick is now stuck in its full pass
485 // Started only now, so anything it cycles was cycled DURING the pass.
486 stop := make(chan struct{})
487 defer close(stop)
488 go runConvergeLoop(leg, 5*time.Millisecond, stop)
489
490 select {
491 case got := <-cycled:
492 if got != "warm.test@v2" {
493 t.Fatalf("cycled %q", got)
494 }
495 case <-time.After(10 * time.Second):
496 t.Fatal("the converge leg waited for the full pass")
497 }
498 close(unblock)
499 <-tickDone
500}
501