mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The image now carries postgresql18-client (pg_dump and pg_restore 18.6) and a /var/lib/healthlog/backups directory owned by the app user, which the bundled compose file backs with a new `backups` volume for `app` and the commented `app-worker`; a fresh named volume inherits that owner. Before `migrate deploy`, when migrations are pending on a database that has applied some, the entrypoint writes healthlog-<time>-pre-migration.dump with its manifest and keeps the last two. A failed dump on a writable directory stops the boot and names DB_BACKUP_BEFORE_MIGRATE=false; without a writable directory it warns and continues. `docker compose run --rm db-restore <file|latest|list>` runs scripts/db-restore.mjs in the app image with plain node. It opens an off-host .dump.enc with BACKUP_ENCRYPTION_KEY (or a previous key), checks the archive against the manifest's SHA-256, compares the keys in the environment with the manifest's fingerprints and refuses on a different active encryption key unless --ignore-key-mismatch, refuses a database that holds any table, restores in one transaction, runs ANALYZE, and compares every table's row count with the manifest, emptying the database again on a difference. DB_BACKUP_ENABLED, DB_BACKUP_DIR, DB_BACKUP_SCHEDULE, DB_BACKUP_KEEP and DB_BACKUP_BEFORE_MIGRATE join the compose whitelist, both example files and the env manifest.
95 lines
3.6 KiB
JavaScript
95 lines
3.6 KiB
JavaScript
/**
|
|
* Boot-time notice before `prisma migrate deploy`: how many migrations this
|
|
* image is about to apply, and that a dump is taken first
|
|
* (`scripts/db-dump.mjs`, which imports `pendingMigrations` from here).
|
|
*
|
|
* Run by `docker-entrypoint.sh` as `node scripts/pending-migrations.mjs`.
|
|
* It only reports. A migration that changes data cannot be undone by pulling
|
|
* the previous image; the dump the entrypoint takes next is the way back.
|
|
*
|
|
* Best effort by design: any failure to count (no `_prisma_migrations` table
|
|
* yet is not a failure, it means every migration is pending) prints one line
|
|
* saying so and exits 0, because a notice is never a reason to keep the app
|
|
* from starting. `migrate deploy` right after it is what decides.
|
|
*/
|
|
import { readdirSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
/** Migration directory names that carry a `migration.sql`, sorted. */
|
|
export function shippedMigrations(migrationsDir) {
|
|
return readdirSync(migrationsDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.filter((name) => existsSync(join(migrationsDir, name, "migration.sql")))
|
|
.sort();
|
|
}
|
|
|
|
/**
|
|
* The shipped migrations the database has not finished applying. Reads
|
|
* `_prisma_migrations` in the schema the connection string names (Prisma's
|
|
* `?schema=` parameter, `public` when absent); a database without that table
|
|
* has applied nothing.
|
|
*/
|
|
export async function pendingMigrations({ connectionString, migrationsDir }) {
|
|
const shipped = shippedMigrations(migrationsDir);
|
|
const { default: pg } = await import("pg");
|
|
const url = new URL(connectionString);
|
|
const schema = url.searchParams.get("schema") || "public";
|
|
url.searchParams.delete("schema");
|
|
const client = new pg.Client({
|
|
connectionString: url.toString(),
|
|
connectionTimeoutMillis: 5_000,
|
|
statement_timeout: 5_000,
|
|
});
|
|
await client.connect();
|
|
try {
|
|
const table = await client.query(
|
|
"SELECT to_regclass(format('%I._prisma_migrations', $1::text)) AS t",
|
|
[schema],
|
|
);
|
|
if (!table.rows[0]?.t) return shipped;
|
|
const applied = await client.query(
|
|
`SELECT migration_name FROM ${table.rows[0].t}
|
|
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`,
|
|
);
|
|
const done = new Set(applied.rows.map((row) => row.migration_name));
|
|
return shipped.filter((name) => !done.has(name));
|
|
} finally {
|
|
await client.end().catch(() => {});
|
|
}
|
|
}
|
|
|
|
/** The log line for a count, as the entrypoint prints it. */
|
|
export function pendingNotice(count) {
|
|
if (count === 0) return "HealthLog: Database schema is up to date.";
|
|
const noun = count === 1 ? "migration" : "migrations";
|
|
return (
|
|
`HealthLog: ${count} database ${noun} pending. ` +
|
|
"Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is " +
|
|
"written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); " +
|
|
"a migration is not undone by going back to the previous image."
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const connectionString = process.env.DATABASE_URL;
|
|
if (!connectionString) return;
|
|
try {
|
|
const pending = await pendingMigrations({
|
|
connectionString,
|
|
migrationsDir: join(process.cwd(), "prisma", "migrations"),
|
|
});
|
|
console.log(pendingNotice(pending.length));
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
console.log(
|
|
`HealthLog: Could not count pending migrations (${reason}); continuing.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
await main();
|
|
}
|