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
3// Unit-backed tenant tests: the same state machine (activator.go) driving a
4// carlos-app@ unit through the Systemctl seam instead of an exec'd child —
5// no systemd, no AWS. The end-to-end test at the bottom proves the edge
6// shape: a request for a sleeping unit tenant is HELD while the "unit"
7// starts and turns healthy, then forwarded.
8
9import (
10 "context"
11 "errors"
12 "fmt"
13 "io"
14 "net"
15 "net/http"
16 "net/http/httptest"
17 "os"
18 "path/filepath"
19 "sync"
20 "testing"
21 "time"
22
23 "github.com/carlosframework/platform/internal/registry"
24)
25
26// fakeSystemctl records verbs and optionally runs hooks — the Proc-style
27// seam's test double. Hooks let the end-to-end test bind/close a real
28// listener exactly when the "unit" would.
29type fakeSystemctl struct {
30 mu sync.Mutex
31 calls []string
32 startErr error
33 onStart func() // runs on "start", outside the lock
34 onStop func() // runs on "stop", outside the lock
35}
36
37func (f *fakeSystemctl) run(_ context.Context, verb, unit string) error {
38 f.mu.Lock()
39 f.calls = append(f.calls, verb+" "+unit)
40 err := f.startErr
41 onStart, onStop := f.onStart, f.onStop
42 f.mu.Unlock()
43 if verb == "start" {
44 if err != nil {
45 return err
46 }
47 if onStart != nil {
48 onStart()
49 }
50 }
51 if verb == "stop" && onStop != nil {
52 onStop()
53 }
54 return nil
55}
56
57func (f *fakeSystemctl) recorded() []string {
58 f.mu.Lock()
59 defer f.mu.Unlock()
60 return append([]string(nil), f.calls...)
61}
62
63// testUnitRoute is testRoute's unit-backed sibling: a whole-app tenant
64// (carlos-app@<host>) flagged for hibernation.
65func testUnitRoute(host string) registry.Route {
66 return registry.Route{Host: host, Kind: "instance", Socket: "/run/carlos/apps/" + host + ".sock",
67 Hibernate: true, Backing: registry.BackingUnit}
68}
69
70// testUnitActivator wires a Service over a SplitBackend: the usual
71// fakeBackend on the exec side, a UnitBackend with the fake systemctl and an
72// instant health gate on the unit side.
73func testUnitActivator(t *testing.T, idle time.Duration) (*Service, *fakeBackend, *fakeSystemctl, *fakeClock, *registry.Registry) {
74 t.Helper()
75 eb := &fakeBackend{}
76 sc := &fakeSystemctl{}
77 ub := &UnitBackend{
78 Systemctl: sc.run,
79 OptDir: t.TempDir(),
80 WaitHealthy: func(context.Context, registry.Route) error { return nil },
81 }
82 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
83 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
84 if err != nil {
85 t.Fatalf("open registry: %v", err)
86 }
87 a := New(Config{Bucket: "test-bucket", Idle: idle}, &SplitBackend{Exec: eb, Unit: ub}, reg)
88 a.now = clk.now
89 return a, eb, sc, clk, reg
90}
91
92// The whole-app hibernation cycle: a request starts the unit (systemctl
93// start, health-gated); the idle window stops it (systemctl stop); the next
94// request starts it again. The exec side is never touched.
95func TestUnitTenantWakeIdleSleepCycle(t *testing.T) {
96 t.Parallel()
97 a, eb, sc, clk, _ := testUnitActivator(t, 10*time.Second)
98 route := testUnitRoute("kass.example.com")
99 unit := UnitName(route.Host)
100
101 release, err := a.beginRequest(context.Background(), route)
102 if err != nil {
103 t.Fatalf("beginRequest: %v", err)
104 }
105 release()
106 if got := sc.recorded(); len(got) != 1 || got[0] != "start "+unit {
107 t.Fatalf("wake systemctl calls = %v, want [start %s]", got, unit)
108 }
109
110 // Not yet idle: the unit stays up.
111 clk.advance(9 * time.Second)
112 a.sweepOnce()
113 if a.entryCount() != 1 {
114 t.Fatal("unit tenant hibernated before its idle window elapsed")
115 }
116
117 // Idle: stopped via systemctl, entry forgotten.
118 clk.advance(2 * time.Second)
119 a.sweepOnce()
120 waitFor(t, "unit hibernation", func() bool { return a.entryCount() == 0 })
121 if got := sc.recorded(); len(got) != 2 || got[1] != "stop "+unit {
122 t.Fatalf("sleep systemctl calls = %v, want [... stop %s]", got, unit)
123 }
124
125 // Re-wake: a fresh start.
126 release, err = a.beginRequest(context.Background(), route)
127 if err != nil {
128 t.Fatalf("beginRequest after sleep: %v", err)
129 }
130 release()
131 if got := sc.recorded(); len(got) != 3 || got[2] != "start "+unit {
132 t.Fatalf("re-wake systemctl calls = %v, want a second start", got)
133 }
134
135 // The exec backend was never involved: no lease claims, no spawns.
136 if acquires, restores, starts := eb.counts(); acquires != 0 || restores != 0 || starts != 0 {
137 t.Fatalf("unit tenant leaked into the exec backend: acquires=%d restores=%d starts=%d", acquires, restores, starts)
138 }
139}
140
141// SplitBackend dispatch, the other direction: an exec-backed route wakes
142// through the fake exec backend and never touches systemctl.
143func TestSplitBackendExecRouteBypassesUnit(t *testing.T) {
144 t.Parallel()
145 a, eb, sc, _, _ := testUnitActivator(t, time.Minute)
146
147 release, err := a.beginRequest(context.Background(), testRoute("plain.example.com"))
148 if err != nil {
149 t.Fatalf("beginRequest: %v", err)
150 }
151 release()
152 if acquires, restores, starts := eb.counts(); acquires != 1 || restores != 1 || starts != 1 {
153 t.Fatalf("exec route skipped the exec backend: acquires=%d restores=%d starts=%d", acquires, restores, starts)
154 }
155 if got := sc.recorded(); len(got) != 0 {
156 t.Fatalf("exec route touched systemctl: %v", got)
157 }
158}
159
160// Restore and Seed dispatch by Backing like every other SplitBackend method —
161// and a unit tenant, whose Restore is never fresh, never reaches a seed even
162// with one declared on its route.
163func TestSplitBackendDispatchesFreshAndSeedByBacking(t *testing.T) {
164 eb := &fakeBackend{restoreFresh: true}
165 ub := &UnitBackend{Systemctl: (&fakeSystemctl{}).run, OptDir: t.TempDir()}
166 sb := &SplitBackend{Exec: eb, Unit: ub}
167 ctx := context.Background()
168
169 if fresh, err := sb.Restore(ctx, testRoute("plain.example.com")); err != nil || !fresh {
170 t.Fatalf("exec route Restore = %v, %v; want true, nil", fresh, err)
171 }
172 unitRoute := testUnitRoute("kass.example.com")
173 unitRoute.Seed = `["seed"]`
174 if fresh, err := sb.Restore(ctx, unitRoute); err != nil || fresh {
175 t.Fatalf("unit route Restore = %v, %v; want false, nil", fresh, err)
176 }
177
178 if err := sb.Seed(ctx, testRoute("plain.example.com")); err != nil {
179 t.Fatalf("exec route Seed: %v", err)
180 }
181 if got := eb.seedCount(); got != 1 {
182 t.Fatalf("exec-side seeds = %d, want 1", got)
183 }
184 if err := sb.Seed(ctx, unitRoute); err != nil {
185 t.Fatalf("unit route Seed: %v", err)
186 }
187 if got := eb.seedCount(); got != 1 {
188 t.Fatalf("a unit tenant's seed leaked into the exec backend (seeds=%d)", got)
189 }
190}
191
192// A unit that starts but never turns healthy fails the wake cleanly: the
193// unit is put back down (no half-up tenant lingering behind a 503) and no
194// entry is left, so the next request retries from scratch.
195func TestUnitTenantFailedHealthStopsUnit(t *testing.T) {
196 route := testUnitRoute("broken.example.com")
197 unit := UnitName(route.Host)
198
199 sc := &fakeSystemctl{}
200 ub := &UnitBackend{Systemctl: sc.run, OptDir: t.TempDir(),
201 WaitHealthy: func(context.Context, registry.Route) error { return errors.New("never healthy") }}
202 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
203 if err != nil {
204 t.Fatalf("open registry: %v", err)
205 }
206 a := New(Config{Bucket: "test-bucket", Idle: time.Minute}, &SplitBackend{Exec: &fakeBackend{}, Unit: ub}, reg)
207
208 if _, err := a.beginRequest(context.Background(), route); err == nil {
209 t.Fatal("wake succeeded despite the health gate failing")
210 }
211 if got := sc.recorded(); len(got) != 2 || got[0] != "start "+unit || got[1] != "stop "+unit {
212 t.Fatalf("systemctl calls = %v, want [start %s, stop %s]", got, unit, unit)
213 }
214 if a.entryCount() != 0 {
215 t.Fatal("failed unit wake left an entry behind")
216 }
217}
218
219// A start that fails outright (unit file missing, systemd unreachable)
220// fails the wake with no stop attempt and no entry.
221func TestUnitTenantStartFailureFailsWake(t *testing.T) {
222 a, _, sc, _, _ := testUnitActivator(t, time.Minute)
223 sc.startErr = errors.New("Unit carlos-app@gone.example.com.service not found")
224
225 if _, err := a.beginRequest(context.Background(), testUnitRoute("gone.example.com")); err == nil {
226 t.Fatal("wake succeeded despite systemctl start failing")
227 }
228 if got := sc.recorded(); len(got) != 1 {
229 t.Fatalf("systemctl calls = %v, want just the failed start", got)
230 }
231 if a.entryCount() != 0 {
232 t.Fatal("failed start left an entry behind")
233 }
234}
235
236// Shutdown puts unit tenants down like exec children — deliberately: with
237// the tenant's carlos-app@.socket unit enabled, systemd's wake-on-connect
238// brings it back on the next connection even while the agent is away, and
239// the next agent's health-gated wake re-adopts it (start on a running unit
240// is a no-op).
241func TestUnitTenantShutdownStopsUnit(t *testing.T) {
242 a, _, sc, _, _ := testUnitActivator(t, time.Hour)
243 route := testUnitRoute("kass.example.com")
244
245 release, err := a.beginRequest(context.Background(), route)
246 if err != nil {
247 t.Fatalf("beginRequest: %v", err)
248 }
249 release()
250
251 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
252 defer cancel()
253 a.Shutdown(ctx)
254 if a.entryCount() != 0 {
255 t.Fatal("shutdown left the unit entry behind")
256 }
257 got := sc.recorded()
258 if len(got) != 2 || got[1] != "stop "+UnitName(route.Host) {
259 t.Fatalf("systemctl calls = %v, want the shutdown stop", got)
260 }
261}
262
263// The inert lease/replication seams: everything the state machine calls on
264// them must succeed and report healthy, so the machine treats a unit tenant
265// exactly like a replicating exec child. (Replication ownership really
266// lives with the root carlos-litestream unit — see backend_unit.go.)
267func TestUnitBackendInertSeams(t *testing.T) {
268 b := &UnitBackend{Systemctl: (&fakeSystemctl{}).run, OptDir: t.TempDir()}
269 ctx := context.Background()
270 rt := testUnitRoute("inert.example.com")
271
272 lease, err := b.Acquire(ctx, rt)
273 if err != nil {
274 t.Fatalf("Acquire: %v", err)
275 }
276 rep, err := lease.Replicate(ctx, rt.DBPath, 0)
277 if err != nil {
278 t.Fatalf("Replicate: %v", err)
279 }
280 if err := rep.Err(); err != nil {
281 t.Fatalf("Rep.Err: %v", err)
282 }
283 if err := rep.Close(ctx); err != nil {
284 t.Fatalf("Rep.Close: %v", err)
285 }
286 if err := lease.Release(ctx); err != nil {
287 t.Fatalf("Release: %v", err)
288 }
289 // Never fresh: the agent cannot see inside a DynamicUser's state dir, so
290 // it must never claim a unit tenant is on its first boot — that claim is
291 // what would run an app's seed over live data.
292 if fresh, err := b.Restore(ctx, rt); err != nil || fresh {
293 t.Fatalf("Restore = %v, %v; want false, nil", fresh, err)
294 }
295 if err := b.Seed(ctx, rt); err != nil {
296 t.Fatalf("Seed: %v", err)
297 }
298 due, err := b.NextDue(ctx, rt)
299 if err != nil || due != 0 {
300 t.Fatalf("NextDue = %d, %v; want 0, nil (unit tenants sleep until a request)", due, err)
301 }
302 if b.HasSidecar(rt) {
303 t.Fatal("unit tenants must not report agent-managed sidecars")
304 }
305}
306
307// Worker spawn is exec-child machinery, exactly like sidecar co-spawn: a
308// unit tenant that wants a worker runs it inside its own unit.
309func TestUnitBackendRefusesWorkers(t *testing.T) {
310 var b UnitBackend
311 if _, _, err := b.StartWorker(context.Background(), registry.Route{Host: "x"}, nil); err == nil {
312 t.Fatal("unit-backed tenants have no agent-managed workers, exactly as they have no agent-managed sidecars")
313 }
314}
315
316// LiveVersion reads the same live/<host> symlink layout as ExecBackend, so
317// build convergence cycles awake unit tenants onto shipped builds.
318func TestUnitBackendLiveVersion(t *testing.T) {
319 opt := t.TempDir()
320 b := &UnitBackend{Systemctl: (&fakeSystemctl{}).run, OptDir: opt}
321
322 if v, err := b.LiveVersion("new.example.com"); err != nil || v != "" {
323 t.Fatalf("LiveVersion(no symlink) = %q, %v; want empty, nil", v, err)
324 }
325 verDir := filepath.Join(opt, "versions", "abc1234")
326 if err := os.MkdirAll(verDir, 0o755); err != nil {
327 t.Fatal(err)
328 }
329 if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil {
330 t.Fatal(err)
331 }
332 if err := os.Symlink(filepath.Join(verDir, "app"), filepath.Join(opt, "live", "kass.example.com")); err != nil {
333 t.Fatal(err)
334 }
335 if v, err := b.LiveVersion("kass.example.com"); err != nil || v != "abc1234" {
336 t.Fatalf("LiveVersion = %q, %v; want abc1234", v, err)
337 }
338
339 // A migrated tenant's link has the account sqid between the version and
340 // the binary; reading "the parent directory" would return the sqid and
341 // convince ConvergeDecision this tenant is converged forever.
342 stageLiveUnderAccount(t, opt, "moved.example.com", "kqmvexample", "def5678", "kass")
343 if v, err := b.LiveVersion("moved.example.com"); err != nil || v != "def5678" {
344 t.Fatalf("LiveVersion (account-scoped) = %q, %v; want def5678", v, err)
345 }
346}
347
348// The end-to-end shape the feature promises: a request for a SLEEPING unit
349// tenant is held at the edge while the unit starts and its upstream turns
350// healthy (the real TCP prober — no injected health seam here), then
351// forwarded; after the idle window the unit is stopped for real (listener
352// closed); the next request repeats the cycle. TCP Addr keeps the test
353// sandbox-safe; the unix-socket path differs only in the dialer.
354func TestUnitTenantWakeServesHeldRequest(t *testing.T) {
355 // Pre-pick the tenant's port so the route can exist before the "unit" runs.
356 ln, err := net.Listen("tcp", "127.0.0.1:0")
357 if err != nil {
358 t.Fatalf("pick port: %v", err)
359 }
360 addr := ln.Addr().String()
361 ln.Close()
362
363 // The "unit": the fake systemctl binds/closes a real HTTP listener on
364 // start/stop, with a deliberate boot delay so the request provably waits
365 // on the health gate.
366 var srvMu sync.Mutex
367 var srv *http.Server
368 sc := &fakeSystemctl{}
369 sc.onStart = func() {
370 time.Sleep(200 * time.Millisecond) // boot time — the edge must hold the request
371 l, err := net.Listen("tcp", addr)
372 if err != nil {
373 t.Errorf("unit listen: %v", err)
374 return
375 }
376 s := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
377 io.WriteString(w, "kass up")
378 })}
379 srvMu.Lock()
380 srv = s
381 srvMu.Unlock()
382 go s.Serve(l)
383 }
384 sc.onStop = func() {
385 srvMu.Lock()
386 s := srv
387 srv = nil
388 srvMu.Unlock()
389 if s != nil {
390 s.Close()
391 }
392 }
393
394 old := spawnHealthTimeout
395 spawnHealthTimeout = 5 * time.Second
396 t.Cleanup(func() { spawnHealthTimeout = old })
397
398 eb := &fakeBackend{}
399 ub := &UnitBackend{Systemctl: sc.run, OptDir: t.TempDir()} // nil WaitHealthy: the real prober
400 clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
401 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
402 if err != nil {
403 t.Fatalf("open registry: %v", err)
404 }
405 a := New(Config{Bucket: "test-bucket", Idle: 10 * time.Second}, &SplitBackend{Exec: eb, Unit: ub}, reg)
406 a.now = clk.now
407
408 route := registry.Route{Host: "kass.example.com", Kind: "instance", Addr: addr,
409 Hibernate: true, Backing: registry.BackingUnit}
410 if err := reg.Upsert(route); err != nil {
411 t.Fatal(err)
412 }
413 rt, err := newTestRouter(reg, a)
414 if err != nil {
415 t.Fatalf("new router: %v", err)
416 }
417 front := httptest.NewServer(rt)
418 t.Cleanup(front.Close)
419 t.Cleanup(func() { sc.onStop() }) // in case the test dies mid-cycle
420
421 // Asleep → the request wakes the unit and is answered by it.
422 if body, code := proxyGet(t, front.URL, "kass.example.com"); code != 200 || body != "kass up" {
423 t.Fatalf("held request = %d %q, want 200 kass up", code, body)
424 }
425
426 // Idle out → the unit is stopped for real: its listener is gone.
427 clk.advance(11 * time.Second)
428 a.sweepOnce()
429 waitFor(t, "unit hibernation", func() bool { return a.entryCount() == 0 })
430 waitFor(t, "listener closed", func() bool {
431 conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
432 if err == nil {
433 conn.Close()
434 return false
435 }
436 return true
437 })
438
439 // The next request wakes it again.
440 if body, code := proxyGet(t, front.URL, "kass.example.com"); code != 200 || body != "kass up" {
441 t.Fatalf("post-sleep request = %d %q, want 200 kass up", code, body)
442 }
443 want := []string{
444 "start " + UnitName(route.Host),
445 "stop " + UnitName(route.Host),
446 "start " + UnitName(route.Host),
447 }
448 if got := sc.recorded(); fmt.Sprint(got) != fmt.Sprint(want) {
449 t.Fatalf("systemctl calls = %v, want %v", got, want)
450 }
451}
452