Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

spindle: wire mill and executor roles into server

dawn (Jul 18, 2026, 9:55 AM +0300) e40b866d efd0c82a

+397 -78
+94
docker-compose.mill.yml
··· 1 + # Mill-mode overlay for localinfra. 2 + # 3 + # The base docker-compose.yml runs a single standalone spindle. This overlay 4 + # turns that same spindle into a *mill host* (it places jobs instead of running 5 + # them) and adds one *executor* spindle that dials the mill over a websocket and 6 + # runs the real microvm/nixery engines. Repos still point at spindle.* exactly 7 + # as before — only where the job executes changes. 8 + # 9 + # standalone (default): docker compose --profile linux up 10 + # mill + executor: docker compose -f docker-compose.yml -f docker-compose.mill.yml --profile linux up 11 + # 12 + # The shared secret is a dev default; override with SPINDLE_MILL_SHARED_SECRET. 13 + 14 + services: 15 + # the existing front door becomes the mill host. it keeps its identity, 16 + # jetstream subscription and /events + /logs endpoints; its microvm/nixery 17 + # engines are swapped for the mill placement engine. 18 + spindle: 19 + environment: 20 + SPINDLE_ROLE: mill 21 + SPINDLE_MILL_SHARED_SECRET: "${SPINDLE_MILL_SHARED_SECRET:-localmill}" 22 + 23 + # a normal spindle that runs the real engines, but takes its work from the 24 + # mill instead of being pointed at directly. dials out (NAT-friendly), so it 25 + # needs no caddy alias and no PDS record. 26 + spindle-executor: 27 + profiles: ["linux"] 28 + build: 29 + context: . 30 + dockerfile: localinfra/spindle.Dockerfile 31 + restart: unless-stopped 32 + environment: 33 + SPINDLE_ROLE: executor 34 + SPINDLE_SERVER_HOSTNAME: executor.tngl.boltless.dev 35 + SPINDLE_SERVER_LISTEN_ADDR: 0.0.0.0:6555 36 + SPINDLE_SERVER_DB_PATH: /var/lib/spindle/spindle.db 37 + SPINDLE_SERVER_PLC_URL: https://plc.tngl.boltless.dev 38 + SPINDLE_SERVER_JETSTREAM_ENDPOINT: wss://jetstream.tngl.boltless.dev/subscribe 39 + SPINDLE_SERVER_DEV: "true" 40 + SPINDLE_SERVER_DEV_EXTRA_HOSTS: knot.tngl.boltless.dev,mirror.tngl.boltless.dev 41 + SPINDLE_SERVER_TAP_DB_PATH: /var/lib/spindle/tap.db 42 + SPINDLE_SERVER_TAP_RELAY_URL: https://pds.tngl.boltless.dev 43 + SPINDLE_MICROVM_PIPELINES_IMAGE_DIR: /var/lib/spindle/images 44 + SPINDLE_MICROVM_PIPELINES_OVERLAY_DIR: /var/lib/spindle/overlays 45 + # distinct vsock agent port from the base spindle, in case both ever run 46 + SPINDLE_MICROVM_PIPELINES_AGENT_PORT: "11241" 47 + SPINDLE_S3_LOG_BUCKET: "" 48 + SPINDLE_MICROVM_PIPELINES_ENABLE_CGROUPS: "false" 49 + SPINDLE_NIX_CACHE_READ_URLS: http://ncps:8501 50 + SPINDLE_NIX_CACHE_TRUSTED_PUBLIC_KEYS: cache.local:F7YqpMzuBdILYd/v+wMZN2YKxCzliXQyFmeezOxw7rU= 51 + SPINDLE_NIX_CACHE_UPLOAD_URL: http://ncps:8501/upload 52 + # join the mill instead of being pointed at directly. plain ws to the 53 + # mill container on the internal network (no caddy / TLS in the loop). 54 + SPINDLE_MILL_URL: ws://spindle:6555/mill 55 + SPINDLE_MILL_SHARED_SECRET: "${SPINDLE_MILL_SHARED_SECRET:-localmill}" 56 + devices: 57 + - /dev/vsock:/dev/vsock 58 + - /dev/kvm:/dev/kvm 59 + - /dev/vhost-vsock:/dev/vhost-vsock 60 + - /dev/net/tun:/dev/net/tun 61 + cap_add: 62 + - NET_ADMIN 63 + - SYS_ADMIN 64 + security_opt: 65 + - label=disable 66 + - seccomp=unconfined 67 + volumes: 68 + - spindle-executor-data:/var/lib/spindle 69 + - spindle-executor-logs:/var/log/spindle 70 + - ./out/localinfra-spindle-images:/var/lib/spindle/images:ro 71 + - init-state:/shared:ro 72 + - ./localinfra/certs/root.crt:/usr/local/share/ca-certificates/caddy.crt:ro 73 + healthcheck: 74 + test: ["CMD", "wget", "-qO-", "http://localhost:6555/"] 75 + interval: 2s 76 + timeout: 2s 77 + retries: 30 78 + start_period: 5s 79 + depends_on: 80 + plc: 81 + condition: service_started 82 + jetstream: 83 + condition: service_started 84 + init-accounts: 85 + condition: service_completed_successfully 86 + ncps: 87 + condition: service_started 88 + spindle: 89 + condition: service_healthy 90 + networks: [tngl] 91 + 92 + volumes: 93 + spindle-executor-data: 94 + spindle-executor-logs:
+111
spindle/mill/integration_test.go
··· 1 + package mill 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "net/http/httptest" 10 + "path/filepath" 11 + "strings" 12 + "testing" 13 + "time" 14 + 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/notifier" 17 + "tangled.org/core/spindle/config" 18 + "tangled.org/core/spindle/db" 19 + "tangled.org/core/spindle/engines/dummy" 20 + "tangled.org/core/spindle/mill/executor" 21 + "tangled.org/core/spindle/models" 22 + ) 23 + 24 + func TestEndToEndDummyJob(t *testing.T) { 25 + ctx, cancel := context.WithCancel(context.Background()) 26 + defer cancel() 27 + l := slog.New(slog.NewTextHandler(io.Discard, nil)) 28 + 29 + millDir := t.TempDir() 30 + bdb, err := db.Make(ctx, filepath.Join(millDir, "mill.db")) 31 + if err != nil { 32 + t.Fatalf("mill db: %v", err) 33 + } 34 + bn := notifier.New() 35 + mill := New(l, Config{LogDir: millDir, ReconnectGrace: time.Minute, BidTimeout: 2 * time.Second}) 36 + mill.Attach(bdb, &bn) 37 + 38 + srv := httptest.NewServer(http.HandlerFunc(mill.HandleExecutorConn)) 39 + defer srv.Close() 40 + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") 41 + 42 + execDir := t.TempDir() 43 + edb, err := db.Make(ctx, filepath.Join(execDir, "exec.db")) 44 + if err != nil { 45 + t.Fatalf("exec db: %v", err) 46 + } 47 + en := notifier.New() 48 + cfg := &config.Config{} 49 + cfg.Server.LogDir = execDir 50 + cfg.Server.Hostname = "exec-1" 51 + cfg.Mill.URL = wsURL 52 + cfg.Mill.Seats = 2 53 + 54 + engines := map[string]models.Engine{"dummy": dummy.New(l)} 55 + exec := executor.New(cfg, engines, edb, &en, l) 56 + go exec.Connect(ctx) 57 + 58 + be := NewEngine("dummy", mill) 59 + twf := tangled.Pipeline_Workflow{ 60 + Name: "build", 61 + Raw: "steps:\n - name: hello\n command: echo hi\n", 62 + } 63 + wf, err := be.InitWorkflow(twf, tangled.Pipeline{}) 64 + if err != nil { 65 + t.Fatalf("InitWorkflow: %v", err) 66 + } 67 + wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} 68 + 69 + placeCtx, placeCancel := context.WithTimeout(ctx, 10*time.Second) 70 + defer placeCancel() 71 + 72 + slot, err := mill.place(placeCtx, "dummy", wid, wf) 73 + if err != nil { 74 + t.Fatalf("place: %v", err) 75 + } 76 + defer slot.Release() 77 + 78 + if err := mill.commitAndWait(placeCtx, wf, nil); err != nil { 79 + t.Fatalf("commitAndWait: %v, want success", err) 80 + } 81 + 82 + if !waitForStatus(t, bdb, wid, "running") { 83 + t.Fatal("mill never saw relayed running status") 84 + } 85 + } 86 + 87 + func waitForStatus(t *testing.T, d *db.DB, wid models.WorkflowId, want string) bool { 88 + t.Helper() 89 + deadline := time.Now().Add(3 * time.Second) 90 + aturi := string(wid.PipelineId.AtUri()) 91 + for time.Now().Before(deadline) { 92 + evs, err := d.GetEvents(0, 1000) 93 + if err != nil { 94 + t.Fatalf("GetEvents: %v", err) 95 + } 96 + for _, ev := range evs { 97 + if ev.Nsid != tangled.PipelineStatusNSID { 98 + continue 99 + } 100 + var st tangled.PipelineStatus 101 + if err := json.Unmarshal(ev.EventJson, &st); err != nil { 102 + continue 103 + } 104 + if st.Pipeline == aturi && st.Workflow == wid.Name && st.Status == want { 105 + return true 106 + } 107 + } 108 + time.Sleep(50 * time.Millisecond) 109 + } 110 + return false 111 + }
+148 -78
spindle/server.go
··· 39 39 "tangled.org/core/spindle/engines/microvm" 40 40 "tangled.org/core/spindle/engines/nixery" 41 41 "tangled.org/core/spindle/git" 42 + "tangled.org/core/spindle/mill" 43 + "tangled.org/core/spindle/mill/executor" 42 44 "tangled.org/core/spindle/models" 43 45 "tangled.org/core/spindle/queue" 44 46 "tangled.org/core/spindle/secrets" ··· 73 75 motd []byte 74 76 motdMu sync.RWMutex 75 77 rootCtx context.Context 78 + 79 + // set only when this spindle hosts the mill / joins one as an executor. 80 + mill *mill.Mill 81 + exec *executor.Executor 76 82 } 77 83 78 84 // New creates a new Spindle server with the provided configuration and engines. 79 85 func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]models.Engine) (*Spindle, error) { 80 86 logger := log.FromContext(ctx) 87 + n := notifier.New() 88 + 89 + if cfg.Role == config.RoleExecutor { 90 + if err := cleanupOrphanRepos(ctx, d, logger); err != nil { 91 + return nil, fmt.Errorf("failed to run startup cleanup: %w", err) 92 + } 93 + } else if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil { 94 + return nil, fmt.Errorf("failed to run startup migrations: %w", err) 95 + } 96 + 97 + spindle := &Spindle{ 98 + db: d, 99 + l: logger, 100 + n: &n, 101 + engs: engines, 102 + cfg: cfg, 103 + motd: defaultMotd, 104 + rootCtx: ctx, 105 + } 106 + if cfg.Role == config.RoleExecutor { 107 + return spindle, nil 108 + } 81 109 82 110 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 83 111 if err != nil { 84 112 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 85 113 } 86 114 e.E.EnableAutoSave(true) 87 - 88 - n := notifier.New() 115 + spindle.e = e 89 116 90 - var vault secrets.Manager 91 117 switch cfg.Server.Secrets.Provider { 92 118 case "openbao": 93 119 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 94 120 return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 95 121 } 96 - vault, err = secrets.NewOpenBaoManager( 122 + spindle.vault, err = secrets.NewOpenBaoManager( 97 123 cfg.Server.Secrets.OpenBao.ProxyAddr, 98 124 logger, 99 125 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), ··· 103 129 } 104 130 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 105 131 case "sqlite", "": 106 - vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 132 + spindle.vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 107 133 if err != nil { 108 134 return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 109 135 } ··· 112 138 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 113 139 } 114 140 115 - if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil { 116 - return nil, fmt.Errorf("failed to run startup migrations: %w", err) 117 - } 118 - 119 141 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 142 + spindle.jq = jq 120 143 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 121 144 122 145 collections := []string{ ··· 129 152 if err != nil { 130 153 return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 131 154 } 155 + spindle.jc = jc 132 156 jc.AddDid(cfg.Server.Owner) 133 157 // pull records are created by arbitrary users too, same hack as in tap 134 158 jc.ExemptCollection(tangled.RepoPullNSID) ··· 152 176 } 153 177 } 154 178 155 - resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 156 - 157 - spindle := &Spindle{ 158 - jc: jc, 159 - e: e, 160 - db: d, 161 - l: logger, 162 - n: &n, 163 - engs: engines, 164 - jq: jq, 165 - cfg: cfg, 166 - res: resolver, 167 - verify: repoverify.New(resolver, cfg.Server.Dev), 168 - vault: vault, 169 - motd: defaultMotd, 170 - rootCtx: ctx, 171 - } 179 + spindle.res = idresolver.DefaultResolver(cfg.Server.PlcUrl) 180 + spindle.verify = repoverify.New(spindle.res, cfg.Server.Dev) 172 181 173 182 err = e.AddSpindle(rbacDomain) 174 183 if err != nil { ··· 275 284 276 285 // Start starts the Spindle server (blocking). 277 286 func (s *Spindle) Start(ctx context.Context) error { 278 - // starts a job queue runner in the background 279 - s.jq.Start() 280 - defer s.jq.Stop() 287 + // Only standalone runs the local queue. Mill hosts place directly onto 288 + // executors, and executors only run jobs explicitly assigned by a mill. 289 + if s.cfg.Role == config.RoleStandalone { 290 + s.jq.Start() 291 + defer s.jq.Stop() 292 + } 293 + 294 + // An executor dials out to its mill and takes work from it. 295 + if s.exec != nil { 296 + go s.exec.Connect(ctx) 297 + } 281 298 282 299 // Stop vault token renewal if it implements Stopper 283 300 if stopper, ok := s.vault.(secrets.Stopper); ok { 284 301 defer stopper.Stop() 285 302 } 286 303 287 - tapCtx, tapCancel := context.WithCancel(ctx) 304 + if s.cfg.Role != config.RoleExecutor { 305 + tapCtx, tapCancel := context.WithCancel(ctx) 288 306 289 - if s.cfg.Server.Tap.Embed { 290 - emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap")) 291 - if err != nil { 292 - tapCancel() 293 - return fmt.Errorf("starting embedded tap: %w", err) 307 + if s.cfg.Server.Tap.Embed { 308 + emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap")) 309 + if err != nil { 310 + tapCancel() 311 + return fmt.Errorf("starting embedded tap: %w", err) 312 + } 313 + s.embedTap = emb 314 + defer func() { 315 + tapCancel() 316 + s.embedTap.Shutdown() 317 + }() 318 + 319 + go s.watchTapDrain(tapCtx, tapCancel) 320 + } else { 321 + defer tapCancel() 294 322 } 295 - s.embedTap = emb 296 - defer func() { 297 - tapCancel() 298 - s.embedTap.Shutdown() 323 + 324 + go func() { 325 + s.l.Info("starting knot event consumer") 326 + s.ks.Start(ctx) 299 327 }() 300 328 301 - go s.watchTapDrain(tapCtx, tapCancel) 302 - } else { 303 - defer tapCancel() 329 + s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url) 330 + s.tap.Start(tapCtx) 304 331 } 305 - 306 - go func() { 307 - s.l.Info("starting knot event consumer") 308 - s.ks.Start(ctx) 309 - }() 310 - 311 - s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url) 312 - s.tap.Start(tapCtx) 313 332 314 333 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 315 334 return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) ··· 355 374 return fmt.Errorf("failed to setup db: %w", err) 356 375 } 357 376 358 - nixeryEng, err := nixery.New(ctx, cfg) 359 - if err != nil { 360 - return err 377 + logger := log.FromContext(ctx) 378 + 379 + var engines map[string]models.Engine 380 + var m *mill.Mill 381 + 382 + if cfg.Role == config.RoleMill { 383 + // mill host: register engines that place jobs on executors instead of 384 + // running them. All names share one Mill. 385 + m = mill.New(log.SubLogger(logger, "mill"), mill.Config{ 386 + SharedSecret: cfg.Mill.SharedSecret, 387 + MaxPending: cfg.Mill.MaxPending, 388 + ReconnectGrace: cfg.Mill.ReconnectGrace, 389 + LogDir: cfg.Server.LogDir, 390 + }) 391 + engines = map[string]models.Engine{ 392 + "nixery": mill.NewEngine("nixery", m), 393 + "microvm": mill.NewEngine("microvm", m), 394 + "dummy": mill.NewEngine("dummy", m), 395 + } 396 + } else { 397 + // standalone and executor both run real engines locally. 398 + nixeryEng, err := nixery.New(ctx, cfg) 399 + if err != nil { 400 + return err 401 + } 402 + microvmEng, err := microvm.New(ctx, cfg, d) 403 + if err != nil { 404 + return err 405 + } 406 + engines = map[string]models.Engine{ 407 + "nixery": nixeryEng, 408 + "microvm": microvmEng, 409 + "dummy": dummy.New(logger), 410 + } 361 411 } 362 412 363 - microvmEng, err := microvm.New(ctx, cfg, d) 413 + s, err := New(ctx, cfg, d, engines) 364 414 if err != nil { 365 415 return err 366 416 } 367 417 368 - s, err := New(ctx, cfg, d, map[string]models.Engine{ 369 - "nixery": nixeryEng, 370 - "microvm": microvmEng, 371 - "dummy": dummy.New(log.FromContext(ctx)), 372 - }) 373 - if err != nil { 374 - return err 418 + if m != nil { 419 + // resolve the chicken-and-egg: the engines (built above) hold the mill, 420 + // but the mill's db/notifier are created inside New. 421 + m.Attach(s.DB(), s.Notifier()) 422 + s.mill = m 423 + } 424 + if cfg.Role == config.RoleExecutor { 425 + s.exec = executor.New(cfg, engines, s.DB(), s.Notifier(), log.SubLogger(logger, "executor")) 375 426 } 376 427 377 428 return s.Start(ctx) ··· 383 434 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 384 435 w.Write(s.GetMotdContent()) 385 436 }) 437 + if s.cfg.Role == config.RoleExecutor { 438 + return mux 439 + } 440 + 386 441 mux.HandleFunc("/events", s.Events) 387 442 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 443 + 444 + // mill host: executors dial in here (plain ws, shared-secret auth). 445 + if s.mill != nil { 446 + mux.HandleFunc("/mill", s.mill.HandleExecutorConn) 447 + } 388 448 389 449 mux.Mount("/xrpc", s.XrpcRouter()) 390 450 return mux ··· 823 883 workflows[eng] = append(workflows[eng], *ewf) 824 884 } 825 885 826 - // enqueue pipeline 827 - ok := s.jq.Enqueue(repoDid, queue.Job{ 828 - Run: func() error { 829 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, &models.Pipeline{ 830 - RepoDid: repoDid, 831 - Workflows: workflows, 832 - TrustedSource: trustedSource, 833 - }, pipelineId) 834 - return nil 835 - }, 836 - OnFail: func(jobError error) { 837 - s.l.Error("pipeline run failed", "error", jobError) 838 - }, 839 - }) 840 - if !ok { 841 - return fmt.Errorf("failed to enqueue pipeline: queue is full") 886 + pipeline := &models.Pipeline{ 887 + RepoDid: repoDid, 888 + Workflows: workflows, 889 + TrustedSource: trustedSource, 842 890 } 843 - s.l.Info("pipeline enqueued successfully", "id", pipelineId) 891 + 892 + if s.mill != nil { 893 + // mill host: no bounded pool. Each job blocks in placement 894 + // (AcquireWorkflowSlot) which the user sees as pending; the only 895 + // bound is the mill's maxPending. rootCtx is the long-lived consumer 896 + // context, so the goroutine safely outlives this call. 897 + go engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, pipeline, pipelineId) 898 + s.l.Info("pipeline handed to mill placement", "id", pipelineId) 899 + } else { 900 + ok := s.jq.Enqueue(repoDid, queue.Job{ 901 + Run: func() error { 902 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, pipeline, pipelineId) 903 + return nil 904 + }, 905 + OnFail: func(jobError error) { 906 + s.l.Error("pipeline run failed", "error", jobError) 907 + }, 908 + }) 909 + if !ok { 910 + return fmt.Errorf("failed to enqueue pipeline: queue is full") 911 + } 912 + s.l.Info("pipeline enqueued successfully", "id", pipelineId) 913 + } 844 914 845 915 // after successful enqueue, emit StatusPending for all workflows 846 916 for _, ewfs := range workflows {
+44
spindle/server_test.go
··· 1 1 package spindle 2 2 3 3 import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "path/filepath" 4 8 "testing" 5 9 6 10 kgit "tangled.org/core/knotserver/git" 11 + "tangled.org/core/spindle/config" 12 + "tangled.org/core/spindle/db" 13 + "tangled.org/core/spindle/models" 7 14 ) 8 15 9 16 func TestHasSkipCIPushOption(t *testing.T) { ··· 53 60 }) 54 61 } 55 62 } 63 + 64 + func TestExecutorRoleBuildsMinimalSpindle(t *testing.T) { 65 + ctx := context.Background() 66 + dbPath := filepath.Join(t.TempDir(), "spindle.db") 67 + d, err := db.Make(ctx, dbPath) 68 + if err != nil { 69 + t.Fatalf("db.Make() error = %v", err) 70 + } 71 + 72 + cfg := &config.Config{Role: config.RoleExecutor} 73 + cfg.Server.DBPath = dbPath 74 + cfg.Server.Hostname = "executor.test" 75 + cfg.Server.Tap.Embed = true 76 + 77 + s, err := New(ctx, cfg, d, map[string]models.Engine{}) 78 + if err != nil { 79 + t.Fatalf("New() error = %v", err) 80 + } 81 + 82 + if s.jc != nil || s.tap != nil || s.e != nil || s.jq != nil || s.ks != nil || s.res != nil || s.vault != nil { 83 + t.Fatal("executor role built coordinator-only spindle dependencies") 84 + } 85 + 86 + rr := httptest.NewRecorder() 87 + req := httptest.NewRequest(http.MethodGet, "/", nil) 88 + s.Router().ServeHTTP(rr, req) 89 + if rr.Code != http.StatusOK { 90 + t.Fatalf("root status = %d, want %d", rr.Code, http.StatusOK) 91 + } 92 + 93 + rr = httptest.NewRecorder() 94 + req = httptest.NewRequest(http.MethodGet, "/xrpc/_health", nil) 95 + s.Router().ServeHTTP(rr, req) 96 + if rr.Code != http.StatusNotFound { 97 + t.Fatalf("executor xrpc status = %d, want %d", rr.Code, http.StatusNotFound) 98 + } 99 + }