conductor

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

commit 24d7089bae258cfd6b9386c0fc2408cccb4cb67c
parent 248bd2fd6de9fd7d522e73b19101f1ffca50cd0a
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 18:18:07 +0200

Expire artifacts and logs, configurable per project

Diffstat:
Mconductor.example.yaml | 21+++++++++++++++++++++
Mdocs/api.md | 9++++++++-
Mdocs/deployment.md | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/pipeline.md | 11+++++++++++
Amigrations/mysql/004_retention.sql | 12++++++++++++
Amigrations/postgres/004_retention.sql | 11+++++++++++
Amigrations/sqlite/004_retention.sql | 32++++++++++++++++++++++++++++++++
Msrc/conductor/app.js | 27++++++++++++++++++++++++++-
Msrc/conductor/index.js | 4+++-
Asrc/conductor/retention.js | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/conductor/routes/manage.js | 22++++++++++++++++++++++
Msrc/conductor/routes/workers.js | 29+++++++++++++++++++++++++----
Msrc/conductor/ui/pages.js | 36+++++++++++++++++++++++++++++++++++-
Msrc/conductor/ui/routes.js | 35+++++++++++++++++++++++++++++++++++
Msrc/lib/config.js | 37+++++++++++++++++++++++++++++++++++++
Msrc/lib/projects.js | 29+++++++++++++++++++++++++++++
Mtest/helpers/harness.js | 3+++
Atest/retention.test.js | 524+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/ui.test.js | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 1185 insertions(+), 8 deletions(-)

