eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync/atomic"
13 "testing"
14)
15
16// controlTestEnv redirects every path the creation core touches into temp dirs
17// and replaces systemd with a fake that binds the instance's unix socket itself
18// (serving the health check and the setup-invite mint), so addInstance runs its
19// real state machine — env files, registry, symlinks, health checks — with no
20// systemd and no /data.
21func controlTestEnv(t *testing.T) *Registry {
22 t.Helper()
23
24 oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk, oldReady := instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes, confirmPublicReady
25 t.Cleanup(func() {
26 instanceDataDir, instanceSocketDir, optDir, selfPath, systemctl, freeDiskBytes, confirmPublicReady = oldData, oldSock, oldOpt, oldSelf, oldSysctl, oldDisk, oldReady
27 })
28 confirmPublicReady = func(string, string) {} // no router to dial in tests
29
30 instanceDataDir = t.TempDir()
31 optDir = t.TempDir()
32 // The socket dir must stay under the ~104-byte sun_path limit, and t.TempDir
33 // embeds the test name — use a plain short temp dir instead.
34 sockDir, err := os.MkdirTemp("", "lcs")
35 if err != nil {
36 t.Fatal(err)
37 }
38 t.Cleanup(func() { os.RemoveAll(sockDir) })
39 instanceSocketDir = sockDir
40
41 // Stage a stub file, not the multi-MB test binary.
42 stub := filepath.Join(t.TempDir(), "stub")
43 if err := os.WriteFile(stub, []byte("#!/bin/sh\n"), 0o755); err != nil {
44 t.Fatal(err)
45 }
46 selfPath = func() (string, error) { return stub, nil }
47
48 freeDiskBytes = func(string) (uint64, error) { return 100 << 30, nil }
49
50 // The systemd stand-in: "enable --now lchat@<host>" binds the instance socket
51 // and answers like a healthy instance; everything else is a no-op.
52 systemctl = func(args ...string) error {
53 if len(args) == 3 && args[0] == "enable" && args[1] == "--now" && strings.HasPrefix(args[2], "lchat@") {
54 host := strings.TrimPrefix(args[2], "lchat@")
55 ln, err := net.Listen("unix", filepath.Join(instanceSocketDir, host+".sock"))
56 if err != nil {
57 return err
58 }
59 mux := http.NewServeMux()
60 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
61 mux.HandleFunc("POST /api/admin/key-invites", func(w http.ResponseWriter, r *http.Request) {
62 writeJSON(w, 200, map[string]any{"ok": true})
63 })
64 srv := &http.Server{Handler: mux}
65 go srv.Serve(ln)
66 t.Cleanup(func() { srv.Close() })
67 }
68 return nil
69 }
70
71 reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
72 if err != nil {
73 t.Fatal(err)
74 }
75 return reg
76}
77
78func newTestControld(t *testing.T, reg *Registry, maxInstances int) (*httptest.Server, string) {
79 t.Helper()
80 token := "test-control-token"
81 c := &controld{reg: reg, tokenHash: hashToken(token), maxInstances: maxInstances, minFreeGB: 5}
82 srv := httptest.NewServer(c.handler())
83 t.Cleanup(srv.Close)
84 return srv, token
85}
86
87func controlCreate(t *testing.T, srv *httptest.Server, token, host string) *http.Response {
88 t.Helper()
89 body, _ := json.Marshal(map[string]string{"host": host})
90 req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body))
91 if token != "" {
92 req.Header.Set("Authorization", "Bearer "+token)
93 }
94 resp, err := http.DefaultClient.Do(req)
95 if err != nil {
96 t.Fatal(err)
97 }
98 t.Cleanup(func() { resp.Body.Close() })
99 return resp
100}
101
102func decodeBody(t *testing.T, resp *http.Response) map[string]any {
103 t.Helper()
104 var m map[string]any
105 if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
106 t.Fatalf("decode body: %v", err)
107 }
108 return m
109}
110
111// wakeOnProbe replaces confirmPublicReady with a fake first wake: it binds the
112// host's socket and answers like a freshly spawned child (health check + the
113// setup-invite mint) — which is exactly what the router's activator does when
114// the create-path readiness probe hits a hibernating host. controlTestEnv's
115// cleanup restores the real confirmPublicReady.
116func wakeOnProbe(t *testing.T) {
117 t.Helper()
118 confirmPublicReady = func(h, _ string) {
119 ln, err := net.Listen("unix", filepath.Join(instanceSocketDir, h+".sock"))
120 if err != nil {
121 t.Errorf("bind woken socket: %v", err)
122 return
123 }
124 mux := http.NewServeMux()
125 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
126 mux.HandleFunc("POST /api/admin/key-invites", func(w http.ResponseWriter, r *http.Request) {
127 writeJSON(w, 200, map[string]any{"ok": true})
128 })
129 srv := &http.Server{Handler: mux}
130 go srv.Serve(ln)
131 t.Cleanup(func() { srv.Close() })
132 }
133}
134
135// socketTripwire binds a host's instance socket and counts connections — proof
136// that a code path stayed a pure metadata read (dialed no socket, woke nothing).
137func socketTripwire(t *testing.T, host string) *atomic.Int32 {
138 t.Helper()
139 var dials atomic.Int32
140 ln, err := net.Listen("unix", filepath.Join(instanceSocketDir, host+".sock"))
141 if err != nil {
142 t.Fatal(err)
143 }
144 t.Cleanup(func() { ln.Close() })
145 go func() {
146 for {
147 c, err := ln.Accept()
148 if err != nil {
149 return
150 }
151 dials.Add(1)
152 c.Close()
153 }
154 }()
155 return &dials
156}
157
158// The replication endpoint: records the class on the registry route and
159// reloads the router exactly once per actual change — a matching class is a
160// no-op with no reload (the cloud sweep re-pushes every pass), and garbage
161// classes / unknown hosts never touch anything.
162func TestControldReplicationEndpoint(t *testing.T) {
163 reg := controlTestEnv(t)
164 if err := reg.upsert(Route{Host: "space.11ch.at", Kind: "instance", Socket: "/run/s.sock", Hibernate: true}); err != nil {
165 t.Fatal(err)
166 }
167 if err := reg.upsert(Route{Host: "svc.11ch.at", Kind: "service", Socket: "/run/v.sock"}); err != nil {
168 t.Fatal(err)
169 }
170 srv, token := newTestControld(t, reg, 40)
171
172 var reloads int
173 systemctl = func(args ...string) error {
174 if len(args) == 2 && args[0] == "reload" {
175 reloads++
176 }
177 return nil
178 }
179
180 post := func(host, class, tok string) *http.Response {
181 t.Helper()
182 body, _ := json.Marshal(map[string]string{"class": class})
183 req, _ := http.NewRequest("POST", srv.URL+"/instances/"+host+"/replication", bytes.NewReader(body))
184 if tok != "" {
185 req.Header.Set("Authorization", "Bearer "+tok)
186 }
187 resp, err := http.DefaultClient.Do(req)
188 if err != nil {
189 t.Fatal(err)
190 }
191 t.Cleanup(func() { resp.Body.Close() })
192 return resp
193 }
194
195 if resp := post("space.11ch.at", "daily", ""); resp.StatusCode != http.StatusUnauthorized {
196 t.Errorf("no token: status = %d, want 401", resp.StatusCode)
197 }
198 if resp := post("space.11ch.at", "hourly", token); resp.StatusCode != http.StatusBadRequest {
199 t.Errorf("bad class: status = %d, want 400", resp.StatusCode)
200 }
201 if resp := post("ghost.11ch.at", "daily", token); resp.StatusCode != http.StatusNotFound {
202 t.Errorf("unknown host: status = %d, want 404", resp.StatusCode)
203 }
204 if resp := post("svc.11ch.at", "daily", token); resp.StatusCode != http.StatusNotFound {
205 t.Errorf("service route: status = %d, want 404", resp.StatusCode)
206 }
207 if rt, _ := reg.get("space.11ch.at"); rt.Replication != "" || reloads != 0 {
208 t.Fatalf("rejected requests left marks: class=%q reloads=%d", rt.Replication, reloads)
209 }
210
211 resp := post("space.11ch.at", "daily", token)
212 if resp.StatusCode != http.StatusOK {
213 t.Fatalf("set: status = %d, want 200", resp.StatusCode)
214 }
215 if body := decodeBody(t, resp); body["changed"] != true {
216 t.Errorf("set: changed = %v, want true", body["changed"])
217 }
218 if rt, _ := reg.get("space.11ch.at"); rt.Replication != "daily" {
219 t.Fatalf("class not recorded: %q", rt.Replication)
220 }
221 if reloads != 1 {
222 t.Fatalf("reloads after set = %d, want 1", reloads)
223 }
224
225 // Idempotent re-push (the sweep's steady state): no change, no reload.
226 resp = post("space.11ch.at", "daily", token)
227 if resp.StatusCode != http.StatusOK {
228 t.Fatalf("re-push: status = %d, want 200", resp.StatusCode)
229 }
230 if body := decodeBody(t, resp); body["changed"] != false {
231 t.Errorf("re-push: changed = %v, want false", body["changed"])
232 }
233 if reloads != 1 {
234 t.Fatalf("re-push reloaded the router: %d reloads", reloads)
235 }
236}
237
238// A create request carrying a replication class produces a route born on that
239// rung (what the provisioner sends for a fresh free space).
240func TestControldCreateBornClassed(t *testing.T) {
241 reg := controlTestEnv(t)
242 srv, token := newTestControld(t, reg, 40)
243
244 body, _ := json.Marshal(map[string]string{"host": "born.11ch.at", "replication": "daily"})
245 req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body))
246 req.Header.Set("Authorization", "Bearer "+token)
247 resp, err := http.DefaultClient.Do(req)
248 if err != nil {
249 t.Fatal(err)
250 }
251 defer resp.Body.Close()
252 if resp.StatusCode != http.StatusCreated {
253 t.Fatalf("create: status = %d, want 201", resp.StatusCode)
254 }
255 rt, err := reg.get("born.11ch.at")
256 if err != nil || rt == nil {
257 t.Fatalf("route: %+v, %v", rt, err)
258 }
259 if rt.Replication != "daily" {
260 t.Errorf("born class = %q, want daily", rt.Replication)
261 }
262}
263
264// A create carrying home_region records it on the route (multi-edge Phase 1):
265// the field crosses the socket in controlCreateReq → addInstanceOpts.HomeRegion
266// → the registry row the mesh later places by (mesh.candidatesFor). The value is
267// trimmed but not otherwise touched; omitted leaves the space region-agnostic.
268func TestControldCreateStampsHomeRegion(t *testing.T) {
269 reg := controlTestEnv(t)
270 srv, token := newTestControld(t, reg, 40)
271
272 create := func(host, region string) {
273 t.Helper()
274 payload := map[string]string{"host": host}
275 if region != "" {
276 payload["home_region"] = region
277 }
278 body, _ := json.Marshal(payload)
279 req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body))
280 req.Header.Set("Authorization", "Bearer "+token)
281 resp, err := http.DefaultClient.Do(req)
282 if err != nil {
283 t.Fatal(err)
284 }
285 defer resp.Body.Close()
286 if resp.StatusCode != http.StatusCreated {
287 t.Fatalf("create %s: status = %d, want 201", host, resp.StatusCode)
288 }
289 }
290
291 create("ie-space.11ch.at", " ie ") // trimmed crossing the boundary
292 if rt, _ := reg.get("ie-space.11ch.at"); rt == nil || rt.HomeRegion != "ie" {
293 t.Fatalf("route = %+v, want home_region=ie", rt)
294 }
295 // Omitted ⇒ region-agnostic, exactly today's behaviour (home_region "").
296 create("agnostic.11ch.at", "")
297 if rt, _ := reg.get("agnostic.11ch.at"); rt == nil || rt.HomeRegion != "" {
298 t.Fatalf("route = %+v, want empty home_region", rt)
299 }
300}
301
302func TestControldRequiresToken(t *testing.T) {
303 reg := controlTestEnv(t)
304 srv, _ := newTestControld(t, reg, 40)
305
306 if resp := controlCreate(t, srv, "", "a.example.com"); resp.StatusCode != http.StatusUnauthorized {
307 t.Errorf("no token: status = %d, want 401", resp.StatusCode)
308 }
309 if resp := controlCreate(t, srv, "wrong", "a.example.com"); resp.StatusCode != http.StatusUnauthorized {
310 t.Errorf("bad token: status = %d, want 401", resp.StatusCode)
311 }
312}
313
314func TestControldCreatesInstance(t *testing.T) {
315 reg := controlTestEnv(t)
316 srv, token := newTestControld(t, reg, 40)
317
318 resp := controlCreate(t, srv, token, "new.example.com")
319 if resp.StatusCode != http.StatusCreated {
320 t.Fatalf("status = %d, want 201", resp.StatusCode)
321 }
322 m := decodeBody(t, resp)
323 if m["host"] != "new.example.com" || m["url"] != "https://new.example.com" {
324 t.Errorf("host/url = %v/%v", m["host"], m["url"])
325 }
326 setup, _ := m["setup_link"].(string)
327 if !strings.HasPrefix(setup, "https://new.example.com/join/setup#") {
328 t.Errorf("setup_link = %q", setup)
329 }
330 admin, _ := m["admin_link"].(string)
331 if !strings.HasPrefix(admin, "https://new.example.com/admin#token=") {
332 t.Errorf("admin_link = %q", admin)
333 }
334
335 // The route is registered ready and the env file holds the same token.
336 rt, err := reg.get("new.example.com")
337 if err != nil || rt == nil || rt.Status != "ready" || rt.Kind != "instance" {
338 t.Fatalf("route = %+v, err %v", rt, err)
339 }
340 env, err := readEnvFile(filepath.Join(instanceDataDir, "new.example.com.env"))
341 if err != nil {
342 t.Fatal(err)
343 }
344 if !strings.HasSuffix(admin, env["LCHAT_ADMIN_TOKEN"]) {
345 t.Error("admin_link token does not match the env file")
346 }
347}
348
349func TestControldConfirmsPublicReady(t *testing.T) {
350 reg := controlTestEnv(t)
351 var confirmed string
352 confirmPublicReady = func(host, addr string) { confirmed = host }
353 srv, token := newTestControld(t, reg, 40)
354
355 if resp := controlCreate(t, srv, token, "ready.example.com"); resp.StatusCode != http.StatusCreated {
356 t.Fatalf("status = %d, want 201", resp.StatusCode)
357 }
358 // Readiness through the public path is confirmed for the new host before the
359 // setup link is returned, so a click lands on a ready admin sign-on.
360 if confirmed != "ready.example.com" {
361 t.Errorf("confirmPublicReady called with %q, want ready.example.com", confirmed)
362 }
363}
364
365func TestControldAtCapacity(t *testing.T) {
366 reg := controlTestEnv(t)
367 srv, token := newTestControld(t, reg, 1)
368
369 if err := reg.upsert(Route{Host: "full.example.com", Kind: "instance", Socket: "/x.sock", Status: "ready"}); err != nil {
370 t.Fatal(err)
371 }
372 resp := controlCreate(t, srv, token, "new.example.com")
373 if resp.StatusCode != http.StatusServiceUnavailable {
374 t.Fatalf("status = %d, want 503", resp.StatusCode)
375 }
376 if m := decodeBody(t, resp); m["error"] != "at_capacity" {
377 t.Errorf("error = %v, want at_capacity", m["error"])
378 }
379}
380
381func TestControldServiceRoutesDontCountTowardCapacity(t *testing.T) {
382 reg := controlTestEnv(t)
383 srv, token := newTestControld(t, reg, 1)
384
385 // A 'service' route (home, push, cloud) must not eat instance capacity.
386 if err := reg.upsert(Route{Host: "home.example.com", Kind: "service", Socket: "/h.sock", Status: "ready"}); err != nil {
387 t.Fatal(err)
388 }
389 if resp := controlCreate(t, srv, token, "new.example.com"); resp.StatusCode != http.StatusCreated {
390 t.Errorf("status = %d, want 201", resp.StatusCode)
391 }
392}
393
394func TestControldDiskFloor(t *testing.T) {
395 reg := controlTestEnv(t)
396 srv, token := newTestControld(t, reg, 40)
397 freeDiskBytes = func(string) (uint64, error) { return 1 << 30, nil } // 1 GB free, floor is 5
398
399 resp := controlCreate(t, srv, token, "new.example.com")
400 if resp.StatusCode != http.StatusServiceUnavailable {
401 t.Fatalf("status = %d, want 503", resp.StatusCode)
402 }
403 if m := decodeBody(t, resp); m["error"] != "at_capacity" {
404 t.Errorf("error = %v, want at_capacity", m["error"])
405 }
406}
407
408func TestControldHostExists(t *testing.T) {
409 reg := controlTestEnv(t)
410 srv, token := newTestControld(t, reg, 40)
411
412 if err := reg.upsert(Route{Host: "taken.example.com", Kind: "instance", Socket: "/x.sock", Status: "ready"}); err != nil {
413 t.Fatal(err)
414 }
415 resp := controlCreate(t, srv, token, "taken.example.com")
416 if resp.StatusCode != http.StatusConflict {
417 t.Fatalf("status = %d, want 409", resp.StatusCode)
418 }
419 if m := decodeBody(t, resp); m["error"] != "host_exists" {
420 t.Errorf("error = %v, want host_exists", m["error"])
421 }
422}
423
424func TestControldInvalidHost(t *testing.T) {
425 reg := controlTestEnv(t)
426 srv, token := newTestControld(t, reg, 40)
427
428 resp := controlCreate(t, srv, token, "Bad Host!")
429 if resp.StatusCode != http.StatusBadRequest {
430 t.Fatalf("status = %d, want 400", resp.StatusCode)
431 }
432 if m := decodeBody(t, resp); m["error"] != "invalid_host" {
433 t.Errorf("error = %v, want invalid_host", m["error"])
434 }
435}
436
437func TestControldDeleteInstance(t *testing.T) {
438 reg := controlTestEnv(t)
439 srv, token := newTestControld(t, reg, 40)
440
441 // Create a real (fake-systemd) instance first.
442 if resp := controlCreate(t, srv, token, "gone.example.com"); resp.StatusCode != http.StatusCreated {
443 t.Fatalf("create: %d", resp.StatusCode)
444 }
445
446 del := func(tok, host string) *http.Response {
447 req, _ := http.NewRequest("DELETE", srv.URL+"/instances/"+host, nil)
448 if tok != "" {
449 req.Header.Set("Authorization", "Bearer "+tok)
450 }
451 resp, err := http.DefaultClient.Do(req)
452 if err != nil {
453 t.Fatal(err)
454 }
455 t.Cleanup(func() { resp.Body.Close() })
456 return resp
457 }
458
459 if r := del("", "gone.example.com"); r.StatusCode != http.StatusUnauthorized {
460 t.Fatalf("no token: %d, want 401", r.StatusCode)
461 }
462 if r := del(token, "never-was.example.com"); r.StatusCode != http.StatusNotFound {
463 t.Fatalf("unknown host: %d, want 404", r.StatusCode)
464 }
465 if r := del(token, "gone.example.com"); r.StatusCode != http.StatusOK {
466 t.Fatalf("delete: %d, want 200", r.StatusCode)
467 }
468
469 // The route is gone…
470 if rt, _ := reg.get("gone.example.com"); rt != nil {
471 t.Errorf("route still present after delete: %+v", rt)
472 }
473 // …the live data files are gone from /data…
474 if _, err := os.Stat(filepath.Join(instanceDataDir, "gone.example.com.env")); !os.IsNotExist(err) {
475 t.Error("env file still in /data — should be archived")
476 }
477 // …but ARCHIVED into the trash, not destroyed (the operator safety net).
478 matches, err := filepath.Glob(filepath.Join(instanceDataDir, "trash", "gone.example.com-*", "gone.example.com.env"))
479 if err != nil || len(matches) != 1 {
480 t.Errorf("archived env not found in trash (matches=%v err=%v)", matches, err)
481 }
482}
483
484// ---- hibernate-by-default creates (#550) ---------------------------------------
485
486// With LCHAT_CONTROL_HIBERNATE_CREATES on, a provisioner create is born
487// hibernating: routed + env + staged binary, setup link minted through the
488// probe-triggered first wake — but no systemd unit and no DB (the wake boots
489// fresh).
490func TestControldHibernateCreates(t *testing.T) {
491 reg := controlTestEnv(t)
492 // Pure recorder: the hibernating path must never enable a unit, so nothing
493 // here binds the socket for it — only the fake wake below does.
494 var calls []string
495 systemctl = func(args ...string) error {
496 calls = append(calls, strings.Join(args, " "))
497 return nil
498 }
499 wakeOnProbe(t)
500
501 token := "test-control-token"
502 c := &controld{reg: reg, tokenHash: hashToken(token), maxInstances: 40, minFreeGB: 5, hibernateCreates: true}
503 srv := httptest.NewServer(c.handler())
504 t.Cleanup(srv.Close)
505
506 resp := controlCreate(t, srv, token, "hib.11ch.at")
507 if resp.StatusCode != http.StatusCreated {
508 t.Fatalf("status = %d, want 201", resp.StatusCode)
509 }
510 m := decodeBody(t, resp)
511 if setup, _ := m["setup_link"].(string); !strings.HasPrefix(setup, "https://hib.11ch.at/join/setup#") {
512 t.Errorf("setup_link = %q — the probe-woken instance should have minted it", setup)
513 }
514
515 rt, err := reg.get("hib.11ch.at")
516 if err != nil || rt == nil || rt.Status != "ready" || !rt.Hibernate {
517 t.Fatalf("route = %+v, err %v; want a ready hibernating instance", rt, err)
518 }
519 for _, call := range calls {
520 if strings.HasPrefix(call, "enable ") {
521 t.Errorf("a systemd unit was touched: %q", call)
522 }
523 }
524 if _, err := os.Stat(filepath.Join(instanceDataDir, "hib.11ch.at.db")); !os.IsNotExist(err) {
525 t.Error("a DB was created — the first wake should boot fresh")
526 }
527 // The env file IS written — the future wake's spawn layers it (admin token).
528 if _, err := os.Stat(filepath.Join(instanceDataDir, "hib.11ch.at.env")); err != nil {
529 t.Errorf("env file: %v", err)
530 }
531}
532
533// The flag defaults OFF: without it, provisioner creates stay systemd-managed —
534// the flip is a config decision, never a side effect of deploying this code.
535func TestControldHibernateCreatesDefaultOff(t *testing.T) {
536 reg := controlTestEnv(t)
537 srv, token := newTestControld(t, reg, 40)
538
539 if resp := controlCreate(t, srv, token, "plain.11ch.at"); resp.StatusCode != http.StatusCreated {
540 t.Fatalf("status = %d, want 201", resp.StatusCode)
541 }
542 rt, _ := reg.get("plain.11ch.at")
543 if rt == nil || rt.Hibernate {
544 t.Fatalf("route = %+v; the default create must stay systemd-managed", rt)
545 }
546}
547
548// A hibernating space that has never woken has no local DB — its stats are
549// definitional (nothing to count, nothing set up, no content), answered from
550// the registry row alone. The tripwire proves no socket was dialed and the
551// recorder proves no unit was touched: a reaper sweep over dormant spaces must
552// never wake them.
553func TestControldStatsNeverWokenHibernating(t *testing.T) {
554 reg := controlTestEnv(t)
555 var calls []string
556 systemctl = func(args ...string) error {
557 calls = append(calls, strings.Join(args, " "))
558 return nil
559 }
560
561 const host = "dormant.11ch.at"
562 sock := filepath.Join(instanceSocketDir, host+".sock")
563 if err := reg.upsert(Route{Host: host, Kind: "instance", Socket: sock, Status: "ready", Hibernate: true}); err != nil {
564 t.Fatal(err)
565 }
566 dials := socketTripwire(t, host)
567 srv, token := newTestControld(t, reg, 40)
568
569 req, _ := http.NewRequest("GET", srv.URL+"/instances/"+host+"/stats", nil)
570 req.Header.Set("Authorization", "Bearer "+token)
571 resp, err := http.DefaultClient.Do(req)
572 if err != nil {
573 t.Fatal(err)
574 }
575 defer resp.Body.Close()
576 if resp.StatusCode != http.StatusOK {
577 t.Fatalf("stats = HTTP %d, want 200 (definitional, not unknown_host)", resp.StatusCode)
578 }
579 var body struct {
580 Members int `json:"members"`
581 SetupComplete bool `json:"setup_complete"`
582 HasContent bool `json:"has_content"`
583 }
584 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
585 t.Fatal(err)
586 }
587 if body.Members != 0 || body.SetupComplete || body.HasContent {
588 t.Errorf("stats = %+v, want the definitional 0/false/false", body)
589 }
590 if n := dials.Load(); n != 0 {
591 t.Errorf("stats dialed the instance socket %d time(s) — it must stay a pure metadata read", n)
592 }
593 if len(calls) != 0 {
594 t.Errorf("stats touched systemd: %v", calls)
595 }
596}
597
598// An asleep hibernating space (woken before, now stopped) answers from its
599// local DB — the warm cache the activator left at the last sleep — read-only,
600// again with zero socket touches.
601func TestControldStatsAsleepHibernatingAnswersFromFile(t *testing.T) {
602 reg := controlTestEnv(t)
603 const host = "napping.11ch.at"
604
605 // The warm-cache DB: the pre-seeded admin, never set up, no content.
606 db, err := openDB(filepath.Join(instanceDataDir, host+".db"))
607 if err != nil {
608 t.Fatal(err)
609 }
610 if _, err := db.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil {
611 t.Fatal(err)
612 }
613 db.sql.Close()
614
615 sock := filepath.Join(instanceSocketDir, host+".sock")
616 if err := reg.upsert(Route{Host: host, Kind: "instance", Socket: sock, Status: "ready", Hibernate: true}); err != nil {
617 t.Fatal(err)
618 }
619 dials := socketTripwire(t, host)
620 srv, token := newTestControld(t, reg, 40)
621
622 req, _ := http.NewRequest("GET", srv.URL+"/instances/"+host+"/stats", nil)
623 req.Header.Set("Authorization", "Bearer "+token)
624 resp, err := http.DefaultClient.Do(req)
625 if err != nil {
626 t.Fatal(err)
627 }
628 defer resp.Body.Close()
629 if resp.StatusCode != http.StatusOK {
630 t.Fatalf("stats = HTTP %d, want 200", resp.StatusCode)
631 }
632 var body struct {
633 Members int `json:"members"`
634 SetupComplete bool `json:"setup_complete"`
635 HasContent bool `json:"has_content"`
636 }
637 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
638 t.Fatal(err)
639 }
640 if body.Members != 1 || body.SetupComplete || body.HasContent {
641 t.Errorf("stats = %+v, want 1/false/false (from the warm-cache DB)", body)
642 }
643 if n := dials.Load(); n != 0 {
644 t.Errorf("stats dialed the instance socket %d time(s) — the DB file is the source", n)
645 }
646}
647
648// The definitional answer is ONLY for hibernating rows: a host with no route at
649// all, and a registered systemd-managed instance whose DB is missing (a failed
650// create), both stay 404 — the reaper keeps skipping those, as before.
651func TestControldStatsMissingStays404(t *testing.T) {
652 reg := controlTestEnv(t)
653 srv, token := newTestControld(t, reg, 40)
654
655 stat := func(host string) int {
656 req, _ := http.NewRequest("GET", srv.URL+"/instances/"+host+"/stats", nil)
657 req.Header.Set("Authorization", "Bearer "+token)
658 resp, err := http.DefaultClient.Do(req)
659 if err != nil {
660 t.Fatal(err)
661 }
662 resp.Body.Close()
663 return resp.StatusCode
664 }
665
666 if code := stat("never-was.11ch.at"); code != http.StatusNotFound {
667 t.Errorf("unregistered host: %d, want 404", code)
668 }
669 if err := reg.upsert(Route{Host: "broken.11ch.at", Kind: "instance", Socket: "/x.sock", Status: "failed"}); err != nil {
670 t.Fatal(err)
671 }
672 if code := stat("broken.11ch.at"); code != http.StatusNotFound {
673 t.Errorf("non-hibernating row without a DB: %d, want 404", code)
674 }
675}
676
677// Under hibernate-creates the capacity cap counts MATERIALIZED (systemd-managed)
678// instances only — hibernating rows cost no process, so they don't consume the
679// box's RAM budget; a separate, much higher ceiling bounds total registrations.
680func TestControldCapCountsMaterializedOnlyWhenHibernateCreates(t *testing.T) {
681 reg := controlTestEnv(t)
682
683 // Three hibernating rows against maxInstances=1: with the flag on they
684 // don't count, so a (hibernating) create still succeeds…
685 for _, h := range []string{"h1.11ch.at", "h2.11ch.at", "h3.11ch.at"} {
686 if err := reg.upsert(Route{Host: h, Kind: "instance", Socket: "/run/" + h + ".sock", Status: "ready", Hibernate: true}); err != nil {
687 t.Fatal(err)
688 }
689 }
690 token := "test-control-token"
691 c := &controld{reg: reg, tokenHash: hashToken(token), maxInstances: 1, maxRegistered: 10, minFreeGB: 5, hibernateCreates: true}
692 srv := httptest.NewServer(c.handler())
693 t.Cleanup(srv.Close)
694 if resp := controlCreate(t, srv, token, "new1.11ch.at"); resp.StatusCode != http.StatusCreated {
695 t.Fatalf("create with 3 hibernating rows: %d, want 201 (they must not count)", resp.StatusCode)
696 }
697
698 // …a materialized row DOES hold the maxInstances cap…
699 if err := reg.upsert(Route{Host: "always-on.11ch.at", Kind: "instance", Socket: "/run/a.sock", Status: "ready"}); err != nil {
700 t.Fatal(err)
701 }
702 resp := controlCreate(t, srv, token, "new2.11ch.at")
703 if resp.StatusCode != http.StatusServiceUnavailable {
704 t.Fatalf("create at the materialized cap: %d, want 503", resp.StatusCode)
705 }
706 if m := decodeBody(t, resp); m["error"] != "at_capacity" {
707 t.Errorf("error = %v, want at_capacity", m["error"])
708 }
709
710 // …and the registration ceiling bounds total rows, hibernating included
711 // (5 instance rows now: h1-h3 + new1 hibernating, always-on materialized).
712 c2 := &controld{reg: reg, tokenHash: hashToken(token), maxInstances: 40, maxRegistered: 5, minFreeGB: 5, hibernateCreates: true}
713 srv2 := httptest.NewServer(c2.handler())
714 t.Cleanup(srv2.Close)
715 resp2 := controlCreate(t, srv2, token, "new3.11ch.at")
716 if resp2.StatusCode != http.StatusServiceUnavailable {
717 t.Fatalf("create at the registration ceiling: %d, want 503", resp2.StatusCode)
718 }
719 if m := decodeBody(t, resp2); m["error"] != "at_capacity" {
720 t.Errorf("error = %v, want at_capacity", m["error"])
721 }
722}
723
724// --- materialize (the controld-mediated fix for mesh-replicated routes' first wake) ---
725
726func controlMaterialize(t *testing.T, srv *httptest.Server, token, host string) *http.Response {
727 t.Helper()
728 req, _ := http.NewRequest("POST", srv.URL+"/instances/"+host+"/materialize", nil)
729 if token != "" {
730 req.Header.Set("Authorization", "Bearer "+token)
731 }
732 resp, err := http.DefaultClient.Do(req)
733 if err != nil {
734 t.Fatal(err)
735 }
736 t.Cleanup(func() { resp.Body.Close() })
737 return resp
738}
739
740func TestControldMaterializeRequiresToken(t *testing.T) {
741 reg := controlTestEnv(t)
742 srv, _ := newTestControld(t, reg, 40)
743 if resp := controlMaterialize(t, srv, "", "a.example.com"); resp.StatusCode != http.StatusUnauthorized {
744 t.Errorf("no token: status = %d, want 401", resp.StatusCode)
745 }
746}
747
748func TestControldMaterializeUnknownHost(t *testing.T) {
749 reg := controlTestEnv(t)
750 srv, token := newTestControld(t, reg, 40)
751 resp := controlMaterialize(t, srv, token, "never-registered.example.com")
752 if resp.StatusCode != http.StatusNotFound {
753 t.Fatalf("status = %d, want 404", resp.StatusCode)
754 }
755 if m := decodeBody(t, resp); m["error"] != "unknown_host" {
756 t.Errorf("error = %v, want unknown_host", m["error"])
757 }
758}
759
760// Mirrors the exact scenario found live 2026-07-13: a route with a registry
761// row but no live symlink (meshreplicate.go's meshRouteHandler only ever
762// upserts the row) must come out materialized — staged binary + live symlink
763// — after one call, ready for s3Backend.start to exec.
764func TestControldMaterializeStagesAndPointsLive(t *testing.T) {
765 reg := controlTestEnv(t)
766 if err := reg.upsert(Route{Host: "seeded.11ch.at", Kind: "instance", Socket: "/run/seeded.sock", Hibernate: true}); err != nil {
767 t.Fatal(err)
768 }
769 if _, err := os.Stat(liveLink("seeded.11ch.at")); err == nil {
770 t.Fatal("test setup: live symlink should not exist yet")
771 }
772
773 srv, token := newTestControld(t, reg, 40)
774 resp := controlMaterialize(t, srv, token, "seeded.11ch.at")
775 if resp.StatusCode != http.StatusOK {
776 t.Fatalf("status = %d, want 200", resp.StatusCode)
777 }
778 m := decodeBody(t, resp)
779 if m["ok"] != true || m["version"] == "" {
780 t.Fatalf("body = %v, want ok:true + a version", m)
781 }
782
783 ver, err := liveVersion("seeded.11ch.at")
784 if err != nil || ver == "" {
785 t.Fatalf("liveVersion after materialize: %v, %v — symlink was not created", ver, err)
786 }
787 if _, err := os.Stat(versionBinPath(ver)); err != nil {
788 t.Fatalf("staged binary missing at the version path: %v", err)
789 }
790}
791
792// A second call (the retry a wake would make) must not fail just because the
793// symlink already exists — pointLive happily repoints at the same version.
794func TestControldMaterializeIdempotent(t *testing.T) {
795 reg := controlTestEnv(t)
796 if err := reg.upsert(Route{Host: "seeded2.11ch.at", Kind: "instance", Socket: "/run/seeded2.sock", Hibernate: true}); err != nil {
797 t.Fatal(err)
798 }
799 srv, token := newTestControld(t, reg, 40)
800 for i := 0; i < 2; i++ {
801 if resp := controlMaterialize(t, srv, token, "seeded2.11ch.at"); resp.StatusCode != http.StatusOK {
802 t.Fatalf("call %d: status = %d, want 200", i, resp.StatusCode)
803 }
804 }
805}
806
807// A box-level default home origin fills provisioner creates that name none
808// (LCHAT_CONTROL_HOME_ORIGIN — non-production fleets front a different home);
809// an explicit request value still wins.
810func TestControldHomeOriginDefault(t *testing.T) {
811 reg := controlTestEnv(t)
812 c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5,
813 homeOrigin: "https://home.ie.dev.example"}
814 srv := httptest.NewServer(c.handler())
815 defer srv.Close()
816
817 create := func(payload map[string]string) {
818 t.Helper()
819 body, _ := json.Marshal(payload)
820 req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body))
821 req.Header.Set("Authorization", "Bearer test-control-token")
822 resp, err := http.DefaultClient.Do(req)
823 if err != nil {
824 t.Fatal(err)
825 }
826 defer resp.Body.Close()
827 if resp.StatusCode != http.StatusCreated {
828 t.Fatalf("create %v: HTTP %d", payload, resp.StatusCode)
829 }
830 }
831 envOrigin := func(host string) string {
832 t.Helper()
833 env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
834 if err != nil {
835 t.Fatal(err)
836 }
837 return env["LCHAT_HOME_ORIGIN"]
838 }
839
840 create(map[string]string{"host": "defaulted.example.com"})
841 if got := envOrigin("defaulted.example.com"); got != "https://home.ie.dev.example" {
842 t.Fatalf("defaulted create's LCHAT_HOME_ORIGIN = %q", got)
843 }
844 create(map[string]string{"host": "explicit.example.com", "home_origin": "https://home.other.example"})
845 if got := envOrigin("explicit.example.com"); got != "https://home.other.example" {
846 t.Fatalf("explicit create's LCHAT_HOME_ORIGIN = %q, want the request's own", got)
847 }
848}
849