conductor

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

commit ece142938b360219e241e7c2fb0eb938dab8e37c
Author: finwo <finwo@pm.me>
Date:   Fri, 18 Sep 2026 23:09:00 +0200

Config, database and storage foundation

Diffstat:
A.editorconfig | 13+++++++++++++
A.gitignore | 20++++++++++++++++++++
ALICENSE.md | 34++++++++++++++++++++++++++++++++++
Aconductor.example.yaml | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amigrations/mysql/001_initial.sql | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amigrations/postgres/001_initial.sql | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amigrations/sqlite/001_initial.sql | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackage.json | 28++++++++++++++++++++++++++++
Asrc/lib/config.js | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/db/dialect.js | 17+++++++++++++++++
Asrc/lib/db/index.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/db/migrate.js | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/db/mysql.js | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/db/params.js | 34++++++++++++++++++++++++++++++++++
Asrc/lib/db/postgres.js | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/db/query.js | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/db/sqlite.js | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/ids.js | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/secretbox.js | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/storage/index.js | 28++++++++++++++++++++++++++++
Asrc/lib/storage/key.js | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/storage/local.js | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/storage/s3.js | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/storage/sigv4.js | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/ascii.test.js | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/config.test.js | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/db.test.js | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/query.test.js | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/secretbox.test.js | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/sigv4.test.js | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/storage.test.js | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
31 files changed, 3513 insertions(+), 0 deletions(-)

