conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }