app.js (5452B)
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 { createRefreshTokens } from '../lib/auth/refresh.js'; 21 import { createScheduler } from './scheduler.js'; 22 import { createRetention } from './retention.js'; 23 24 import apiRoutes from './routes/api.js'; 25 import { ok } from './routes/envelope.js'; 26 import authRoutes from './auth/index.js'; 27 import staticRoutes, { contentSecurityPolicy } from '../lib/static.js'; 28 29 export async function createServices(cfg, options = {}) { 30 ensureStateDirs(cfg); 31 32 const db = await openDatabase(cfg); 33 if (options.migrate !== false) { 34 await runMigrations(db, { logger: options.migrationLogger }); 35 } 36 37 const secrets = createSecretBox(parseKey(cfg.secrets.encryption_key, 'secrets.encryption_key')); 38 const storage = createStorage(cfg); 39 const logs = createLogStore(cfg); 40 const git = createGit(cfg); 41 const logger = options.logger ?? console; 42 43 const projects = createProjects({ db, secrets }); 44 const workerTokens = createWorkerTokens({ db }); 45 const variables = createVariables({ db, secrets }); 46 const users = createUsers({ db, logger }); 47 const refreshTokens = createRefreshTokens({ db }); 48 const auth = createAuth({ cfg, users, logger }); 49 50 const scheduler = createScheduler({ cfg, db, git, logs, storage, projects, secrets, variables, logger }); 51 const retention = createRetention({ cfg, db, storage, logs, refreshTokens, logger }); 52 53 // Without a first administrator there is no way into the admin surface. 54 // Only done for local accounts; with a provider it 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, refreshTokens, 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 // Enveloped like the rest of the API, so one reader handles every 78 // response the conductor gives. The container healthcheck reads the 79 // status line rather than the body, and is unaffected either way. 80 fastify.get('/health', async (req, reply) => ok(reply, { 81 service: 'conductor', 82 database: services.db.dialect, 83 storage: services.storage.driver, 84 auth: cfg.auth.mode, 85 })); 86 87 await fastify.register(apiRoutes, { ...services, prefix: '/api/v1' }); 88 89 // Discovery is answered in both modes, so the app finds its provider the 90 // same way either way. 91 await fastify.register(authRoutes, { ...services }); 92 93 if (options.ui !== false) { 94 // The application talks to the provider directly, so its origin has to 95 // be reachable from the page. 96 const provider = cfg.auth.oidc.discovery_url ? new URL(cfg.auth.oidc.discovery_url).origin : null; 97 await fastify.register(staticRoutes, { 98 csp: contentSecurityPolicy(provider ? [provider] : []), 99 }); 100 } 101 102 return fastify; 103 } 104 105 // Requeues or fails jobs whose worker went away. Returns a stop function. 106 export function startReaper(services) { 107 const { cfg, scheduler, logger } = services; 108 let running = false; 109 110 const timer = setInterval(async () => { 111 // Skip a tick rather than overlapping if a sweep runs long. 112 if (running) return; 113 running = true; 114 try { 115 await scheduler.reap(); 116 for (const job of await scheduler.settleStalled()) { 117 logger.warn?.(`settled stalled job ${job.id} as ${job.state}`); 118 } 119 } catch (e) { 120 logger.error?.(`reaper failed: ${e.message}`); 121 } finally { 122 running = false; 123 } 124 }, cfg.scheduler.reap_interval * 1000); 125 126 timer.unref(); 127 return () => clearInterval(timer); 128 } 129 130 // Deletes artifacts and logs past their retention. Returns a stop function. 131 export function startRetention(services) { 132 const { cfg, retention, logger } = services; 133 let running = false; 134 135 const timer = setInterval(async () => { 136 // A sweep over a large backlog can outlast its interval, and running 137 // two at once would have them fight over the same rows. 138 if (running) return; 139 running = true; 140 try { 141 await retention.sweep(); 142 } catch (e) { 143 logger.error?.(`retention sweep failed: ${e.message}`); 144 } finally { 145 running = false; 146 } 147 }, cfg.retention.sweep_interval * 1000); 148 149 timer.unref(); 150 return () => clearInterval(timer); 151 }