diff --git a/.editorconfig b/.editorconfig @@ -0,0 +1,13 @@ +# 2d0e4a3f-ac19-430c-bf1b-46c68651ce21 +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_size = 2 +indent_style = space +trim_trailing_whitespace = true + +[Makefile*] +indent_style = tab diff --git a/.gitignore b/.gitignore @@ -0,0 +1,20 @@ +# dependencies +/node_modules/ +/package-lock.json + +# runtime state: sqlite db, mirrors, log spool, artifact storage +/data/ + +# local configuration and secrets +/conductor.yaml +/.env +/secrets/ +/keys/ + +# test scratch +/test/tmp/ + +# editor / os noise +*.swp +*~ +.DS_Store diff --git a/LICENSE.md b/LICENSE.md @@ -0,0 +1,34 @@ +Copyright (c) 2026 finwo + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to use, copy, +modify, and distribute the Software, subject to the following conditions: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions, and the following disclaimer. + + 2. Redistributions in binary form, or any public offering of the Software + (including hosted or managed services), must reproduce the above copyright + notice, this list of conditions, and the following disclaimer in the + documentation and/or other materials provided. + + 3. Any redistribution or public offering of the Software must clearly attribute + the Software to the original copyright holder, reference this License, and + include a link to the official project repository or website. + + 4. The Software may not be renamed, rebranded, or marketed in a manner that + implies it is an independent or proprietary product. Derivative works must + clearly state that they are based on the Software. + + 5. Modifications to copies of the Software must carry prominent notices stating + that changes were made, the nature of the modifications, and the date of the + modifications. + +Any violation of these conditions terminates the permissions granted herein. + +THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/conductor.example.yaml b/conductor.example.yaml @@ -0,0 +1,85 @@ +# conductor.example.yaml - server configuration +# +# Copy to conductor.yaml and edit. Every value here is a default, so an empty +# file is a valid configuration: sqlite on disk, local object storage and +# built-in user accounts. +# +# Any value can also be set from the environment, which takes precedence over +# this file. See ENV_MAP in src/lib/config.js for the full list. +# +# Relative paths resolve against the directory holding this file. + +server: + host: 0.0.0.0 + port: 8080 + # Absolute URL this conductor is reachable at. Workers are handed callback + # URLs derived from it, so it must be correct behind a reverse proxy. + public_url: http://127.0.0.1:8080 + +read_api: + host: 0.0.0.0 + port: 8081 + +database: + # Leave url unset for sqlite. Otherwise the scheme picks the dialect: + # mysql://user:pass@host:3306/conductor + # postgres://user:pass@host:5432/conductor + # The matching driver package must be installed: mysql2 or pg. + url: null + path: ./data/conductor.db + connection_limit: 10 + +storage: + # Used when no s3 bucket is configured below. + path: ./data/storage + # Setting a bucket switches artifacts and finished logs to object storage. + # endpoint, bucket, access_key_id and secret_access_key are all required + # together. Garage and MinIO need force_path_style; AWS S3 does not. + s3: + endpoint: null + region: us-east-1 + bucket: null + access_key_id: null + secret_access_key: null + force_path_style: true + +auth: + # Signs built-in session tokens. Generated per boot when unset, which logs + # everyone out on restart, so set it in production. + session_secret: null + session_ttl: 43200 + # Setting an issuer switches admin authentication to OIDC. Built-in + # accounts are used when it is unset. + oidc: + issuer: null + audience: null + admin_role: conductor-admin + # Created on first boot, only while the users table is empty. With no + # password set, one is generated and written to the log once. + bootstrap_admin: + username: admin + password: null + +secrets: + # 32 bytes as 64 hex characters or base64, for example: + # openssl rand -hex 32 + # Without it, project variables and trigger secrets are stored in the clear + # and marked as such, so they can be re-sealed later. + encryption_key: null + +git: + mirror_path: ./data/mirrors + fetch_interval: 60 + timeout: 300 + +log: + spool_path: ./data/logs + max_size: 67108864 + +scheduler: + # A claimed job whose worker stops reporting for this long is treated as + # lost, then retried or failed. Must exceed reap_interval. + heartbeat_timeout: 120 + reap_interval: 30 + default_job_timeout: 3600 + max_attempts: 3 diff --git a/migrations/mysql/001_initial.sql b/migrations/mysql/001_initial.sql @@ -0,0 +1,144 @@ +-- 001_initial.sql - base schema for conductor (mysql and tidb) +-- +-- Conventions, kept identical to the sqlite dialect: +-- timestamps are BIGINT epoch milliseconds +-- booleans are TINYINT 0 or 1 +-- json documents are TEXT +-- secrets are TEXT produced by src/lib/secretbox.js +-- +-- Column widths are chosen so that every indexed column stays within the +-- InnoDB 3072 byte key limit under utf8mb4. + +CREATE TABLE projects ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + repo_url TEXT NOT NULL, + default_branch VARCHAR(255) NOT NULL DEFAULT 'main', + config_path VARCHAR(255) NOT NULL DEFAULT '.conductor.yml', + -- How workers obtain the source: 'archive' has them download a tarball + -- from the conductor, 'clone' has them git clone repo_url themselves. + source_mode VARCHAR(16) NOT NULL DEFAULT 'archive', + -- Shared secret for trigger HMAC verification, encrypted at rest. + trigger_secret TEXT, + enabled TINYINT NOT NULL DEFAULT 1, + run_counter BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE users ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(191) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'viewer', + disabled TINYINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + last_login_at BIGINT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE worker_tokens ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + enabled TINYINT NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + last_seen_at BIGINT, + last_ip VARCHAR(64) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE runs ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + project_id VARCHAR(64) NOT NULL, + number BIGINT NOT NULL, + ref VARCHAR(255), + base_sha VARCHAR(64), + head_sha VARCHAR(64) NOT NULL, + -- Named trigger_type rather than trigger, which is reserved in MySQL. + trigger_type VARCHAR(32) NOT NULL DEFAULT 'push', + actor VARCHAR(255), + title TEXT, + state VARCHAR(16) NOT NULL DEFAULT 'pending', + -- Resolved pipeline as scheduled, retained so a run stays explainable + -- after the repository moves on. + pipeline MEDIUMTEXT, + error TEXT, + created_at BIGINT NOT NULL, + started_at BIGINT, + finished_at BIGINT, + CONSTRAINT fk_runs_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE KEY idx_runs_project_number (project_id, number), + KEY idx_runs_created (created_at), + KEY idx_runs_state (state) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE jobs ( + id VARCHAR(96) NOT NULL PRIMARY KEY, + run_id VARCHAR(64) NOT NULL, + -- Expanded name, for example 'package:pkg=musl'. + name VARCHAR(191) NOT NULL, + -- Template name before matrix and arch expansion. + base_name VARCHAR(191) NOT NULL, + arch VARCHAR(32), + image TEXT NOT NULL, + -- json array of worker feature names this job needs. + requires TEXT NOT NULL, + -- json document: script, env, services, artifacts, cache, matrix values. + spec MEDIUMTEXT NOT NULL, + state VARCHAR(16) NOT NULL DEFAULT 'queued', + allow_failure TINYINT NOT NULL DEFAULT 0, + attempt INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 1, + timeout INT NOT NULL, + exit_code INT, + error TEXT, + log_key VARCHAR(255), + log_size BIGINT NOT NULL DEFAULT 0, + worker_token_id VARCHAR(64), + worker_name VARCHAR(255), + claimed_at BIGINT, + heartbeat_at BIGINT, + created_at BIGINT NOT NULL, + started_at BIGINT, + finished_at BIGINT, + CONSTRAINT fk_jobs_run FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE, + UNIQUE KEY idx_jobs_run_name (run_id, name), + KEY idx_jobs_run (run_id), + KEY idx_jobs_state (state), + -- Supports the reaper scan for claimed jobs that stopped reporting. + KEY idx_jobs_heartbeat (state, heartbeat_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE job_deps ( + job_id VARCHAR(96) NOT NULL, + depends_on_id VARCHAR(96) NOT NULL, + PRIMARY KEY (job_id, depends_on_id), + KEY idx_job_deps_reverse (depends_on_id), + CONSTRAINT fk_job_deps_job FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE, + CONSTRAINT fk_job_deps_target FOREIGN KEY (depends_on_id) REFERENCES jobs(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE artifacts ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + job_id VARCHAR(96) NOT NULL, + run_id VARCHAR(64) NOT NULL, + path VARCHAR(1024) NOT NULL, + storage_key VARCHAR(1024) NOT NULL, + size BIGINT NOT NULL, + sha256 CHAR(64) NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT, + CONSTRAINT fk_artifacts_job FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE, + KEY idx_artifacts_job (job_id), + KEY idx_artifacts_run (run_id), + KEY idx_artifacts_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE project_variables ( + project_id VARCHAR(64) NOT NULL, + name VARCHAR(191) NOT NULL, + value TEXT NOT NULL, + masked TINYINT NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + PRIMARY KEY (project_id, name), + CONSTRAINT fk_project_variables_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/postgres/001_initial.sql b/migrations/postgres/001_initial.sql @@ -0,0 +1,140 @@ +-- 001_initial.sql - base schema for conductor (postgres) +-- +-- Conventions, kept identical to the sqlite and mysql dialects: +-- timestamps are BIGINT epoch milliseconds +-- booleans are SMALLINT 0 or 1, deliberately not the native BOOLEAN type, +-- so that a row read back looks the same on every dialect +-- json documents are TEXT, not JSONB, since nothing queries inside them +-- secrets are TEXT produced by src/lib/secretbox.js + +CREATE TABLE projects ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + repo_url TEXT NOT NULL, + default_branch TEXT NOT NULL DEFAULT 'main', + config_path TEXT NOT NULL DEFAULT '.conductor.yml', + -- How workers obtain the source: 'archive' has them download a tarball + -- from the conductor, 'clone' has them git clone repo_url themselves. + source_mode TEXT NOT NULL DEFAULT 'archive', + -- Shared secret for trigger HMAC verification, encrypted at rest. + trigger_secret TEXT, + enabled SMALLINT NOT NULL DEFAULT 1, + run_counter BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE users ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + disabled SMALLINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + last_login_at BIGINT +); + +CREATE TABLE worker_tokens ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + last_seen_at BIGINT, + last_ip TEXT +); + +CREATE TABLE runs ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + number BIGINT NOT NULL, + ref TEXT, + base_sha TEXT, + head_sha TEXT NOT NULL, + -- Named trigger_type rather than trigger, which is reserved in MySQL. + trigger_type TEXT NOT NULL DEFAULT 'push', + actor TEXT, + title TEXT, + state TEXT NOT NULL DEFAULT 'pending', + -- Resolved pipeline as scheduled, retained so a run stays explainable + -- after the repository moves on. + pipeline TEXT, + error TEXT, + created_at BIGINT NOT NULL, + started_at BIGINT, + finished_at BIGINT +); + +CREATE UNIQUE INDEX idx_runs_project_number ON runs (project_id, number); +CREATE INDEX idx_runs_created ON runs (created_at); +CREATE INDEX idx_runs_state ON runs (state); + +CREATE TABLE jobs ( + id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + -- Expanded name, for example 'package:pkg=musl'. + name TEXT NOT NULL, + -- Template name before matrix and arch expansion. + base_name TEXT NOT NULL, + arch TEXT, + image TEXT NOT NULL, + -- json array of worker feature names this job needs. + requires TEXT NOT NULL, + -- json document: script, env, services, artifacts, cache, matrix values. + spec TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'queued', + allow_failure SMALLINT NOT NULL DEFAULT 0, + attempt INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 1, + timeout INTEGER NOT NULL, + exit_code INTEGER, + error TEXT, + log_key TEXT, + log_size BIGINT NOT NULL DEFAULT 0, + worker_token_id TEXT, + worker_name TEXT, + claimed_at BIGINT, + heartbeat_at BIGINT, + created_at BIGINT NOT NULL, + started_at BIGINT, + finished_at BIGINT +); + +CREATE UNIQUE INDEX idx_jobs_run_name ON jobs (run_id, name); +CREATE INDEX idx_jobs_run ON jobs (run_id); +CREATE INDEX idx_jobs_state ON jobs (state); +-- Supports the reaper scan for claimed jobs that stopped reporting. +CREATE INDEX idx_jobs_heartbeat ON jobs (state, heartbeat_at); + +CREATE TABLE job_deps ( + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + depends_on_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + PRIMARY KEY (job_id, depends_on_id) +); + +CREATE INDEX idx_job_deps_reverse ON job_deps (depends_on_id); + +CREATE TABLE artifacts ( + id TEXT NOT NULL PRIMARY KEY, + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + run_id TEXT NOT NULL, + path TEXT NOT NULL, + storage_key TEXT NOT NULL, + size BIGINT NOT NULL, + sha256 CHAR(64) NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT +); + +CREATE INDEX idx_artifacts_job ON artifacts (job_id); +CREATE INDEX idx_artifacts_run ON artifacts (run_id); +CREATE INDEX idx_artifacts_expires ON artifacts (expires_at); + +CREATE TABLE project_variables ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + value TEXT NOT NULL, + masked SMALLINT NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + PRIMARY KEY (project_id, name) +); diff --git a/migrations/sqlite/001_initial.sql b/migrations/sqlite/001_initial.sql @@ -0,0 +1,139 @@ +-- 001_initial.sql - base schema for conductor (sqlite) +-- +-- Conventions, kept identical to the mysql dialect: +-- timestamps are INTEGER epoch milliseconds +-- booleans are INTEGER 0 or 1 +-- json documents are TEXT +-- secrets are TEXT produced by src/lib/secretbox.js + +CREATE TABLE projects ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + repo_url TEXT NOT NULL, + default_branch TEXT NOT NULL DEFAULT 'main', + config_path TEXT NOT NULL DEFAULT '.conductor.yml', + -- How workers obtain the source: 'archive' has them download a tarball + -- from the conductor, 'clone' has them git clone repo_url themselves. + source_mode TEXT NOT NULL DEFAULT 'archive', + -- Shared secret for trigger HMAC verification, encrypted at rest. + trigger_secret TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + run_counter INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE users ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + disabled INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_login_at INTEGER +); + +CREATE TABLE worker_tokens ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + last_seen_at INTEGER, + last_ip TEXT +); + +CREATE TABLE runs ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + number INTEGER NOT NULL, + ref TEXT, + base_sha TEXT, + head_sha TEXT NOT NULL, + -- Named trigger_type rather than trigger, which is reserved in MySQL. + trigger_type TEXT NOT NULL DEFAULT 'push', + actor TEXT, + title TEXT, + state TEXT NOT NULL DEFAULT 'pending', + -- Resolved pipeline as scheduled, retained so a run stays explainable + -- after the repository moves on. + pipeline TEXT, + error TEXT, + created_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER +); + +CREATE UNIQUE INDEX idx_runs_project_number ON runs (project_id, number); +CREATE INDEX idx_runs_created ON runs (created_at); +CREATE INDEX idx_runs_state ON runs (state); + +CREATE TABLE jobs ( + id TEXT NOT NULL PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + -- Expanded name, for example 'package:pkg=musl'. + name TEXT NOT NULL, + -- Template name before matrix and arch expansion. + base_name TEXT NOT NULL, + arch TEXT, + image TEXT NOT NULL, + -- json array of worker feature names this job needs. + requires TEXT NOT NULL, + -- json document: script, env, services, artifacts, cache, matrix values. + spec TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'queued', + allow_failure INTEGER NOT NULL DEFAULT 0, + attempt INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 1, + timeout INTEGER NOT NULL, + exit_code INTEGER, + error TEXT, + log_key TEXT, + log_size INTEGER NOT NULL DEFAULT 0, + worker_token_id TEXT, + worker_name TEXT, + claimed_at INTEGER, + heartbeat_at INTEGER, + created_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER +); + +CREATE UNIQUE INDEX idx_jobs_run_name ON jobs (run_id, name); +CREATE INDEX idx_jobs_run ON jobs (run_id); +CREATE INDEX idx_jobs_state ON jobs (state); +-- Supports the reaper scan for claimed jobs that stopped reporting. +CREATE INDEX idx_jobs_heartbeat ON jobs (state, heartbeat_at); + +CREATE TABLE job_deps ( + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + depends_on_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + PRIMARY KEY (job_id, depends_on_id) +); + +CREATE INDEX idx_job_deps_reverse ON job_deps (depends_on_id); + +CREATE TABLE artifacts ( + id TEXT NOT NULL PRIMARY KEY, + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + run_id TEXT NOT NULL, + path TEXT NOT NULL, + storage_key TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER +); + +CREATE INDEX idx_artifacts_job ON artifacts (job_id); +CREATE INDEX idx_artifacts_run ON artifacts (run_id); +CREATE INDEX idx_artifacts_expires ON artifacts (expires_at); + +CREATE TABLE project_variables ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + value TEXT NOT NULL, + masked INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + PRIMARY KEY (project_id, name) +); diff --git a/package.json b/package.json @@ -0,0 +1,28 @@ +{ + "name": "conductor", + "version": "0.1.0", + "description": "Stand-alone CI system: in-repo pipelines, containerised jobs, pull-based workers", + "type": "module", + "main": "src/conductor/index.js", + "scripts": { + "start": "node src/conductor/index.js", + "dev": "node --watch src/conductor/index.js", + "read-api": "node src/read-api/index.js", + "worker": "node src/worker/agent.js", + "migrate": "node src/lib/db/migrate-cli.js", + "test": "node --test \"test/**/*.test.js\"" + }, + "dependencies": { + "@fastify/cors": "^11.3.0", + "fastify": "^5.12.5", + "jose": "^6.2.12", + "yaml": "^2.8.1" + }, + "optionalDependencies": { + "mysql2": "^3.24.4", + "pg": "^8.16.3" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/src/lib/config.js b/src/lib/config.js @@ -0,0 +1,317 @@ +// src/lib/config.js - configuration loading for all conductor services +// +// Resolution order, lowest precedence first: +// 1. built-in defaults (see DEFAULTS) +// 2. YAML file, from CONDUCTOR_CONFIG or ./conductor.yaml when present +// 3. environment variables (see ENV_MAP) +// +// Two capabilities switch on automatically when configured, and fall back +// otherwise: +// storage.s3.bucket set -> S3 object storage, else local filesystem +// auth.oidc.issuer set -> OIDC bearer auth, else built-in user accounts + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import YAML from 'yaml'; +import { dialectFromUrl } from './db/dialect.js'; + +const DEFAULTS = { + server: { + host: '0.0.0.0', + port: 8080, + // Absolute URL this conductor is reachable at. Used when handing workers + // callback URLs, so it must be correct behind a reverse proxy. + public_url: 'http://127.0.0.1:8080', + }, + read_api: { + host: '0.0.0.0', + port: 8081, + }, + database: { + // Unset means sqlite at database.path. Otherwise the scheme selects the + // dialect: mysql:// or postgres:// (postgresql:// is accepted too). + url: null, + path: './data/conductor.db', + // Pool sizing for the networked dialects, ignored by sqlite. + connection_limit: 10, + }, + storage: { + path: './data/storage', + s3: { + endpoint: null, + region: 'us-east-1', + bucket: null, + access_key_id: null, + secret_access_key: null, + // Garage and MinIO need path style addressing. + force_path_style: true, + }, + }, + auth: { + // Signs built-in session tokens. Random per boot when unset, which + // invalidates existing sessions on restart. + session_secret: null, + session_ttl: 43200, + oidc: { + issuer: null, + audience: null, + admin_role: 'conductor-admin', + }, + // Created on first boot when the users table is empty. + bootstrap_admin: { + username: 'admin', + password: null, + }, + }, + secrets: { + // 32 bytes, hex or base64, for AES-256-GCM encryption of project + // variables at rest. Required only once project variables are used. + encryption_key: null, + }, + git: { + mirror_path: './data/mirrors', + // Seconds before a cached mirror fetch is considered stale. + fetch_interval: 60, + timeout: 300, + }, + log: { + spool_path: './data/logs', + // Refuse log appends past this size, to bound a runaway job. + max_size: 64 * 1024 * 1024, + }, + scheduler: { + // A claimed job whose worker stops sending heartbeats for this long is + // considered lost and is requeued or failed. + heartbeat_timeout: 120, + reap_interval: 30, + default_job_timeout: 3600, + max_attempts: 3, + }, +}; + +// [environment variable, dotted config path, parser] +const ENV_MAP = [ + ['CONDUCTOR_HOST', 'server.host', String], + ['CONDUCTOR_PORT', 'server.port', toInt], + ['CONDUCTOR_PUBLIC_URL', 'server.public_url', String], + ['CONDUCTOR_READ_API_HOST', 'read_api.host', String], + ['CONDUCTOR_READ_API_PORT', 'read_api.port', toInt], + ['CONDUCTOR_DATABASE_URL', 'database.url', String], + ['CONDUCTOR_DATABASE_PATH', 'database.path', String], + ['CONDUCTOR_STORAGE_PATH', 'storage.path', String], + ['CONDUCTOR_S3_ENDPOINT', 'storage.s3.endpoint', String], + ['CONDUCTOR_S3_REGION', 'storage.s3.region', String], + ['CONDUCTOR_S3_BUCKET', 'storage.s3.bucket', String], + ['CONDUCTOR_S3_ACCESS_KEY_ID', 'storage.s3.access_key_id', String], + ['CONDUCTOR_S3_SECRET_ACCESS_KEY', 'storage.s3.secret_access_key', String], + ['CONDUCTOR_S3_FORCE_PATH_STYLE', 'storage.s3.force_path_style', toBool], + ['CONDUCTOR_SESSION_SECRET', 'auth.session_secret', String], + ['CONDUCTOR_OIDC_ISSUER', 'auth.oidc.issuer', String], + ['CONDUCTOR_OIDC_AUDIENCE', 'auth.oidc.audience', String], + ['CONDUCTOR_OIDC_ADMIN_ROLE', 'auth.oidc.admin_role', String], + ['CONDUCTOR_ADMIN_USERNAME', 'auth.bootstrap_admin.username', String], + ['CONDUCTOR_ADMIN_PASSWORD', 'auth.bootstrap_admin.password', String], + ['CONDUCTOR_SECRET_KEY', 'secrets.encryption_key', String], + ['CONDUCTOR_MIRROR_PATH', 'git.mirror_path', String], + ['CONDUCTOR_LOG_PATH', 'log.spool_path', String], +]; + +// Paths resolved relative to the config file directory, or cwd when there is +// no config file. +const PATH_KEYS = ['database.path', 'storage.path', 'git.mirror_path', 'log.spool_path']; + +function toInt(v) { + const n = parseInt(v, 10); + if (!Number.isFinite(n)) throw new Error(`expected an integer, got ${JSON.stringify(v)}`); + return n; +} + +function toBool(v) { + if (typeof v === 'boolean') return v; + const s = String(v).toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(s)) return true; + if (['0', 'false', 'no', 'off'].includes(s)) return false; + throw new Error(`expected a boolean, got ${JSON.stringify(v)}`); +} + +function isPlainObject(v) { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +function deepMerge(base, overlay) { + const out = Array.isArray(base) ? [...base] : { ...base }; + for (const [k, v] of Object.entries(overlay || {})) { + if (v === undefined) continue; + out[k] = isPlainObject(v) && isPlainObject(base?.[k]) ? deepMerge(base[k], v) : v; + } + return out; +} + +function getPath(obj, dotted) { + return dotted.split('.').reduce((acc, k) => (acc == null ? acc : acc[k]), obj); +} + +function setPath(obj, dotted, value) { + const keys = dotted.split('.'); + const last = keys.pop(); + let cur = obj; + for (const k of keys) { + if (!isPlainObject(cur[k])) cur[k] = {}; + cur = cur[k]; + } + cur[last] = value; +} + +function deepFreeze(obj) { + for (const v of Object.values(obj)) { + if (isPlainObject(v) || Array.isArray(v)) deepFreeze(v); + } + return Object.freeze(obj); +} + +// Reads a 32 byte key given as hex or base64. Returns a Buffer, or null. +export function parseKey(value, label) { + if (!value) return null; + let buf = null; + if (/^[0-9a-fA-F]{64}$/.test(value)) buf = Buffer.from(value, 'hex'); + else buf = Buffer.from(value, 'base64'); + if (buf.length !== 32) { + throw new Error(`${label} must decode to 32 bytes, got ${buf.length}; use 64 hex chars or base64`); + } + return buf; +} + +function validate(cfg) { + const errors = []; + + if (!Number.isInteger(cfg.server.port) || cfg.server.port < 1 || cfg.server.port > 65535) { + errors.push(`server.port must be between 1 and 65535, got ${cfg.server.port}`); + } + if (!Number.isInteger(cfg.read_api.port) || cfg.read_api.port < 1 || cfg.read_api.port > 65535) { + errors.push(`read_api.port must be between 1 and 65535, got ${cfg.read_api.port}`); + } + if (cfg.database.url && dialectFromUrl(cfg.database.url) === null) { + errors.push( + `database.url has an unsupported scheme: ${String(cfg.database.url).split(':')[0]}; ` + + 'use mysql://, postgres:// or leave it unset for sqlite' + ); + } + + const s3 = cfg.storage.s3; + const s3Given = [s3.endpoint, s3.bucket, s3.access_key_id, s3.secret_access_key].filter(Boolean); + if (s3Given.length > 0 && s3Given.length < 4) { + errors.push( + 'storage.s3 is partially configured; endpoint, bucket, access_key_id and ' + + 'secret_access_key are all required, or leave them all unset for local storage' + ); + } + + if (cfg.auth.oidc.issuer) { + try { + new URL(cfg.auth.oidc.issuer); + } catch { + errors.push(`auth.oidc.issuer must be an absolute URL, got ${JSON.stringify(cfg.auth.oidc.issuer)}`); + } + } + + try { + new URL(cfg.server.public_url); + } catch { + errors.push(`server.public_url must be an absolute URL, got ${JSON.stringify(cfg.server.public_url)}`); + } + + try { + parseKey(cfg.secrets.encryption_key, 'secrets.encryption_key'); + } catch (e) { + errors.push(e.message); + } + + if (cfg.scheduler.heartbeat_timeout <= cfg.scheduler.reap_interval) { + errors.push( + `scheduler.heartbeat_timeout (${cfg.scheduler.heartbeat_timeout}) must be greater than ` + + `scheduler.reap_interval (${cfg.scheduler.reap_interval}), or healthy jobs will be reaped` + ); + } + + if (errors.length > 0) { + throw new Error(`invalid configuration:\n - ${errors.join('\n - ')}`); + } +} + +export function loadConfig(explicitPath) { + const file = explicitPath || process.env.CONDUCTOR_CONFIG || 'conductor.yaml'; + let cfg = structuredClone(DEFAULTS); + let baseDir = process.cwd(); + let source = null; + + if (fs.existsSync(file)) { + const text = fs.readFileSync(file, 'utf8'); + let parsed; + try { + parsed = YAML.parse(text) || {}; + } catch (e) { + throw new Error(`failed to parse config file ${file}: ${e.message}`); + } + if (!isPlainObject(parsed)) { + throw new Error(`config file ${file} must contain a YAML mapping at the top level`); + } + cfg = deepMerge(cfg, parsed); + baseDir = path.dirname(path.resolve(file)); + source = path.resolve(file); + } else if (explicitPath || process.env.CONDUCTOR_CONFIG) { + throw new Error(`config file not found: ${file}`); + } + + for (const [env, dotted, parse] of ENV_MAP) { + const raw = process.env[env]; + if (raw === undefined || raw === '') continue; + try { + setPath(cfg, dotted, parse(raw)); + } catch (e) { + throw new Error(`invalid value for ${env}: ${e.message}`); + } + } + + // A sqlite: or file: url is just another way of spelling database.path. + // Collapse it now so that everything downstream sees one representation. + if (cfg.database.url && /^(sqlite|file):/i.test(cfg.database.url)) { + const raw = cfg.database.url.replace(/^(sqlite|file):(\/\/)?/i, ''); + if (!raw) throw new Error(`database.url ${cfg.database.url} does not contain a file path`); + cfg.database.path = raw; + cfg.database.url = null; + } + + for (const key of PATH_KEYS) { + const v = getPath(cfg, key); + if (typeof v === 'string' && v.length > 0) setPath(cfg, key, path.resolve(baseDir, v)); + } + + validate(cfg); + + // Derived flags, so callers never re-implement the fallback rules. + cfg.source = source; + cfg.database.dialect = dialectFromUrl(cfg.database.url); + cfg.storage.driver = cfg.storage.s3.bucket ? 's3' : 'local'; + cfg.auth.mode = cfg.auth.oidc.issuer ? 'oidc' : 'local'; + + if (!cfg.auth.session_secret) { + cfg.auth.session_secret = crypto.randomBytes(32).toString('hex'); + cfg.auth.session_secret_ephemeral = true; + } + + return deepFreeze(cfg); +} + +// Directories that must exist before a service starts. Which ones matter +// depends on the selected drivers, so this is derived rather than fixed. +export function stateDirs(cfg) { + const dirs = [cfg.git.mirror_path, cfg.log.spool_path]; + if (cfg.storage.driver === 'local') dirs.push(cfg.storage.path); + if (cfg.database.dialect === 'sqlite') dirs.push(path.dirname(cfg.database.path)); + return [...new Set(dirs)]; +} + +export function ensureStateDirs(cfg) { + for (const dir of stateDirs(cfg)) fs.mkdirSync(dir, { recursive: true }); +} diff --git a/src/lib/db/dialect.js b/src/lib/db/dialect.js @@ -0,0 +1,17 @@ +// src/lib/db/dialect.js - dialect naming and detection +// +// Kept separate from index.js so that configuration loading can resolve a +// dialect without pulling in any driver module. + +export const DIALECTS = ['sqlite', 'mysql', 'postgres']; + +// Maps a database.url scheme onto a dialect name. An unset url means sqlite. +// Returns null for a scheme that is not supported. +export function dialectFromUrl(url) { + if (!url) return 'sqlite'; + const scheme = String(url).split(':')[0].toLowerCase(); + if (scheme === 'mysql') return 'mysql'; + if (scheme === 'postgres' || scheme === 'postgresql') return 'postgres'; + if (scheme === 'sqlite' || scheme === 'file') return 'sqlite'; + return null; +} diff --git a/src/lib/db/index.js b/src/lib/db/index.js @@ -0,0 +1,47 @@ +// src/lib/db/index.js - database driver selection +// +// Every driver exposes the same surface: +// all(sql, params) -> array of rows +// get(sql, params) -> first row, or undefined +// run(sql, params) -> { changes, lastInsertId } +// exec(sql) -> no result, for DDL, bind markers are not parsed +// transaction(fn) -> fn receives a scoped handle, nesting uses savepoints +// close() +// +// Params is an object, and queries use named {name} markers which each driver +// compiles to its own placeholder syntax. See query.js. +// +// db.get('SELECT * FROM jobs WHERE run_id = {run}', { run: runId }) +// +// Portability rules that apply to every query written against this layer: +// - named {name} markers only, never ? or $n directly +// - timestamps are epoch milliseconds, produced by Date.now() +// - booleans are stored as 0 and 1, never a native boolean type +// - no dialect specific functions; keep those behind a driver method +// - no reliance on lastInsertId; identifiers are generated by the caller +// - no upsert syntax; ON DUPLICATE KEY and ON CONFLICT are not portable, +// so do the read and the write explicitly inside a transaction +// +// One behavioural note on run().changes: mysql counts rows actually changed, +// while sqlite and postgres count rows matched. An UPDATE that writes a value +// identical to the current one therefore reports 0 on mysql and 1 elsewhere. +// The conditional claim pattern used by the scheduler always writes a +// different state, so it is unaffected; avoid depending on changes for an +// update that may be a no-op. + +import { openSqlite } from './sqlite.js'; +import { openMysql } from './mysql.js'; +import { openPostgres } from './postgres.js'; + +export { DIALECTS, dialectFromUrl } from './dialect.js'; + +export async function openDatabase(cfg) { + switch (cfg.database.dialect) { + case 'mysql': return openMysql(cfg); + case 'postgres': return openPostgres(cfg); + case 'sqlite': return openSqlite(cfg); + default: throw new Error(`unknown database dialect: ${cfg.database.dialect}`); + } +} + +export { runMigrations } from './migrate.js'; diff --git a/src/lib/db/migrate.js b/src/lib/db/migrate.js @@ -0,0 +1,219 @@ +// src/lib/db/migrate.js - forward-only migration runner +// +// Migrations live in migrations/<dialect>/NNN_name.sql and are applied in +// filename order. Each applied file is recorded with a checksum, so editing a +// migration that has already run is reported as an error rather than silently +// diverging between environments. +// +// Note on atomicity: sqlite and postgres both run DDL inside a transaction, +// so a failed migration rolls back completely there. MySQL and TiDB implicitly +// commit on DDL, so a file that fails halfway can leave its earlier statements +// applied. The surrounding transaction is still worth having on those, since +// it keeps the bookkeeping insert and any DML atomic. Keep one logical change +// per file so a partial apply stays easy to reason about. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { RULES } from './query.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const MIGRATIONS_ROOT = path.resolve(HERE, '../../../migrations'); + +const DDL = { + sqlite: ` + CREATE TABLE IF NOT EXISTS _migrations ( + filename TEXT PRIMARY KEY, + checksum TEXT NOT NULL, + executed_at INTEGER NOT NULL + ) + `, + mysql: ` + CREATE TABLE IF NOT EXISTS _migrations ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + checksum CHAR(64) NOT NULL, + executed_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `, + postgres: ` + CREATE TABLE IF NOT EXISTS _migrations ( + filename TEXT NOT NULL PRIMARY KEY, + checksum CHAR(64) NOT NULL, + executed_at BIGINT NOT NULL + ) + `, +}; + +// Splits a SQL file into statements on semicolons, while respecting string +// literals, quoted identifiers and comments. Splitting on a bare semicolon, +// as the prototype did, corrupts any statement containing one inside a +// literal. Lexing rules are per dialect, shared with the query compiler. +export function splitStatements(sql, dialect = 'sqlite') { + const rules = RULES[dialect]; + if (!rules) throw new Error(`unknown dialect: ${dialect}`); + + const out = []; + let cur = ''; + let i = 0; + + while (i < sql.length) { + const c = sql[i]; + const next = sql[i + 1]; + + if ((c === '-' && next === '-') || (rules.hash && c === '#')) { + const nl = sql.indexOf('\n', i); + if (nl === -1) break; + cur += '\n'; + i = nl + 1; + continue; + } + + if (c === '/' && next === '*') { + let depth = 1; + let j = i + 2; + while (j < sql.length && depth > 0) { + if (rules.nestedBlock && sql[j] === '/' && sql[j + 1] === '*') { + depth += 1; + j += 2; + } else if (sql[j] === '*' && sql[j + 1] === '/') { + depth -= 1; + j += 2; + } else { + j += 1; + } + } + cur += ' '; + i = j; + continue; + } + + if (c === "'" || c === '"' || (rules.backtick && c === '`')) { + const quote = c; + cur += c; + i += 1; + while (i < sql.length) { + // Backslash escapes apply to MySQL string literals, not to backtick + // quoted identifiers. + if (rules.backslash && quote !== '`' && sql[i] === '\\') { + cur += sql[i] + (sql[i + 1] ?? ''); + i += 2; + continue; + } + if (sql[i] === quote) { + // A doubled quote is a literal quote, not a terminator. + if (sql[i + 1] === quote) { + cur += quote + quote; + i += 2; + continue; + } + cur += quote; + i += 1; + break; + } + cur += sql[i]; + i += 1; + } + continue; + } + + // Dollar quoted bodies routinely contain semicolons. + if (rules.dollar && c === '$') { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); + if (m) { + const tag = m[0]; + const end = sql.indexOf(tag, i + tag.length); + const stop = end === -1 ? sql.length : end + tag.length; + cur += sql.slice(i, stop); + i = stop; + continue; + } + } + + if (c === ';') { + if (cur.trim()) out.push(cur.trim()); + cur = ''; + i += 1; + continue; + } + + cur += c; + i += 1; + } + + if (cur.trim()) out.push(cur.trim()); + return out; +} + +function checksum(sql) { + // Normalize line endings so a CRLF checkout does not invalidate history. + return crypto.createHash('sha256').update(sql.replace(/\r\n/g, '\n')).digest('hex'); +} + +export async function runMigrations(db, options = {}) { + const dir = options.dir || path.join(MIGRATIONS_ROOT, db.dialect); + const log = options.logger || ((m) => console.log(`[migrate] ${m}`)); + + await db.exec(DDL[db.dialect]); + + const appliedRows = await db.all('SELECT filename, checksum FROM _migrations'); + const applied = new Map(appliedRows.map((r) => [r.filename, r.checksum])); + + let files; + try { + files = (await fs.readdir(dir)).filter((f) => f.endsWith('.sql')).sort(); + } catch (e) { + if (e.code === 'ENOENT') throw new Error(`no migrations directory for dialect ${db.dialect}: ${dir}`); + throw e; + } + + const pending = []; + for (const file of files) { + const sql = await fs.readFile(path.join(dir, file), 'utf8'); + const sum = checksum(sql); + const prev = applied.get(file); + if (prev === undefined) { + pending.push({ file, sql, sum }); + continue; + } + if (prev !== sum) { + throw new Error( + `migration ${file} was modified after it was applied\n` + + ` recorded: ${prev}\n` + + ` on disk: ${sum}\n` + + 'Add a new migration instead of editing an applied one.' + ); + } + } + + // Detect files removed from disk but still recorded, which usually means a + // downgrade or a bad merge. + for (const name of applied.keys()) { + if (!files.includes(name)) { + throw new Error(`migration ${name} is recorded as applied but is missing from ${dir}`); + } + } + + if (pending.length === 0) { + log(`up to date (${files.length} applied)`); + return { applied: [], total: files.length }; + } + + const done = []; + for (const { file, sql, sum } of pending) { + log(`applying ${file}`); + const statements = splitStatements(sql, db.dialect); + if (statements.length === 0) throw new Error(`migration ${file} contains no statements`); + await db.transaction(async (tx) => { + for (const stmt of statements) await tx.exec(stmt); + await tx.run( + 'INSERT INTO _migrations (filename, checksum, executed_at) VALUES ({file}, {sum}, {at})', + { file, sum, at: Date.now() } + ); + }); + done.push(file); + } + + log(`applied ${done.length} migration(s)`); + return { applied: done, total: files.length }; +} diff --git a/src/lib/db/mysql.js b/src/lib/db/mysql.js @@ -0,0 +1,145 @@ +// src/lib/db/mysql.js - MySQL and TiDB driver built on mysql2 +// +// mysql2 is an optional dependency: it is only required when database.url +// uses a mysql scheme, so a default sqlite install does not need it. + +import { compileCached, bindParams } from './query.js'; + +async function loadDriver() { + try { + return (await import('mysql2/promise')).default; + } catch (e) { + throw new Error( + 'database.url uses a mysql scheme but the mysql2 package is not installed. ' + + 'Run `npm install mysql2`, or unset database.url to use sqlite.', + { cause: e } + ); + } +} + +function fail(e, sql) { + const err = new Error(`mysql: ${e.message}\n sql: ${sql.trim().split('\n')[0]}`); + err.cause = e; + err.code = e.code; + throw err; +} + +// Wraps either the pool or a single transaction connection. +function wrap(runner) { + function compile(sql, params) { + const compiled = compileCached(sql, 'mysql'); + return { text: compiled.sql, values: bindParams(compiled.keys, params, sql) }; + } + + return { + dialect: 'mysql', + + async all(sql, params = {}) { + const { text, values } = compile(sql, params); + try { + const [rows] = await runner.query(text, values); + return rows; + } catch (e) { + fail(e, sql); + } + }, + + async get(sql, params = {}) { + const { text, values } = compile(sql, params); + try { + const [rows] = await runner.query(text, values); + return rows[0]; + } catch (e) { + fail(e, sql); + } + }, + + async run(sql, params = {}) { + const { text, values } = compile(sql, params); + try { + const [res] = await runner.query(text, values); + return { + changes: res.affectedRows ?? 0, + lastInsertId: res.insertId === undefined || res.insertId === 0 ? null : Number(res.insertId), + }; + } catch (e) { + fail(e, sql); + } + }, + + // Raw DDL, no bind markers are interpreted. + async exec(sql) { + try { + await runner.query(sql); + } catch (e) { + fail(e, sql); + } + }, + }; +} + +export async function openMysql(cfg) { + const mysql = await loadDriver(); + const pool = mysql.createPool({ + uri: cfg.database.url, + connectionLimit: cfg.database.connection_limit, + waitForConnections: true, + // Epoch millisecond timestamps sit well inside the safe integer range, + // so BIGINT should come back as a number rather than a string. + supportBigNumbers: true, + bigNumberStrings: false, + dateStrings: true, + multipleStatements: false, + }); + + const api = wrap(pool); + + api.transaction = async function transaction(fn) { + const conn = await pool.getConnection(); + const tx = wrap(conn); + let depth = 0; + + // Savepoints give the same composability as the other drivers. + tx.transaction = async (inner) => { + const name = `sp_${depth}`; + depth += 1; + await conn.query(`SAVEPOINT ${name}`); + try { + const r = await inner(tx); + await conn.query(`RELEASE SAVEPOINT ${name}`); + depth -= 1; + return r; + } catch (e) { + try { + await conn.query(`ROLLBACK TO SAVEPOINT ${name}`); + } catch { + // Preserve the original failure. + } + depth -= 1; + throw e; + } + }; + + try { + await conn.beginTransaction(); + const result = await fn(tx); + await conn.commit(); + return result; + } catch (e) { + try { + await conn.rollback(); + } catch { + // Preserve the original failure. + } + throw e; + } finally { + conn.release(); + } + }; + + api.close = async () => { + await pool.end(); + }; + + return api; +} diff --git a/src/lib/db/params.js b/src/lib/db/params.js @@ -0,0 +1,34 @@ +// src/lib/db/params.js - bind value normalization shared by every driver +// +// Callers write portable application code and let this file absorb the +// differences between drivers. node:sqlite rejects booleans, undefined and +// Date objects outright; mysql2 and pg accept them but would each apply their +// own timestamp handling. Timestamps are epoch milliseconds everywhere, so +// Date values are flattened here rather than handed to any driver. + +export function normalizeValue(v, key) { + if (v === undefined || v === null) return null; + if (typeof v === 'boolean') return v ? 1 : 0; + if (v instanceof Date) { + if (Number.isNaN(v.getTime())) { + throw new Error(`bind parameter ${key ? `{${key}} ` : ''}is an invalid Date`); + } + return v.getTime(); + } + if (typeof v === 'bigint') { + if (v > BigInt(Number.MAX_SAFE_INTEGER) || v < BigInt(Number.MIN_SAFE_INTEGER)) { + throw new Error(`bind parameter ${key ? `{${key}} ` : ''}exceeds the safe integer range: ${v}`); + } + return Number(v); + } + if (typeof v === 'number' || typeof v === 'string') return v; + if (Buffer.isBuffer(v) || v instanceof Uint8Array) return v; + + // Objects and arrays are almost always a forgotten JSON.stringify, or a + // dotted key that should have addressed a leaf. Both are worth catching. + throw new Error( + `bind parameter ${key ? `{${key}} ` : ''}has unsupported type ${ + Array.isArray(v) ? 'array' : typeof v + }; serialize it first, or address a nested value with a dotted key` + ); +} diff --git a/src/lib/db/postgres.js b/src/lib/db/postgres.js @@ -0,0 +1,147 @@ +// src/lib/db/postgres.js - PostgreSQL driver built on pg +// +// pg is an optional dependency: it is only required when database.url uses a +// postgres scheme. +// +// Two adaptations are made so that portable queries keep working: +// - {name} markers compile to $n, see query.js +// - int8 is parsed to Number, since epoch millisecond timestamps are well +// inside the safe integer range and pg returns int8 as a string otherwise +// +// lastInsertId is always null here. The schema uses application generated +// string identifiers throughout, so nothing depends on it; retrieving one +// would need a RETURNING clause the other dialects do not accept. + +import { compileCached, bindParams } from './query.js'; + +async function loadDriver() { + try { + return (await import('pg')).default; + } catch (e) { + throw new Error( + 'database.url uses a postgres scheme but the pg package is not installed. ' + + 'Run `npm install pg`, or unset database.url to use sqlite.', + { cause: e } + ); + } +} + +function fail(e, sql) { + const err = new Error(`postgres: ${e.message}\n sql: ${sql.trim().split('\n')[0]}`); + err.cause = e; + err.code = e.code; + throw err; +} + +// Wraps either the pool or a single transaction client. +function wrap(runner) { + function compile(sql, params) { + const compiled = compileCached(sql, 'postgres'); + return { text: compiled.sql, values: bindParams(compiled.keys, params, sql) }; + } + + return { + dialect: 'postgres', + + async all(sql, params = {}) { + const { text, values } = compile(sql, params); + try { + const res = await runner.query(text, values); + return res.rows; + } catch (e) { + fail(e, sql); + } + }, + + async get(sql, params = {}) { + const { text, values } = compile(sql, params); + try { + const res = await runner.query(text, values); + return res.rows[0]; + } catch (e) { + fail(e, sql); + } + }, + + async run(sql, params = {}) { + const { text, values } = compile(sql, params); + try { + const res = await runner.query(text, values); + return { changes: res.rowCount ?? 0, lastInsertId: null }; + } catch (e) { + fail(e, sql); + } + }, + + // Raw DDL, no bind markers are interpreted. + async exec(sql) { + try { + await runner.query(sql); + } catch (e) { + fail(e, sql); + } + }, + }; +} + +export async function openPostgres(cfg) { + const pg = await loadDriver(); + + // int8 (oid 20) would otherwise arrive as a string. + pg.types.setTypeParser(20, (v) => (v === null ? null : Number(v))); + + const pool = new pg.Pool({ + connectionString: cfg.database.url, + max: cfg.database.connection_limit, + }); + + const api = wrap(pool); + + api.transaction = async function transaction(fn) { + const client = await pool.connect(); + const tx = wrap(client); + let depth = 0; + + tx.transaction = async (inner) => { + const name = `sp_${depth}`; + depth += 1; + await client.query(`SAVEPOINT ${name}`); + try { + const r = await inner(tx); + await client.query(`RELEASE SAVEPOINT ${name}`); + depth -= 1; + return r; + } catch (e) { + try { + await client.query(`ROLLBACK TO SAVEPOINT ${name}`); + } catch { + // Preserve the original failure. + } + depth -= 1; + throw e; + } + }; + + try { + await client.query('BEGIN'); + const result = await fn(tx); + await client.query('COMMIT'); + return result; + } catch (e) { + try { + await client.query('ROLLBACK'); + } catch { + // Preserve the original failure. + } + throw e; + } finally { + client.release(); + } + }; + + api.close = async () => { + await pool.end(); + }; + + return api; +} diff --git a/src/lib/db/query.js b/src/lib/db/query.js @@ -0,0 +1,205 @@ +// src/lib/db/query.js - named placeholder compilation +// +// Queries are written once, against every dialect, using {name} markers: +// +// db.get('SELECT * FROM jobs WHERE run_id = {run} AND state = {state}', +// { run: runId, state: 'queued' }) +// +// The marker is compiled to whatever the driver wants (? for sqlite and +// mysql, $n for postgres) and the values are collected into a positional +// array in order of appearance. Binding by name rather than by position +// removes the class of bug where a query gains a clause and every later +// argument silently shifts. +// +// Markers are only recognised in ordinary SQL text. Anything inside a string +// literal, a quoted identifier, a dollar quoted block or a comment is passed +// through untouched, so a JSON literal such as '{}' is safe. Outside those, +// braces are escaped by doubling, so {{name}} emits the literal text {name}. +// +// Dotted names address nested values, so {actor.name} reads params.actor.name. +// A key that exists verbatim on the object wins over path traversal, which +// lets callers pass an already flattened object if they prefer. + +import { normalizeValue } from './params.js'; + +const NAME = /^\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\}/; + +// Per dialect lexing rules. Everything here is about deciding what to skip. +export const RULES = { + sqlite: { hash: false, backtick: true, backslash: false, dollar: false, nestedBlock: false }, + mysql: { hash: true, backtick: true, backslash: true, dollar: false, nestedBlock: false }, + postgres: { hash: false, backtick: false, backslash: false, dollar: true, nestedBlock: true }, +}; + +function placeholder(dialect, index) { + return dialect === 'postgres' ? `$${index}` : '?'; +} + +export function compileQuery(sql, dialect) { + const rules = RULES[dialect]; + if (!rules) throw new Error(`unknown dialect: ${dialect}`); + + let out = ''; + const keys = []; + let i = 0; + + while (i < sql.length) { + const c = sql[i]; + const next = sql[i + 1]; + + if ((c === '-' && next === '-') || (rules.hash && c === '#')) { + const nl = sql.indexOf('\n', i); + const end = nl === -1 ? sql.length : nl; + out += sql.slice(i, end); + i = end; + continue; + } + + if (c === '/' && next === '*') { + let depth = 1; + let j = i + 2; + while (j < sql.length && depth > 0) { + if (rules.nestedBlock && sql[j] === '/' && sql[j + 1] === '*') { + depth += 1; + j += 2; + } else if (sql[j] === '*' && sql[j + 1] === '/') { + depth -= 1; + j += 2; + } else { + j += 1; + } + } + out += sql.slice(i, j); + i = j; + continue; + } + + if (c === "'" || c === '"' || (rules.backtick && c === '`')) { + const quote = c; + let j = i + 1; + while (j < sql.length) { + if (rules.backslash && quote !== '`' && sql[j] === '\\') { + j += 2; + continue; + } + if (sql[j] === quote) { + // A doubled quote is an escaped quote, not a terminator. + if (sql[j + 1] === quote) { + j += 2; + continue; + } + j += 1; + break; + } + j += 1; + } + out += sql.slice(i, j); + i = j; + continue; + } + + if (rules.dollar && c === '$') { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); + if (m) { + const tag = m[0]; + const end = sql.indexOf(tag, i + tag.length); + const stop = end === -1 ? sql.length : end + tag.length; + out += sql.slice(i, stop); + i = stop; + continue; + } + } + + // Doubling escapes a brace, so {{name}} yields the literal text {name}. + if (c === '}' && next === '}') { + out += '}'; + i += 2; + continue; + } + + if (c === '{') { + if (next === '{') { + out += '{'; + i += 2; + continue; + } + const m = NAME.exec(sql.slice(i)); + if (!m) { + throw new Error( + `malformed bind marker at offset ${i} in query:\n ${sql.trim()}\n` + + 'Expected {name} or {outer.inner}; write {{ for a literal brace.' + ); + } + keys.push(m[1]); + out += placeholder(dialect, keys.length); + i += m[0].length; + continue; + } + + out += c; + i += 1; + } + + return { sql: out, keys }; +} + +// Compilation is pure and queries come from source code, so the set is finite. +const cache = new Map(); + +export function compileCached(sql, dialect) { + const cacheKey = `${dialect}\u0000${sql}`; + let entry = cache.get(cacheKey); + if (!entry) { + entry = compileQuery(sql, dialect); + cache.set(cacheKey, entry); + } + return entry; +} + +function resolve(params, key) { + // An exact property wins, so a pre-flattened object works unchanged. + if (params != null && Object.hasOwn(params, key)) { + return { found: true, value: params[key] }; + } + if (!key.includes('.')) return { found: false }; + + let cur = params; + for (const part of key.split('.')) { + if (cur == null || typeof cur !== 'object') return { found: false }; + if (!Object.hasOwn(cur, part)) return { found: false }; + cur = cur[part]; + } + return { found: true, value: cur }; +} + +export function bindParams(keys, params, sql) { + if (keys.length === 0) return []; + + if (params == null || typeof params !== 'object' || Array.isArray(params)) { + throw new Error( + `bind parameters must be an object, got ${Array.isArray(params) ? 'an array' : typeof params}\n` + + ` query: ${sql.trim().split('\n')[0]}\n` + + ` expected keys: ${[...new Set(keys)].join(', ')}` + ); + } + + const missing = []; + const values = keys.map((key) => { + const hit = resolve(params, key); + if (!hit.found) { + missing.push(key); + return null; + } + return normalizeValue(hit.value, key); + }); + + if (missing.length > 0) { + throw new Error( + `missing bind parameter(s): ${[...new Set(missing)].join(', ')}\n` + + ` query: ${sql.trim().split('\n')[0]}\n` + + ` supplied: ${Object.keys(params).join(', ') || '(none)'}` + ); + } + + return values; +} diff --git a/src/lib/db/sqlite.js b/src/lib/db/sqlite.js @@ -0,0 +1,124 @@ +// src/lib/db/sqlite.js - sqlite driver built on node:sqlite +// +// node:sqlite is synchronous. The methods here are async purely so that +// callers can treat every dialect identically; there is no thread pool behind +// them. Because each statement runs to completion before the event loop +// turns, a transaction on this driver cannot interleave with another within +// the same process. + +import { DatabaseSync } from 'node:sqlite'; +import { compileCached, bindParams } from './query.js'; + +export function openSqlite(cfg) { + const db = new DatabaseSync(cfg.database.path); + + // WAL lets the read-api read while the conductor writes. + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA synchronous = NORMAL'); + + const statements = new Map(); + + function prepare(sql) { + const compiled = compileCached(sql, 'sqlite'); + let stmt = statements.get(compiled.sql); + if (!stmt) { + stmt = db.prepare(compiled.sql); + statements.set(compiled.sql, stmt); + } + return { stmt, keys: compiled.keys }; + } + + function fail(e, sql) { + const err = new Error(`sqlite: ${e.message}\n sql: ${sql.trim().split('\n')[0]}`); + err.cause = e; + err.code = e.code; + throw err; + } + + // node:sqlite hands back null prototype objects. mysql2 and pg both return + // ordinary objects, so rows are converted here rather than leaving callers + // to discover the difference. + const toPlain = (row) => (row === undefined ? undefined : { ...row }); + + const api = { + dialect: 'sqlite', + + async all(sql, params = {}) { + const { stmt, keys } = prepare(sql); + const values = bindParams(keys, params, sql); + try { + return stmt.all(...values).map(toPlain); + } catch (e) { + fail(e, sql); + } + }, + + async get(sql, params = {}) { + const { stmt, keys } = prepare(sql); + const values = bindParams(keys, params, sql); + try { + return toPlain(stmt.get(...values)); + } catch (e) { + fail(e, sql); + } + }, + + async run(sql, params = {}) { + const { stmt, keys } = prepare(sql); + const values = bindParams(keys, params, sql); + try { + const r = stmt.run(...values); + return { + changes: Number(r.changes), + lastInsertId: r.lastInsertRowid === undefined ? null : Number(r.lastInsertRowid), + }; + } catch (e) { + fail(e, sql); + } + }, + + // Raw DDL, no bind markers are interpreted. + async exec(sql) { + try { + db.exec(sql); + } catch (e) { + fail(e, sql); + } + }, + + async close() { + statements.clear(); + db.close(); + }, + }; + + // Nesting uses savepoints so that a helper which opens a transaction can be + // composed inside a larger one. + let depth = 0; + api.transaction = async function transaction(fn) { + const name = `sp_${depth}`; + if (depth === 0) db.exec('BEGIN IMMEDIATE'); + else db.exec(`SAVEPOINT ${name}`); + depth += 1; + try { + const result = await fn(api); + depth -= 1; + if (depth === 0) db.exec('COMMIT'); + else db.exec(`RELEASE ${name}`); + return result; + } catch (e) { + depth -= 1; + try { + if (depth === 0) db.exec('ROLLBACK'); + else db.exec(`ROLLBACK TO ${name}`); + } catch { + // The original error is more useful than a rollback failure. + } + throw e; + } + }; + + return api; +} diff --git a/src/lib/ids.js b/src/lib/ids.js @@ -0,0 +1,83 @@ +// src/lib/ids.js - identifier and token generation +// +// Identifiers are generated by the application rather than by the database, +// because lastInsertId is not portable across the three supported dialects +// and because a conductor needs an id before it has written anything. + +import crypto from 'node:crypto'; + +// Crockford base32 without the ambiguous letters I, L, O and U. Case +// insensitive and safe to read aloud or paste into a URL. +const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'; + +export function randomId(length = 24) { + // Rejection free: 256 is not a multiple of 32, so mask to 5 bits. + const bytes = crypto.randomBytes(length); + let out = ''; + for (let i = 0; i < length; i += 1) out += ALPHABET[bytes[i] & 31]; + return out; +} + +// Sortable by creation time, which keeps run listings stable without relying +// on a clock skewed created_at. 8 chars of millisecond timestamp in base32 +// followed by 12 random chars. +export function timeOrderedId(now = Date.now()) { + let stamp = ''; + let remaining = now; + for (let i = 0; i < 8; i += 1) { + stamp = ALPHABET[remaining % 32] + stamp; + remaining = Math.floor(remaining / 32); + } + return stamp + randomId(12); +} + +export const newRunId = () => timeOrderedId(); +export const newProjectId = () => randomId(16); +export const newArtifactId = () => randomId(24); +export const newUserId = () => randomId(16); +export const newWorkerTokenId = () => randomId(16); + +// Job ids stay human readable, since they appear in logs, storage keys and +// URLs. The jobs.id column is 96 characters, so an unusually long job name +// falls back to a truncated form with a hash suffix rather than being +// rejected or silently colliding. +export const JOB_ID_MAX = 96; + +export function jobId(runId, jobName) { + const direct = `${runId}:${jobName}`; + if (direct.length <= JOB_ID_MAX) return direct; + + const digest = crypto.createHash('sha256').update(jobName).digest('hex').slice(0, 12); + const room = JOB_ID_MAX - runId.length - 1 - 1 - digest.length; + if (room < 1) { + throw new Error(`run id ${runId} leaves no room for a job id`); + } + return `${runId}:${jobName.slice(0, room)}~${digest}`; +} + +// Worker and session tokens. The plaintext is shown once and only its hash +// is stored, so a database leak does not yield usable credentials. +export function newToken() { + return crypto.randomBytes(32).toString('hex'); +} + +export function hashToken(token) { + return crypto.createHash('sha256').update(String(token)).digest('hex'); +} + +// Compares two hex digests without leaking position through timing. +export function safeEqualHex(a, b) { + if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false; + return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); +} + +// A project id derived from a human supplied name, for the common case where +// an operator wants a readable slug rather than a random string. +export function slugify(input, fallbackLength = 16) { + const slug = String(input) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + return slug || randomId(fallbackLength); +} diff --git a/src/lib/secretbox.js b/src/lib/secretbox.js @@ -0,0 +1,87 @@ +// src/lib/secretbox.js - encryption at rest for stored secrets +// +// Used for project trigger secrets and project variables. When +// secrets.encryption_key is configured, values are sealed with AES-256-GCM. +// When it is not, values are stored base64 encoded and clearly marked as +// plaintext, so that an operator can tell at a glance which rows are +// protected and a later key rollout can find what needs upgrading. +// +// Stored format is a dotted, self describing string: +// v1.<iv>.<tag>.<ciphertext> sealed, all parts base64 +// plain.<value> not encrypted, value base64 +// +// The optional aad argument binds a ciphertext to its location, so a row +// copied into a different project or variable name fails to open. + +import crypto from 'node:crypto'; + +const IV_BYTES = 12; + +export function createSecretBox(key) { + const enabled = Boolean(key); + if (enabled && (!Buffer.isBuffer(key) || key.length !== 32)) { + throw new Error('secret box key must be a 32 byte Buffer'); + } + + return { + enabled, + + seal(plaintext, aad) { + if (typeof plaintext !== 'string') throw new Error('secret value must be a string'); + if (!enabled) return `plain.${Buffer.from(plaintext, 'utf8').toString('base64')}`; + + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + if (aad) cipher.setAAD(Buffer.from(aad, 'utf8')); + const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `v1.${iv.toString('base64')}.${tag.toString('base64')}.${ct.toString('base64')}`; + }, + + open(stored, aad) { + if (typeof stored !== 'string' || stored.length === 0) { + throw new Error('stored secret is empty'); + } + + if (stored.startsWith('plain.')) { + return Buffer.from(stored.slice(6), 'base64').toString('utf8'); + } + + if (!stored.startsWith('v1.')) { + throw new Error('stored secret has an unrecognised format'); + } + if (!enabled) { + throw new Error( + 'stored secret is encrypted but secrets.encryption_key is not configured; ' + + 'set it to the key this value was sealed with' + ); + } + + const parts = stored.split('.'); + if (parts.length !== 4) throw new Error('stored secret is malformed'); + const [, ivB64, tagB64, ctB64] = parts; + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64')); + decipher.setAuthTag(Buffer.from(tagB64, 'base64')); + if (aad) decipher.setAAD(Buffer.from(aad, 'utf8')); + try { + return Buffer.concat([ + decipher.update(Buffer.from(ctB64, 'base64')), + decipher.final(), + ]).toString('utf8'); + } catch (e) { + throw new Error( + 'stored secret failed to decrypt; the encryption key may have changed, ' + + 'or the value was moved between rows', + { cause: e } + ); + } + }, + + // True when a value is stored without encryption, so an admin endpoint + // can report what a key rollout would need to re-seal. + isPlaintext(stored) { + return typeof stored === 'string' && stored.startsWith('plain.'); + }, + }; +} diff --git a/src/lib/storage/index.js b/src/lib/storage/index.js @@ -0,0 +1,28 @@ +// src/lib/storage/index.js - object storage backend selection +// +// S3 is used when storage.s3.bucket is configured, otherwise objects go to +// the local filesystem. Both backends expose the same surface: +// +// put(key, body, opts) -> { key, size, sha256 } +// get(key, { range }) -> { key, size, stream }, throws StorageNotFound +// head(key) -> { key, size, modified } or null +// delete(key) -> resolves even when the key is absent +// presign(key, opts) -> absolute URL, or null when unsupported +// +// Callers must handle a null from presign by streaming get() themselves, +// since the local backend has no URL to hand out. + +import { createLocalStorage } from './local.js'; +import { createS3Storage } from './s3.js'; + +export { StorageNotFound, assertKey, sanitizeRelativePath } from './key.js'; + +export function createStorage(cfg) { + return cfg.storage.driver === 's3' ? createS3Storage(cfg) : createLocalStorage(cfg); +} + +// Key layout, kept in one place so the read-api and the conductor agree. +export const keys = { + artifact: (runId, jobId, relPath) => `artifacts/${runId}/${jobId}/${relPath}`, + log: (runId, jobId) => `logs/${runId}/${jobId}.log`, +}; diff --git a/src/lib/storage/key.js b/src/lib/storage/key.js @@ -0,0 +1,52 @@ +// src/lib/storage/key.js - object key validation shared by both backends +// +// Keys are slash separated and always relative. Validation is centralised +// because a key frequently originates from a job artifact path, which is +// attacker controlled for any worker or repository that is not fully trusted. + +export class StorageNotFound extends Error { + constructor(key) { + super(`object not found: ${key}`); + this.name = 'StorageNotFound'; + this.code = 'ENOENT'; + this.key = key; + } +} + +export function assertKey(key) { + if (typeof key !== 'string' || key.length === 0) { + throw new Error('storage key must be a non-empty string'); + } + if (key.length > 1024) { + throw new Error(`storage key exceeds 1024 characters: ${key.slice(0, 64)}...`); + } + if (key.startsWith('/')) { + throw new Error(`storage key must be relative: ${key}`); + } + if (key.includes('\\')) { + throw new Error(`storage key must use forward slashes: ${key}`); + } + // A NUL byte truncates the path in some syscalls. + if (key.includes('\0')) { + throw new Error('storage key contains a NUL byte'); + } + for (const segment of key.split('/')) { + if (segment === '' || segment === '.' || segment === '..') { + throw new Error(`storage key contains an invalid path segment: ${key}`); + } + } + return key; +} + +// Normalizes a path reported by a worker into a safe key suffix. Returns null +// when nothing usable remains, so the caller can reject the upload. +export function sanitizeRelativePath(input) { + if (typeof input !== 'string') return null; + const parts = input + .replace(/\\/g, '/') + .split('/') + .filter((p) => p !== '' && p !== '.' && p !== '..' && !p.includes('\0')); + if (parts.length === 0) return null; + const joined = parts.join('/'); + return joined.length > 512 ? null : joined; +} diff --git a/src/lib/storage/local.js b/src/lib/storage/local.js @@ -0,0 +1,111 @@ +// src/lib/storage/local.js - filesystem object storage +// +// The default backend, used whenever no S3 bucket is configured. Objects are +// plain files under storage.path, so artifacts remain inspectable with +// ordinary tools and a single node install needs nothing else running. + +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { pipeline } from 'node:stream/promises'; +import { Readable } from 'node:stream'; +import { StorageNotFound, assertKey } from './key.js'; + +export function createLocalStorage(cfg) { + const root = cfg.storage.path; + + function resolve(key) { + assertKey(key); + const full = path.resolve(root, key); + // Defence in depth: assertKey already rejects traversal, but a symlinked + // storage root should not become an escape either. + if (full !== root && !full.startsWith(root + path.sep)) { + throw new Error(`storage key escapes the storage root: ${key}`); + } + return full; + } + + return { + driver: 'local', + + async put(key, body, opts = {}) { + const full = resolve(key); + await fsp.mkdir(path.dirname(full), { recursive: true }); + + // Write to a sibling temp file and rename, so a reader never observes a + // partially written object. + const tmp = `${full}.${crypto.randomBytes(6).toString('hex')}.part`; + const hash = crypto.createHash('sha256'); + let size = 0; + + try { + if (Buffer.isBuffer(body) || typeof body === 'string') { + const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'); + hash.update(buf); + size = buf.length; + await fsp.writeFile(tmp, buf); + } else { + const source = body instanceof Readable ? body : Readable.from(body); + const out = fs.createWriteStream(tmp); + source.on('data', (chunk) => { + hash.update(chunk); + size += chunk.length; + }); + await pipeline(source, out); + } + await fsp.rename(tmp, full); + } catch (e) { + await fsp.rm(tmp, { force: true }); + throw e; + } + + if (opts.size !== undefined && opts.size !== size) { + await fsp.rm(full, { force: true }); + throw new Error(`storage put size mismatch for ${key}: declared ${opts.size}, wrote ${size}`); + } + + return { key, size, sha256: hash.digest('hex') }; + }, + + async get(key, opts = {}) { + const full = resolve(key); + let stat; + try { + stat = await fsp.stat(full); + } catch (e) { + if (e.code === 'ENOENT') throw new StorageNotFound(key); + throw e; + } + + const range = opts.range; + const start = range?.start ?? 0; + const end = range?.end ?? undefined; + + return { + key, + size: stat.size, + stream: fs.createReadStream(full, end === undefined ? { start } : { start, end }), + }; + }, + + async head(key) { + try { + const stat = await fsp.stat(resolve(key)); + return { key, size: stat.size, modified: stat.mtimeMs }; + } catch (e) { + if (e.code === 'ENOENT') return null; + throw e; + } + }, + + async delete(key) { + await fsp.rm(resolve(key), { force: true }); + }, + + // Local files cannot be handed out directly; callers stream them instead. + async presign() { + return null; + }, + }; +} diff --git a/src/lib/storage/s3.js b/src/lib/storage/s3.js @@ -0,0 +1,162 @@ +// src/lib/storage/s3.js - S3 compatible object storage +// +// Engaged as soon as storage.s3.bucket is configured. Tested against Garage +// and MinIO, which both need path style addressing; set force_path_style to +// false for AWS S3 proper. +// +// Requests go out over fetch with SigV4 headers. Uploads of a known Buffer +// are signed over their content hash; streamed uploads use UNSIGNED-PAYLOAD, +// which every S3 implementation accepts and which avoids buffering an entire +// artifact in memory just to hash it. + +import crypto from 'node:crypto'; +import { Readable } from 'node:stream'; +import { signRequest, presignUrl, sha256Hex, uriEncode, UNSIGNED_PAYLOAD } from './sigv4.js'; +import { StorageNotFound, assertKey } from './key.js'; + +export function createS3Storage(cfg) { + const s3 = cfg.storage.s3; + const endpoint = new URL(s3.endpoint); + const creds = { + region: s3.region, + accessKeyId: s3.access_key_id, + secretAccessKey: s3.secret_access_key, + }; + + // Any path prefix on the endpoint is preserved, so an S3 gateway mounted + // under a subpath keeps working. + const prefix = endpoint.pathname.replace(/\/+$/, ''); + + function objectUrl(key) { + assertKey(key); + const url = new URL(endpoint.toString()); + // The signer treats the path as final, so encode it here and exactly + // once. Slashes stay literal to keep the key hierarchy intact. + const encoded = uriEncode(key, false); + if (s3.force_path_style) { + url.pathname = `${prefix}/${s3.bucket}/${encoded}`; + } else { + url.host = `${s3.bucket}.${endpoint.host}`; + url.pathname = `${prefix}/${encoded}`; + } + return url; + } + + async function send(method, key, { headers = {}, body, payloadHash } = {}) { + const url = objectUrl(key); + const signed = signRequest({ + method, + url, + headers, + payloadHash: payloadHash ?? sha256Hex(''), + ...creds, + }); + + const init = { method, headers: signed }; + if (body !== undefined) { + init.body = body; + // Required by undici when streaming a request body. + if (body instanceof ReadableStream) init.duplex = 'half'; + } + return fetch(url, init); + } + + async function errorFrom(res, action, key) { + let detail = ''; + try { + detail = (await res.text()).slice(0, 512); + } catch { + // The status alone is still useful. + } + return new Error(`s3 ${action} failed for ${key}: ${res.status} ${res.statusText}\n${detail}`); + } + + return { + driver: 's3', + + async put(key, body, opts = {}) { + const headers = {}; + if (opts.contentType) headers['content-type'] = opts.contentType; + + let payload; + let payloadHash; + let size = opts.size; + let sha256 = opts.sha256; + + if (Buffer.isBuffer(body) || typeof body === 'string') { + const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'); + payload = buf; + sha256 = crypto.createHash('sha256').update(buf).digest('hex'); + payloadHash = sha256; + size = buf.length; + headers['content-length'] = String(size); + } else { + if (size === undefined) { + throw new Error( + `s3 put of a stream needs an explicit size for ${key}; ` + + 'buffer the object first if the length is unknown' + ); + } + payloadHash = UNSIGNED_PAYLOAD; + headers['content-length'] = String(size); + payload = body instanceof Readable ? Readable.toWeb(body) : body; + } + + const res = await send('PUT', key, { headers, body: payload, payloadHash }); + if (!res.ok) throw await errorFrom(res, 'put', key); + await res.arrayBuffer(); + + return { key, size, sha256: sha256 ?? null }; + }, + + async get(key, opts = {}) { + const headers = {}; + if (opts.range) { + const { start = 0, end } = opts.range; + headers.range = `bytes=${start}-${end === undefined ? '' : end}`; + } + + const res = await send('GET', key, { headers }); + if (res.status === 404) throw new StorageNotFound(key); + if (!res.ok) throw await errorFrom(res, 'get', key); + + const len = res.headers.get('content-length'); + return { + key, + size: len === null ? null : Number(len), + stream: Readable.fromWeb(res.body), + }; + }, + + async head(key) { + const res = await send('HEAD', key); + if (res.status === 404) return null; + if (!res.ok) throw await errorFrom(res, 'head', key); + const len = res.headers.get('content-length'); + const modified = res.headers.get('last-modified'); + return { + key, + size: len === null ? null : Number(len), + modified: modified ? Date.parse(modified) : null, + }; + }, + + async delete(key) { + const res = await send('DELETE', key); + // A missing object is already in the desired state. + if (!res.ok && res.status !== 404) throw await errorFrom(res, 'delete', key); + await res.arrayBuffer(); + }, + + // Lets the read-api redirect a client straight at the object store + // instead of proxying the bytes. + async presign(key, opts = {}) { + return presignUrl({ + method: opts.method || 'GET', + url: objectUrl(key), + expires: opts.expires ?? 3600, + ...creds, + }); + }, + }; +} diff --git a/src/lib/storage/sigv4.js b/src/lib/storage/sigv4.js @@ -0,0 +1,187 @@ +// src/lib/storage/sigv4.js - AWS Signature Version 4 signing +// +// Only what an S3 compatible object store needs: PUT, GET, HEAD, DELETE and +// presigned URLs. Written against node:crypto rather than pulling in the AWS +// SDK, which would add roughly a hundred transitive packages for four verbs. +// +// Verified against the published AWS test vectors, see test/sigv4.test.js. + +import crypto from 'node:crypto'; + +const ALGORITHM = 'AWS4-HMAC-SHA256'; +export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'; +export const EMPTY_SHA256 = crypto.createHash('sha256').update('').digest('hex'); + +function hmac(key, data) { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest(); +} + +export function sha256Hex(data) { + return crypto.createHash('sha256').update(data).digest('hex'); +} + +// RFC 3986 encoding. encodeURIComponent leaves ! ' ( ) * alone, which AWS +// expects to be percent encoded. +export function uriEncode(str, encodeSlash = true) { + let out = ''; + for (const ch of String(str)) { + if (/[A-Za-z0-9\-._~]/.test(ch)) { + out += ch; + } else if (ch === '/') { + out += encodeSlash ? '%2F' : '/'; + } else { + for (const byte of Buffer.from(ch, 'utf8')) { + out += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + } + } + return out; +} + +// 20150830T123600Z and 20150830 +export function amzDate(date = new Date()) { + const iso = date.toISOString().replace(/[:-]|\.\d{3}/g, ''); + return { amz: iso, stamp: iso.slice(0, 8) }; +} + +function canonicalQuery(searchParams) { + const pairs = []; + for (const [k, v] of searchParams) pairs.push([uriEncode(k), uriEncode(v)]); + // Sort by encoded key, then encoded value. + pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)); + return pairs.map(([k, v]) => `${k}=${v}`).join('&'); +} + +// The canonical URI is the request path exactly as it goes on the wire. +// Callers must therefore hand in a URL whose path is already RFC 3986 +// encoded, which is what uriEncode(key, false) produces. Encoding here +// instead would double encode, turning a %20 into %25 20 and yielding +// SignatureDoesNotMatch for any key containing a space or a plus. +// +// Note this is correct for S3 specifically. Other AWS services expect the +// path to be normalized and encoded a second time. +function canonicalPath(pathname) { + return pathname === '' ? '/' : pathname; +} + +function signingKey(secretAccessKey, stamp, region, service) { + const kDate = hmac(`AWS4${secretAccessKey}`, stamp); + const kRegion = hmac(kDate, region); + const kService = hmac(kRegion, service); + return hmac(kService, 'aws4_request'); +} + +function buildCanonical({ method, url, headers, payloadHash }) { + const lowered = Object.entries(headers) + .map(([k, v]) => [k.toLowerCase(), String(v).trim().replace(/\s+/g, ' ')]) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + + const canonicalHeaders = lowered.map(([k, v]) => `${k}:${v}\n`).join(''); + const signedHeaders = lowered.map(([k]) => k).join(';'); + + const canonicalRequest = [ + method.toUpperCase(), + canonicalPath(url.pathname), + canonicalQuery(url.searchParams), + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n'); + + return { canonicalRequest, signedHeaders }; +} + +// Signs a request and returns the headers to send, including Authorization. +export function signRequest(opts) { + const { + method, + url, + headers = {}, + payloadHash = EMPTY_SHA256, + region, + service = 's3', + accessKeyId, + secretAccessKey, + date = new Date(), + } = opts; + + const target = url instanceof URL ? url : new URL(url); + const { amz, stamp } = amzDate(date); + + const signedHeaderSet = { + ...headers, + host: target.host, + 'x-amz-date': amz, + }; + if (service === 's3') signedHeaderSet['x-amz-content-sha256'] = payloadHash; + + const { canonicalRequest, signedHeaders } = buildCanonical({ + method, + url: target, + headers: signedHeaderSet, + payloadHash, + }); + + const scope = `${stamp}/${region}/${service}/aws4_request`; + const stringToSign = [ALGORITHM, amz, scope, sha256Hex(canonicalRequest)].join('\n'); + const signature = crypto + .createHmac('sha256', signingKey(secretAccessKey, stamp, region, service)) + .update(stringToSign, 'utf8') + .digest('hex'); + + return { + ...signedHeaderSet, + authorization: + `${ALGORITHM} Credential=${accessKeyId}/${scope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}`, + }; +} + +// Produces a presigned URL, where the signature travels in the query string +// and no Authorization header is needed. +export function presignUrl(opts) { + const { + method = 'GET', + url, + headers = {}, + expires = 3600, + region, + service = 's3', + accessKeyId, + secretAccessKey, + date = new Date(), + } = opts; + + const target = new URL(url instanceof URL ? url.toString() : url); + const { amz, stamp } = amzDate(date); + const scope = `${stamp}/${region}/${service}/aws4_request`; + + // Only host is signed, so the URL works from any client. + const signedHeaderSet = { ...headers, host: target.host }; + const signedHeaders = Object.keys(signedHeaderSet) + .map((k) => k.toLowerCase()) + .sort() + .join(';'); + + target.searchParams.set('X-Amz-Algorithm', ALGORITHM); + target.searchParams.set('X-Amz-Credential', `${accessKeyId}/${scope}`); + target.searchParams.set('X-Amz-Date', amz); + target.searchParams.set('X-Amz-Expires', String(expires)); + target.searchParams.set('X-Amz-SignedHeaders', signedHeaders); + + const { canonicalRequest } = buildCanonical({ + method, + url: target, + headers: signedHeaderSet, + payloadHash: UNSIGNED_PAYLOAD, + }); + + const stringToSign = [ALGORITHM, amz, scope, sha256Hex(canonicalRequest)].join('\n'); + const signature = crypto + .createHmac('sha256', signingKey(secretAccessKey, stamp, region, service)) + .update(stringToSign, 'utf8') + .digest('hex'); + + target.searchParams.set('X-Amz-Signature', signature); + return target.toString(); +} diff --git a/test/ascii.test.js b/test/ascii.test.js @@ -0,0 +1,82 @@ +// test/ascii.test.js - enforces the repository wide ASCII rule +// +// Every source and documentation file must be plain ASCII. This is a hard +// project requirement rather than a style preference, so it is tested rather +// than left to review. Smart quotes, dashes and arrows pasted in from a +// browser are the usual cause of a failure here. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const SKIP_DIRS = new Set(['.git', 'node_modules', 'data', 'tmp']); +const CHECK_EXT = new Set([ + '.js', '.mjs', '.cjs', '.json', '.md', '.sql', '.yml', '.yaml', + '.html', '.css', '.sh', '.txt', +]); +const CHECK_NAMES = new Set(['.editorconfig', '.gitignore', 'Dockerfile', 'post-receive']); + +async function* walk(dir) { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + yield* walk(path.join(dir, entry.name)); + } else if (entry.isFile()) { + yield path.join(dir, entry.name); + } + } +} + +function describeChar(ch) { + const code = ch.codePointAt(0); + return `U+${code.toString(16).toUpperCase().padStart(4, '0')} ${JSON.stringify(ch)}`; +} + +test('every tracked text file is pure ASCII', async () => { + const offences = []; + + for await (const file of walk(ROOT)) { + const ext = path.extname(file); + const base = path.basename(file); + if (!CHECK_EXT.has(ext) && !CHECK_NAMES.has(base)) continue; + + const text = await fs.readFile(file, 'utf8'); + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + for (const ch of lines[i]) { + const code = ch.codePointAt(0); + // Tab, and the printable range. Newlines are already stripped. + if (code === 9 || (code >= 32 && code <= 126)) continue; + offences.push(`${path.relative(ROOT, file)}:${i + 1}: ${describeChar(ch)}`); + break; + } + } + } + + assert.deepEqual(offences, [], `non-ASCII characters found:\n${offences.join('\n')}`); +}); + +test('no file uses CRLF line endings or trailing whitespace', async () => { + const offences = []; + + for await (const file of walk(ROOT)) { + const ext = path.extname(file); + const base = path.basename(file); + if (!CHECK_EXT.has(ext) && !CHECK_NAMES.has(base)) continue; + + const text = await fs.readFile(file, 'utf8'); + const rel = path.relative(ROOT, file); + if (text.includes('\r')) offences.push(`${rel}: contains a carriage return`); + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + if (/[ \t]+$/.test(lines[i])) offences.push(`${rel}:${i + 1}: trailing whitespace`); + } + if (text.length > 0 && !text.endsWith('\n')) offences.push(`${rel}: missing final newline`); + } + + assert.deepEqual(offences, [], `formatting problems found:\n${offences.join('\n')}`); +}); diff --git a/test/config.test.js b/test/config.test.js @@ -0,0 +1,166 @@ +// test/config.test.js - configuration loading, precedence and validation + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadConfig, stateDirs, parseKey } from '../src/lib/config.js'; + +// loadConfig reads process.env, so every case runs with a clean slate. +async function withEnv(env, fn) { + const saved = {}; + for (const key of Object.keys(process.env)) { + if (key.startsWith('CONDUCTOR_')) { + saved[key] = process.env[key]; + delete process.env[key]; + } + } + Object.assign(process.env, env); + try { + return await fn(); + } finally { + for (const key of Object.keys(process.env)) { + if (key.startsWith('CONDUCTOR_')) delete process.env[key]; + } + Object.assign(process.env, saved); + } +} + +async function tempDir() { + return fs.mkdtemp(path.join(os.tmpdir(), 'conductor-cfg-')); +} + +test('defaults select sqlite, local storage and local auth', async () => { + await withEnv({}, () => { + const cfg = loadConfig(); + assert.equal(cfg.database.dialect, 'sqlite'); + assert.equal(cfg.storage.driver, 'local'); + assert.equal(cfg.auth.mode, 'local'); + assert.equal(cfg.server.port, 8080); + }); +}); + +test('configuring an s3 bucket switches the storage driver', async () => { + await withEnv({ + CONDUCTOR_S3_ENDPOINT: 'http://127.0.0.1:3900', + CONDUCTOR_S3_BUCKET: 'ci', + CONDUCTOR_S3_ACCESS_KEY_ID: 'k', + CONDUCTOR_S3_SECRET_ACCESS_KEY: 's', + }, () => { + assert.equal(loadConfig().storage.driver, 's3'); + }); +}); + +test('a partial s3 configuration is rejected rather than half applied', async () => { + await withEnv({ CONDUCTOR_S3_BUCKET: 'ci' }, () => { + assert.throws(() => loadConfig(), /partially configured/); + }); +}); + +test('configuring an oidc issuer switches the auth mode', async () => { + await withEnv({ CONDUCTOR_OIDC_ISSUER: 'https://idp.example.com/realms/ci' }, () => { + assert.equal(loadConfig().auth.mode, 'oidc'); + }); +}); + +test('a database url selects its dialect', async () => { + await withEnv({ CONDUCTOR_DATABASE_URL: 'postgres://u:p@h:5432/ci' }, () => { + assert.equal(loadConfig().database.dialect, 'postgres'); + }); + await withEnv({ CONDUCTOR_DATABASE_URL: 'mysql://u:p@h:3306/ci' }, () => { + assert.equal(loadConfig().database.dialect, 'mysql'); + }); +}); + +test('an unsupported database scheme is reported clearly', async () => { + await withEnv({ CONDUCTOR_DATABASE_URL: 'mongodb://h/ci' }, () => { + assert.throws(() => loadConfig(), /unsupported scheme/); + }); +}); + +test('a sqlite url collapses into database.path', async () => { + await withEnv({ CONDUCTOR_DATABASE_URL: 'sqlite:///var/lib/conductor/c.db' }, () => { + const cfg = loadConfig(); + assert.equal(cfg.database.dialect, 'sqlite'); + assert.equal(cfg.database.url, null); + assert.equal(cfg.database.path, '/var/lib/conductor/c.db'); + }); +}); + +test('environment variables override the config file', async () => { + const dir = await tempDir(); + const file = path.join(dir, 'conductor.yaml'); + await fs.writeFile(file, 'server:\n port: 9999\n public_url: https://ci.example.com\n'); + + await withEnv({ CONDUCTOR_CONFIG: file, CONDUCTOR_PORT: '7777' }, () => { + const cfg = loadConfig(); + assert.equal(cfg.server.port, 7777); + assert.equal(cfg.server.public_url, 'https://ci.example.com'); + }); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('relative paths resolve against the config file directory', async () => { + const dir = await tempDir(); + const file = path.join(dir, 'conductor.yaml'); + await fs.writeFile(file, 'database:\n path: ./state/c.db\n'); + + await withEnv({ CONDUCTOR_CONFIG: file }, () => { + assert.equal(loadConfig().database.path, path.join(dir, 'state/c.db')); + }); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('a missing explicit config file is an error, a missing default is not', async () => { + await withEnv({ CONDUCTOR_CONFIG: '/nonexistent/conductor.yaml' }, () => { + assert.throws(() => loadConfig(), /config file not found/); + }); + await withEnv({}, () => { + assert.doesNotThrow(() => loadConfig()); + }); +}); + +test('a heartbeat timeout below the reap interval is rejected', async () => { + const dir = await tempDir(); + const file = path.join(dir, 'conductor.yaml'); + await fs.writeFile(file, 'scheduler:\n heartbeat_timeout: 10\n reap_interval: 30\n'); + + await withEnv({ CONDUCTOR_CONFIG: file }, () => { + assert.throws(() => loadConfig(), /must be greater than/); + }); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('a non-integer port is rejected with the variable named', async () => { + await withEnv({ CONDUCTOR_PORT: 'http' }, () => { + assert.throws(() => loadConfig(), /CONDUCTOR_PORT/); + }); +}); + +test('parseKey accepts hex and base64, and rejects the wrong length', () => { + assert.equal(parseKey('a'.repeat(64), 'k').length, 32); + assert.equal(parseKey(Buffer.alloc(32).toString('base64'), 'k').length, 32); + assert.throws(() => parseKey('abcd', 'secrets.encryption_key'), /must decode to 32 bytes/); +}); + +test('stateDirs omits the storage directory when s3 is in use', async () => { + await withEnv({}, () => { + assert.ok(stateDirs(loadConfig()).some((d) => d.endsWith('storage'))); + }); + await withEnv({ + CONDUCTOR_S3_ENDPOINT: 'http://127.0.0.1:3900', + CONDUCTOR_S3_BUCKET: 'ci', + CONDUCTOR_S3_ACCESS_KEY_ID: 'k', + CONDUCTOR_S3_SECRET_ACCESS_KEY: 's', + }, () => { + assert.ok(!stateDirs(loadConfig()).some((d) => d.endsWith('storage'))); + }); +}); + +test('the loaded config is frozen', async () => { + await withEnv({}, () => { + const cfg = loadConfig(); + assert.throws(() => { cfg.server.port = 1; }, TypeError); + }); +}); diff --git a/test/db.test.js b/test/db.test.js @@ -0,0 +1,188 @@ +// test/db.test.js - sqlite driver and migration runner +// +// The mysql and postgres drivers share the query compiler and parameter +// handling that is exercised here. Their wire behaviour is covered by the +// integration suite, which needs a live server. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { openDatabase, runMigrations } from '../src/lib/db/index.js'; +import { splitStatements } from '../src/lib/db/migrate.js'; +import { dialectFromUrl } from '../src/lib/db/dialect.js'; + +const quiet = () => {}; + +async function tempDb() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-db-')); + const cfg = { database: { dialect: 'sqlite', path: path.join(dir, 'test.db') } }; + const db = await openDatabase(cfg); + return { db, dir, cleanup: async () => { await db.close(); await fs.rm(dir, { recursive: true, force: true }); } }; +} + +test('dialect detection covers every supported scheme', () => { + assert.equal(dialectFromUrl(null), 'sqlite'); + assert.equal(dialectFromUrl('mysql://u:p@h/db'), 'mysql'); + assert.equal(dialectFromUrl('postgres://u:p@h/db'), 'postgres'); + assert.equal(dialectFromUrl('postgresql://u:p@h/db'), 'postgres'); + assert.equal(dialectFromUrl('mongodb://h/db'), null); +}); + +test('statement splitter respects literals and comments', () => { + const sql = [ + "INSERT INTO t VALUES ('a;b');", + '-- a comment with ; in it', + 'CREATE TABLE u (id TEXT);', + ].join('\n'); + const parts = splitStatements(sql, 'sqlite'); + assert.equal(parts.length, 2); + assert.ok(parts[0].includes("'a;b'")); + assert.ok(parts[1].startsWith('CREATE TABLE u')); +}); + +test('statement splitter keeps postgres dollar quoted bodies whole', () => { + const sql = "CREATE FUNCTION f() RETURNS void AS $$ BEGIN a; b; END $$ LANGUAGE plpgsql;"; + assert.equal(splitStatements(sql, 'postgres').length, 1); +}); + +test('migrations apply once and are idempotent', async () => { + const { db, cleanup } = await tempDb(); + try { + const first = await runMigrations(db, { logger: quiet }); + assert.equal(first.applied.length, 1); + + const second = await runMigrations(db, { logger: quiet }); + assert.equal(second.applied.length, 0); + assert.equal(second.total, 1); + } finally { + await cleanup(); + } +}); + +test('an edited migration is refused rather than silently diverging', async () => { + const { db, dir, cleanup } = await tempDb(); + try { + const custom = path.join(dir, 'migrations'); + await fs.mkdir(custom); + await fs.writeFile(path.join(custom, '001_a.sql'), 'CREATE TABLE a (id TEXT);'); + await runMigrations(db, { dir: custom, logger: quiet }); + + await fs.writeFile(path.join(custom, '001_a.sql'), 'CREATE TABLE a (id TEXT, extra TEXT);'); + await assert.rejects( + runMigrations(db, { dir: custom, logger: quiet }), + /was modified after it was applied/ + ); + } finally { + await cleanup(); + } +}); + +test('a migration removed from disk is reported', async () => { + const { db, dir, cleanup } = await tempDb(); + try { + const custom = path.join(dir, 'migrations'); + await fs.mkdir(custom); + await fs.writeFile(path.join(custom, '001_a.sql'), 'CREATE TABLE a (id TEXT);'); + await runMigrations(db, { dir: custom, logger: quiet }); + await fs.rm(path.join(custom, '001_a.sql')); + + await assert.rejects(runMigrations(db, { dir: custom, logger: quiet }), /missing from/); + } finally { + await cleanup(); + } +}); + +test('rows come back as ordinary objects', async () => { + const { db, cleanup } = await tempDb(); + try { + await runMigrations(db, { logger: quiet }); + const now = Date.now(); + await db.run( + 'INSERT INTO projects (id, name, repo_url, created_at, updated_at) VALUES ({id}, {n}, {u}, {t}, {t})', + { id: 'p1', n: 'P', u: 'https://example.invalid/p.git', t: now } + ); + const row = await db.get('SELECT * FROM projects WHERE id = {id}', { id: 'p1' }); + assert.equal(Object.getPrototypeOf(row), Object.prototype); + assert.equal(row.name, 'P'); + assert.equal(row.enabled, 1); + } finally { + await cleanup(); + } +}); + +test('a conditional update claims exactly once', async () => { + const { db, cleanup } = await tempDb(); + try { + await runMigrations(db, { logger: quiet }); + const t = Date.now(); + await db.run('INSERT INTO projects (id,name,repo_url,created_at,updated_at) VALUES ({i},{n},{u},{t},{t})', + { i: 'p', n: 'p', u: 'u', t }); + await db.run('INSERT INTO runs (id,project_id,number,head_sha,created_at) VALUES ({i},{p},{n},{s},{t})', + { i: 'r', p: 'p', n: 1, s: 'sha', t }); + await db.run( + 'INSERT INTO jobs (id,run_id,name,base_name,image,requires,spec,timeout,created_at) ' + + 'VALUES ({i},{r},{n},{n},{img},{req},{spec},{to},{t})', + { i: 'r:b', r: 'r', n: 'b', img: 'alpine', req: '[]', spec: '{}', to: 60, t } + ); + + const claim = 'UPDATE jobs SET state = {to} WHERE id = {id} AND state = {from}'; + const first = await db.run(claim, { to: 'running', id: 'r:b', from: 'queued' }); + const second = await db.run(claim, { to: 'running', id: 'r:b', from: 'queued' }); + assert.equal(first.changes, 1); + assert.equal(second.changes, 0); + } finally { + await cleanup(); + } +}); + +test('deleting a run cascades to its jobs', async () => { + const { db, cleanup } = await tempDb(); + try { + await runMigrations(db, { logger: quiet }); + const t = Date.now(); + await db.run('INSERT INTO projects (id,name,repo_url,created_at,updated_at) VALUES ({i},{n},{u},{t},{t})', + { i: 'p', n: 'p', u: 'u', t }); + await db.run('INSERT INTO runs (id,project_id,number,head_sha,created_at) VALUES ({i},{p},{n},{s},{t})', + { i: 'r', p: 'p', n: 1, s: 'sha', t }); + await db.run( + 'INSERT INTO jobs (id,run_id,name,base_name,image,requires,spec,timeout,created_at) ' + + 'VALUES ({i},{r},{n},{n},{img},{req},{spec},{to},{t})', + { i: 'r:b', r: 'r', n: 'b', img: 'alpine', req: '[]', spec: '{}', to: 60, t } + ); + + await db.run('DELETE FROM runs WHERE id = {id}', { id: 'r' }); + assert.equal((await db.get('SELECT COUNT(*) AS c FROM jobs', {})).c, 0); + } finally { + await cleanup(); + } +}); + +test('a failed transaction rolls back, and a failed savepoint keeps the outer work', async () => { + const { db, cleanup } = await tempDb(); + try { + await runMigrations(db, { logger: quiet }); + const t = Date.now(); + const insert = 'INSERT INTO projects (id,name,repo_url,created_at,updated_at) VALUES ({i},{n},{u},{t},{t})'; + + await assert.rejects(db.transaction(async (tx) => { + await tx.run(insert, { i: 'rolled', n: 'x', u: 'u', t }); + throw new Error('boom'); + }), /boom/); + assert.equal((await db.get('SELECT COUNT(*) AS c FROM projects', {})).c, 0); + + await db.transaction(async (tx) => { + await tx.run(insert, { i: 'kept', n: 'x', u: 'u', t }); + await assert.rejects(tx.transaction(async (inner) => { + await inner.run(insert, { i: 'inner', n: 'x', u: 'u', t }); + throw new Error('inner failed'); + }), /inner failed/); + }); + + const ids = (await db.all('SELECT id FROM projects ORDER BY id', {})).map((r) => r.id); + assert.deepEqual(ids, ['kept']); + } finally { + await cleanup(); + } +}); diff --git a/test/query.test.js b/test/query.test.js @@ -0,0 +1,92 @@ +// test/query.test.js - named bind marker compilation + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { compileQuery, bindParams } from '../src/lib/db/query.js'; + +test('compiles markers to the placeholder each dialect expects', () => { + const sql = 'SELECT * FROM jobs WHERE run_id = {run} AND state = {state}'; + + assert.equal(compileQuery(sql, 'sqlite').sql, 'SELECT * FROM jobs WHERE run_id = ? AND state = ?'); + assert.equal(compileQuery(sql, 'mysql').sql, 'SELECT * FROM jobs WHERE run_id = ? AND state = ?'); + assert.equal(compileQuery(sql, 'postgres').sql, 'SELECT * FROM jobs WHERE run_id = $1 AND state = $2'); + + assert.deepEqual(compileQuery(sql, 'postgres').keys, ['run', 'state']); +}); + +test('numbers postgres placeholders per occurrence, including repeats', () => { + const { sql, keys } = compileQuery('SELECT {a}, {b}, {a}', 'postgres'); + assert.equal(sql, 'SELECT $1, $2, $3'); + assert.deepEqual(keys, ['a', 'b', 'a']); +}); + +test('ignores markers inside string literals and comments', () => { + const sql = "SELECT '{notabind}' AS a, {real} -- {alsonot}\n, \"{quoted}\""; + const { keys } = compileQuery(sql, 'sqlite'); + assert.deepEqual(keys, ['real']); + assert.ok(compileQuery(sql, 'sqlite').sql.includes("'{notabind}'")); +}); + +test('a json literal in default text survives untouched', () => { + const { sql, keys } = compileQuery("UPDATE t SET spec = '{}' WHERE id = {id}", 'postgres'); + assert.equal(sql, "UPDATE t SET spec = '{}' WHERE id = $1"); + assert.deepEqual(keys, ['id']); +}); + +test('doubling a brace escapes it', () => { + const { sql, keys } = compileQuery('SELECT {{name}} , {name}', 'sqlite'); + assert.equal(sql, 'SELECT {name} , ?'); + assert.deepEqual(keys, ['name']); +}); + +test('block comments are skipped, and nest only for postgres', () => { + assert.deepEqual(compileQuery('SELECT /* {no} */ {yes}', 'mysql').keys, ['yes']); + assert.deepEqual(compileQuery('SELECT /* a /* {no} */ b */ {yes}', 'postgres').keys, ['yes']); +}); + +test('postgres dollar quoted bodies are skipped', () => { + const { keys } = compileQuery('SELECT $tag$ {no} $tag$, {yes}', 'postgres'); + assert.deepEqual(keys, ['yes']); +}); + +test('mysql backtick identifiers and hash comments are skipped', () => { + assert.deepEqual(compileQuery('SELECT `col{no}`, {yes} # {alsono}\n', 'mysql').keys, ['yes']); +}); + +test('a malformed marker is rejected with a useful message', () => { + assert.throws(() => compileQuery('SELECT {not a name}', 'sqlite'), /malformed bind marker/); +}); + +test('binds by name, resolving dotted paths', () => { + const { keys, sql } = compileQuery('SELECT {a}, {nested.deep.value}', 'sqlite'); + assert.deepEqual(bindParams(keys, { a: 1, nested: { deep: { value: 'x' } } }, sql), [1, 'x']); +}); + +test('an exact key beats path traversal', () => { + const { keys, sql } = compileQuery('SELECT {a.b}', 'sqlite'); + assert.deepEqual(bindParams(keys, { 'a.b': 'flat', a: { b: 'nested' } }, sql), ['flat']); +}); + +test('reports every missing key at once', () => { + const { keys, sql } = compileQuery('SELECT {a}, {b}, {c}', 'sqlite'); + assert.throws( + () => bindParams(keys, { b: 1 }, sql), + (e) => /missing bind parameter\(s\): a, c/.test(e.message) + ); +}); + +test('rejects an array of parameters, which would silently bind by position', () => { + const { keys, sql } = compileQuery('SELECT {a}', 'sqlite'); + assert.throws(() => bindParams(keys, ['x'], sql), /must be an object/); +}); + +test('normalizes booleans, dates and null', () => { + const { keys, sql } = compileQuery('SELECT {t}, {f}, {d}, {n}', 'sqlite'); + const at = new Date('2026-01-02T03:04:05.678Z'); + assert.deepEqual(bindParams(keys, { t: true, f: false, d: at, n: null }, sql), [1, 0, at.getTime(), null]); +}); + +test('rejects an object value, which is nearly always a forgotten stringify', () => { + const { keys, sql } = compileQuery('SELECT {spec}', 'sqlite'); + assert.throws(() => bindParams(keys, { spec: { a: 1 } }, sql), /unsupported type object/); +}); diff --git a/test/secretbox.test.js b/test/secretbox.test.js @@ -0,0 +1,102 @@ +// test/secretbox.test.js - encryption at rest for stored secrets + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { createSecretBox } from '../src/lib/secretbox.js'; +import { jobId, slugify, randomId, timeOrderedId, hashToken, safeEqualHex, JOB_ID_MAX } from '../src/lib/ids.js'; + +const KEY = crypto.randomBytes(32); + +test('seals and opens a value', () => { + const box = createSecretBox(KEY); + const sealed = box.seal('hunter2'); + assert.ok(sealed.startsWith('v1.')); + assert.ok(!sealed.includes('hunter2')); + assert.equal(box.open(sealed), 'hunter2'); +}); + +test('sealing twice produces different ciphertext', () => { + const box = createSecretBox(KEY); + assert.notEqual(box.seal('same'), box.seal('same')); +}); + +test('without a key, values are stored as marked plaintext', () => { + const box = createSecretBox(null); + const stored = box.seal('visible'); + assert.ok(stored.startsWith('plain.')); + assert.equal(box.open(stored), 'visible'); + assert.equal(box.isPlaintext(stored), true); +}); + +test('a keyed box can still read values written before a key existed', () => { + const plain = createSecretBox(null).seal('legacy'); + assert.equal(createSecretBox(KEY).open(plain), 'legacy'); +}); + +test('an unkeyed box refuses encrypted values with an actionable message', () => { + const sealed = createSecretBox(KEY).seal('secret'); + assert.throws(() => createSecretBox(null).open(sealed), /encryption_key is not configured/); +}); + +test('the wrong key fails to open', () => { + const sealed = createSecretBox(KEY).seal('secret'); + assert.throws(() => createSecretBox(crypto.randomBytes(32)).open(sealed), /failed to decrypt/); +}); + +test('tampering with the ciphertext is detected', () => { + const box = createSecretBox(KEY); + const parts = box.seal('secret').split('.'); + const body = Buffer.from(parts[3], 'base64'); + body[0] ^= 0xff; + parts[3] = body.toString('base64'); + assert.throws(() => box.open(parts.join('.')), /failed to decrypt/); +}); + +test('associated data binds a secret to its location', () => { + const box = createSecretBox(KEY); + const sealed = box.seal('token', 'project:a/var:TOKEN'); + assert.equal(box.open(sealed, 'project:a/var:TOKEN'), 'token'); + assert.throws(() => box.open(sealed, 'project:b/var:TOKEN'), /failed to decrypt/); +}); + +test('a key of the wrong size is refused at construction', () => { + assert.throws(() => createSecretBox(crypto.randomBytes(16)), /32 byte Buffer/); +}); + +test('job ids stay readable and within the column width', () => { + const runId = timeOrderedId(); + assert.equal(jobId(runId, 'build'), `${runId}:build`); + + const long = 'a'.repeat(300); + const id = jobId(runId, long); + assert.ok(id.length <= JOB_ID_MAX, `${id.length} exceeds ${JOB_ID_MAX}`); + assert.ok(id.startsWith(`${runId}:`)); + // Distinct long names must not collide after truncation. + assert.notEqual(id, jobId(runId, `${long}b`)); +}); + +test('time ordered ids sort by creation time', () => { + const early = timeOrderedId(1000000000000); + const late = timeOrderedId(1000000001000); + assert.ok(early < late); + assert.equal(early.length, 20); +}); + +test('random ids use the expected alphabet', () => { + assert.match(randomId(32), /^[0-9abcdefghjkmnpqrstvwxyz]{32}$/); +}); + +test('slugify produces readable project ids and falls back when empty', () => { + assert.equal(slugify('My Project!'), 'my-project'); + assert.equal(slugify(' --weird-- '), 'weird'); + assert.match(slugify('!!!'), /^[0-9a-z]{16}$/); +}); + +test('token hashing and constant time comparison', () => { + const a = hashToken('token-value'); + assert.match(a, /^[0-9a-f]{64}$/); + assert.equal(safeEqualHex(a, hashToken('token-value')), true); + assert.equal(safeEqualHex(a, hashToken('other')), false); + assert.equal(safeEqualHex(a, 'short'), false); +}); diff --git a/test/sigv4.test.js b/test/sigv4.test.js @@ -0,0 +1,122 @@ +// test/sigv4.test.js - AWS Signature Version 4 +// +// The vectors come from the published AWS signature test suite. Getting this +// wrong produces an opaque SignatureDoesNotMatch from the server, so the +// reference cases are worth pinning. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { signRequest, presignUrl, uriEncode, amzDate, EMPTY_SHA256 } from '../src/lib/storage/sigv4.js'; + +const CREDS = { + region: 'us-east-1', + service: 'service', + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + date: new Date(Date.UTC(2015, 7, 30, 12, 36, 0)), +}; + +test('get-vanilla matches the reference signature', () => { + const headers = signRequest({ + method: 'GET', + url: new URL('https://example.amazonaws.com/'), + payloadHash: EMPTY_SHA256, + ...CREDS, + }); + + assert.equal( + headers.authorization, + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, ' + + 'SignedHeaders=host;x-amz-date, ' + + 'Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31' + ); +}); + +test('get-vanilla-query-order-key-case matches the reference signature', () => { + const headers = signRequest({ + method: 'GET', + url: new URL('https://example.amazonaws.com/?Param2=value2&Param1=value1'), + payloadHash: EMPTY_SHA256, + ...CREDS, + }); + + assert.equal( + headers.authorization, + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, ' + + 'SignedHeaders=host;x-amz-date, ' + + 'Signature=b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500' + ); +}); + +test('uriEncode percent encodes the characters encodeURIComponent leaves alone', () => { + assert.equal(uriEncode("a b!'()*"), 'a%20b%21%27%28%29%2A'); + assert.equal(uriEncode('keep~unreserved-._'), 'keep~unreserved-._'); + assert.equal(uriEncode('a/b'), 'a%2Fb'); + assert.equal(uriEncode('a/b', false), 'a/b'); +}); + +test('amzDate produces both required formats', () => { + const { amz, stamp } = amzDate(new Date(Date.UTC(2015, 7, 30, 12, 36, 0))); + assert.equal(amz, '20150830T123600Z'); + assert.equal(stamp, '20150830'); +}); + +test('s3 requests carry the content hash header', () => { + const headers = signRequest({ + method: 'PUT', + url: new URL('https://bucket.example.com/key'), + payloadHash: EMPTY_SHA256, + region: 'us-east-1', + service: 's3', + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'secret', + }); + assert.equal(headers['x-amz-content-sha256'], EMPTY_SHA256); + assert.ok(headers.authorization.includes('x-amz-content-sha256')); +}); + +test('presigned urls carry the signature in the query string', () => { + const url = new URL(presignUrl({ + method: 'GET', + url: new URL('https://example.com/bucket/key'), + expires: 900, + region: 'us-east-1', + service: 's3', + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'secret', + })); + + assert.equal(url.searchParams.get('X-Amz-Algorithm'), 'AWS4-HMAC-SHA256'); + assert.equal(url.searchParams.get('X-Amz-Expires'), '900'); + assert.equal(url.searchParams.get('X-Amz-SignedHeaders'), 'host'); + assert.match(url.searchParams.get('X-Amz-Signature'), /^[0-9a-f]{64}$/); +}); + +test('an already encoded path is not encoded a second time', () => { + // A key containing a space reaches the signer as %20. Re-encoding it to + // %2520 is the bug this guards against. + const url = new URL('https://example.com/bucket/a%20b'); + const headers = signRequest({ + method: 'GET', + url, + payloadHash: EMPTY_SHA256, + region: 'us-east-1', + service: 's3', + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'secret', + }); + + // Signing the same path written differently must differ, proving the path + // is taken verbatim rather than normalized. + const other = signRequest({ + method: 'GET', + url: new URL('https://example.com/bucket/a%2520b'), + payloadHash: EMPTY_SHA256, + region: 'us-east-1', + service: 's3', + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'secret', + }); + + assert.notEqual(headers.authorization, other.authorization); +}); diff --git a/test/storage.test.js b/test/storage.test.js @@ -0,0 +1,193 @@ +// test/storage.test.js - object storage backends +// +// The local backend is always exercised. The S3 backend runs against a live +// server only when CONDUCTOR_TEST_S3_ENDPOINT is set, for example: +// +// docker run -d -p 9000:9000 -e MINIO_ROOT_USER=testkey \ +// -e MINIO_ROOT_PASSWORD=testsecret123 quay.io/minio/minio server /data +// CONDUCTOR_TEST_S3_ENDPOINT=http://127.0.0.1:9000 \ +// CONDUCTOR_TEST_S3_KEY=testkey CONDUCTOR_TEST_S3_SECRET=testsecret123 \ +// npm test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { createStorage, keys, StorageNotFound, assertKey, sanitizeRelativePath } from '../src/lib/storage/index.js'; +import { signRequest, sha256Hex } from '../src/lib/storage/sigv4.js'; + +async function drain(stream) { + let out = ''; + for await (const chunk of stream) out += chunk; + return out; +} + +async function localStorage() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-store-')); + const cfg = { storage: { driver: 'local', path: dir, s3: {} } }; + return { st: createStorage(cfg), cleanup: () => fs.rm(dir, { recursive: true, force: true }) }; +} + +test('key validation rejects traversal and absolute paths', () => { + for (const bad of ['../etc/passwd', '/abs', 'a//b', 'a/../b', 'a/./b', '', 'a\\b', 'a\0b']) { + assert.throws(() => assertKey(bad), new RegExp('storage key'), `expected ${JSON.stringify(bad)} to be rejected`); + } + assert.equal(assertKey('artifacts/run/job/dist/app.tar.gz'), 'artifacts/run/job/dist/app.tar.gz'); +}); + +test('sanitizeRelativePath strips traversal from worker supplied paths', () => { + assert.equal(sanitizeRelativePath('../../etc/passwd'), 'etc/passwd'); + assert.equal(sanitizeRelativePath('./dist/./app.bin'), 'dist/app.bin'); + assert.equal(sanitizeRelativePath('/abs/path'), 'abs/path'); + assert.equal(sanitizeRelativePath('..'), null); + assert.equal(sanitizeRelativePath(''), null); +}); + +test('key layout is stable', () => { + assert.equal(keys.artifact('r1', 'r1:build', 'dist/a.bin'), 'artifacts/r1/r1:build/dist/a.bin'); + assert.equal(keys.log('r1', 'r1:build'), 'logs/r1/r1:build.log'); +}); + +test('local backend round trips a buffer and reports its digest', async () => { + const { st, cleanup } = await localStorage(); + try { + const put = await st.put('a/b/c.txt', Buffer.from('hello conductor')); + assert.equal(put.size, 15); + assert.equal(put.sha256, sha256Hex(Buffer.from('hello conductor'))); + + assert.equal(await drain((await st.get('a/b/c.txt')).stream), 'hello conductor'); + assert.equal((await st.head('a/b/c.txt')).size, 15); + } finally { + await cleanup(); + } +}); + +test('local backend serves ranges', async () => { + const { st, cleanup } = await localStorage(); + try { + await st.put('r.txt', Buffer.from('hello conductor')); + assert.equal(await drain((await st.get('r.txt', { range: { start: 6, end: 14 } })).stream), 'conductor'); + assert.equal(await drain((await st.get('r.txt', { range: { start: 6 } })).stream), 'conductor'); + } finally { + await cleanup(); + } +}); + +test('local backend accepts a stream', async () => { + const { st, cleanup } = await localStorage(); + try { + const put = await st.put('s.log', Readable.from(['line one\n', 'line two\n'])); + assert.equal(put.size, 18); + assert.equal(await drain((await st.get('s.log')).stream), 'line one\nline two\n'); + } finally { + await cleanup(); + } +}); + +test('local backend reports a missing object and deletes idempotently', async () => { + const { st, cleanup } = await localStorage(); + try { + await assert.rejects(st.get('nope'), (e) => e instanceof StorageNotFound); + assert.equal(await st.head('nope'), null); + await st.delete('nope'); + } finally { + await cleanup(); + } +}); + +test('local backend has no presigned urls', async () => { + const { st, cleanup } = await localStorage(); + try { + assert.equal(await st.presign('a.txt'), null); + } finally { + await cleanup(); + } +}); + +test('a partial write leaves no object behind', async () => { + const { st, cleanup } = await localStorage(); + try { + const failing = new Readable({ + read() { this.destroy(new Error('source failed')); }, + }); + await assert.rejects(st.put('partial.bin', failing), /source failed/); + assert.equal(await st.head('partial.bin'), null); + } finally { + await cleanup(); + } +}); + +// S3 coverage, opt in. +const S3_ENDPOINT = process.env.CONDUCTOR_TEST_S3_ENDPOINT; +const s3Options = { skip: S3_ENDPOINT ? false : 'set CONDUCTOR_TEST_S3_ENDPOINT to run S3 tests' }; + +test('s3 backend round trips awkward keys against a live server', s3Options, async () => { + const bucket = `citest${Date.now().toString(36)}`; + const creds = { + region: 'us-east-1', + accessKeyId: process.env.CONDUCTOR_TEST_S3_KEY, + secretAccessKey: process.env.CONDUCTOR_TEST_S3_SECRET, + }; + + const bucketUrl = new URL(`${S3_ENDPOINT.replace(/\/+$/, '')}/${bucket}`); + const created = await fetch(bucketUrl, { + method: 'PUT', + headers: signRequest({ method: 'PUT', url: bucketUrl, payloadHash: sha256Hex(''), ...creds }), + }); + assert.ok(created.ok, `failed to create bucket: ${created.status}`); + + const st = createStorage({ + storage: { + driver: 's3', + s3: { + endpoint: S3_ENDPOINT, + bucket, + region: creds.region, + access_key_id: creds.accessKeyId, + secret_access_key: creds.secretAccessKey, + force_path_style: true, + }, + }, + }); + + // Spaces and plus signs are exactly what a naive signer gets wrong. + const key = 'artifacts/r1/r1:build/dist/app bin+v1(final)!.tar.gz'; + const put = await st.put(key, Buffer.from('hello from s3'), { contentType: 'application/gzip' }); + assert.equal(put.size, 13); + + assert.equal(await drain((await st.get(key)).stream), 'hello from s3'); + assert.equal(await drain((await st.get(key, { range: { start: 6, end: 9 } })).stream), 'from'); + assert.equal((await st.head(key)).size, 13); + + const streamed = await st.put('logs/r1/j.log', Readable.from([Buffer.from('streamed '), Buffer.from('upload')]), { size: 15 }); + assert.equal(streamed.size, 15); + + const presigned = await fetch(await st.presign(key, { expires: 300 })); + assert.equal(presigned.status, 200); + assert.equal(await presigned.text(), 'hello from s3'); + + await assert.rejects(st.get('missing/object'), (e) => e instanceof StorageNotFound); + + await st.delete(key); + assert.equal(await st.head(key), null); + await st.delete(key); +}); + +test('s3 put of a stream without a size is refused', s3Options, async () => { + const st = createStorage({ + storage: { + driver: 's3', + s3: { + endpoint: S3_ENDPOINT, + bucket: 'irrelevant', + region: 'us-east-1', + access_key_id: 'k', + secret_access_key: 's', + force_path_style: true, + }, + }, + }); + await assert.rejects(st.put('k', Readable.from(['x'])), /needs an explicit size/); +});