diff --git a/conductor.example.yaml b/conductor.example.yaml @@ -79,6 +79,27 @@ log: spool_path: ./data/logs max_size: 67108864 +# How long build output is kept. A project may override any of these from +# its settings page; zero means keep forever. +# +# Artifacts follow two rules and survive if either wants them: the last +# artifact_keep_runs runs are kept whatever their age, and anything younger +# than artifact_keep_days is kept however many runs have followed. The most +# recent successful run is always kept, so a project that has gone quiet +# still has something to download. A job that sets artifacts.expire in the +# pipeline overrides all of it with an exact deadline. +# +# Logs go by age alone. They are the reason this exists: written for every +# job, read for almost none, and otherwise never removed. +retention: + artifact_keep_runs: 10 + artifact_keep_days: 30 + log_keep_days: 14 + # Seconds between sweeps, and how much one sweep will delete, so a first + # pass over a long backlog cannot monopolise the database or the bucket. + sweep_interval: 3600 + batch: 500 + scheduler: # A claimed job whose worker stops reporting for this long is treated as # lost, then retried or failed. Must exceed reap_interval. diff --git a/docs/api.md b/docs/api.md @@ -99,10 +99,11 @@ Any signed in user manages what they own; administrators manage everything. | Method | Path | Purpose | | ------ | ----------------------------------------- | -------------------------- | +| GET | `/api/retention` | server retention defaults | | GET | `/api/projects` | projects you may manage | | POST | `/api/projects` | register one | | GET | `/api/projects/:id` | one project | -| PATCH | `/api/projects/:id` | `enabled`, `visibility`, `owner_id` | +| PATCH | `/api/projects/:id` | `enabled`, `visibility`, `owner_id`, retention | | DELETE | `/api/projects/:id` | remove it and its runs | | POST | `/api/projects/:id/trigger-secret` | rotate, returns it once | | GET | `/api/projects/:id/variables` | names only, never values | @@ -118,6 +119,12 @@ Any signed in user manages what they own; administrators manage everything. Creating a project returns the trigger secret, and creating a worker token returns the token. Neither can be read back afterwards; rotate or reissue. +Retention is set with `artifact_keep_runs`, `artifact_keep_days` and +`log_keep_days`. Null follows the server default, zero keeps forever, and +a number is a count of runs or of days. `GET /api/retention` reports what +a null resolves to. See [deployment.md](deployment.md) for how the two +artifact rules combine. + `owner_id` may only be changed by an administrator, and only an administrator can create a shared worker (`{"shared": true}`), which is one that accepts jobs from any project rather than from one owner. diff --git a/docs/deployment.md b/docs/deployment.md @@ -165,6 +165,81 @@ multi gigabyte build output is both slow and surprising. Set `CONDUCTOR_PUBLIC_URL=https://ci.example.com` to match, or workers will be handed callback URLs that do not work. +Retention +--------- + +Nothing is deleted unless this says so, and logs are what fills a disk: +written for every job, read for almost none. The defaults keep artifacts +for 30 days or the last 10 runs, and logs for 14 days. + +```yaml +retention: + artifact_keep_runs: 10 + artifact_keep_days: 30 + log_keep_days: 14 + sweep_interval: 3600 + batch: 500 +``` + +A project overrides any of these from its settings page, or over the API: + +```sh +curl -X PATCH https://ci.example.com/api/projects/demo \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"artifact_keep_runs": 5, "log_keep_days": 30}' +``` + +Three values, three meanings, and the difference matters: + +| Value | Meaning | +| ------ | ---------------------------------------- | +| `null` | follow the server default | +| `0` | keep forever | +| `n` | keep that many runs, or that many days | + +`GET /api/retention` reports the server defaults, so an interface can show +what a `null` actually resolves to. + +### How the artifact rules combine + +An artifact is kept if **either** rule wants it. The last +`artifact_keep_runs` runs keep their artifacts however old they are, and +anything younger than `artifact_keep_days` is kept however many runs have +followed. Setting both is therefore more generous than setting one, not +less. + +The most recent **successful** run is kept regardless, so a project that +has not built in months still has something to download. Only the latest +one: older successes are not protected. + +A job that sets `artifacts.expire` in its pipeline overrides all of the +above with an exact deadline, including the protection for the last good +run. That is deliberate, and is how a bulky intermediate avoids becoming +immortal by being green. + +Logs go by age alone. Sweeping a log deletes the bytes and leaves the job, +so a run stays explainable after its output is gone, and the interface can +tell an expired log apart from one that was never written. + +### What a first sweep will do + +Upgrading applies these defaults to everything already stored. On an +installation that has been running for a while that first sweep will +delete a great deal, so set the values you want before starting the new +version, or set them to `0` and decide later: + +```sh +CONDUCTOR_RETENTION_ARTIFACT_DAYS=0 \ +CONDUCTOR_RETENTION_LOG_DAYS=0 \ + docker compose -f deploy/docker-compose.yml up -d +``` + +A sweep deletes at most `batch` artifacts and `batch` logs per project per +pass, so a large backlog drains over several passes rather than in one +long transaction. Objects are deleted before rows: an object left behind +wastes space, whereas a row left behind is a download that fails. + Backups ------- diff --git a/docs/pipeline.md b/docs/pipeline.md @@ -265,6 +265,17 @@ A bare list is shorthand for `paths`. Paths are relative to the workspace; absolute paths are rejected. `when` is `on_success`, `on_failure` or `always`. +`expire` sets an exact deadline for what this job produces, and overrides +the project's retention policy in both directions: sooner than the project +would delete, or later than it would keep. It also overrides the rule that +protects the most recent successful run, which is the point of it. A +node_modules tree is worth dropping within the hour whether or not its run +was green, and without that the largest artifacts are the ones kept +longest. + +Leave it out and the project's policy applies, which is the usual case. +See [deployment.md](deployment.md) for what those policies are. + Artifacts are streamed rather than buffered, so size is bounded by where they are stored rather than by the conductor's memory. Object storage receives anything over 32 MiB as a multipart upload, which keeps memory diff --git a/migrations/mysql/004_retention.sql b/migrations/mysql/004_retention.sql @@ -0,0 +1,12 @@ +-- 004_retention.sql - how long artifacts and logs are kept +-- +-- See migrations/sqlite/004_retention.sql for the reasoning. + +ALTER TABLE projects + ADD COLUMN artifact_keep_runs INT NULL, + ADD COLUMN artifact_keep_days INT NULL, + ADD COLUMN log_keep_days INT NULL; + +ALTER TABLE jobs + ADD COLUMN log_expired_at BIGINT NULL, + ADD KEY idx_jobs_log_sweep (log_expired_at, finished_at); diff --git a/migrations/postgres/004_retention.sql b/migrations/postgres/004_retention.sql @@ -0,0 +1,11 @@ +-- 004_retention.sql - how long artifacts and logs are kept +-- +-- See migrations/sqlite/004_retention.sql for the reasoning. + +ALTER TABLE projects ADD COLUMN artifact_keep_runs INTEGER; +ALTER TABLE projects ADD COLUMN artifact_keep_days INTEGER; +ALTER TABLE projects ADD COLUMN log_keep_days INTEGER; + +ALTER TABLE jobs ADD COLUMN log_expired_at BIGINT; + +CREATE INDEX idx_jobs_log_sweep ON jobs (log_expired_at, finished_at); diff --git a/migrations/sqlite/004_retention.sql b/migrations/sqlite/004_retention.sql @@ -0,0 +1,32 @@ +-- 004_retention.sql - how long artifacts and logs are kept +-- +-- Without this everything a run produces is kept forever, and a conductor +-- that has been busy for a year is mostly old build output nobody will +-- read again. +-- +-- Each setting is NULL when the project has no opinion, in which case the +-- server default applies. Zero means keep forever, which has to be +-- distinguishable from "unset" or a project could not opt out of a server +-- default that deletes things. +-- +-- Artifacts have two rules and an artifact survives if either wants it: +-- the last artifact_keep_runs runs are kept however old they are, and +-- anything younger than artifact_keep_days is kept however many runs have +-- followed it. The artifacts of the most recent successful run are kept +-- regardless, so a project that has gone quiet still has something to +-- download. A job that set artifacts.expire in the pipeline overrides all +-- of that with an exact deadline, in either direction, since a bulky +-- intermediate is worth dropping early even from a green build. +-- +-- Logs are simpler: they go by age alone. + +ALTER TABLE projects ADD COLUMN artifact_keep_runs INTEGER; +ALTER TABLE projects ADD COLUMN artifact_keep_days INTEGER; +ALTER TABLE projects ADD COLUMN log_keep_days INTEGER; + +-- Distinguishes a log that was swept from one that never existed, so the +-- interface can say which without guessing from an empty log_key. +ALTER TABLE jobs ADD COLUMN log_expired_at INTEGER; + +-- The sweep walks finished jobs oldest first, per project. +CREATE INDEX idx_jobs_log_sweep ON jobs (log_expired_at, finished_at); diff --git a/src/conductor/app.js b/src/conductor/app.js @@ -18,6 +18,7 @@ import { createUsers } from '../lib/users.js'; import { createVariables } from '../lib/variables.js'; import { createAuth } from '../lib/auth/index.js'; import { createScheduler } from './scheduler.js'; +import { createRetention } from './retention.js'; import workerRoutes from './routes/workers.js'; import triggerRoutes from './routes/trigger.js'; @@ -49,6 +50,7 @@ export async function createServices(cfg, options = {}) { const auth = createAuth({ cfg, users, logger }); const scheduler = createScheduler({ cfg, db, git, logs, storage, projects, variables, logger }); + const retention = createRetention({ cfg, db, storage, logs, logger }); // Without a first administrator there is no way into the admin surface. // Only done for local accounts; with OIDC the provider owns identity. @@ -58,7 +60,7 @@ export async function createServices(cfg, options = {}) { return { cfg, db, secrets, storage, logs, git, - projects, workerTokens, variables, users, auth, scheduler, logger, + projects, workerTokens, variables, users, auth, scheduler, retention, logger, }; } @@ -120,3 +122,26 @@ export function startReaper(services) { timer.unref(); return () => clearInterval(timer); } + +// Deletes artifacts and logs past their retention. Returns a stop function. +export function startRetention(services) { + const { cfg, retention, logger } = services; + let running = false; + + const timer = setInterval(async () => { + // A sweep over a large backlog can outlast its interval, and running + // two at once would have them fight over the same rows. + if (running) return; + running = true; + try { + await retention.sweep(); + } catch (e) { + logger.error?.(`retention sweep failed: ${e.message}`); + } finally { + running = false; + } + }, cfg.retention.sweep_interval * 1000); + + timer.unref(); + return () => clearInterval(timer); +} diff --git a/src/conductor/index.js b/src/conductor/index.js @@ -5,7 +5,7 @@ // have declared public, and nothing else. import { loadConfig } from '../lib/config.js'; -import { createServices, buildServer, startReaper } from './app.js'; +import { createServices, buildServer, startReaper, startRetention } from './app.js'; const cfg = loadConfig(); const services = await createServices(cfg); @@ -19,10 +19,12 @@ if (!cfg.secrets.encryption_key) { } const stopReaper = startReaper({ ...services, logger: fastify.log }); +const stopRetention = startRetention({ ...services, logger: fastify.log }); async function shutdown(signal) { fastify.log.info(`${signal} received, shutting down`); stopReaper(); + stopRetention(); try { await fastify.close(); await services.db.close(); diff --git a/src/conductor/retention.js b/src/conductor/retention.js @@ -0,0 +1,212 @@ +// src/conductor/retention.js - deleting build output that is past its time +// +// Everything a run produces is kept forever unless something removes it, +// and logs are the worst of it: written for every job, read for almost +// none, and never stopping. This sweeps both. +// +// Artifacts follow two rules per project, and an artifact survives if +// either wants it. The last artifact_keep_runs runs keep their artifacts +// however old they are, and anything younger than artifact_keep_days is +// kept however many runs have followed. The most recent successful run is +// kept regardless, so a project that has gone quiet still has something +// to download. +// +// A job that set artifacts.expire in its pipeline overrides all of that +// with an exact deadline, in either direction. That is deliberate: the +// point of the key is to drop a bulky intermediate early, and a +// node_modules tree is not worth keeping just because its run was green. +// +// Logs go by age alone. +// +// Deleting is done object first and row second. An orphaned object wastes +// space until the next sweep notices nothing references it; an orphaned +// row points at something that is gone, which is a broken download. The +// cheaper failure is the one to choose. + +const DAY = 24 * 60 * 60 * 1000; + +// A project's own setting, the server default when it has none. Zero is a +// real answer meaning keep forever, so only null and undefined fall +// through to the default. +function setting(projectValue, fallback) { + return projectValue === null || projectValue === undefined ? fallback : projectValue; +} + +export function createRetention({ cfg, db, storage, logs, logger = {} }) { + const defaults = cfg.retention; + + async function deleteObject(key, what) { + if (!key) return true; + try { + await storage.delete(key); + return true; + } catch (e) { + // Leave the row alone so the next sweep tries again rather than + // losing track of the object entirely. + logger.warn?.(`retention: could not delete ${what} ${key}: ${e.message}`); + return false; + } + } + + // Artifacts whose pipeline gave them an explicit deadline. Nothing + // protects these, which is the point of setting one. + async function sweepExpiredArtifacts(now, batch) { + const rows = await db.all( + `SELECT id, storage_key FROM artifacts + WHERE expires_at IS NOT NULL AND expires_at <= {now} + ORDER BY expires_at + LIMIT {batch}`, + { now, batch } + ); + + let deleted = 0; + let bytes = 0; + for (const row of rows) { + if (!(await deleteObject(row.storage_key, 'artifact'))) continue; + const size = await db.get('SELECT size FROM artifacts WHERE id = {id}', { id: row.id }); + await db.run('DELETE FROM artifacts WHERE id = {id}', { id: row.id }); + deleted += 1; + bytes += size?.size ?? 0; + } + return { deleted, bytes }; + } + + async function sweepProjectArtifacts(project, now, batch) { + const keepRuns = setting(project.artifact_keep_runs, defaults.artifact_keep_runs); + const keepDays = setting(project.artifact_keep_days, defaults.artifact_keep_days); + + // Both rules off means keep everything, and with the rules combined by + // whichever keeps longer there is nothing left to delete. + if (keepRuns === 0 && keepDays === 0) return { deleted: 0, bytes: 0 }; + + const protectedRuns = new Set(); + + if (keepRuns > 0) { + const recent = await db.all( + `SELECT id FROM runs WHERE project_id = {project} + ORDER BY number DESC + LIMIT {limit}`, + { project: project.id, limit: keepRuns } + ); + for (const run of recent) protectedRuns.add(run.id); + } + + // The last green run, however old. Looked up separately rather than + // folded into the query above, since it may be far outside the recent + // window on a project that has been failing for a while. + const lastGood = await db.get( + `SELECT id FROM runs WHERE project_id = {project} AND state = 'success' + ORDER BY number DESC + LIMIT 1`, + { project: project.id } + ); + if (lastGood) protectedRuns.add(lastGood.id); + + // With no age rule, anything outside the run window goes; otherwise + // only what is also older than the cutoff. + const cutoff = keepDays > 0 ? now - keepDays * DAY : now; + + const candidates = await db.all( + `SELECT a.id, a.storage_key, a.size, a.run_id + FROM artifacts a + JOIN runs r ON r.id = a.run_id + WHERE r.project_id = {project} + AND a.expires_at IS NULL + AND r.created_at < {cutoff} + AND r.finished_at IS NOT NULL + ORDER BY r.created_at + LIMIT {batch}`, + { project: project.id, cutoff, batch } + ); + + let deleted = 0; + let bytes = 0; + for (const row of candidates) { + if (protectedRuns.has(row.run_id)) continue; + if (!(await deleteObject(row.storage_key, 'artifact'))) continue; + await db.run('DELETE FROM artifacts WHERE id = {id}', { id: row.id }); + deleted += 1; + bytes += row.size ?? 0; + } + return { deleted, bytes }; + } + + async function sweepProjectLogs(project, now, batch) { + const keepDays = setting(project.log_keep_days, defaults.log_keep_days); + if (keepDays === 0) return { deleted: 0, bytes: 0 }; + + const cutoff = now - keepDays * DAY; + + const rows = await db.all( + `SELECT j.id, j.run_id, j.log_key, j.log_size + FROM jobs j + JOIN runs r ON r.id = j.run_id + WHERE r.project_id = {project} + AND j.log_expired_at IS NULL + AND j.finished_at IS NOT NULL + AND j.finished_at < {cutoff} + ORDER BY j.finished_at + LIMIT {batch}`, + { project: project.id, cutoff, batch } + ); + + let deleted = 0; + let bytes = 0; + for (const row of rows) { + if (!(await deleteObject(row.log_key, 'log'))) continue; + + // A job that never finished archiving still has a spool file, and a + // job with nothing to say has neither. Both are fine to ask about. + await logs.remove(row.run_id, row.id).catch((e) => { + logger.warn?.(`retention: could not remove spooled log for ${row.id}: ${e.message}`); + }); + + await db.run( + 'UPDATE jobs SET log_key = NULL, log_expired_at = {now} WHERE id = {id}', + { id: row.id, now } + ); + deleted += 1; + bytes += row.log_size ?? 0; + } + return { deleted, bytes }; + } + + return { + // One pass over everything. Returns what it did, which the tests read + // and the log line summarises. + async sweep({ now = Date.now(), batch = defaults.batch } = {}) { + const total = { + artifacts: 0, artifactBytes: 0, logs: 0, logBytes: 0, + }; + + const expired = await sweepExpiredArtifacts(now, batch); + total.artifacts += expired.deleted; + total.artifactBytes += expired.bytes; + + const projects = await db.all( + `SELECT id, artifact_keep_runs, artifact_keep_days, log_keep_days + FROM projects`, + {} + ); + + for (const project of projects) { + const a = await sweepProjectArtifacts(project, now, batch); + total.artifacts += a.deleted; + total.artifactBytes += a.bytes; + + const l = await sweepProjectLogs(project, now, batch); + total.logs += l.deleted; + total.logBytes += l.bytes; + } + + if (total.artifacts > 0 || total.logs > 0) { + logger.info?.( + `retention: removed ${total.artifacts} artifact(s) and ${total.logs} log(s), ` + + `freeing ${Math.round((total.artifactBytes + total.logBytes) / 1024)} KiB` + ); + } + + return total; + }, + }; +} diff --git a/src/conductor/routes/manage.js b/src/conductor/routes/manage.js @@ -34,6 +34,11 @@ export default async function manageRoutes(fastify, services) { owner_id: p.owner_id, has_trigger_secret: Boolean(p.trigger_secret), run_count: p.run_counter, + // Null means the server default applies, which the caller cannot see + // from here; GET /api/retention reports what those defaults are. + artifact_keep_runs: p.artifact_keep_runs ?? null, + artifact_keep_days: p.artifact_keep_days ?? null, + log_keep_days: p.log_keep_days ?? null, created_at: p.created_at, }); @@ -102,6 +107,17 @@ export default async function manageRoutes(fastify, services) { return reply.send({ project: asPublic(project), trigger_url: triggerUrl(project.id) }); }); + // What a null on a project means. Needed to render "inherited (30 days)" + // rather than an empty box that looks like nothing is set. + fastify.get('/retention', async (req, reply) => reply.send({ + defaults: { + artifact_keep_runs: cfg.retention.artifact_keep_runs, + artifact_keep_days: cfg.retention.artifact_keep_days, + log_keep_days: cfg.retention.log_keep_days, + }, + sweep_interval: cfg.retention.sweep_interval, + })); + fastify.patch('/projects/:id', async (req, reply) => { const project = await manageable(req, reply); if (!project) return reply; @@ -110,6 +126,12 @@ export default async function manageRoutes(fastify, services) { try { if (typeof body.enabled === 'boolean') await projects.setEnabled(project.id, body.enabled); if (typeof body.visibility === 'string') await projects.setVisibility(project.id, body.visibility); + const retention = {}; + for (const key of ['artifact_keep_runs', 'artifact_keep_days', 'log_keep_days']) { + if (Object.hasOwn(body, key)) retention[key] = body[key]; + } + if (Object.keys(retention).length > 0) await projects.setRetention(project.id, retention); + if (Object.hasOwn(body, 'owner_id')) { if (req.user.role !== 'admin') throw new Error('only an administrator may change the owner'); await projects.setOwner(project.id, body.owner_id ?? null); diff --git a/src/conductor/routes/workers.js b/src/conductor/routes/workers.js @@ -34,7 +34,7 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag // Confirms the job exists and is held by the calling worker. async function heldJob(req, reply) { const job = await db.get( - `SELECT j.id, j.run_id, j.name, j.state, j.worker_token_id, j.log_size, + `SELECT j.id, j.run_id, j.name, j.state, j.worker_token_id, j.log_size, j.spec, r.project_id, r.head_sha FROM jobs j JOIN runs r ON r.id = j.run_id WHERE j.id = {id}`, @@ -174,6 +174,22 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag } }); + // artifacts.expire in the pipeline, as an absolute time. Returns null + // when the job said nothing, leaving the artifact to the project policy. + function artifactExpiry(specJson, now) { + if (!specJson) return null; + try { + const spec = typeof specJson === 'string' ? JSON.parse(specJson) : specJson; + const seconds = spec?.artifacts?.expire; + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return now + Math.round(seconds * 1000); + } catch { + // A spec that will not parse is a problem, but not this request's + // problem: the artifact is already stored. + return null; + } + } + // Upload one artifact. The path is worker supplied, so it is sanitized // before it can influence a storage key. fastify.post('/jobs/:id/artifact', async (req, reply) => { @@ -209,9 +225,11 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag } const digest = hasher.digest(); + const now = Date.now(); + await db.run( - `INSERT INTO artifacts (id, job_id, run_id, path, storage_key, size, sha256, created_at) - VALUES ({id}, {job}, {run}, {path}, {key}, {size}, {sha}, {now})`, + `INSERT INTO artifacts (id, job_id, run_id, path, storage_key, size, sha256, created_at, expires_at) + VALUES ({id}, {job}, {run}, {path}, {key}, {size}, {sha}, {now}, {expires})`, { id: newArtifactId(), job: job.id, @@ -220,7 +238,10 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag key, size: stored.size ?? declared, sha: digest, - now: Date.now(), + now, + // An explicit deadline from the pipeline. Absent, the project's + // retention policy decides, which is the usual case. + expires: artifactExpiry(job.spec, now), } ); diff --git a/src/conductor/ui/pages.js b/src/conductor/ui/pages.js @@ -217,7 +217,7 @@ export function projectsTable(projects, user) { </table>`; } -export function projectPage({ project, variables, triggerUrl, secret, user }) { +export function projectPage({ project, variables, triggerUrl, secret, user, retention }) { return html` <h2>${project.id}</h2> ${secret ? oneTimeSecret( @@ -261,6 +261,40 @@ export function projectPage({ project, variables, triggerUrl, secret, user }) { </div> <div class="panel"> + <h3>Retention</h3> + <p class="muted"> + Leave a field empty to follow the server default, shown as the + placeholder. Zero keeps things forever. + </p> + <form hx-patch="/projects/${project.id}/retention" hx-target="body" hx-swap="none"> + <div class="grid"> + <label>keep artifacts for runs + <input name="artifact_keep_runs" type="number" min="0" inputmode="numeric" + placeholder="${retention.artifact_keep_runs}" + value="${project.artifact_keep_runs ?? ''}"> + </label> + <label>keep artifacts for days + <input name="artifact_keep_days" type="number" min="0" inputmode="numeric" + placeholder="${retention.artifact_keep_days}" + value="${project.artifact_keep_days ?? ''}"> + </label> + <label>keep logs for days + <input name="log_keep_days" type="number" min="0" inputmode="numeric" + placeholder="${retention.log_keep_days}" + value="${project.log_keep_days ?? ''}"> + </label> + </div> + <p class="muted"> + An artifact is kept if either rule wants it, so the last + ${project.artifact_keep_runs ?? retention.artifact_keep_runs} runs survive + whatever their age. The most recent successful run is always kept. A job + that sets artifacts.expire overrides all of this. + </p> + <div class="actions"><button type="submit">save</button></div> + </form> + </div> + + <div class="panel"> <h3>Variables</h3> <p class="muted">Injected into every job. Masked values are redacted from logs.</p> ${variablesTable(project, variables)} diff --git a/src/conductor/ui/routes.js b/src/conductor/ui/routes.js @@ -372,6 +372,9 @@ export default async function uiRoutes(fastify, services) { variables: await variables.list(project.id), triggerUrl: `${cfg.server.public_url.replace(/\/+$/, '')}/api/trigger/${project.id}`, secret, + // What an empty field falls back to, so the form can say so + // rather than looking unset. + retention: cfg.retention, }), }); }); @@ -391,6 +394,38 @@ export default async function uiRoutes(fastify, services) { return refresh(reply); }); + fastify.patch('/projects/:id/retention', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const found = await projectFor(req, reply); + if (!found) return reply; + + const body = req.body ?? {}; + const values = {}; + for (const key of ['artifact_keep_runs', 'artifact_keep_days', 'log_keep_days']) { + if (!Object.hasOwn(body, key)) continue; + const raw = String(body[key] ?? '').trim(); + // An empty field means "follow the server default", which is a null + // rather than a zero. A form cannot express that difference on its + // own, so it is decided here. + if (raw === '') { + values[key] = null; + continue; + } + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) { + return fail(reply, `${key} must be a whole number of zero or more, or empty to follow the default`); + } + values[key] = parsed; + } + + try { + await projects.setRetention(found.project.id, values); + } catch (e) { + return fail(reply, e.message); + } + return refresh(reply); + }); + fastify.post('/projects/:id/trigger-secret', async (req, reply) => { if (!fromUi(req, reply)) return reply; const found = await projectFor(req, reply); diff --git a/src/lib/config.js b/src/lib/config.js @@ -83,6 +83,24 @@ const DEFAULTS = { // Refuse log appends past this size, to bound a runaway job. max_size: 64 * 1024 * 1024, }, + // What happens to build output over time. A project may override any of + // these; see migrations/sqlite/004_retention.sql for how the artifact + // rules combine. Zero means keep forever. + retention: { + // The last this many runs keep their artifacts whatever their age. + artifact_keep_runs: 10, + // Artifacts younger than this are kept whatever has followed them. + artifact_keep_days: 30, + // Logs go purely by age, and are the reason this exists at all: they + // are written for every job, read for almost none, and never stop. + log_keep_days: 14, + // How often to sweep. Deleting is not urgent, and a sweep walks every + // project, so this is deliberately not the scheduler's interval. + sweep_interval: 3600, + // How much to delete in one pass, so a first sweep over a large + // backlog cannot monopolise the database or the object store. + batch: 500, + }, scheduler: { // A claimed job whose worker stops sending heartbeats for this long is // considered lost and is requeued or failed. @@ -95,6 +113,10 @@ const DEFAULTS = { // [environment variable, dotted config path, parser] const ENV_MAP = [ + ['CONDUCTOR_RETENTION_ARTIFACT_RUNS', 'retention.artifact_keep_runs', toInt], + ['CONDUCTOR_RETENTION_ARTIFACT_DAYS', 'retention.artifact_keep_days', toInt], + ['CONDUCTOR_RETENTION_LOG_DAYS', 'retention.log_keep_days', toInt], + ['CONDUCTOR_RETENTION_SWEEP_INTERVAL','retention.sweep_interval', toInt], ['CONDUCTOR_HOST', 'server.host', String], ['CONDUCTOR_PORT', 'server.port', toInt], ['CONDUCTOR_PUBLIC_URL', 'server.public_url', String], @@ -236,6 +258,21 @@ function validate(cfg) { ); } + // Retention deletes things, so a nonsensical value here is worth + // refusing at startup rather than discovering from missing artifacts. + for (const key of ['artifact_keep_runs', 'artifact_keep_days', 'log_keep_days']) { + const value = cfg.retention[key]; + if (!Number.isInteger(value) || value < 0) { + errors.push(`retention.${key} must be a whole number of zero or more, got ${JSON.stringify(value)}`); + } + } + + if (!Number.isInteger(cfg.retention.sweep_interval) || cfg.retention.sweep_interval < 60) { + errors.push( + `retention.sweep_interval must be at least 60 seconds, got ${JSON.stringify(cfg.retention.sweep_interval)}` + ); + } + if (errors.length > 0) { throw new Error(`invalid configuration:\n - ${errors.join('\n - ')}`); } diff --git a/src/lib/projects.js b/src/lib/projects.js @@ -20,6 +20,7 @@ export const VISIBILITIES = ['public', 'private']; const COLUMNS = ` id, name, repo_url, default_branch, config_path, source_mode, trigger_secret, enabled, run_counter, owner_id, visibility, + artifact_keep_runs, artifact_keep_days, log_keep_days, created_at, updated_at `; @@ -126,6 +127,34 @@ export function createProjects({ db, secrets }) { ); }, + // Retention overrides. Null hands the decision back to the server + // default; zero means keep forever, and has to stay distinguishable + // from null or a project could not opt out of a default that deletes. + async setRetention(id, values) { + const columns = ['artifact_keep_runs', 'artifact_keep_days', 'log_keep_days']; + const updates = []; + const params = { id, now: Date.now() }; + + for (const column of columns) { + if (!Object.hasOwn(values, column)) continue; + const value = values[column]; + + if (value !== null && (!Number.isInteger(value) || value < 0)) { + throw new Error(`${column} must be a whole number of zero or more, or null to follow the server default`); + } + + updates.push(`${column} = {${column}}`); + params[column] = value; + } + + if (updates.length === 0) return; + + await db.run( + `UPDATE projects SET ${updates.join(', ')}, updated_at = {now} WHERE id = {id}`, + params + ); + }, + async setOwner(id, ownerId) { await db.run( 'UPDATE projects SET owner_id = {owner}, updated_at = {now} WHERE id = {id}', diff --git a/test/helpers/harness.js b/test/helpers/harness.js @@ -97,6 +97,9 @@ export async function startHarness(options = {}) { 'scheduler:', ' heartbeat_timeout: 120', ' reap_interval: 30', + ...(options.retention + ? ['retention:', ...Object.entries(options.retention).map(([k, v]) => ` ${k}: ${v}`)] + : []), 'auth:', ' session_secret: test-session-secret-not-for-real-use', ...(options.oidcIssuer diff --git a/test/retention.test.js b/test/retention.test.js @@ -0,0 +1,524 @@ +// test/retention.test.js - deleting artifacts and logs once they are old +// +// This is the one part of the conductor whose job is to destroy data, so +// the rules are pinned down here rather than left to read from the +// implementation. Runs and jobs are seeded directly with chosen +// timestamps, since waiting fourteen days for a test is not practical. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startHarness } from './helpers/harness.js'; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = 1_800_000_000_000; + +async function withHarness(options, fn) { + const h = await startHarness(options); + try { + return await fn(h); + } finally { + await h.stop(); + } +} + +// Seeds a finished run with one job and one artifact, at a chosen age. +// Returns the identifiers so a test can assert on what survived. +async function seedRun(h, { + number, + ageDays, + state = 'success', + artifactExpiresAt = null, + logSize = 1024, + finished = true, +}) { + const db = h.services.db; + const at = NOW - ageDays * DAY; + const runId = `run-${number}`; + const jobId = `${runId}:build`; + const artifactId = `art-${number}`; + const artifactKey = `artifacts/${runId}/build/out.txt`; + const logKey = `logs/${runId}/build.log`; + + await db.run( + `INSERT INTO runs (id, project_id, number, ref, head_sha, trigger_type, + state, visibility, created_at, started_at, finished_at) + VALUES ({id}, {project}, {number}, {ref}, {sha}, 'push', + {state}, 'public', {at}, {at}, {finished})`, + { + id: runId, + project: h.project.id, + number, + ref: 'refs/heads/main', + sha: 'a'.repeat(40), + state, + at, + finished: finished ? at : null, + } + ); + + await db.run( + `INSERT INTO jobs (id, run_id, name, base_name, image, requires, spec, state, + allow_failure, attempt, max_attempts, timeout, + log_key, log_size, created_at, finished_at) + VALUES ({id}, {run}, 'build', 'build', 'alpine:3', '[]', '{}', {state}, + 0, 1, 1, 3600, {logKey}, {logSize}, {at}, {finished})`, + { + id: jobId, + run: runId, + state: finished ? state : 'running', + logKey, + logSize, + at, + finished: finished ? at : null, + } + ); + + await db.run( + `INSERT INTO artifacts (id, job_id, run_id, path, storage_key, size, sha256, + created_at, expires_at) + VALUES ({id}, {job}, {run}, 'out.txt', {key}, 512, {sha}, {at}, {expires})`, + { + id: artifactId, + job: jobId, + run: runId, + key: artifactKey, + sha: 'b'.repeat(64), + at, + expires: artifactExpiresAt, + } + ); + + // Real objects, so a sweep that forgets to delete one is visible. + await h.services.storage.put(artifactKey, Buffer.from('artifact body')); + await h.services.storage.put(logKey, Buffer.from('log body')); + + return { runId, jobId, artifactId, artifactKey, logKey }; +} + +async function artifactIds(h) { + const rows = await h.services.db.all('SELECT id FROM artifacts ORDER BY id', {}); + return rows.map((r) => r.id); +} + +async function objectExists(h, key) { + try { + return (await h.services.storage.head(key)) !== null; + } catch { + return false; + } +} + +test('an artifact past its own deadline is deleted', async () => { + await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 0 } }, async (h) => { + const fresh = await seedRun(h, { number: 1, ageDays: 0, artifactExpiresAt: NOW + DAY }); + const stale = await seedRun(h, { number: 2, ageDays: 0, artifactExpiresAt: NOW - 1 }); + + const result = await h.services.retention.sweep({ now: NOW }); + + assert.equal(result.artifacts, 1); + assert.deepEqual(await artifactIds(h), ['art-1']); + assert.equal(await objectExists(h, fresh.artifactKey), true); + assert.equal(await objectExists(h, stale.artifactKey), false, 'the object must go too, not just the row'); + }); +}); + +test('an explicit deadline overrides the policy in both directions', async () => { + // Shorter than the policy would allow, on the newest run. + await withHarness({ retention: { artifact_keep_runs: 10, artifact_keep_days: 30 } }, async (h) => { + await seedRun(h, { number: 1, ageDays: 0, artifactExpiresAt: NOW - 1 }); + await h.services.retention.sweep({ now: NOW }); + assert.deepEqual(await artifactIds(h), [], 'a job may expire its artifacts early'); + }); + + // Longer than the policy would allow, on a run far outside it. + await withHarness({ retention: { artifact_keep_runs: 1, artifact_keep_days: 1 } }, async (h) => { + await seedRun(h, { number: 1, ageDays: 400, artifactExpiresAt: NOW + DAY }); + await seedRun(h, { number: 2, ageDays: 0 }); + await h.services.retention.sweep({ now: NOW }); + assert.ok((await artifactIds(h)).includes('art-1'), 'a job may keep its artifacts longer'); + }); +}); + +test('an explicit deadline also beats the last good run', async () => { + // The protection exists so a quiet project keeps something downloadable. + // A job that asked for a short life is asking on purpose, and a bulky + // intermediate should not become immortal by being green. + await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 0 } }, async (h) => { + await seedRun(h, { number: 1, ageDays: 0, state: 'success', artifactExpiresAt: NOW - 1 }); + await h.services.retention.sweep({ now: NOW }); + assert.deepEqual(await artifactIds(h), []); + }); +}); + +test('the last few runs keep their artifacts however old they are', async () => { + await withHarness({ retention: { artifact_keep_runs: 3, artifact_keep_days: 0 } }, async (h) => { + for (let n = 1; n <= 6; n += 1) { + await seedRun(h, { number: n, ageDays: 400, state: n === 6 ? 'failed' : 'failed' }); + } + + await h.services.retention.sweep({ now: NOW }); + + // The three highest numbered runs, regardless of age. + assert.deepEqual(await artifactIds(h), ['art-4', 'art-5', 'art-6']); + }); +}); + +test('recent artifacts are kept however many runs have followed', async () => { + // The two rules are combined by whichever keeps longer, so a run outside + // the count window still survives while it is inside the age window. + await withHarness({ retention: { artifact_keep_runs: 2, artifact_keep_days: 30 } }, async (h) => { + for (let n = 1; n <= 5; n += 1) { + await seedRun(h, { number: n, ageDays: 1, state: 'failed' }); + } + + await h.services.retention.sweep({ now: NOW }); + + assert.equal((await artifactIds(h)).length, 5, 'all are young enough, whatever their position'); + }); +}); + +test('an artifact outside both rules is deleted', async () => { + await withHarness({ retention: { artifact_keep_runs: 2, artifact_keep_days: 7 } }, async (h) => { + const old = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); + await seedRun(h, { number: 2, ageDays: 1, state: 'failed' }); + await seedRun(h, { number: 3, ageDays: 1, state: 'failed' }); + + const result = await h.services.retention.sweep({ now: NOW }); + + assert.equal(result.artifacts, 1); + assert.deepEqual(await artifactIds(h), ['art-2', 'art-3']); + assert.equal(await objectExists(h, old.artifactKey), false); + }); +}); + +test('the most recent successful run is kept whatever the policy says', async () => { + await withHarness({ retention: { artifact_keep_runs: 1, artifact_keep_days: 1 } }, async (h) => { + await seedRun(h, { number: 1, ageDays: 400, state: 'success' }); + await seedRun(h, { number: 2, ageDays: 300, state: 'failed' }); + await seedRun(h, { number: 3, ageDays: 0, state: 'failed' }); + + await h.services.retention.sweep({ now: NOW }); + + // run 1 is the last green one, run 3 is inside both windows, and the + // failed run between them has nothing protecting it. + assert.deepEqual(await artifactIds(h), ['art-1', 'art-3']); + }); +}); + +test('only the latest success is protected, not every success', async () => { + await withHarness({ retention: { artifact_keep_runs: 1, artifact_keep_days: 1 } }, async (h) => { + await seedRun(h, { number: 1, ageDays: 400, state: 'success' }); + await seedRun(h, { number: 2, ageDays: 300, state: 'success' }); + await seedRun(h, { number: 3, ageDays: 0, state: 'failed' }); + + await h.services.retention.sweep({ now: NOW }); + + assert.deepEqual(await artifactIds(h), ['art-2', 'art-3']); + }); +}); + +test('a project may keep artifacts forever despite a server default', async () => { + await withHarness({ retention: { artifact_keep_runs: 1, artifact_keep_days: 1 } }, async (h) => { + await h.services.db.run( + 'UPDATE projects SET artifact_keep_runs = 0, artifact_keep_days = 0 WHERE id = {id}', + { id: h.project.id } + ); + + await seedRun(h, { number: 1, ageDays: 900, state: 'failed' }); + await seedRun(h, { number: 2, ageDays: 900, state: 'failed' }); + + const result = await h.services.retention.sweep({ now: NOW }); + + // Zero has to mean forever rather than immediately, or a project could + // not opt out of a default that deletes things. + assert.equal(result.artifacts, 0); + assert.equal((await artifactIds(h)).length, 2); + }); +}); + +test('a project setting overrides the server default', async () => { + await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 365 } }, async (h) => { + await h.services.db.run( + 'UPDATE projects SET artifact_keep_days = 7 WHERE id = {id}', + { id: h.project.id } + ); + + await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); + + await h.services.retention.sweep({ now: NOW }); + assert.deepEqual(await artifactIds(h), [], 'the stricter project setting applies'); + }); +}); + +test('an unfinished run is never swept', async () => { + await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 1 } }, async (h) => { + // Backdated far enough to be well outside the window, but still going. + await seedRun(h, { number: 1, ageDays: 400, state: 'running', finished: false }); + + const result = await h.services.retention.sweep({ now: NOW }); + + assert.equal(result.artifacts, 0); + assert.equal(result.logs, 0); + assert.equal((await artifactIds(h)).length, 1); + }); +}); + +test('logs older than the limit are removed but the job remains', async () => { + await withHarness({ retention: { log_keep_days: 14 } }, async (h) => { + const old = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); + const recent = await seedRun(h, { number: 2, ageDays: 1, state: 'failed' }); + + const result = await h.services.retention.sweep({ now: NOW }); + + assert.equal(result.logs, 1); + assert.equal(await objectExists(h, old.logKey), false); + assert.equal(await objectExists(h, recent.logKey), true); + + // The job itself is history and stays, so a run remains explainable + // after its output has gone. + const job = await h.services.db.get( + 'SELECT id, log_key, log_expired_at, state FROM jobs WHERE id = {id}', + { id: old.jobId } + ); + assert.ok(job, 'the job row must survive'); + assert.equal(job.log_key, null); + assert.equal(job.state, 'failed'); + assert.equal(job.log_expired_at, NOW); + }); +}); + +test('a swept log is distinguishable from one that never existed', async () => { + await withHarness({ retention: { log_keep_days: 1 } }, async (h) => { + const swept = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); + await h.services.retention.sweep({ now: NOW }); + + const gone = await h.services.db.get( + 'SELECT log_key, log_expired_at FROM jobs WHERE id = {id}', { id: swept.jobId } + ); + // Both have no log_key, so the timestamp is the only thing telling + // "expired" apart from "never wrote anything". + assert.equal(gone.log_key, null); + assert.ok(gone.log_expired_at > 0); + }); +}); + +test('a log is swept only once', async () => { + await withHarness({ retention: { log_keep_days: 1 } }, async (h) => { + await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); + + const first = await h.services.retention.sweep({ now: NOW }); + const second = await h.services.retention.sweep({ now: NOW }); + + assert.equal(first.logs, 1); + assert.equal(second.logs, 0, 'a swept log must not be counted again on every pass'); + }); +}); + +test('zero keeps logs forever', async () => { + await withHarness({ retention: { log_keep_days: 0 } }, async (h) => { + const old = await seedRun(h, { number: 1, ageDays: 900, state: 'failed' }); + const result = await h.services.retention.sweep({ now: NOW }); + + assert.equal(result.logs, 0); + assert.equal(await objectExists(h, old.logKey), true); + }); +}); + +test('a storage failure leaves the row for the next pass', async () => { + await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 1 } }, async (h) => { + await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); + + const real = h.services.storage.delete; + h.services.storage.delete = async () => { throw new Error('bucket unreachable'); }; + + const failed = await h.services.retention.sweep({ now: NOW }); + assert.equal(failed.artifacts, 0); + assert.equal((await artifactIds(h)).length, 1, 'the row must survive so the object is not orphaned'); + + h.services.storage.delete = real; + + const recovered = await h.services.retention.sweep({ now: NOW }); + assert.equal(recovered.artifacts, 1); + assert.deepEqual(await artifactIds(h), []); + }); +}); + +test('a sweep deletes no more than its batch', async () => { + await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 1 } }, async (h) => { + for (let n = 1; n <= 5; n += 1) { + await seedRun(h, { number: n, ageDays: 30, state: 'failed' }); + } + + const first = await h.services.retention.sweep({ now: NOW, batch: 2 }); + assert.equal(first.artifacts, 2, 'a backlog must not be swept in one go'); + assert.equal((await artifactIds(h)).length, 3); + + await h.services.retention.sweep({ now: NOW, batch: 10 }); + assert.equal((await artifactIds(h)).length, 0); + }); +}); + +test('one project policy does not reach into another', async () => { + await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 1 } }, async (h) => { + await h.services.projects.create({ + id: 'other', + name: 'Other', + repo_url: h.repoDir, + trigger_secret: 'other-secret', + visibility: 'public', + }); + await h.services.db.run( + 'UPDATE projects SET artifact_keep_days = 0 WHERE id = {id}', { id: 'other' } + ); + + await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); + + // An old artifact under the project that keeps everything. + await h.services.db.run( + `INSERT INTO runs (id, project_id, number, ref, head_sha, trigger_type, + state, visibility, created_at, started_at, finished_at) + VALUES ('other-run', 'other', 1, 'refs/heads/main', {sha}, 'push', + 'failed', 'public', {at}, {at}, {at})`, + { sha: 'c'.repeat(40), at: NOW - 900 * DAY } + ); + await h.services.db.run( + `INSERT INTO jobs (id, run_id, name, base_name, image, requires, spec, state, + allow_failure, attempt, max_attempts, timeout, log_size, + created_at, finished_at) + VALUES ('other-run:build', 'other-run', 'build', 'build', 'alpine:3', '[]', '{}', + 'failed', 0, 1, 1, 3600, 0, {at}, {at})`, + { at: NOW - 900 * DAY } + ); + await h.services.db.run( + `INSERT INTO artifacts (id, job_id, run_id, path, storage_key, size, sha256, created_at) + VALUES ('art-other', 'other-run:build', 'other-run', 'out.txt', + 'artifacts/other/out.txt', 1, {sha}, {at})`, + { sha: 'd'.repeat(64), at: NOW - 900 * DAY } + ); + + await h.services.retention.sweep({ now: NOW }); + + assert.deepEqual(await artifactIds(h), ['art-other']); + }); +}); + +test('artifacts.expire in a pipeline reaches the stored artifact', async () => { + // The key has been in the schema since the beginning and was parsed, + // stored in the job spec, and then ignored by everything. This is the + // path from pipeline text to a deadline on the row. + const pipeline = [ + 'version: 1', + 'defaults:', + ' image: alpine:3', + 'jobs:', + ' build:', + " script: ['make']", + ' artifacts:', + ' paths: [out/**]', + ' expire: 1h', + ' keep:', + " script: ['make']", + ' artifacts:', + ' paths: [out/**]', + '', + ].join('\n'); + + await withHarness({ pipeline }, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + const before = Date.now(); + for (const name of ['build', 'keep']) { + const res = await h.poll({}); + assert.equal(res.statusCode, 200, `expected to claim ${name}`); + const job = res.json().job; + + const upload = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, + headers: { + ...h.auth, + 'content-type': 'application/octet-stream', + 'x-artifact-path': 'out/result.txt', + }, + payload: Buffer.from('packaged'), + }); + assert.equal(upload.statusCode, 200); + } + + const rows = await h.services.db.all( + `SELECT j.base_name AS name, a.expires_at + FROM artifacts a JOIN jobs j ON j.id = a.job_id + ORDER BY j.base_name`, + {} + ); + + const byName = Object.fromEntries(rows.map((r) => [r.name, r.expires_at])); + + // An hour from when it was stored, give or take the time the test took. + assert.ok(byName.build >= before + 3600 * 1000, 'expire should be an hour out'); + assert.ok(byName.build <= Date.now() + 3600 * 1000); + + // The job that said nothing is left to the project policy. + assert.equal(byName.keep, null); + }); +}); + +test('the api reports and accepts project retention', async () => { + await withHarness({ + bootstrap: true, + allowLocalLogin: true, + retention: { artifact_keep_runs: 7, artifact_keep_days: 21, log_keep_days: 9 }, + }, async (h) => { + const login = await h.app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'admin', password: 'bootstrap-password' }, + }); + assert.equal(login.statusCode, 200); + const auth = { authorization: `Bearer ${login.json().token}` }; + + // The defaults have to be discoverable, or a null on a project is + // meaningless to whoever is reading it. + const defaults = await h.app.inject({ method: 'GET', url: '/api/retention', headers: auth }); + assert.equal(defaults.statusCode, 200); + assert.deepEqual(defaults.json().defaults, { + artifact_keep_runs: 7, + artifact_keep_days: 21, + log_keep_days: 9, + }); + + const before = await h.app.inject({ + method: 'GET', url: `/api/projects/${h.project.id}`, headers: auth, + }); + assert.equal(before.json().project.artifact_keep_days, null, 'unset means inherited'); + + const patched = await h.app.inject({ + method: 'PATCH', + url: `/api/projects/${h.project.id}`, + headers: auth, + payload: { artifact_keep_runs: 3, artifact_keep_days: 0, log_keep_days: 30 }, + }); + assert.equal(patched.statusCode, 200); + assert.equal(patched.json().project.artifact_keep_runs, 3); + assert.equal(patched.json().project.artifact_keep_days, 0, 'zero is a value, not an absence'); + assert.equal(patched.json().project.log_keep_days, 30); + + // And back to following the server default. + const cleared = await h.app.inject({ + method: 'PATCH', + url: `/api/projects/${h.project.id}`, + headers: auth, + payload: { artifact_keep_runs: null }, + }); + assert.equal(cleared.json().project.artifact_keep_runs, null); + + const rejected = await h.app.inject({ + method: 'PATCH', + url: `/api/projects/${h.project.id}`, + headers: auth, + payload: { log_keep_days: -5 }, + }); + assert.equal(rejected.statusCode, 400); + assert.match(rejected.json().error, /zero or more/); + }); +}); diff --git a/test/ui.test.js b/test/ui.test.js @@ -390,3 +390,67 @@ test('the stylesheet and htmx are served, and nothing else is', async () => { } }); }); + +// --- retention --- + +test('the project page shows retention, with server defaults as placeholders', async () => { + await withUi({ + allowLocalLogin: true, + retention: { artifact_keep_runs: 8, artifact_keep_days: 25, log_keep_days: 11 }, + }, async (h) => { + const session = await signIn(h); + const page = await get(h, `/projects/${h.project.id}`, session); + + assert.equal(page.statusCode, 200); + assert.match(page.body, /Retention/); + + // An unset field shows the default it inherits rather than an empty + // box that looks like nothing is configured. + assert.match(page.body, /name="artifact_keep_runs"[^>]*placeholder="8"/); + assert.match(page.body, /name="artifact_keep_days"[^>]*placeholder="25"/); + assert.match(page.body, /name="log_keep_days"[^>]*placeholder="11"/); + }); +}); + +test('saving retention through the form stores it, and empty means inherit', async () => { + await withUi({ + allowLocalLogin: true, + retention: { artifact_keep_runs: 8, artifact_keep_days: 25, log_keep_days: 11 }, + }, async (h) => { + const session = await signIn(h); + + const saved = await h.app.inject({ + method: 'PATCH', + url: `/projects/${h.project.id}/retention`, + ...form(session, { artifact_keep_runs: '3', artifact_keep_days: '0', log_keep_days: '' }), + }); + assert.ok(saved.statusCode < 400, `unexpected ${saved.statusCode}: ${saved.body}`); + + const project = await h.services.projects.get(h.project.id); + assert.equal(project.artifact_keep_runs, 3); + // Zero and empty have to end up different, or a project cannot say + // "keep forever" as distinct from "use the default". + assert.equal(project.artifact_keep_days, 0); + assert.equal(project.log_keep_days, null); + + const page = await get(h, `/projects/${h.project.id}`, session); + assert.match(page.body, /name="artifact_keep_days"[^>]*value="0"/); + assert.match(page.body, /name="log_keep_days"[^>]*value=""/); + }); +}); + +test('a negative retention value is refused by the form', async () => { + await withUi({ allowLocalLogin: true }, async (h) => { + const session = await signIn(h); + + const res = await h.app.inject({ + method: 'PATCH', + url: `/projects/${h.project.id}/retention`, + ...form(session, { log_keep_days: '-3' }), + }); + + assert.ok(res.statusCode >= 400, 'a negative value must not be stored'); + const project = await h.services.projects.get(h.project.id); + assert.equal(project.log_keep_days, null); + }); +});