Updates 1
CI / ci (push) Canceled after 0s

The session built:

Appointments page: a single list, newest first.
Block Time: edit and delete.
Customer CSV export: with barber notes excluded.
Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs.

It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
This commit is contained in:
Stefan Lazic committed 2026-10-17 22:29:00 +01:00
1 parent 9f981173f4
commit 17eee5b932
95 files changed
+7955 -150

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { performBackup } from "./backup";
import { BACKUP_MEMBERS, checkMigrationCompatibility, parseManifest, type BackupManifest } from "./backup-manifest";
import { backupFilePath } from "./backup-store";
import { generateComposeYaml } from "./compose-template";
import { composeCreate, composeDown, composeUp, listImageMigrations } from "./docker";
import { mediaRestoreCommand, mysqlImportCommand } from "./docker-commands";
import { readEnvValue } from "./env-file";
import { waitForHealth } from "./health";
import { uploadsDir } from "./manager-paths";
import { assertDomainAvailable, recreateDatabaseAndUser, writeCaddySiteAndReload } from "./provisioning";
import { runPipeline } from "./process";
import { sevenZipExtractToStdoutCommand, sevenZipReadMember, sevenZipTest } from "./sevenzip";
import { stackDir, stackExists } from "./stacks";
/**
* Restore a backup archive as a business — onto an existing business
* (replace) or onto a fresh server where it never existed. The step order
* is the contract (and is asserted in tests):
*
* verify → check → safety backup (replace only) → stop → recreate DB
* → import dump → write config + Caddy → restore media → start → health
*
* The database password comes from the backed-up .env (so that .env keeps
* working untouched); the dump already contains the "installed" row, so
* the setup wizard doesn't appear — the final step insists on
* `installed: true`. Like performBackup this does not take the business
* lock; the restore job holds it.
*/
export const RESTORE_STEPS = [
{ id: "verify", label: "Verify the archive and passphrase" },
{ id: "check", label: "Check for conflicts and schema version" },
{ id: "safety", label: "Take a safety backup of the current business" },
{ id: "stop", label: "Stop the current stack" },
{ id: "database", label: "Recreate the database and user" },
{ id: "import", label: "Import the database dump" },
{ id: "config", label: "Write configuration and the Caddy site" },
{ id: "media", label: "Restore media files" },
{ id: "start", label: "Start the stack" },
{ id: "health", label: "Wait for the app to report installed" },
] as const;
export type RestoreSource = { kind: "backup"; slug: string; fileName: string } | { kind: "upload"; uploadId: string };
const UPLOAD_ID_PATTERN = /^[a-f0-9]{16}$/;
export function resolveArchivePath(source: RestoreSource): string {
if (source.kind === "backup") return backupFilePath(source.slug, source.fileName);
if (!UPLOAD_ID_PATTERN.test(source.uploadId)) throw new Error("Invalid upload reference.");
return path.join(uploadsDir(), `${source.uploadId}.7z`);
}
export interface RestorePlan {
manifest: BackupManifest;
/** A stack with this slug already exists — restoring needs explicit "replace". */
slugExists: boolean;
/** Another business already uses the backup's domain. */
domainConflict: string | null;
schema: { ok: true } | { ok: false; reason: string };
}
/** Fast: opens only the (encrypted) header and reads manifest.json. A wrong passphrase fails here. Does not verify the payload. */
export async function readArchiveManifest(archivePath: string, passphrase: string): Promise<BackupManifest> {
return parseManifest(await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.manifest));
}
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive or wrong passphrase. */
export async function inspectArchive(archivePath: string, passphrase: string): Promise<BackupManifest> {
await sevenZipTest(archivePath, passphrase);
return readArchiveManifest(archivePath, passphrase);
}
/** Read-only conflict/version report shown on the Restore page before the operator confirms. */
export async function planRestore(manifest: BackupManifest): Promise<RestorePlan> {
const slugExists = await stackExists(manifest.slug);
let domainConflict: string | null = null;
try {
await assertDomainAvailable(manifest.domain, manifest.slug);
} catch (error) {
domainConflict = error instanceof Error ? error.message : "Domain conflict.";
}
let schema: RestorePlan["schema"];
try {
schema = checkMigrationCompatibility(manifest.lastMigration, await listImageMigrations());
} catch (error) {
schema = { ok: false, reason: `Couldn't read the app image's migrations: ${error instanceof Error ? error.message : String(error)}` };
}
return { manifest, slugExists, domainConflict, schema };
}
export interface RestoreContext {
log(line: string): void;
step<T>(stepId: string, fn: () => Promise<T>): Promise<T>;
skip(stepId: string, detail?: string): void;
}
export interface RestoreResult {
slug: string;
domain: string;
safetyBackup: string | null;
}
export async function performRestore(params: {
archivePath: string;
passphrase: string;
/** Operator confirmed overwriting an existing business with the same slug. */
replace: boolean;
ctx: RestoreContext;
}): Promise<RestoreResult> {
const { archivePath, passphrase, replace, ctx } = params;
const { manifest, envContent } = await ctx.step("verify", async () => {
ctx.log("Testing the archive (7zz t)…");
const parsed = await inspectArchive(archivePath, passphrase);
const env = await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.env);
ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`);
// The .env inside the archive must agree with the manifest — the
// password for the new database user comes from it.
const envDb = readEnvValue(env, "DATABASE_NAME");
const envUser = readEnvValue(env, "DATABASE_USER");
const envPassword = readEnvValue(env, "DATABASE_PASSWORD");
if (envDb !== parsed.databaseName || envUser !== parsed.databaseUser || !envPassword) {
throw new Error("The archive's .env doesn't match its manifest (database name/user/password) — refusing to restore it.");
}
return { manifest: parsed, envContent: env };
});
const slug = manifest.slug;
const dir = stackDir(slug);
const exists = await stackExists(slug);
await ctx.step("check", async () => {
if (exists && !replace) {
throw new Error(`A business named "${slug}" already exists. Confirm "replace" to overwrite it (a safety backup is taken first).`);
}
await assertDomainAvailable(manifest.domain, slug);
const compatibility = checkMigrationCompatibility(manifest.lastMigration, await listImageMigrations());
if (!compatibility.ok) throw new Error(compatibility.reason);
ctx.log("No conflicts; the backup's schema is compatible with the running app image.");
});
let safetyBackup: string | null = null;
if (exists) {
safetyBackup = await ctx.step("safety", async () => {
ctx.log(`"${slug}" exists — taking a safety backup before replacing it…`);
const result = await performBackup(slug, "safety", { log: ctx.log });
ctx.log(`Safety backup: ${result.fileName}`);
return result.fileName;
});
} else {
ctx.skip("safety", "No existing business to protect.");
}
if (exists) {
await ctx.step("stop", async () => {
// -v: the old media volume goes too, so files deleted since the backup don't linger.
await composeDown(dir, { removeVolumes: true });
});
} else {
ctx.skip("stop", "Nothing running under this slug.");
}
await ctx.step("database", async () => {
await recreateDatabaseAndUser(manifest.databaseName, manifest.databaseUser, readEnvValue(envContent, "DATABASE_PASSWORD")!);
});
await ctx.step("import", async () => {
ctx.log("Importing db.sql (streamed from the archive)…");
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
});
await ctx.step("config", async () => {
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, ".env"), envContent, { encoding: "utf8", mode: 0o600 });
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
await writeCaddySiteAndReload(slug, manifest.domain);
ctx.log("Wrote .env and compose.yaml, and reloaded Caddy.");
});
await ctx.step("media", async () => {
// Create (don't start) the container so the named volume exists with compose's labels before it's filled.
await composeCreate(dir);
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
ctx.log("Media restored and handed to uid 1001.");
});
await ctx.step("start", async () => {
// The entrypoint runs `prisma migrate deploy`, so an older dump upgrades itself here.
await composeUp(dir);
});
await ctx.step("health", async () => {
// Log only when the answer changes — polling every few seconds would otherwise bury the log tail.
let lastSeen = "";
await waitForHealth(slug, {
requireInstalled: true,
onAttempt: (s) => {
const seen = `HTTP ${s.httpStatus ?? "no response"}, installed=${String(s.installed)}`;
if (seen !== lastSeen) ctx.log(`health: ${seen}`);
lastSeen = seen;
},
});
});
return { slug, domain: manifest.domain, safetyBackup };
}