BetterFrame/server/src/shared/artifact-cleanup.ts
Mitchell R 851274d05d
fix: PG cloud_accounts migration + rollout-safe cleanup + setup cursor
- Add cloud_accounts table to PostgreSQL tenant migrations (was only
  in SQLite).
- Artifact cleanup now skips releases referenced by active/queued/paused
  rollouts (CASCADE would delete the rollout).
- Add invisible cursor theme install to setup-pi-kiosk.sh (was only
  in pi-gen image build).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-23 02:59:27 +02:00

146 lines
4.4 KiB
TypeScript

/**
* 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<boolean> {
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<number> {
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<string, typeof active>();
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<number> {
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<string, typeof active>();
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) };
}