app.js (5400B)
1 // src/conductor/app.js - assembles the conductor 2 // 3 // Separated from index.js so tests can build a server on a temporary 4 // database without starting a listener or installing signal handlers. 5 6 import Fastify from 'fastify'; 7 import cors from '@fastify/cors'; 8 9 import { ensureStateDirs, parseKey } from '../lib/config.js'; 10 import { openDatabase, runMigrations } from '../lib/db/index.js'; 11 import { createStorage } from '../lib/storage/index.js'; 12 import { createSecretBox } from '../lib/secretbox.js'; 13 import { createLogStore } from '../lib/log.js'; 14 import { createGit } from '../lib/git.js'; 15 import { createProjects } from '../lib/projects.js'; 16 import { createWorkerTokens } from '../lib/workers.js'; 17 import { createUsers } from '../lib/users.js'; 18 import { createVariables } from '../lib/variables.js'; 19 import { createAuth } from '../lib/auth/index.js'; 20 import { createScheduler } from './scheduler.js'; 21 import { createRetention } from './retention.js'; 22 23 import workerRoutes from './routes/workers.js'; 24 import taskRoutes from './routes/tasks.js'; 25 import jobRoutes from './routes/jobs.js'; 26 import artifactRoutes from './routes/artifacts.js'; 27 import uiRoutes from './ui/routes.js'; 28 import staticRoutes from '../lib/static.js'; 29 30 export async function createServices(cfg, options = {}) { 31 ensureStateDirs(cfg); 32 33 const db = await openDatabase(cfg); 34 if (options.migrate !== false) { 35 await runMigrations(db, { logger: options.migrationLogger }); 36 } 37 38 const secrets = createSecretBox(parseKey(cfg.secrets.encryption_key, 'secrets.encryption_key')); 39 const storage = createStorage(cfg); 40 const logs = createLogStore(cfg); 41 const git = createGit(cfg); 42 const logger = options.logger ?? console; 43 44 const projects = createProjects({ db, secrets }); 45 const workerTokens = createWorkerTokens({ db }); 46 const variables = createVariables({ db, secrets }); 47 const users = createUsers({ db, logger }); 48 const auth = createAuth({ cfg, users, logger }); 49 50 const scheduler = createScheduler({ cfg, db, git, logs, storage, projects, variables, logger }); 51 const retention = createRetention({ cfg, db, storage, logs, logger }); 52 53 // Without a first administrator there is no way into the admin surface. 54 // Only done for local accounts; with OIDC the provider owns identity. 55 if (options.bootstrap !== false && auth.localLogin) { 56 await users.bootstrap(cfg); 57 } 58 59 return { 60 cfg, db, secrets, storage, logs, git, 61 projects, workerTokens, variables, users, auth, scheduler, retention, logger, 62 }; 63 } 64 65 export async function buildServer(services, options = {}) { 66 const { cfg } = services; 67 const fastify = Fastify({ 68 logger: options.logger ?? { level: process.env.LOG_LEVEL || 'info' }, 69 // Workers upload artifacts as raw streams; this only bounds parsed 70 // bodies such as the trigger payload. 71 bodyLimit: 1024 * 1024, 72 trustProxy: options.trustProxy ?? true, 73 }); 74 75 await fastify.register(cors, { origin: true }); 76 77 fastify.get('/health', async () => ({ 78 ok: true, 79 service: 'conductor', 80 database: services.db.dialect, 81 storage: services.storage.driver, 82 auth: cfg.auth.mode, 83 })); 84 85 // The only HTTP surface besides the interface: creating and reading jobs, 86 // reading tasks back, the edge workers poll, and artifact downloads. 87 // Everything else is the UI. 88 // 89 // workerRoutes and taskRoutes share the /api/v1/tasks prefix deliberately. 90 // They stay separate plugins because the worker token hook covers its own 91 // plugin entirely, and reading a task back is not behind it. 92 await fastify.register(jobRoutes, { ...services, prefix: '/api/v1' }); 93 await fastify.register(workerRoutes, { ...services, prefix: '/api/v1' }); 94 await fastify.register(taskRoutes, { ...services, prefix: '/api/v1' }); 95 await fastify.register(artifactRoutes, { ...services, prefix: '/api/v1' }); 96 97 // Signing in, managing projects and registering workers all happen in the 98 // interface; there is no separate management API. 99 if (options.ui !== false) { 100 await fastify.register(staticRoutes, {}); 101 await fastify.register(uiRoutes, { ...services }); 102 } 103 104 return fastify; 105 } 106 107 // Requeues or fails jobs whose worker went away. Returns a stop function. 108 export function startReaper(services) { 109 const { cfg, scheduler, logger } = services; 110 let running = false; 111 112 const timer = setInterval(async () => { 113 // Skip a tick rather than overlapping if a sweep runs long. 114 if (running) return; 115 running = true; 116 try { 117 await scheduler.reap(); 118 } catch (e) { 119 logger.error?.(`reaper failed: ${e.message}`); 120 } finally { 121 running = false; 122 } 123 }, cfg.scheduler.reap_interval * 1000); 124 125 timer.unref(); 126 return () => clearInterval(timer); 127 } 128 129 // Deletes artifacts and logs past their retention. Returns a stop function. 130 export function startRetention(services) { 131 const { cfg, retention, logger } = services; 132 let running = false; 133 134 const timer = setInterval(async () => { 135 // A sweep over a large backlog can outlast its interval, and running 136 // two at once would have them fight over the same rows. 137 if (running) return; 138 running = true; 139 try { 140 await retention.sweep(); 141 } catch (e) { 142 logger.error?.(`retention sweep failed: ${e.message}`); 143 } finally { 144 running = false; 145 } 146 }, cfg.retention.sweep_interval * 1000); 147 148 timer.unref(); 149 return () => clearInterval(timer); 150 }