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:
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);
+ });
+});