1import type { Context, Hono } from "hono";2import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";3import { listRepos } from "../bucket/repo.ts";4import type { SyncTarget } from "../bucket/sync.ts";5import { tarballGeneration } from "../mirror/queries.ts";6import { openMirror } from "../mirror/store.ts";7import { html } from "../pages/html.ts";8import { formatSize, plural, relativeTime } from "../pages/layout.ts";9import { keepState, setting } from "../server/db.ts";10import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";11import { allRepos } from "./store.ts";1213const TARBALL = /^\.kepr\/tarballs\/([^/]+)\/([^/]+)\/([0-9a-f]{6}|[0-9a-f]{12})\.tar\.gz$/;14const HIDDEN = /^\.kepr\/hidden\/([^/]+)$/;1516type RepoUse = { history: number; builds: number; tarballs: number };17type Tarball = { key: string; repo: string; save: string; size: number };18type Survey = {19 20 at: number;21 use: [repo: string, use: RepoUse][];22 kept: Tarball[];23 orphans: [key: string, size: number][];24 kepr: number;25 total: number;26};27282930const MEASURE_MS = 60 * 60 * 1000;313233let turn: Promise<unknown> = Promise.resolve();34function inStorageTurn<T>(work: () => Promise<T>): Promise<T> {35 const mine = turn.then(work);36 turn = mine.catch(() => {});37 return mine;38}3940export function startStorageLoop(target: SyncTarget): void {41 const tick = () =>42 measureStorage(target).catch((error) =>43 console.error(`kepr: measuring storage failed: ${error}`),44 );45 tick();46 setInterval(tick, MEASURE_MS);47}48495051525354export function measureStorage(target: SyncTarget): Promise<void> {55 return inStorageTurn(() => measure(target));56}5758async function measure(target: SyncTarget): Promise<void> {59 const sizes = await target.bucket.sizes("");60 const repos = await listRepos(target.bucket);61 const isOrphan = orphanTest(target, repos);62 const use = new Map<string, RepoUse>(63 repos.map((repo) => [repo, { history: 0, builds: 0, tarballs: 0 }]),64 );65 const survey: Survey = {66 at: Math.floor(Date.now() / 1000),67 use: [],68 kept: [],69 orphans: [],70 kepr: 0,71 total: 0,72 };73 for (const [key, size] of sizes) {74 survey.total += size;75 const tarball = TARBALL.exec(key);76 if (isOrphan(key)) survey.orphans.push([key, size]);77 else if (tarball) {78 const [, repo = "", , save = ""] = tarball;79 80 (use.get(repo) as RepoUse).tarballs += size;81 survey.kept.push({ key, repo, save, size });82 } else if (key.startsWith(KEPR_PREFIX)) survey.kepr += size;83 else {84 const [repo = "", kind] = key.split("/");85 const owner = use.get(repo);86 if (owner && kind === "releases") owner.builds += size;87 else if (owner) owner.history += size;88 }89 }90 survey.use = [...use];91 keepState(target.kepr, "storage", JSON.stringify(survey));92}93949596979899function orphanTest(target: SyncTarget, repos: string[]): (key: string) => boolean {100 const registered = new Set(allRepos(target.kepr).map((repo) => repo.name));101 const generations = new Map(102 repos103 .filter((repo) => registered.has(repo))104 .map((repo) => [repo, tarballGeneration(openMirror(target.mirrorDir, repo))]),105 );106 return (key) => {107 const tarball = TARBALL.exec(key);108 const [, repo = "", generation] = tarball ?? HIDDEN.exec(key) ?? [];109 if (!repos.includes(repo)) return tarball !== null || HIDDEN.test(key);110 111 return tarball !== null && generations.has(repo) && generations.get(repo) !== generation;112 };113}114115function storedSurvey(ctx: AdminContext): Survey | null {116 const stored = setting(ctx.kepr, "storage");117 return stored ? (JSON.parse(stored) as Survey) : null;118}119120121function withoutKeys(survey: Survey, gone: Set<string>): Survey {122 const sizeOf = new Map([...survey.orphans, ...survey.kept.map((t) => [t.key, t.size] as const)]);123 const freed = [...gone].reduce((sum, key) => sum + (sizeOf.get(key) ?? 0), 0);124 const use = survey.use.map(([repo, u]): [string, RepoUse] => {125 const dropped = survey.kept.filter((t) => t.repo === repo && gone.has(t.key));126 return [repo, { ...u, tarballs: u.tarballs - dropped.reduce((sum, t) => sum + t.size, 0) }];127 });128 return {129 ...survey,130 use,131 kept: survey.kept.filter((t) => !gone.has(t.key)),132 orphans: survey.orphans.filter(([key]) => !gone.has(key)),133 total: survey.total - freed,134 };135}136137138139140141async function deleteKeys(ctx: AdminContext, fields: Record<string, string>): Promise<string> {142 const survey = storedSurvey(ctx);143 if (!survey) return "unknown";144 let doomed: string[];145 if (fields.action === "delete_orphans") {146 147 const isOrphan = orphanTest(ctx.sync, await listRepos(ctx.sync.bucket));148 doomed = survey.orphans.map(([key]) => key).filter(isOrphan);149 } else {150 doomed = survey.kept.filter((t) => t.key === fields.key).map((t) => t.key);151 if (doomed.length === 0) return "unknown";152 }153 const gone = new Set<string>();154 try {155 for (const key of doomed) {156 await ctx.sync.bucket.remove(key);157 gone.add(key);158 }159 } catch (error) {160 if (!(error instanceof BucketWriteError)) throw error;161 return error.message;162 } finally {163 keepState(ctx.kepr, "storage", JSON.stringify(withoutKeys(survey, gone)));164 }165 return "";166}167168function storagePage(c: Context, ctx: AdminContext, error: string) {169 const survey = storedSurvey(ctx);170 if (!survey) {171 const body = html`${notice(error)}172<p class="admin-intro">Not measured yet: Kepr measures the bucket when it starts and every hour.</p>`;173 return adminPage(c, ctx, "storage", body);174 }175 const { use, kept, orphans, kepr, total } = survey;176 const orphanBytes = orphans.reduce((sum, [, size]) => sum + size, 0);177 const body = html`${notice(error)}178<p class="admin-intro">The bucket holds ${formatSize(total)}; Kepr's own folder ${formatSize(kepr)}179besides its kept tarballs. Measured ${relativeTime(survey.at)}; Kepr measures it every hour.</p>180<section class="repo-section"><h2 class="section-label mono">by repository</h2>181<table class="admin-table mono">182<thead><tr><th>repo</th><th>history</th><th>builds</th><th>kept tarballs</th></tr></thead>183<tbody>184${use.map(185 ([repo, u]) => html`<tr>186<td>${repo}</td>187<td>${formatSize(u.history)}</td>188<td>${formatSize(u.builds)}</td>189<td>${formatSize(u.tarballs)}</td>190</tr>`,191)}192</tbody></table></section>193<section class="repo-section"><h2 class="section-label mono">orphans</h2>194${195 orphans.length === 0196 ? html`<p class="muted mono">no orphans</p>`197 : html`<form class="issues-form" method="post" action="/admin/storage">198${formField(c)}<input type="hidden" name="action" value="delete_orphans">199<p class="issue-meta mono">${plural(orphans.length, "orphan")}, ${formatSize(orphanBytes)}: tarballs200of an older generation and files of repos no longer in the bucket.</p>201<button class="issues-submit" type="submit">delete orphans</button></form>`202}203</section>204<section class="repo-section"><h2 class="section-label mono">kept tarballs</h2>205${kept.length === 0 && html`<p class="muted mono">none kept</p>`}206${kept.map(207 (t) => html`<form class="issue-row" method="post" action="/admin/storage">208${formField(c)}<input type="hidden" name="action" value="delete_tarball">209<input type="hidden" name="key" value="${t.key}">210<span class="mono">${t.repo} ${t.save}.tar.gz</span>211<span class="issue-meta mono">${formatSize(t.size)}</span>212<button class="issues-submit" type="submit">delete</button></form>`,213)}214</section>`;215 return adminPage(c, ctx, "storage", body);216}217218219export function registerStorage(app: Hono, ctx: AdminContext): void {220 app.get("/admin/storage", (c) => c.html(storagePage(c, ctx, "")));221222 app.post("/admin/storage", async (c) => {223 const fields = await textFields(c);224 225 let outcome = "";226 await ctx.syncer.hold(() =>227 inStorageTurn(async () => {228 outcome = await deleteKeys(ctx, fields);229 }),230 );231 if (outcome === "unknown") return c.notFound();232 if (outcome) return c.html(storagePage(c, ctx, outcome), 503);233 return c.redirect("/admin/storage?saved=1", 303);234 });235}