commit d7ba2dbf620b4a0890b9079134588b75146b2dfe
parent aa7a27b773f0823569cb384dad4527f5d9faafe2
Author: finwo <finwo@pm.me>
Date: Sat, 19 Sep 2026 02:17:01 +0200
Read-api service and dashboard
Diffstat:
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, []);
+});