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 13, 2026, 4:22 PM +0300) eade059d 0b6d2316

+405 -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:
+119
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 + // TestEndToEndDummyJob wires a real mill Mill to a real executor over a 25 + // websocket and runs one dummy job end to end: place -> reserve -> commit -> 26 + // run -> terminal, asserting commitAndWait succeeds and the mill re-authored 27 + // the relayed running-status row. 28 + func TestEndToEndDummyJob(t *testing.T) { 29 + ctx, cancel := context.WithCancel(context.Background()) 30 + defer cancel() 31 + l := slog.New(slog.NewTextHandler(io.Discard, nil)) 32 + 33 + // --- mill side --- 34 + millDir := t.TempDir() 35 + bdb, err := db.Make(ctx, filepath.Join(millDir, "mill.db")) 36 + if err != nil { 37 + t.Fatalf("mill db: %v", err) 38 + } 39 + bn := notifier.New() 40 + mill := New(l, Config{LogDir: millDir, ReconnectGrace: time.Minute, BidTimeout: 2 * time.Second}) 41 + mill.Attach(bdb, &bn) 42 + 43 + srv := httptest.NewServer(http.HandlerFunc(mill.HandleExecutorConn)) 44 + defer srv.Close() 45 + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") 46 + 47 + // --- executor side --- 48 + execDir := t.TempDir() 49 + edb, err := db.Make(ctx, filepath.Join(execDir, "exec.db")) 50 + if err != nil { 51 + t.Fatalf("exec db: %v", err) 52 + } 53 + en := notifier.New() 54 + cfg := &config.Config{} 55 + cfg.Server.LogDir = execDir 56 + cfg.Server.Hostname = "exec-1" 57 + cfg.Mill.URL = wsURL 58 + cfg.Mill.Seats = 2 59 + 60 + engines := map[string]models.Engine{"dummy": dummy.New(l)} 61 + exec := executor.New(cfg, engines, edb, &en, l) 62 + go exec.Connect(ctx) 63 + 64 + // --- place a dummy job --- 65 + be := NewEngine("dummy", mill) 66 + twf := tangled.Pipeline_Workflow{ 67 + Name: "build", 68 + Raw: "steps:\n - name: hello\n command: echo hi\n", 69 + } 70 + wf, err := be.InitWorkflow(twf, tangled.Pipeline{}) 71 + if err != nil { 72 + t.Fatalf("InitWorkflow: %v", err) 73 + } 74 + wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "knot.test", Rkey: "rkey1"}, Name: "build"} 75 + 76 + placeCtx, placeCancel := context.WithTimeout(ctx, 10*time.Second) 77 + defer placeCancel() 78 + 79 + slot, err := mill.place(placeCtx, "dummy", wid, wf) 80 + if err != nil { 81 + t.Fatalf("place: %v", err) 82 + } 83 + defer slot.Release() 84 + 85 + if err := mill.commitAndWait(placeCtx, wf, nil); err != nil { 86 + t.Fatalf("commitAndWait: %v, want success", err) 87 + } 88 + 89 + // the mill should have re-authored the relayed running-status row. 90 + if !waitForStatus(t, bdb, wid, "running") { 91 + t.Fatal("mill never saw relayed running status") 92 + } 93 + } 94 + 95 + func waitForStatus(t *testing.T, d *db.DB, wid models.WorkflowId, want string) bool { 96 + t.Helper() 97 + deadline := time.Now().Add(3 * time.Second) 98 + aturi := string(wid.PipelineId.AtUri()) 99 + for time.Now().Before(deadline) { 100 + evs, err := d.GetEvents(0, 1000) 101 + if err != nil { 102 + t.Fatalf("GetEvents: %v", err) 103 + } 104 + for _, ev := range evs { 105 + if ev.Nsid != tangled.PipelineStatusNSID { 106 + continue 107 + } 108 + var st tangled.PipelineStatus 109 + if err := json.Unmarshal(ev.EventJson, &st); err != nil { 110 + continue 111 + } 112 + if st.Pipeline == aturi && st.Workflow == wid.Name && st.Status == want { 113 + return true 114 + } 115 + } 116 + time.Sleep(50 * time.Millisecond) 117 + } 118 + return false 119 + }
+148 -78
spindle/server.go
··· 36 36 "tangled.org/core/spindle/engines/microvm" 37 37 "tangled.org/core/spindle/engines/nixery" 38 38 "tangled.org/core/spindle/git" 39 + "tangled.org/core/spindle/mill" 40 + "tangled.org/core/spindle/mill/executor" 39 41 "tangled.org/core/spindle/models" 40 42 "tangled.org/core/spindle/queue" 41 43 "tangled.org/core/spindle/secrets" ··· 70 72 motd []byte 71 73 motdMu sync.RWMutex 72 74 rootCtx context.Context 75 + 76 + // set only when this spindle hosts the mill / joins one as an executor. 77 + mill *mill.Mill 78 + exec *executor.Executor 73 79 } 74 80 75 81 // New creates a new Spindle server with the provided configuration and engines. 76 82 func New(ctx context.Context, cfg *config.Config, d *db.DB, engines map[string]models.Engine) (*Spindle, error) { 77 83 logger := log.FromContext(ctx) 84 + n := notifier.New() 85 + 86 + if cfg.Role == config.RoleExecutor { 87 + if err := cleanupOrphanRepos(ctx, d, logger); err != nil { 88 + return nil, fmt.Errorf("failed to run startup cleanup: %w", err) 89 + } 90 + } else if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil { 91 + return nil, fmt.Errorf("failed to run startup migrations: %w", err) 92 + } 93 + 94 + spindle := &Spindle{ 95 + db: d, 96 + l: logger, 97 + n: &n, 98 + engs: engines, 99 + cfg: cfg, 100 + motd: defaultMotd, 101 + rootCtx: ctx, 102 + } 103 + if cfg.Role == config.RoleExecutor { 104 + return spindle, nil 105 + } 78 106 79 107 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 80 108 if err != nil { 81 109 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 82 110 } 83 111 e.E.EnableAutoSave(true) 112 + spindle.e = e 84 113 85 - n := notifier.New() 86 - 87 - var vault secrets.Manager 88 114 switch cfg.Server.Secrets.Provider { 89 115 case "openbao": 90 116 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 91 117 return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 92 118 } 93 - vault, err = secrets.NewOpenBaoManager( 119 + spindle.vault, err = secrets.NewOpenBaoManager( 94 120 cfg.Server.Secrets.OpenBao.ProxyAddr, 95 121 logger, 96 122 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), ··· 100 126 } 101 127 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 102 128 case "sqlite", "": 103 - vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 129 + spindle.vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 104 130 if err != nil { 105 131 return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 106 132 } ··· 109 135 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 110 136 } 111 137 112 - if err := runStartupMigrations(ctx, d, cfg.Server.Tap.Embed, cfg.Server.Tap.DBPath, logger); err != nil { 113 - return nil, fmt.Errorf("failed to run startup migrations: %w", err) 114 - } 115 - 116 138 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 139 + spindle.jq = jq 117 140 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 118 141 119 142 collections := []string{ ··· 125 148 if err != nil { 126 149 return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 127 150 } 151 + spindle.jc = jc 128 152 jc.AddDid(cfg.Server.Owner) 129 153 130 154 // Check if the spindle knows about any Dids; ··· 146 170 } 147 171 } 148 172 149 - resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 150 - 151 - spindle := &Spindle{ 152 - jc: jc, 153 - e: e, 154 - db: d, 155 - l: logger, 156 - n: &n, 157 - engs: engines, 158 - jq: jq, 159 - cfg: cfg, 160 - res: resolver, 161 - verify: repoverify.New(resolver, cfg.Server.Dev), 162 - vault: vault, 163 - motd: defaultMotd, 164 - rootCtx: ctx, 165 - } 173 + spindle.res = idresolver.DefaultResolver(cfg.Server.PlcUrl) 174 + spindle.verify = repoverify.New(spindle.res, cfg.Server.Dev) 166 175 167 176 err = e.AddSpindle(rbacDomain) 168 177 if err != nil { ··· 269 278 270 279 // Start starts the Spindle server (blocking). 271 280 func (s *Spindle) Start(ctx context.Context) error { 272 - // starts a job queue runner in the background 273 - s.jq.Start() 274 - defer s.jq.Stop() 281 + // Only standalone runs the local queue. Mill hosts place directly onto 282 + // executors, and executors only run jobs explicitly assigned by a mill. 283 + if s.cfg.Role == config.RoleStandalone { 284 + s.jq.Start() 285 + defer s.jq.Stop() 286 + } 287 + 288 + // An executor dials out to its mill and takes work from it. 289 + if s.exec != nil { 290 + go s.exec.Connect(ctx) 291 + } 275 292 276 293 // Stop vault token renewal if it implements Stopper 277 294 if stopper, ok := s.vault.(secrets.Stopper); ok { 278 295 defer stopper.Stop() 279 296 } 280 297 281 - tapCtx, tapCancel := context.WithCancel(ctx) 298 + if s.cfg.Role != config.RoleExecutor { 299 + tapCtx, tapCancel := context.WithCancel(ctx) 282 300 283 - if s.cfg.Server.Tap.Embed { 284 - emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap")) 285 - if err != nil { 286 - tapCancel() 287 - return fmt.Errorf("starting embedded tap: %w", err) 301 + if s.cfg.Server.Tap.Embed { 302 + emb, err := startEmbeddedTap(tapCtx, s.cfg, log.SubLogger(s.l, "embedtap")) 303 + if err != nil { 304 + tapCancel() 305 + return fmt.Errorf("starting embedded tap: %w", err) 306 + } 307 + s.embedTap = emb 308 + defer func() { 309 + tapCancel() 310 + s.embedTap.Shutdown() 311 + }() 312 + 313 + go s.watchTapDrain(tapCtx, tapCancel) 314 + } else { 315 + defer tapCancel() 288 316 } 289 - s.embedTap = emb 290 - defer func() { 291 - tapCancel() 292 - s.embedTap.Shutdown() 317 + 318 + go func() { 319 + s.l.Info("starting knot event consumer") 320 + s.ks.Start(ctx) 293 321 }() 294 322 295 - go s.watchTapDrain(tapCtx, tapCancel) 296 - } else { 297 - defer tapCancel() 323 + s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url) 324 + s.tap.Start(tapCtx) 298 325 } 299 326 300 - go func() { 301 - s.l.Info("starting knot event consumer") 302 - s.ks.Start(ctx) 303 - }() 304 - 305 - s.l.Info("starting tap client", "url", s.cfg.Server.Tap.Url) 306 - s.tap.Start(tapCtx) 307 - 308 327 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 309 328 return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 310 329 } ··· 349 368 return fmt.Errorf("failed to setup db: %w", err) 350 369 } 351 370 352 - nixeryEng, err := nixery.New(ctx, cfg) 353 - if err != nil { 354 - return err 371 + logger := log.FromContext(ctx) 372 + 373 + var engines map[string]models.Engine 374 + var m *mill.Mill 375 + 376 + if cfg.Role == config.RoleMill { 377 + // mill host: register engines that place jobs on executors instead of 378 + // running them. All names share one Mill. 379 + m = mill.New(log.SubLogger(logger, "mill"), mill.Config{ 380 + SharedSecret: cfg.Mill.SharedSecret, 381 + MaxPending: cfg.Mill.MaxPending, 382 + ReconnectGrace: cfg.Mill.ReconnectGrace, 383 + LogDir: cfg.Server.LogDir, 384 + }) 385 + engines = map[string]models.Engine{ 386 + "nixery": mill.NewEngine("nixery", m), 387 + "microvm": mill.NewEngine("microvm", m), 388 + "dummy": mill.NewEngine("dummy", m), 389 + } 390 + } else { 391 + // standalone and executor both run real engines locally. 392 + nixeryEng, err := nixery.New(ctx, cfg) 393 + if err != nil { 394 + return err 395 + } 396 + microvmEng, err := microvm.New(ctx, cfg, d) 397 + if err != nil { 398 + return err 399 + } 400 + engines = map[string]models.Engine{ 401 + "nixery": nixeryEng, 402 + "microvm": microvmEng, 403 + "dummy": dummy.New(logger), 404 + } 355 405 } 356 406 357 - microvmEng, err := microvm.New(ctx, cfg, d) 407 + s, err := New(ctx, cfg, d, engines) 358 408 if err != nil { 359 409 return err 360 410 } 361 411 362 - s, err := New(ctx, cfg, d, map[string]models.Engine{ 363 - "nixery": nixeryEng, 364 - "microvm": microvmEng, 365 - "dummy": dummy.New(log.FromContext(ctx)), 366 - }) 367 - if err != nil { 368 - return err 412 + if m != nil { 413 + // resolve the chicken-and-egg: the engines (built above) hold the mill, 414 + // but the mill's db/notifier are created inside New. 415 + m.Attach(s.DB(), s.Notifier()) 416 + s.mill = m 417 + } 418 + if cfg.Role == config.RoleExecutor { 419 + s.exec = executor.New(cfg, engines, s.DB(), s.Notifier(), log.SubLogger(logger, "executor")) 369 420 } 370 421 371 422 return s.Start(ctx) ··· 377 428 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 378 429 w.Write(s.GetMotdContent()) 379 430 }) 431 + if s.cfg.Role == config.RoleExecutor { 432 + return mux 433 + } 434 + 380 435 mux.HandleFunc("/events", s.Events) 381 436 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 437 + 438 + // mill host: executors dial in here (plain ws, shared-secret auth). 439 + if s.mill != nil { 440 + mux.HandleFunc("/mill", s.mill.HandleExecutorConn) 441 + } 382 442 383 443 mux.Mount("/xrpc", s.XrpcRouter()) 384 444 return mux ··· 760 820 workflows[eng] = append(workflows[eng], *ewf) 761 821 } 762 822 763 - // enqueue pipeline 764 - ok := s.jq.Enqueue(repoDid, queue.Job{ 765 - Run: func() error { 766 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, &models.Pipeline{ 767 - RepoDid: repoDid, 768 - Workflows: workflows, 769 - TrustedSource: trustedSource, 770 - }, pipelineId) 771 - return nil 772 - }, 773 - OnFail: func(jobError error) { 774 - s.l.Error("pipeline run failed", "error", jobError) 775 - }, 776 - }) 777 - if !ok { 778 - return fmt.Errorf("failed to enqueue pipeline: queue is full") 823 + pipeline := &models.Pipeline{ 824 + RepoDid: repoDid, 825 + Workflows: workflows, 826 + TrustedSource: trustedSource, 779 827 } 780 - s.l.Info("pipeline enqueued successfully", "id", pipelineId) 828 + 829 + if s.mill != nil { 830 + // mill host: no bounded pool. Each job blocks in placement 831 + // (AcquireWorkflowSlot) which the user sees as pending; the only 832 + // bound is the mill's maxPending. rootCtx is the long-lived consumer 833 + // context, so the goroutine safely outlives this call. 834 + go engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, pipeline, pipelineId) 835 + s.l.Info("pipeline handed to mill placement", "id", pipelineId) 836 + } else { 837 + ok := s.jq.Enqueue(repoDid, queue.Job{ 838 + Run: func() error { 839 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, s.rootCtx, pipeline, pipelineId) 840 + return nil 841 + }, 842 + OnFail: func(jobError error) { 843 + s.l.Error("pipeline run failed", "error", jobError) 844 + }, 845 + }) 846 + if !ok { 847 + return fmt.Errorf("failed to enqueue pipeline: queue is full") 848 + } 849 + s.l.Info("pipeline enqueued successfully", "id", pipelineId) 850 + } 781 851 782 852 // after successful enqueue, emit StatusPending for all workflows 783 853 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 + }