conductor

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

commit d7ba2dbf620b4a0890b9079134588b75146b2dfe
parent aa7a27b773f0823569cb384dad4527f5d9faafe2
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 02:17:01 +0200

Read-api service and dashboard

Diffstat:
MREADME.md | 19++++++++++++++++---
Adashboard/app.js | 440+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adashboard/index.html | 26++++++++++++++++++++++++++
Adashboard/style.css | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/conductor/app.js | 7+++++++
Msrc/conductor/routes/runs.js | 28++++++++++++++++++++++++----
Asrc/lib/static.js | 48++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/read-api/app.js | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/read-api/index.js | 29+++++++++++++++++++++++++++++
Atest/read-api.test.js | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/syntax.test.js | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 1044 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md @@ -31,10 +31,10 @@ Under construction. Working today: - pipeline parsing, matrix and architecture expansion, dependency graphs - triggers, run scheduling, the worker API, live logs, artifacts, reaping - the worker: containerised jobs, services, features, caches, cleanup + - accounts, OIDC, the admin API, and project variables + - the read-api and the dashboard -Not yet built: user accounts and the admin API, the read-api service, and the -dashboard. Until the admin API exists, projects and worker tokens are managed -with `src/admin-cli.js`. +Not yet built: container images and deployment manifests. Installation ------------ @@ -47,6 +47,19 @@ cp conductor.example.yaml conductor.yaml npm start ``` +On first start an administrator account is created and its generated +password written to the log once. The dashboard is on the same port. + +Run the public read surface separately when the write surface should not be +exposed: + +```sh +npm run read-api +``` + +It serves the dashboard and the read API only. The trigger, worker and admin +routes are not registered on it at all, so no credential can reach them. + `mysql2` and `pg` are optional and only needed when `database.url` points at one of those servers. diff --git a/dashboard/app.js b/dashboard/app.js @@ -0,0 +1,440 @@ +// dashboard/app.js - the conductor dashboard +// +// Plain ES modules, no framework and no build step. The whole thing is a +// run list, a run view and a log tail, which does not justify a dependency +// or a toolchain. +// +// All requests are relative, so the same files work whether they are served +// by the conductor or by the read-api. The admin controls appear only when +// the write surface answers, which it does not on the read-api. + +const view = document.getElementById('view'); +const crumbs = document.getElementById('crumbs'); +const session = document.getElementById('session'); +const statusLine = document.getElementById('status'); + +const state = { + admin: null, // null until probed, then true or false + user: null, + token: sessionStorage.getItem('conductor_token'), + timers: [], +}; + +// --- tiny dom helper --- + +function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value === null || value === undefined || value === false) continue; + if (key === 'class') node.className = value; + else if (key === 'text') node.textContent = value; + else if (key.startsWith('on')) node.addEventListener(key.slice(2), value); + else node.setAttribute(key, value); + } + for (const child of children.flat()) { + if (child === null || child === undefined || child === false) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} + +function render(...nodes) { + view.replaceChildren(...nodes.flat().filter(Boolean)); +} + +function clearTimers() { + for (const timer of state.timers) clearInterval(timer); + state.timers = []; +} + +function every(ms, fn) { + state.timers.push(setInterval(fn, ms)); +} + +// --- formatting --- + +const badge = (s) => el('span', { class: `badge ${s}`, text: s }); + +function ago(ms) { + if (!ms) return ''; + const seconds = Math.round((Date.now() - ms) / 1000); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`; + return new Date(ms).toISOString().slice(0, 10); +} + +function duration(from, to) { + if (!from) return ''; + const seconds = Math.round(((to ?? Date.now()) - from) / 1000); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} + +const short = (sha) => (sha ? String(sha).slice(0, 8) : ''); + +// --- api --- + +async function api(path, options = {}) { + const headers = { ...(options.headers ?? {}) }; + if (state.token) headers.authorization = `Bearer ${state.token}`; + if (options.body) headers['content-type'] = 'application/json'; + + const res = await fetch(path, { ...options, headers }); + if (!res.ok) { + let detail = ''; + try { + detail = (await res.json()).error ?? ''; + } catch { + // Not JSON; the status is enough. + } + const error = new Error(detail || `${res.status} ${res.statusText}`); + error.status = res.status; + throw error; + } + return res.status === 204 ? null : res.json(); +} + +function note(message, isError = false) { + statusLine.textContent = message; + statusLine.className = isError ? 'error' : 'muted'; +} + +// --- session --- + +async function probeAdmin() { + try { + const mode = await api('/api/auth/mode'); + state.admin = true; + state.localLogin = mode.local_login; + } catch { + // The read-api does not mount the auth routes at all. + state.admin = false; + } + if (state.admin && state.token) { + try { + state.user = (await api('/api/auth/me')).user; + } catch { + state.token = null; + sessionStorage.removeItem('conductor_token'); + } + } + renderSession(); +} + +function renderSession() { + if (!state.admin) { + session.replaceChildren(el('span', { class: 'muted', text: 'read only' })); + return; + } + if (state.user) { + session.replaceChildren( + el('span', { class: 'muted', text: `${state.user.username} (${state.user.role}) ` }), + el('button', { onclick: logout, text: 'sign out' }) + ); + return; + } + session.replaceChildren(el('button', { onclick: showLogin, text: 'sign in' })); +} + +function showLogin() { + const username = el('input', { placeholder: 'username', autocomplete: 'username' }); + const password = el('input', { type: 'password', placeholder: 'password', autocomplete: 'current-password' }); + const message = el('p', { class: 'error' }); + + const submit = async () => { + try { + const result = await api('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username: username.value, password: password.value }), + }); + state.token = result.token; + state.user = result.user; + sessionStorage.setItem('conductor_token', result.token); + renderSession(); + route(); + } catch (e) { + message.textContent = e.message; + } + }; + + clearTimers(); + render(el('div', { class: 'panel' }, + el('h2', { text: 'Sign in' }), + el('div', { class: 'actions' }, username, password, el('button', { onclick: submit, text: 'sign in' })), + message)); + + password.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); + username.focus(); +} + +function logout() { + state.token = null; + state.user = null; + sessionStorage.removeItem('conductor_token'); + api('/api/auth/logout', { method: 'POST' }).catch(() => {}); + renderSession(); + route(); +} + +// --- runs --- + +async function showRuns() { + const body = el('tbody'); + const table = el('table', + {}, + el('thead', {}, el('tr', {}, + el('th', { text: 'run' }), + el('th', { text: 'project' }), + el('th', { text: 'ref' }), + el('th', { text: 'commit' }), + el('th', { text: 'state' }), + el('th', { text: 'started' }))), + body); + + render(el('h2', { text: 'Runs' }), table); + + async function refresh() { + try { + const { runs } = await api('/api/runs?limit=50'); + note(`${runs.length} run(s), updated ${new Date().toLocaleTimeString()}`); + body.replaceChildren(...runs.map((run) => el('tr', { class: 'clickable', onclick: () => { location.hash = `#/run/${run.id}`; } }, + el('td', {}, el('a', { href: `#/run/${run.id}`, text: `#${run.number}` })), + el('td', { text: run.project_id }), + el('td', { class: 'muted', text: (run.ref ?? '').replace('refs/heads/', '') }), + el('td', { class: 'mono muted', text: short(run.head_sha) }), + el('td', {}, badge(run.state)), + el('td', { class: 'muted', text: ago(run.created_at) })))); + + if (runs.length === 0) { + body.replaceChildren(el('tr', {}, el('td', { colspan: '6', class: 'muted', text: 'No runs yet.' }))); + } + } catch (e) { + note(e.message, true); + } + } + + await refresh(); + every(5000, refresh); +} + +async function showRun(runId) { + const header = el('div', { class: 'panel' }); + const stages = el('div'); + render(header, stages); + + async function refresh() { + let data; + try { + data = await api(`/api/runs/${encodeURIComponent(runId)}`); + } catch (e) { + note(e.message, true); + render(el('p', { class: 'error', text: `Could not load run: ${e.message}` })); + clearTimers(); + return; + } + + const { run, jobs } = data; + crumbs.replaceChildren(el('a', { href: '#/', text: 'runs' }), document.createTextNode(` / #${run.number}`)); + + const info = el('div', { class: 'row' }, + field('project', run.project_id), + field('state', null, badge(run.state)), + field('ref', (run.ref ?? '').replace('refs/heads/', '') || '-'), + field('commit', short(run.head_sha)), + field('trigger', `${run.trigger_type}${run.actor ? ` by ${run.actor}` : ''}`), + field('duration', duration(run.started_at, run.finished_at))); + + header.replaceChildren( + el('h2', { text: run.title || `Run #${run.number}` }), + info, + adminActions(run)); + + // Jobs are grouped by graph depth, which is how the pipeline reads. + const byDepth = new Map(); + for (const job of jobs) { + const depth = job.spec_depth ?? depthOf(job, jobs); + if (!byDepth.has(depth)) byDepth.set(depth, []); + byDepth.get(depth).push(job); + } + + stages.replaceChildren(...[...byDepth.keys()].sort((a, b) => a - b).map((depth) => { + const rows = byDepth.get(depth).sort((a, b) => a.name.localeCompare(b.name)); + return el('div', { class: 'stage' }, + el('h3', { text: `stage ${depth + 1}` }), + el('table', {}, el('tbody', {}, ...rows.map(jobRow)))); + })); + + note(`updated ${new Date().toLocaleTimeString()}`); + if (['success', 'failed', 'cancelled'].includes(run.state)) clearTimers(); + } + + await refresh(); + every(3000, refresh); +} + +function jobRow(job) { + return el('tr', {}, + el('td', {}, el('a', { href: `#/job/${encodeURIComponent(job.id)}`, text: job.name })), + el('td', {}, badge(job.state)), + el('td', { class: 'muted', text: job.arch ?? '' }), + el('td', { class: 'muted', text: job.worker_name ?? '' }), + el('td', { class: 'muted', text: duration(job.started_at, job.finished_at) }), + el('td', { class: 'muted', text: job.exit_code === null ? '' : `exit ${job.exit_code}` })); +} + +// Depth is not stored on the row, so it is recomputed from the edges. +function depthOf(job, jobs) { + const byId = new Map(jobs.map((j) => [j.id, j])); + const seen = new Set(); + const walk = (current) => { + if (seen.has(current.id)) return 0; + seen.add(current.id); + const deps = (current.needs ?? []).map((id) => byId.get(id)).filter(Boolean); + return deps.length === 0 ? 0 : Math.max(...deps.map((d) => walk(d) + 1)); + }; + return walk(job); +} + +function field(label, value, node) { + return el('div', {}, el('span', { class: 'label', text: label }), node ?? el('span', { text: value ?? '' })); +} + +function adminActions(run) { + if (!state.admin || !state.user || state.user.role !== 'admin') return null; + + const actions = el('div', { class: 'actions' }); + if (run.state === 'running') { + actions.append(el('button', { + text: 'cancel', + onclick: async () => { + try { + await api(`/api/admin/runs/${encodeURIComponent(run.id)}/cancel`, { method: 'POST', body: '{}' }); + note('run cancelled'); + } catch (e) { + note(e.message, true); + } + }, + })); + } + actions.append(el('button', { + text: 'run again', + onclick: async () => { + try { + const result = await api(`/api/admin/runs/${encodeURIComponent(run.id)}/retry`, { method: 'POST', body: '{}' }); + location.hash = `#/run/${result.run_id}`; + } catch (e) { + note(e.message, true); + } + }, + })); + return actions; +} + +// --- job and log --- + +async function showJob(jobId) { + const header = el('div', { class: 'panel' }); + const logEl = el('pre', { class: 'log', text: '' }); + const artifactsEl = el('div'); + render(header, el('h3', { text: 'log' }), logEl, artifactsEl); + + let offset = 0; + let complete = false; + let following = true; + + logEl.addEventListener('scroll', () => { + following = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 24; + }); + + async function refreshHeader() { + const { job, artifacts } = await api(`/api/jobs/${encodeURIComponent(jobId)}`); + crumbs.replaceChildren( + el('a', { href: '#/', text: 'runs' }), + document.createTextNode(' / '), + el('a', { href: `#/run/${job.run_id}`, text: 'run' }), + document.createTextNode(` / ${job.name}`)); + + header.replaceChildren( + el('h2', { text: job.name }), + el('div', { class: 'row' }, + field('state', null, badge(job.state)), + field('image', job.image), + field('arch', job.arch ?? '-'), + field('attempt', `${job.attempt} of ${job.max_attempts}`), + field('worker', job.worker_name ?? '-'), + field('duration', duration(job.started_at, job.finished_at))), + job.error ? el('p', { class: 'error', text: job.error }) : null); + + if (artifacts.length > 0) { + artifactsEl.replaceChildren( + el('h3', { text: 'artifacts' }), + el('table', {}, el('tbody', {}, ...artifacts.map((a) => el('tr', {}, + el('td', {}, el('a', { href: `/api/artifacts/${a.id}`, text: a.path })), + el('td', { class: 'muted', text: `${a.size} bytes` }), + el('td', { class: 'mono muted', text: a.sha256.slice(0, 12) })))))); + } else { + artifactsEl.replaceChildren(); + } + + return job; + } + + async function tail() { + const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/log?offset=${offset}`); + if (!res.ok) return; + const text = await res.text(); + if (text.length > 0) { + logEl.append(document.createTextNode(text)); + if (following) logEl.scrollTop = logEl.scrollHeight; + } + offset = Number(res.headers.get('x-log-offset') ?? offset) + new TextEncoder().encode(text).length; + complete = res.headers.get('x-log-complete') === 'true'; + } + + try { + const job = await refreshHeader(); + await tail(); + if (complete && ['success', 'failed', 'skipped', 'cancelled'].includes(job.state)) { + note('job finished'); + return; + } + } catch (e) { + note(e.message, true); + render(el('p', { class: 'error', text: `Could not load job: ${e.message}` })); + return; + } + + every(1500, async () => { + try { + const job = await refreshHeader(); + await tail(); + if (complete && ['success', 'failed', 'skipped', 'cancelled'].includes(job.state)) { + clearTimers(); + note('job finished'); + } + } catch (e) { + note(e.message, true); + } + }); +} + +// --- routing --- + +function route() { + clearTimers(); + crumbs.replaceChildren(); + const hash = location.hash.replace(/^#/, '') || '/'; + + const runMatch = /^\/run\/(.+)$/.exec(hash); + const jobMatch = /^\/job\/(.+)$/.exec(hash); + + if (jobMatch) showJob(decodeURIComponent(jobMatch[1])); + else if (runMatch) showRun(decodeURIComponent(runMatch[1])); + else showRuns(); +} + +window.addEventListener('hashchange', route); + +await probeAdmin(); +route(); diff --git a/dashboard/index.html b/dashboard/index.html @@ -0,0 +1,26 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>conductor</title> +<link rel="stylesheet" href="style.css"> +</head> +<body> +<header> + <h1><a href="#/">conductor</a></h1> + <nav id="crumbs"></nav> + <div id="session"></div> +</header> + +<main id="view"> + <p class="muted">Loading.</p> +</main> + +<footer> + <span id="status" class="muted"></span> +</footer> + +<script src="app.js" type="module"></script> +</body> +</html> diff --git a/dashboard/style.css b/dashboard/style.css @@ -0,0 +1,122 @@ +/* dashboard/style.css - one stylesheet, no framework */ + +:root { + --bg: #101214; + --panel: #171a1d; + --border: #272b30; + --text: #dfe3e6; + --muted: #8b949e; + --link: #6cb6ff; + --queued: #3a4046; + --running: #b58900; + --success: #2e7d32; + --failed: #c62828; + --skipped: #4a5058; + --cancelled: #6a4a8a; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } + +code, pre, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +header { + display: flex; + align-items: center; + gap: 1rem; + padding: .75rem 1.25rem; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +header h1 { font-size: 1rem; margin: 0; font-weight: 600; } +header h1 a { color: var(--text); } +header nav { flex: 1; color: var(--muted); font-size: .85rem; } +header nav a { color: var(--muted); } + +main { padding: 1.25rem; max-width: 1100px; margin: 0 auto; } +footer { padding: .5rem 1.25rem; font-size: .8rem; } + +.muted { color: var(--muted); } +.error { color: #ff8b8b; } + +table { width: 100%; border-collapse: collapse; margin-top: .5rem; } +th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 500; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; } +tbody tr:hover { background: #1b1f23; } + +.badge { + display: inline-block; + padding: .05rem .45rem; + border-radius: .75rem; + font-size: .75rem; + background: var(--queued); + color: #fff; +} +.badge.running { background: var(--running); color: #1a1a1a; } +.badge.success { background: var(--success); } +.badge.failed { background: var(--failed); } +.badge.skipped { background: var(--skipped); } +.badge.cancelled { background: var(--cancelled); } +.badge.pending { background: var(--queued); } + +.stage { margin: 1rem 0; } +.stage h3 { font-size: .8rem; color: var(--muted); margin: 0 0 .35rem; font-weight: 500; } + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: .75rem 1rem; + margin-bottom: 1rem; +} + +.row { display: flex; gap: 1.5rem; flex-wrap: wrap; } +.row > div { min-width: 8rem; } +.row .label { color: var(--muted); font-size: .75rem; display: block; } + +pre.log { + background: #0b0d0f; + border: 1px solid var(--border); + border-radius: 6px; + padding: .75rem 1rem; + max-height: 32rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: .82rem; + margin: 0; +} + +button { + background: #22262b; + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: .3rem .7rem; + cursor: pointer; + font-size: .85rem; +} +button:hover { background: #2b3036; } +button:disabled { opacity: .5; cursor: default; } + +input { + background: #0b0d0f; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + padding: .3rem .5rem; + font-size: .85rem; +} + +.actions { display: flex; gap: .5rem; align-items: center; margin-top: .75rem; } +.clickable { cursor: pointer; } diff --git a/src/conductor/app.js b/src/conductor/app.js @@ -24,6 +24,7 @@ import triggerRoutes from './routes/trigger.js'; import runRoutes from './routes/runs.js'; import authRoutes from './routes/auth.js'; import adminRoutes from './routes/admin.js'; +import staticRoutes from '../lib/static.js'; export async function createServices(cfg, options = {}) { ensureStateDirs(cfg); @@ -85,6 +86,12 @@ export async function buildServer(services, options = {}) { await fastify.register(adminRoutes, { ...services, prefix: '/api/admin' }); await fastify.register(runRoutes, { ...services, prefix: '/api' }); + // Served here as well as by the read-api, so a single process install + // has a working interface without deploying both. + if (options.dashboard !== false) { + await fastify.register(staticRoutes, {}); + } + return fastify; } diff --git a/src/conductor/routes/runs.js b/src/conductor/routes/runs.js @@ -78,10 +78,7 @@ export default async function runRoutes(fastify, { db, logs, storage }) { { job: job.id } ); - return reply.send({ - job: { ...job, requires: safeJson(job.requires, []), spec: safeJson(job.spec, {}) }, - artifacts, - }); + return reply.send({ job: publicJob(job), artifacts }); }); // Incremental log tail. While a job runs the bytes come from the local @@ -145,6 +142,29 @@ export default async function runRoutes(fastify, { db, logs, storage }) { }); } +// These routes are mounted by the read-api, which is meant to be exposed +// publicly, so the job environment is never returned. It is the one field +// that carries project variables once a job is dispatched, and there is no +// use for it here that is worth the risk. Everything else in the spec +// already appears in the log or the pipeline file. +function publicJob(job) { + const spec = safeJson(job.spec, {}); + const { env, ...rest } = job; + return { + ...rest, + requires: safeJson(job.requires, []), + spec: { + script: spec.script ?? [], + needs: spec.needs ?? [], + matrix: spec.matrix ?? {}, + depth: spec.depth ?? 0, + cache: spec.cache ? { key: spec.cache.key, paths: spec.cache.paths } : null, + artifacts: spec.artifacts ?? null, + services: (spec.services ?? []).map((s) => ({ image: s.image, alias: s.alias })), + }, + }; +} + function clamp(raw, min, max, fallback) { const n = Number(raw); if (!Number.isFinite(n)) return fallback; diff --git a/src/lib/static.js b/src/lib/static.js @@ -0,0 +1,48 @@ +// src/lib/static.js - serving the dashboard +// +// The dashboard is three files, so an allowlist is used rather than a +// general static file server. Nothing can be requested that is not named +// here, which makes path traversal impossible by construction instead of by +// careful escaping. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const DASHBOARD_DIR = path.resolve(HERE, '../../dashboard'); + +const FILES = { + '/': { file: 'index.html', type: 'text/html; charset=utf-8' }, + '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' }, + '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' }, + '/style.css': { file: 'style.css', type: 'text/css; charset=utf-8' }, +}; + +export default async function staticRoutes(fastify, { dir = DASHBOARD_DIR } = {}) { + // Read once at startup. These are small and never change at runtime. + const cache = new Map(); + + for (const [route, entry] of Object.entries(FILES)) { + let body; + try { + body = await fs.readFile(path.join(dir, entry.file)); + } catch { + // A deployment that does not ship the dashboard simply has no UI. + continue; + } + const etag = `"${crypto.createHash('sha256').update(body).digest('hex').slice(0, 32)}"`; + cache.set(route, { body, type: entry.type, etag }); + } + + for (const [route, entry] of cache) { + fastify.get(route, async (req, reply) => { + if (req.headers['if-none-match'] === entry.etag) return reply.code(304).send(); + reply.header('content-type', entry.type); + reply.header('etag', entry.etag); + reply.header('cache-control', 'no-cache'); + return reply.send(entry.body); + }); + } +} diff --git a/src/read-api/app.js b/src/read-api/app.js @@ -0,0 +1,67 @@ +// src/read-api/app.js - the public read surface +// +// Deliberately a separate service. It opens the same database but mounts +// only the read routes, so the trigger, worker and admin surfaces are not +// reachable on a port exposed to the internet. Nothing here can change +// state, which is a property of what is mounted rather than of a check that +// somebody has to remember to write. +// +// The schema belongs to the conductor, so this never runs migrations. + +import Fastify from 'fastify'; +import cors from '@fastify/cors'; + +import { openDatabase } from '../lib/db/index.js'; +import { createStorage } from '../lib/storage/index.js'; +import { createLogStore } from '../lib/log.js'; +import runRoutes from '../conductor/routes/runs.js'; +import staticRoutes from '../lib/static.js'; + +export async function createReadServices(cfg) { + const db = await openDatabase(cfg); + + // Fail at startup with something actionable rather than on the first + // request with a bare SQL error. + try { + await db.get('SELECT 1 FROM runs LIMIT 1'); + } catch (e) { + await db.close(); + throw new Error( + 'the conductor schema is missing or unreadable. Start the conductor first, ' + + `or run npm run migrate.\n ${e.message}`, + { cause: e } + ); + } + + return { + cfg, + db, + storage: createStorage(cfg), + logs: createLogStore(cfg), + }; +} + +export async function buildReadServer(services, options = {}) { + const { cfg } = services; + const fastify = Fastify({ + logger: options.logger ?? { level: process.env.LOG_LEVEL || 'info' }, + trustProxy: options.trustProxy ?? true, + }); + + await fastify.register(cors, { origin: true }); + + fastify.get('/health', async () => ({ + ok: true, + service: 'read-api', + database: services.db.dialect, + storage: services.storage.driver, + })); + + await fastify.register(runRoutes, { ...services, prefix: '/api' }); + + if (options.dashboard !== false) { + await fastify.register(staticRoutes, {}); + } + + return fastify; +} diff --git a/src/read-api/index.js b/src/read-api/index.js @@ -0,0 +1,29 @@ +// src/read-api/index.js - read-api entry point +// +// Serves the public read surface and the dashboard. Safe to expose without +// a reverse proxy in front of the write surface, because the write routes +// are not registered here at all. + +import { loadConfig } from '../lib/config.js'; +import { createReadServices, buildReadServer } from './app.js'; + +const cfg = loadConfig(); +const services = await createReadServices(cfg); +const fastify = await buildReadServer(services); + +async function shutdown(signal) { + fastify.log.info(`${signal} received, shutting down`); + try { + await fastify.close(); + await services.db.close(); + } catch (e) { + fastify.log.error({ err: e }, 'shutdown failed'); + process.exitCode = 1; + } +} + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { shutdown(signal); }); +} + +await fastify.listen({ port: cfg.read_api.port, host: cfg.read_api.host }); diff --git a/test/read-api.test.js b/test/read-api.test.js @@ -0,0 +1,200 @@ +// test/read-api.test.js - the public read surface +// +// The point of running this separately is that the write surface is not +// reachable on it. That is asserted here rather than assumed, because it is +// the only thing making the service safe to expose. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startHarness } from './helpers/harness.js'; +import { createReadServices, buildReadServer } from '../src/read-api/app.js'; + +// Brings up a read-api against the same database the harness conductor uses. +async function withBoth(options, fn) { + const h = await startHarness(options); + const services = await createReadServices(h.cfg); + const read = await buildReadServer(services, { logger: false }); + try { + return await fn(h, read); + } finally { + await read.close(); + await services.db.close(); + await h.stop(); + } +} + +test('the read-api reports its drivers', async () => { + await withBoth({}, async (h, read) => { + const res = await read.inject({ method: 'GET', url: '/health' }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().service, 'read-api'); + }); +}); + +test('the write surface is not mounted at all', async () => { + await withBoth({}, async (h, read) => { + const writes = [ + ['POST', '/api/trigger/demo'], + ['GET', '/api/workers/poll'], + ['GET', '/api/admin/projects'], + ['POST', '/api/admin/worker-tokens'], + ['POST', '/api/auth/login'], + ['GET', '/api/auth/me'], + ]; + + for (const [method, url] of writes) { + const res = await read.inject({ method, url }); + // 404 rather than 401: the route does not exist here, so no + // credential could ever reach it. + assert.equal(res.statusCode, 404, `${method} ${url} should not exist on the read-api`); + } + }); +}); + +test('runs, jobs and logs are readable', async () => { + await withBoth({}, async (h, read) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const list = await read.inject({ method: 'GET', url: '/api/runs' }); + assert.equal(list.statusCode, 200); + assert.equal(list.json().runs.length, 1); + + const detail = await read.inject({ method: 'GET', url: `/api/runs/${run}` }); + assert.equal(detail.statusCode, 200); + assert.equal(detail.json().jobs.length, 6); + // Edges are exposed so a client can draw the graph. + assert.ok(detail.json().jobs.every((j) => Array.isArray(j.needs))); + }); +}); + +test('a job environment is never exposed', async () => { + await withBoth({}, async (h, read) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({ arches: 'x86_64' })).json().job; + + // The job really does carry an environment when dispatched. + assert.ok(Object.keys(job.env).length > 0); + + const res = await read.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); + assert.equal(res.statusCode, 200); + + const body = res.json(); + assert.equal(body.job.env, undefined); + assert.equal(body.job.spec.env, undefined); + // The useful parts are still there. + assert.ok(Array.isArray(body.job.spec.script)); + assert.ok(Array.isArray(body.job.spec.needs)); + }); +}); + +test('a project variable cannot be read back through the read-api', async () => { + await withBoth({ bootstrap: true }, async (h, read) => { + await h.services.variables.set('demo', 'SECRET_TOKEN', 'do-not-expose-this'); + + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({})).json().job; + assert.equal(job.env.SECRET_TOKEN, 'do-not-expose-this'); + + for (const url of ['/api/runs', `/api/runs/${job.run_id}`, `/api/jobs/${encodeURIComponent(job.id)}`]) { + const res = await read.inject({ method: 'GET', url }); + assert.ok(!res.body.includes('do-not-expose-this'), `${url} leaked a variable`); + } + }); +}); + +test('logs and artifacts are served', async () => { + await withBoth({}, async (h, read) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({ arches: 'x86_64' })).json().job; + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + headers: { ...h.auth, 'content-type': 'application/octet-stream' }, + payload: Buffer.from('building\ndone\n'), + }); + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, + headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'dist/out.bin' }, + payload: Buffer.from('artifact bytes'), + }); + + const log = await read.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); + assert.equal(log.body, 'building\ndone\n'); + assert.equal(log.headers['x-log-complete'], 'false'); + + const detail = await read.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); + const artifact = detail.json().artifacts[0]; + const download = await read.inject({ method: 'GET', url: `/api/artifacts/${artifact.id}` }); + assert.equal(download.body, 'artifact bytes'); + }); +}); + +test('an incremental log tail returns only what is new', async () => { + await withBoth({}, async (h, read) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({ arches: 'x86_64' })).json().job; + const url = `/api/workers/jobs/${encodeURIComponent(job.id)}/log`; + const headers = { ...h.auth, 'content-type': 'application/octet-stream' }; + + await h.app.inject({ method: 'POST', url, headers, payload: Buffer.from('first\n') }); + const first = await read.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); + assert.equal(first.body, 'first\n'); + const offset = Number(first.headers['x-log-size']); + + await h.app.inject({ method: 'POST', url, headers, payload: Buffer.from('second\n') }); + const next = await read.inject({ + method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log?offset=${offset}`, + }); + assert.equal(next.body, 'second\n'); + }); +}); + +test('the dashboard is served, and only its own files', async () => { + await withBoth({}, async (h, read) => { + const index = await read.inject({ method: 'GET', url: '/' }); + assert.equal(index.statusCode, 200); + assert.match(index.headers['content-type'], /text\/html/); + assert.match(index.body, /<title>conductor<\/title>/); + + for (const path of ['/app.js', '/style.css']) { + const res = await read.inject({ method: 'GET', url: path }); + assert.equal(res.statusCode, 200); + } + + // Nothing outside the allowlist exists, so traversal has nothing to hit. + for (const path of ['/../package.json', '/conductor.yaml', '/index.js']) { + assert.equal((await read.inject({ method: 'GET', url: path })).statusCode, 404); + } + }); +}); + +test('the dashboard is also served by the conductor', async () => { + await withBoth({}, async (h) => { + const res = await h.app.inject({ method: 'GET', url: '/' }); + assert.equal(res.statusCode, 200); + assert.match(res.body, /conductor/); + }); +}); + +test('a cached dashboard file is not resent', async () => { + await withBoth({}, async (h, read) => { + const first = await read.inject({ method: 'GET', url: '/app.js' }); + const etag = first.headers.etag; + assert.ok(etag); + + const second = await read.inject({ method: 'GET', url: '/app.js', headers: { 'if-none-match': etag } }); + assert.equal(second.statusCode, 304); + }); +}); + +test('the read-api refuses to start against a database with no schema', async () => { + const h = await startHarness({}); + try { + await h.services.db.exec('DROP TABLE runs'); + await assert.rejects(createReadServices(h.cfg), /schema is missing/); + } finally { + await h.stop(); + } +}); diff --git a/test/syntax.test.js b/test/syntax.test.js @@ -0,0 +1,65 @@ +// test/syntax.test.js - every JavaScript file parses +// +// Most modules are covered because a test imports them, but entry points +// and the dashboard are never imported by anything: the entry points start +// listeners, and the dashboard only ever runs in a browser. A syntax error +// in either would otherwise reach a deployment. +// +// node --check parses without executing, which is what makes this safe to +// run over files that would start a server on import. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFileAsync = promisify(execFile); +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SKIP = new Set(['.git', 'node_modules', 'data', 'tmp']); + +async function* walk(dir) { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP.has(entry.name)) continue; + yield* walk(path.join(dir, entry.name)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + yield path.join(dir, entry.name); + } + } +} + +test('every javascript file parses', async () => { + const files = []; + for await (const file of walk(ROOT)) files.push(file); + assert.ok(files.length > 20, `expected to find source files, found ${files.length}`); + + const failures = []; + // Batched, since each check is a process. + for (let i = 0; i < files.length; i += 8) { + const batch = files.slice(i, i + 8).map(async (file) => { + try { + await execFileAsync(process.execPath, ['--check', file], { timeout: 20000 }); + } catch (e) { + failures.push(`${path.relative(ROOT, file)}: ${(e.stderr || e.message).trim().split('\n').slice(0, 3).join(' ')}`); + } + }); + await Promise.all(batch); + } + + assert.deepEqual(failures, [], `files failed to parse:\n${failures.join('\n')}`); +}); + +test('the shell scripts parse', async () => { + const failures = []; + for (const script of ['hooks/post-receive']) { + try { + await execFileAsync('sh', ['-n', path.join(ROOT, script)], { timeout: 20000 }); + } catch (e) { + failures.push(`${script}: ${(e.stderr || e.message).trim()}`); + } + } + assert.deepEqual(failures, []); +});