/** * Periodic cleanup of firmware + OS update artifact files. * * Strategy: * 1. Delete artifact files for yanked releases (DB row kept for audit). * Skip if an active/queued/paused rollout references the release. * 2. For each channel, keep only the N most recent non-yanked releases. * Older ones get artifact deleted + DB row removed. Skip if referenced * by an active rollout. * * Runs every 6 hours. Safe to call concurrently — worst case is two passes * trying to delete the same file (ENOENT is swallowed). */ import { unlink } from "node:fs/promises"; import type { Repository } from "../plugins/service-store/repository.js"; interface CleanupLog { info(msg: string): void; warn(msg: string): void; } const KEEP_PER_CHANNEL = 5; const INTERVAL_MS = 6 * 60 * 60 * 1000; async function removeFile(path: string): Promise { try { await unlink(path); return true; } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return false; throw err; } } async function cleanupFirmware(repo: Repository, log: CleanupLog): Promise { let cleaned = 0; const rollouts = await repo.listFirmwareRollouts(); const activeReleaseIds = new Set( rollouts .filter((r) => r.state === "queued" || r.state === "active" || r.state === "paused") .map((r) => r.release_id), ); const yanked = await repo.listYankedFirmwareReleases(); for (const r of yanked) { if (activeReleaseIds.has(r.id)) continue; if (await removeFile(r.artifact_path)) { log.info(`firmware cleanup: deleted artifact for yanked ${r.version} (${r.arch})`); cleaned++; } await repo.deleteFirmwareRelease(r.id); } const all = await repo.listFirmwareReleases(); const active = all.filter((r) => !r.yanked_at); const byKey = new Map(); for (const r of active) { const key = `${r.channel}:${r.arch}`; const list = byKey.get(key) ?? []; list.push(r); byKey.set(key, list); } for (const [, releases] of byKey) { releases.sort((a, b) => b.uploaded_at.localeCompare(a.uploaded_at)); const excess = releases.slice(KEEP_PER_CHANNEL); for (const r of excess) { if (activeReleaseIds.has(r.id)) continue; if (await removeFile(r.artifact_path)) { log.info(`firmware cleanup: pruned old ${r.version} (${r.arch})`); cleaned++; } await repo.deleteFirmwareRelease(r.id); } } return cleaned; } async function cleanupOsUpdates(repo: Repository, log: CleanupLog): Promise { let cleaned = 0; const rollouts = await repo.listOsUpdateRollouts(); const activeReleaseIds = new Set( rollouts .filter((r) => r.state === "queued" || r.state === "active" || r.state === "paused") .map((r) => r.release_id), ); const yanked = await repo.listYankedOsUpdateReleases(); for (const r of yanked) { if (activeReleaseIds.has(r.id)) continue; if (await removeFile(r.artifact_path)) { log.info(`os-update cleanup: deleted artifact for yanked ${r.version}`); cleaned++; } await repo.deleteOsUpdateRelease(r.id); } const all = await repo.listOsUpdateReleases(); const active = all.filter((r) => !r.yanked_at); const byKey = new Map(); for (const r of active) { const key = `${r.channel}:${r.compatibility}`; const list = byKey.get(key) ?? []; list.push(r); byKey.set(key, list); } for (const [, releases] of byKey) { releases.sort((a, b) => b.uploaded_at.localeCompare(a.uploaded_at)); const excess = releases.slice(KEEP_PER_CHANNEL); for (const r of excess) { if (activeReleaseIds.has(r.id)) continue; if (await removeFile(r.artifact_path)) { log.info(`os-update cleanup: pruned old ${r.version}`); cleaned++; } await repo.deleteOsUpdateRelease(r.id); } } return cleaned; } export function startArtifactCleanup( repo: Repository, log: CleanupLog, ): { stop: () => void } { async function run() { try { const fw = await cleanupFirmware(repo, log); const os = await cleanupOsUpdates(repo, log); if (fw + os > 0) { log.info(`artifact cleanup: removed ${fw} firmware + ${os} OS artifacts`); } } catch (err) { log.warn(`artifact cleanup failed: ${(err as Error).message}`); } } void run(); const timer = setInterval(() => void run(), INTERVAL_MS); return { stop: () => clearInterval(timer) }; }