58d7fe07cda0
list and download every backup, back up now, and show the last failure and restore steps (v3.0.138)
- time
- 2026-09-29 22:45 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- backup workspace
- parent
- 301147eeaa38
↓ kepr-58d7fe07.tar.gzatlas at this save
files changed6 files
package.json+1 −1src/admin/backups.ts+65 −0src/admin/console.ts+3 −30src/server/app.ts+2 −0src/server/backup.ts+54 −24tests/backupworkspace.test.ts+77 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.137",3+ "version": "3.0.138",44 "private": true,55 "type": "module",66 "scripts": {src/admin/backups.ts+65 −0
@@ -0,0 +1,65 @@1+import type { Context, Hono } from "hono";2+import { html } from "../pages/html.ts";3+import { formatSize } from "../pages/layout.ts";4+import {5+ attemptBackup,6+ backupBytes,7+ backups,8+ backupTarball,9+ makeBackup,10+} from "../server/backup.ts";11+import { setting } from "../server/db.ts";12+import { type AdminContext, adminPage, formField, notice } from "./console.ts";13+14+/** `20260926T031500Z` as `2026-09-26 03:15:00 UTC`. */15+function backupTime(name: string): string {16+ return name.replace(/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/, "$1-$2-$3 $4:$5:$6 UTC");17+}18+19+/** §24's Backups section: every kept backup, back up now, the last failure, and how to restore. */20+export function backupsPage(c: Context, ctx: AdminContext, error = ""): string {21+ const kept = backups(ctx.dataDir).reverse();22+ const newest = kept[0];23+ const failure = setting(ctx.kepr, "backup_error");24+ const body = html`${notice(error)}25+${failure && !error && html`<p class="issues-error mono" role="alert">The last backup failed: ${failure}</p>`}26+${27+ newest28+ ? html`<p>Last backup: ${backupTime(newest)}. <a href="/admin/backup">download backup</a></p>`29+ : html`<p>No backup yet.</p>`30+}31+<form method="post" action="/admin/backup">${formField(c)}32+<button class="issues-submit" type="submit">back up now</button></form>33+<p>Kepr keeps the last 14 daily backups of its database and assets.</p>34+<ul class="admin-releases mono">${kept.map(35+ (name) => html`<li><a href="/admin/backup?name=${name}">${backupTime(name)}</a>36+<span class="muted">${formatSize(backupBytes(ctx.dataDir, name))}</span></li>`,37+ )}</ul>38+<section class="repo-section"><h2 class="section-label mono">restoring one</h2>39+<div class="markdown"><ol>40+<li>Stop Kepr.</li>41+<li>Unpack the download: it holds a folder with kepr.db and assets/.</li>42+<li>Put kepr.db and assets/ in the data directory named in kepr.toml, over the old ones.</li>43+<li>Start Kepr. Its first sync rebuilds the mirrors, and everyone signs in again.</li>44+</ol>45+<p class="muted">Backups stay on this server. Keep a copy elsewhere, along with kepr.toml.</p>46+<p class="muted">Sessions and bucket credentials are not included.</p></div></section>`;47+ return adminPage(c, ctx, "backups", body);48+}49+50+export function registerBackups(app: Hono, ctx: AdminContext): void {51+ app.get("/admin/backup", (c) => {52+ const backup = backupTarball(ctx.dataDir, c.req.query("name"));53+ if (!backup) return c.notFound();54+ return c.body(backup.bytes, 200, {55+ "Content-Type": "application/gzip",56+ "Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,57+ });58+ });59+60+ app.post("/admin/backup", (c) => {61+ const failure = attemptBackup(ctx.kepr, () => makeBackup(ctx.kepr, ctx.dataDir));62+ if (failure) return c.html(backupsPage(c, ctx, `The backup failed: ${failure}`), 500);63+ return c.redirect("/admin?section=backups&saved=1", 303);64+ });65+}src/admin/console.ts+3 −30
@@ -4,11 +4,12 @@44 import { operatorIssues } from "../issues/store.ts";55 import { type Html, html } from "../pages/html.ts";66 import { type PageContext, page, plural, relativeTime } from "../pages/layout.ts";7-import { backups, newestBackupTarball } from "../server/backup.ts";7+import { backups } from "../server/backup.ts";88 import type { Config } from "../server/config.ts";99 import { connectKeys, MACHINES_LISTED } from "../server/connect.ts";1010 import { setting } from "../server/db.ts";1111 import { formToken } from "../server/login.ts";12+import { backupsPage } from "./backups.ts";1213 import { allRepos } from "./store.ts";1314 1415 export type AdminContext = PageContext & {@@ -132,26 +133,7 @@132133 /** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */133134 export function registerStatus(app: Hono, ctx: AdminContext): void {134135 app.get("/admin", async (c) => {135- if (c.req.query("section") === "backups") {136- const newest = backups(ctx.dataDir).at(-1);137- const backup = newest138- ? html`<p>Last backup: ${newest.replace(139- /^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,140- "$1-$2-$3 $4:$5:$6 UTC",141- )}. <a href="/admin/backup">download backup</a></p>`142- : html`<p>No backup yet.</p>`;143- return c.html(144- adminPage(145- c,146- ctx,147- "backups",148- html`${backup}149-<p>Kepr keeps the last 14 daily backups of its database and assets.</p>150-<p class="muted">Backups stay on this server. Keep a copy elsewhere, along with kepr.toml.</p>151-<p class="muted">Sessions and bucket credentials are not included.</p>`,152- ),153- );154- }136+ if (c.req.query("section") === "backups") return c.html(backupsPage(c, ctx));155137 let bucket: Html;156138 try {157139 const repos = await listRepos(ctx.sync.bucket);@@ -218,14 +200,5 @@218200 <p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,219201 ),220202 );221- });222-223- app.get("/admin/backup", (c) => {224- const backup = newestBackupTarball(ctx.dataDir);225- if (!backup) return c.notFound();226- return c.body(backup.bytes, 200, {227- "Content-Type": "application/gzip",228- "Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,229- });230203 });231204 }src/server/app.ts+2 −0
@@ -3,6 +3,7 @@33 import { Hono } from "hono";44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";6+import { registerBackups } from "../admin/backups.ts";67 import { registerBucket } from "../admin/bucket.ts";78 import { registerStatus } from "../admin/console.ts";89 import { registerEmail } from "../admin/email.ts";@@ -129,6 +130,7 @@129130 registerBucket(app, admin);130131 registerNotifications(app, admin);131132 registerEmail(app, admin);133+ registerBackups(app, admin);132134 registerRepoText(app, admin);133135 registerHousekeeping(app, ctx);134136 registerFrontPages(app, ctx);src/server/backup.ts+54 −24
@@ -7,10 +7,11 @@77 readFileSync,88 renameSync,99 rmSync,10+ statSync,1011 } from "node:fs";1112 import { join, relative } from "node:path";1213 import { buildTarball } from "../tarball/tar.ts";13-import { notify } from "./db.ts";14+import { notify, setting } from "./db.ts";1415 1516 const KEEP = 14;1617 const DAY_MS = 24 * 3600 * 1000;@@ -25,7 +26,8 @@2526 /** The backups in `data/backups/`, oldest first. */2627 export function backups(dataDir: string): string[] {2728 const root = join(dataDir, "backups");28- return existsSync(root)29+ // Not a folder is no backups, so the console still shows why the next one fails.30+ return existsSync(root) && statSync(root).isDirectory()2931 ? readdirSync(root)3032 .filter((name) => NAME.test(name))3133 .sort()@@ -68,36 +70,64 @@6870 6971 /** Daily backups: checked at startup and every hour, so a restart never skips a day. */7072 export function startBackupLoop(kepr: Database, dataDir: string): void {71- // Only a backup that starts failing is told, not every hourly retry of one that keeps failing.72- let failing = false;73- const tick = () => {74- try {75- backupIfDue(kepr, dataDir);76- failing = false;77- } catch (error) {78- console.error(`kepr: backup failed: ${error}`);79- if (!failing)80- notify(kepr, "backup_failed", `Backup failed: ${error}`, "/admin?section=backups");81- failing = true;82- }83- };73+ const tick = () => attemptBackup(kepr, () => backupIfDue(kepr, dataDir));8474 tick();8575 setInterval(tick, CHECK_MS);8676 }8777 88-/** The newest backup as a `.tar.gz` for "download backup" (§24), or null when there is none. */89-export function newestBackupTarball(90- dataDir: string,91-): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {92- const name = backups(dataDir).at(-1);93- if (!name) return null;78+/**79+ * One backup attempt. A failure is kept for the console until a backup succeeds (§24), and only a80+ * backup that starts failing is told, not every hourly retry. Returns the failure, or null.81+ */82+export function attemptBackup(kepr: Database, make: () => unknown): string | null {83+ try {84+ make();85+ kepr.query("DELETE FROM settings WHERE key = 'backup_error'").run();86+ return null;87+ } catch (error) {88+ const message = String(error);89+ console.error(`kepr: backup failed: ${message}`);90+ if (!setting(kepr, "backup_error")) {91+ notify(kepr, "backup_failed", `Backup failed: ${message}`, "/admin?section=backups");92+ }93+ kepr94+ .query("INSERT OR REPLACE INTO settings (key, value) VALUES ('backup_error', ?)")95+ .run(message);96+ return message;97+ }98+}99+100+function backupFiles(dataDir: string, name: string): { path: string; full: string }[] {94101 const dir = join(dataDir, "backups", name);95- const files = readdirSync(dir, { recursive: true, withFileTypes: true })102+ return readdirSync(dir, { recursive: true, withFileTypes: true })96103 .filter((entry) => entry.isFile())97104 .map((entry) => {98- const path = join(entry.parentPath, entry.name);99- return { path: relative(dir, path), bytes: readFileSync(path) };105+ const full = join(entry.parentPath, entry.name);106+ return { path: relative(dir, full), full };100107 });108+}109+110+/** What one backup takes on disk. */111+export function backupBytes(dataDir: string, name: string): number {112+ return backupFiles(dataDir, name).reduce((sum, file) => sum + statSync(file.full).size, 0);113+}114+115+/**116+ * A kept backup as a `.tar.gz` for the console's downloads (§24), the newest when no name is117+ * given; null when there is no such backup.118+ */119+export function backupTarball(120+ dataDir: string,121+ wanted?: string,122+): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {123+ const kept = backups(dataDir);124+ // Only a name from the list, so a request can never reach outside data/backups/.125+ const name = wanted === undefined ? kept.at(-1) : kept.find((known) => known === wanted);126+ if (!name) return null;127+ const files = backupFiles(dataDir, name).map((file) => ({128+ path: file.path,129+ bytes: readFileSync(file.full),130+ }));101131 // Owner-only once unpacked: kepr.db holds reporters' contact emails.102132 return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };103133 }tests/backupworkspace.test.ts+77 −0
@@ -0,0 +1,77 @@1+// Covers §24's backup workspace: every kept backup listed and downloadable, back up now, the last2+// failure until a backup succeeds, and the written restore steps.3+4+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5+import { unlinkSync, writeFileSync } from "node:fs";6+import { backups, makeBackup, startBackupLoop } from "../src/server/backup.ts";7+import { setting } from "../src/server/db.ts";8+import { Browser, type Instance, instance } from "./browser.ts";9+import { makeRepos } from "./fixture.ts";10+11+describe("the backup workspace (§24)", () => {12+ let inst: Instance;13+ let operator: Browser;14+ let clock = Date.parse("2026-09-27T03:00:00Z");15+ const page = async () => (await operator.get("/admin?section=backups")).text();16+17+ beforeAll(async () => {18+ setSystemTime(new Date(clock));19+ const repos = await makeRepos(["app"]);20+ inst = await instance(repos.bucketDir);21+ for (let day = 3; day > 0; day--) {22+ makeBackup(inst.kepr, inst.dataDir, clock - day * 86_400_000);23+ }24+ operator = new Browser(inst.app);25+ await operator.signIn();26+ });27+ afterAll(() => setSystemTime());28+29+ test("lists every kept backup, newest first, with its size and its own download", async () => {30+ const text = await page();31+ const listed = [...text.matchAll(/href="\/admin\/backup\?name=(\d{8}T\d{6}Z)"/g)].map(32+ (match) => match[1],33+ );34+ expect(listed).toEqual(["20260926T030000Z", "20260925T030000Z", "20260924T030000Z"]);35+ expect(text).toMatch(/2026-09-24 03:00:00 UTC<\/a>\s*<span class="muted">[\d.]+ KB<\/span>/);36+ const download = await operator.request("/admin/backup?name=20260925T030000Z");37+ expect(download.status).toBe(200);38+ expect(download.headers.get("content-disposition")).toBe(39+ 'attachment; filename="kepr-backup-20260925T030000Z.tar.gz"',40+ );41+ expect((await operator.request("/admin/backup?name=20260101T000000Z")).status).toBe(404);42+ expect((await operator.request("/admin/backup?name=../kepr.db")).status).toBe(404);43+ });44+45+ test("says how to restore one by hand", async () => {46+ const text = await page();47+ expect(text).toContain("Stop Kepr.");48+ expect(text).toContain("kepr.db and assets/");49+ });50+51+ test("back up now makes one at once, even with today's done", async () => {52+ clock += 3600_000;53+ setSystemTime(new Date(clock));54+ const before = backups(inst.dataDir).length;55+ const made = await operator.post("/admin/backup", {});56+ expect(made.status).toBe(303);57+ expect(made.headers.get("location")).toBe("/admin?section=backups&saved=1");58+ expect(backups(inst.dataDir).length).toBe(before + 1);59+ expect(backups(inst.dataDir).at(-1)).toBe("20260927T040000Z");60+ });61+62+ test("the last failure stays shown until a backup succeeds", async () => {63+ const saved = `${inst.dataDir}/backups-kept`;64+ Bun.spawnSync(["mv", `${inst.dataDir}/backups`, saved]);65+ writeFileSync(`${inst.dataDir}/backups`, "");66+ startBackupLoop(inst.kepr, inst.dataDir);67+ expect(setting(inst.kepr, "backup_error")).toContain("ENOTDIR");68+ const failed = await operator.post("/admin/backup", {});69+ expect(failed.status).toBe(500);70+ expect(await page()).toContain("The last backup failed:");71+ unlinkSync(`${inst.dataDir}/backups`);72+ Bun.spawnSync(["mv", saved, `${inst.dataDir}/backups`]);73+ expect((await operator.post("/admin/backup", {})).status).toBe(303);74+ expect(setting(inst.kepr, "backup_error")).toBeNull();75+ expect(await page()).not.toContain("The last backup failed:");76+ });77+});