retention.test.js (21793B)
1 // test/retention.test.js - deleting artifacts and logs once they are old 2 // 3 // This is the one part of the conductor whose task is to destroy data, so 4 // the rules are pinned down here rather than left to read from the 5 // implementation. Jobs and tasks are seeded directly with chosen 6 // timestamps, since waiting fourteen days for a test is not practical. 7 8 import test from 'node:test'; 9 import assert from 'node:assert/strict'; 10 import { startHarness } from './helpers/harness.js'; 11 12 const HOUR = 60 * 60 * 1000; 13 const DAY = 24 * HOUR; 14 const NOW = 1_800_000_000_000; 15 16 async function withHarness(options, fn) { 17 const h = await startHarness(options); 18 try { 19 return await fn(h); 20 } finally { 21 await h.stop(); 22 } 23 } 24 25 // Seeds a finished job with one task and one artifact, at a chosen age. 26 // Returns the identifiers so a test can assert on what survived. 27 async function seedRun(h, { 28 number, 29 ageDays, 30 state = 'success', 31 artifactExpiresAt = null, 32 logSize = 1024, 33 finished = true, 34 }) { 35 const db = h.services.db; 36 const at = NOW - ageDays * DAY; 37 const jobId = `job-${number}`; 38 const taskId = `${jobId}:build`; 39 const artifactId = `art-${number}`; 40 const artifactKey = `artifacts/${jobId}/build/out.txt`; 41 const logKey = `logs/${jobId}/build.log`; 42 43 await db.run( 44 `INSERT INTO jobs (id, project_id, number, ref, head_sha, trigger_type, 45 state, visibility, created_at, started_at, finished_at) 46 VALUES ({id}, {project}, {number}, {ref}, {sha}, 'push', 47 {state}, 'public', {at}, {at}, {finished})`, 48 { 49 id: jobId, 50 project: h.project.id, 51 number, 52 ref: 'refs/heads/main', 53 sha: 'a'.repeat(40), 54 state, 55 at, 56 finished: finished ? at : null, 57 } 58 ); 59 60 await db.run( 61 `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state, 62 allow_failure, attempt, max_attempts, timeout, 63 log_key, log_size, created_at, finished_at) 64 VALUES ({id}, {job}, 'build', 'build', 'alpine:3', '[]', '{}', {state}, 65 0, 1, 1, 3600, {logKey}, {logSize}, {at}, {finished})`, 66 { 67 id: taskId, 68 job: jobId, 69 state: finished ? state : 'running', 70 logKey, 71 logSize, 72 at, 73 finished: finished ? at : null, 74 } 75 ); 76 77 await db.run( 78 `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, 79 created_at, expires_at) 80 VALUES ({id}, {task}, {job}, 'out.txt', {key}, 512, {sha}, {at}, {expires})`, 81 { 82 id: artifactId, 83 task: taskId, 84 job: jobId, 85 key: artifactKey, 86 sha: 'b'.repeat(64), 87 at, 88 expires: artifactExpiresAt, 89 } 90 ); 91 92 // Real objects, so a sweep that forgets to delete one is visible. 93 await h.services.storage.put(artifactKey, Buffer.from('artifact body')); 94 await h.services.storage.put(logKey, Buffer.from('log body')); 95 96 return { jobId, taskId, artifactId, artifactKey, logKey }; 97 } 98 99 async function artifactIds(h) { 100 const rows = await h.services.db.all('SELECT id FROM artifacts ORDER BY id', {}); 101 return rows.map((r) => r.id); 102 } 103 104 async function objectExists(h, key) { 105 try { 106 return (await h.services.storage.head(key)) !== null; 107 } catch { 108 return false; 109 } 110 } 111 112 test('an artifact past its own deadline is deleted', async () => { 113 await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 0 } }, async (h) => { 114 const fresh = await seedRun(h, { number: 1, ageDays: 0, artifactExpiresAt: NOW + DAY }); 115 const stale = await seedRun(h, { number: 2, ageDays: 0, artifactExpiresAt: NOW - 1 }); 116 117 const result = await h.services.retention.sweep({ now: NOW }); 118 119 assert.equal(result.artifacts, 1); 120 assert.deepEqual(await artifactIds(h), ['art-1']); 121 assert.equal(await objectExists(h, fresh.artifactKey), true); 122 assert.equal(await objectExists(h, stale.artifactKey), false, 'the object must go too, not just the row'); 123 }); 124 }); 125 126 test('an explicit deadline overrides the policy in both directions', async () => { 127 // Shorter than the policy would allow, on the newest job. 128 await withHarness({ retention: { artifact_keep_jobs: 10, artifact_keep_days: 30 } }, async (h) => { 129 await seedRun(h, { number: 1, ageDays: 0, artifactExpiresAt: NOW - 1 }); 130 await h.services.retention.sweep({ now: NOW }); 131 assert.deepEqual(await artifactIds(h), [], 'a task may expire its artifacts early'); 132 }); 133 134 // Longer than the policy would allow, on a job far outside it. 135 await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => { 136 await seedRun(h, { number: 1, ageDays: 400, artifactExpiresAt: NOW + DAY }); 137 await seedRun(h, { number: 2, ageDays: 0 }); 138 await h.services.retention.sweep({ now: NOW }); 139 assert.ok((await artifactIds(h)).includes('art-1'), 'a task may keep its artifacts longer'); 140 }); 141 }); 142 143 test('an explicit deadline also beats the last good job', async () => { 144 // The protection exists so a quiet project keeps something downloadable. 145 // A task that asked for a short life is asking on purpose, and a bulky 146 // intermediate should not become immortal by being green. 147 await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 0 } }, async (h) => { 148 await seedRun(h, { number: 1, ageDays: 0, state: 'success', artifactExpiresAt: NOW - 1 }); 149 await h.services.retention.sweep({ now: NOW }); 150 assert.deepEqual(await artifactIds(h), []); 151 }); 152 }); 153 154 test('the last few jobs keep their artifacts however old they are', async () => { 155 await withHarness({ retention: { artifact_keep_jobs: 3, artifact_keep_days: 0 } }, async (h) => { 156 for (let n = 1; n <= 6; n += 1) { 157 await seedRun(h, { number: n, ageDays: 400, state: n === 6 ? 'failed' : 'failed' }); 158 } 159 160 await h.services.retention.sweep({ now: NOW }); 161 162 // The three highest numbered jobs, regardless of age. 163 assert.deepEqual(await artifactIds(h), ['art-4', 'art-5', 'art-6']); 164 }); 165 }); 166 167 test('recent artifacts are kept however many jobs have followed', async () => { 168 // The two rules are combined by whichever keeps longer, so a job outside 169 // the count window still survives while it is inside the age window. 170 await withHarness({ retention: { artifact_keep_jobs: 2, artifact_keep_days: 30 } }, async (h) => { 171 for (let n = 1; n <= 5; n += 1) { 172 await seedRun(h, { number: n, ageDays: 1, state: 'failed' }); 173 } 174 175 await h.services.retention.sweep({ now: NOW }); 176 177 assert.equal((await artifactIds(h)).length, 5, 'all are young enough, whatever their position'); 178 }); 179 }); 180 181 test('an artifact outside both rules is deleted', async () => { 182 await withHarness({ retention: { artifact_keep_jobs: 2, artifact_keep_days: 7 } }, async (h) => { 183 const old = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); 184 await seedRun(h, { number: 2, ageDays: 1, state: 'failed' }); 185 await seedRun(h, { number: 3, ageDays: 1, state: 'failed' }); 186 187 const result = await h.services.retention.sweep({ now: NOW }); 188 189 assert.equal(result.artifacts, 1); 190 assert.deepEqual(await artifactIds(h), ['art-2', 'art-3']); 191 assert.equal(await objectExists(h, old.artifactKey), false); 192 }); 193 }); 194 195 test('the most recent successful job is kept whatever the policy says', async () => { 196 await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => { 197 await seedRun(h, { number: 1, ageDays: 400, state: 'success' }); 198 await seedRun(h, { number: 2, ageDays: 300, state: 'failed' }); 199 await seedRun(h, { number: 3, ageDays: 0, state: 'failed' }); 200 201 await h.services.retention.sweep({ now: NOW }); 202 203 // job 1 is the last green one, job 3 is inside both windows, and the 204 // failed job between them has nothing protecting it. 205 assert.deepEqual(await artifactIds(h), ['art-1', 'art-3']); 206 }); 207 }); 208 209 test('only the latest success is protected, not every success', async () => { 210 await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => { 211 await seedRun(h, { number: 1, ageDays: 400, state: 'success' }); 212 await seedRun(h, { number: 2, ageDays: 300, state: 'success' }); 213 await seedRun(h, { number: 3, ageDays: 0, state: 'failed' }); 214 215 await h.services.retention.sweep({ now: NOW }); 216 217 assert.deepEqual(await artifactIds(h), ['art-2', 'art-3']); 218 }); 219 }); 220 221 test('a project may keep artifacts forever despite a server default', async () => { 222 await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => { 223 await h.services.db.run( 224 'UPDATE projects SET artifact_keep_jobs = 0, artifact_keep_days = 0 WHERE id = {id}', 225 { id: h.project.id } 226 ); 227 228 await seedRun(h, { number: 1, ageDays: 900, state: 'failed' }); 229 await seedRun(h, { number: 2, ageDays: 900, state: 'failed' }); 230 231 const result = await h.services.retention.sweep({ now: NOW }); 232 233 // Zero has to mean forever rather than immediately, or a project could 234 // not opt out of a default that deletes things. 235 assert.equal(result.artifacts, 0); 236 assert.equal((await artifactIds(h)).length, 2); 237 }); 238 }); 239 240 test('a project setting overrides the server default', async () => { 241 await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 365 } }, async (h) => { 242 await h.services.db.run( 243 'UPDATE projects SET artifact_keep_days = 7 WHERE id = {id}', 244 { id: h.project.id } 245 ); 246 247 await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); 248 249 await h.services.retention.sweep({ now: NOW }); 250 assert.deepEqual(await artifactIds(h), [], 'the stricter project setting applies'); 251 }); 252 }); 253 254 test('an unfinished job is never swept', async () => { 255 await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => { 256 // Backdated far enough to be well outside the window, but still going. 257 await seedRun(h, { number: 1, ageDays: 400, state: 'running', finished: false }); 258 259 const result = await h.services.retention.sweep({ now: NOW }); 260 261 assert.equal(result.artifacts, 0); 262 assert.equal(result.logs, 0); 263 assert.equal((await artifactIds(h)).length, 1); 264 }); 265 }); 266 267 test('logs older than the limit are removed but the task remains', async () => { 268 await withHarness({ retention: { log_keep_days: 14 } }, async (h) => { 269 const old = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); 270 const recent = await seedRun(h, { number: 2, ageDays: 1, state: 'failed' }); 271 272 const result = await h.services.retention.sweep({ now: NOW }); 273 274 assert.equal(result.logs, 1); 275 assert.equal(await objectExists(h, old.logKey), false); 276 assert.equal(await objectExists(h, recent.logKey), true); 277 278 // The task itself is history and stays, so a job remains explainable 279 // after its output has gone. 280 const task = await h.services.db.get( 281 'SELECT id, log_key, log_expired_at, state FROM tasks WHERE id = {id}', 282 { id: old.taskId } 283 ); 284 assert.ok(task, 'the task row must survive'); 285 assert.equal(task.log_key, null); 286 assert.equal(task.state, 'failed'); 287 assert.equal(task.log_expired_at, NOW); 288 }); 289 }); 290 291 test('a swept log is distinguishable from one that never existed', async () => { 292 await withHarness({ retention: { log_keep_days: 1 } }, async (h) => { 293 const swept = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); 294 await h.services.retention.sweep({ now: NOW }); 295 296 const gone = await h.services.db.get( 297 'SELECT log_key, log_expired_at FROM tasks WHERE id = {id}', { id: swept.taskId } 298 ); 299 // Both have no log_key, so the timestamp is the only thing telling 300 // "expired" apart from "never wrote anything". 301 assert.equal(gone.log_key, null); 302 assert.ok(gone.log_expired_at > 0); 303 }); 304 }); 305 306 test('a log is swept only once', async () => { 307 await withHarness({ retention: { log_keep_days: 1 } }, async (h) => { 308 await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); 309 310 const first = await h.services.retention.sweep({ now: NOW }); 311 const second = await h.services.retention.sweep({ now: NOW }); 312 313 assert.equal(first.logs, 1); 314 assert.equal(second.logs, 0, 'a swept log must not be counted again on every pass'); 315 }); 316 }); 317 318 test('zero keeps logs forever', async () => { 319 await withHarness({ retention: { log_keep_days: 0 } }, async (h) => { 320 const old = await seedRun(h, { number: 1, ageDays: 900, state: 'failed' }); 321 const result = await h.services.retention.sweep({ now: NOW }); 322 323 assert.equal(result.logs, 0); 324 assert.equal(await objectExists(h, old.logKey), true); 325 }); 326 }); 327 328 test('a storage failure leaves the row for the next pass', async () => { 329 await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => { 330 await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); 331 332 const real = h.services.storage.delete; 333 h.services.storage.delete = async () => { throw new Error('bucket unreachable'); }; 334 335 const failed = await h.services.retention.sweep({ now: NOW }); 336 assert.equal(failed.artifacts, 0); 337 assert.equal((await artifactIds(h)).length, 1, 'the row must survive so the object is not orphaned'); 338 339 h.services.storage.delete = real; 340 341 const recovered = await h.services.retention.sweep({ now: NOW }); 342 assert.equal(recovered.artifacts, 1); 343 assert.deepEqual(await artifactIds(h), []); 344 }); 345 }); 346 347 test('a sweep deletes no more than its batch', async () => { 348 await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => { 349 for (let n = 1; n <= 5; n += 1) { 350 await seedRun(h, { number: n, ageDays: 30, state: 'failed' }); 351 } 352 353 const first = await h.services.retention.sweep({ now: NOW, batch: 2 }); 354 assert.equal(first.artifacts, 2, 'a backlog must not be swept in one go'); 355 assert.equal((await artifactIds(h)).length, 3); 356 357 await h.services.retention.sweep({ now: NOW, batch: 10 }); 358 assert.equal((await artifactIds(h)).length, 0); 359 }); 360 }); 361 362 test('one project policy does not reach into another', async () => { 363 await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => { 364 await h.services.projects.create({ 365 id: 'other', 366 name: 'Other', 367 repo_url: h.repoDir, 368 trigger_secret: 'other-secret', 369 visibility: 'public', 370 }, { allowLocalPath: true }); 371 await h.services.db.run( 372 'UPDATE projects SET artifact_keep_days = 0 WHERE id = {id}', { id: 'other' } 373 ); 374 375 await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); 376 377 // An old artifact under the project that keeps everything. 378 await h.services.db.run( 379 `INSERT INTO jobs (id, project_id, number, ref, head_sha, trigger_type, 380 state, visibility, created_at, started_at, finished_at) 381 VALUES ('other-job', 'other', 1, 'refs/heads/main', {sha}, 'push', 382 'failed', 'public', {at}, {at}, {at})`, 383 { sha: 'c'.repeat(40), at: NOW - 900 * DAY } 384 ); 385 await h.services.db.run( 386 `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state, 387 allow_failure, attempt, max_attempts, timeout, log_size, 388 created_at, finished_at) 389 VALUES ('other-task', 'other-job', 'build', 'build', 'alpine:3', '[]', '{}', 390 'failed', 0, 1, 1, 3600, 0, {at}, {at})`, 391 { at: NOW - 900 * DAY } 392 ); 393 await h.services.db.run( 394 `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, created_at) 395 VALUES ('art-other', 'other-task', 'other-job', 'out.txt', 396 'artifacts/other/out.txt', 1, {sha}, {at})`, 397 { sha: 'd'.repeat(64), at: NOW - 900 * DAY } 398 ); 399 400 await h.services.retention.sweep({ now: NOW }); 401 402 assert.deepEqual(await artifactIds(h), ['art-other']); 403 }); 404 }); 405 406 test('artifacts.expire in a pipeline reaches the stored artifact', async () => { 407 // The key has been in the schema since the beginning and was parsed, 408 // stored in the task spec, and then ignored by everything. This is the 409 // path from pipeline text to a deadline on the row. 410 const pipeline = [ 411 'version: 1', 412 'defaults:', 413 ' image: alpine:3', 414 'tasks:', 415 ' build:', 416 " script: ['make']", 417 ' artifacts:', 418 ' paths: [out/**]', 419 ' expire: 1h', 420 ' keep:', 421 " script: ['make']", 422 ' artifacts:', 423 ' paths: [out/**]', 424 '', 425 ].join('\n'); 426 427 await withHarness({ pipeline }, async (h) => { 428 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 429 await h.waitForJob(res.json().data.job_id); 430 431 const before = Date.now(); 432 for (const name of ['build', 'keep']) { 433 const res = await h.claim({}); 434 assert.equal(res.statusCode, 200, `expected to claim ${name}`); 435 const task = res.json().data.task; 436 437 const upload = await h.app.inject({ 438 method: 'POST', 439 url: `/api/v1/tasks/${task.id}/artifacts`, 440 headers: { 441 ...h.auth, 442 'content-type': 'application/octet-stream', 443 'x-artifact-path': 'out/result.txt', 444 }, 445 payload: Buffer.from('packaged'), 446 }); 447 assert.equal(upload.statusCode, 200); 448 } 449 450 const rows = await h.services.db.all( 451 `SELECT t.base_name AS name, a.expires_at 452 FROM artifacts a JOIN tasks t ON t.id = a.task_id 453 ORDER BY t.base_name`, 454 {} 455 ); 456 457 const byName = Object.fromEntries(rows.map((r) => [r.name, r.expires_at])); 458 459 // An hour from when it was stored, give or take the time the test took. 460 assert.ok(byName.build >= before + 3600 * 1000, 'expire should be an hour out'); 461 assert.ok(byName.build <= Date.now() + 3600 * 1000); 462 463 // The task that said nothing is left to the project policy. 464 assert.equal(byName.keep, null); 465 }); 466 }); 467 468 test('the api reports and accepts project retention', async () => { 469 await withHarness({ 470 bootstrap: true, 471 retention: { artifact_keep_jobs: 7, artifact_keep_days: 21, log_keep_days: 9 }, 472 }, async (h) => { 473 const { headers } = await h.login(); 474 const json = { ...headers, 'content-type': 'application/json' }; 475 476 const patch = (payload) => h.app.inject({ 477 method: 'PATCH', 478 url: `/api/v1/projects/${h.project.id}`, 479 headers: json, 480 payload: JSON.stringify(payload), 481 }); 482 483 const before = await h.app.inject({ 484 method: 'GET', url: `/api/v1/projects/${h.project.id}`, headers, 485 }); 486 assert.equal(before.json().data.artifact_keep_days, null, 'unset means inherited'); 487 488 const patched = await patch({ artifact_keep_jobs: 3, artifact_keep_days: 0, log_keep_days: 30 }); 489 assert.equal(patched.statusCode, 200, patched.body); 490 assert.equal(patched.json().data.artifact_keep_jobs, 3); 491 assert.equal(patched.json().data.artifact_keep_days, 0, 'zero is a value, not an absence'); 492 assert.equal(patched.json().data.log_keep_days, 30); 493 494 // And back to following the server default. 495 const cleared = await patch({ artifact_keep_jobs: null }); 496 assert.equal(cleared.json().data.artifact_keep_jobs, null); 497 498 const rejected = await patch({ log_keep_days: -5 }); 499 assert.equal(rejected.statusCode, 400); 500 assert.match(rejected.json().error.message, /zero or more/); 501 }); 502 }); 503 504 test('a session outlives its expiry only until the next sweep', async () => { 505 await withHarness({ bootstrap: true }, async (h) => { 506 const grant = await h.login(); 507 const live = await h.services.refreshTokens.page({ offset: 0, limit: 10 }); 508 assert.equal(live.total, 1); 509 510 // Still within the grace window, so it can be reported as ended. 511 await h.services.db.run( 512 'UPDATE refresh_tokens SET expires_at = {then}', 513 { then: Date.now() - 1000 } 514 ); 515 assert.equal((await h.services.retention.sweep()).sessions, 0); 516 517 const refused = await h.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token }); 518 assert.equal(refused.statusCode, 400); 519 520 await h.services.db.run( 521 'UPDATE refresh_tokens SET expires_at = {then}', 522 { then: Date.now() - 3 * 24 * 60 * 60 * 1000 } 523 ); 524 assert.equal((await h.services.retention.sweep()).sessions, 1); 525 }); 526 }); 527 528 test('a machine that has stopped claiming is forgotten', async () => { 529 await withHarness({ retention: { worker_agent_keep_hours: 24 } }, async (h) => { 530 const { workerTokens, db } = h.services; 531 532 await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'gone', features: ['dind'] }); 533 await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'here', features: ['unos-sign-keys'] }); 534 // recordAgent stamps the real clock, and NOW is not the real clock. 535 await db.run("UPDATE worker_agents SET last_seen_at = {then} WHERE name = 'gone'", { then: NOW - 25 * HOUR }); 536 await db.run("UPDATE worker_agents SET last_seen_at = {then} WHERE name = 'here'", { then: NOW - 1 * HOUR }); 537 538 const result = await h.services.retention.sweep({ now: NOW }); 539 540 assert.equal(result.agents, 1); 541 const left = await workerTokens.agentsFor(h.worker.id); 542 assert.deepEqual(left.map((a) => a.name), ['here']); 543 }); 544 }); 545 546 test('zero keeps machines for ever', async () => { 547 await withHarness({ retention: { worker_agent_keep_hours: 0 } }, async (h) => { 548 const { workerTokens, db } = h.services; 549 550 await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'ancient', features: [] }); 551 await db.run('UPDATE worker_agents SET last_seen_at = {then}', { then: NOW - 365 * DAY }); 552 553 const result = await h.services.retention.sweep({ now: NOW }); 554 555 assert.equal(result.agents, 0); 556 assert.equal((await workerTokens.agentsFor(h.worker.id)).length, 1); 557 }); 558 });