conductor

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

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 }