A Delete button now sits next to Edit on each business's detail page, styled in red to distinguish it from the safe actions. It opens a dedicated confirmation page (matching the Add/Edit page pattern) that clearly lists exactly what gets destroyed — container + media volume, the database by name, the Caddy site/domain, and the stack directory — and requires typing the business's exact slug before the Delete button even becomes clickable. This is a stronger guard than a plain "are you sure?" popup, given what we just learned about how easy it is to lose data here. deleteBusiness() in provisioning.ts reuses the same primitives as create/edit (composeDown, reloadCaddy, the shared MySQL root connection), and is deliberately best-effort past the first step — if a piece is already gone (matching exactly the kind of half-broken state we just fixed for blake-heritage), it still finishes removing the rest and reports what it couldn't clean up automatically, rather than getting stuck. Verified live: provisioned a real test business, confirmed its container/database/user/volume/Caddy site all existed, deleted it, and confirmed every single one was actually gone — including checking Caddy's running config (not just the file on disk) to prove the reload took effect. Also confirmed the confirm-button correctly stays disabled for a wrong slug and only enables on an exact match. Your real production database was never touched throughout.
This commit is contained in:
7 files changed
+245
-18
No files matched your search
@@ -36,8 +36,10 @@ export async function composeUp(stackDir: string, options: { forceRecreate?: boo
|
||||
await runDockerCompose(stackDir, args);
|
||||
}
|
||||
|
||||
export async function composeDown(stackDir: string): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["down"]);
|
||||
export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise<void> {
|
||||
const args = ["down"];
|
||||
if (options.removeVolumes) args.push("-v");
|
||||
await runDockerCompose(stackDir, args);
|
||||
}
|
||||
|
||||
export async function composeRestart(stackDir: string): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import mariadb from "mariadb";
|
||||
import mariadb, { type Connection } from "mariadb";
|
||||
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
|
||||
import { composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
|
||||
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
|
||||
|
||||
@@ -45,20 +45,13 @@ async function writeCaddySiteAndReload(slug: string, domain: string): Promise<vo
|
||||
await reloadCaddy();
|
||||
}
|
||||
|
||||
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||
throw new Error("Refusing to create a database/user from an unsafe identifier.");
|
||||
}
|
||||
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
|
||||
throw new Error("Refusing to create a user with an unexpected password format.");
|
||||
}
|
||||
|
||||
async function connectAsRoot(): Promise<Connection> {
|
||||
const host = process.env.MYSQL_HOST ?? "next-chair-mysql";
|
||||
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
|
||||
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
|
||||
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
|
||||
|
||||
const conn = await mariadb.createConnection({
|
||||
return mariadb.createConnection({
|
||||
host,
|
||||
port,
|
||||
user: "root",
|
||||
@@ -72,6 +65,17 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string,
|
||||
// available client side" before this option was added here.
|
||||
allowPublicKeyRetrieval: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||
throw new Error("Refusing to create a database/user from an unsafe identifier.");
|
||||
}
|
||||
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
|
||||
throw new Error("Refusing to create a user with an unexpected password format.");
|
||||
}
|
||||
|
||||
const conn = await connectAsRoot();
|
||||
try {
|
||||
// Identifiers are backtick-quoted AND already validated against
|
||||
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
|
||||
@@ -85,6 +89,21 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string,
|
||||
}
|
||||
}
|
||||
|
||||
/** Irreversible — only ever called from deleteBusiness, itself gated behind a type-the-slug confirmation in the UI. */
|
||||
async function dropDatabaseAndUser(databaseName: string, databaseUser: string): Promise<void> {
|
||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||
throw new Error("Refusing to drop a database/user from an unsafe identifier.");
|
||||
}
|
||||
|
||||
const conn = await connectAsRoot();
|
||||
try {
|
||||
await conn.query(`DROP DATABASE IF EXISTS \`${databaseName}\``);
|
||||
await conn.query(`DROP USER IF EXISTS '${databaseUser}'@'%'`);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProvisionBusinessInput {
|
||||
name: string;
|
||||
slug: string;
|
||||
@@ -227,3 +246,60 @@ export async function updateBusiness(input: UpdateBusinessInput): Promise<Update
|
||||
|
||||
return { slug, domain, restarted: domainChanged };
|
||||
}
|
||||
|
||||
export interface DeleteBusinessResult {
|
||||
slug: string;
|
||||
/** Non-fatal problems along the way (e.g. the container was already gone) — the deletion still completes, but the operator should know what didn't clean up on its own. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Irreversible: removes the container, its media volume, its database and
|
||||
* database user, its Caddy site, and its entire stack directory. The UI
|
||||
* gates this behind typing the exact slug to confirm (see
|
||||
* dashboard/[slug]/delete/) — this function itself does no further
|
||||
* confirmation, so any new caller must provide its own.
|
||||
*
|
||||
* Deliberately best-effort past the first step: a stack can be deleted
|
||||
* from a half-broken state (container already removed by hand, database
|
||||
* already dropped, no Caddy site ever configured — exactly the kind of
|
||||
* state updateBusiness's own guard now prevents going forward, but this
|
||||
* needs to clean up businesses that got into that state before the fix
|
||||
* existed) — one missing piece shouldn't block removing the rest.
|
||||
*/
|
||||
export async function deleteBusiness(slug: string): Promise<DeleteBusinessResult> {
|
||||
const normalizedSlug = slug.trim().toLowerCase();
|
||||
if (!isValidSlug(normalizedSlug) || !(await stackExists(normalizedSlug))) {
|
||||
throw new Error(`No such business: ${normalizedSlug}`);
|
||||
}
|
||||
|
||||
const dir = stackDir(normalizedSlug);
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
await composeDown(dir, { removeVolumes: true });
|
||||
} catch (error) {
|
||||
warnings.push(`Couldn't stop/remove its container: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const currentEnv = await readEnvFile(path.join(dir, ".env"));
|
||||
const databaseName = currentEnv ? readEnvValue(currentEnv, "DATABASE_NAME") : null;
|
||||
if (databaseName) {
|
||||
try {
|
||||
await dropDatabaseAndUser(databaseName, databaseName);
|
||||
} catch (error) {
|
||||
warnings.push(`Couldn't drop its database: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(path.join(stacksRoot(), "infra", "sites", `${normalizedSlug}.caddy`), { force: true });
|
||||
await reloadCaddy();
|
||||
} catch (error) {
|
||||
warnings.push(`Couldn't remove/reload its Caddy site: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
|
||||
return { slug: normalizedSlug, warnings };
|
||||
}
|
||||
Reference in new issue
Block a user