update: the api backs up before migrating; scripts/update.sh for compose installs
Docker only, never bare metal (owner, 2026-10-21). A new version with pending migrations backs up first (database, voicemail, sounds, MOH) and stops if it can't (SKIP_BACKUP_BEFORE_MIGRATE to override). update.sh: checks, changelog, backup (node dist/backup-cli.js), log compaction, build while the old version runs, restart, self-check, and rollback with restore. Container logs capped in compose. Admin guide: Updating, for compose and Portainer.
This commit is contained in:
1 parent
8d5475cfbf
commit
54e1e201a1
9 files changed
+376
-6
No files matched your search
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// `node dist/backup-cli.js` in the api container: one backup like the one before migrating
|
||||
// (database, voicemail, custom sounds, music on hold; no recordings), its file name printed.
|
||||
// scripts/update.sh runs it in the running (old) api before updating.
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createDb } from './db/client.ts';
|
||||
import { backupBeforeMigrate } from './services/backup.ts';
|
||||
|
||||
const config = loadConfig();
|
||||
const { db, pool } = createDb(config.DATABASE_URL);
|
||||
try {
|
||||
console.log((await backupBeforeMigrate(config, db)).name);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
@@ -34,6 +34,8 @@ const EnvSchema = z.object({
|
||||
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
TRUST_PROXY: z.stringbool().default(true),
|
||||
/** Migrate even when the backup before migrating fails (index.ts). For a fix-it-now moment only. */
|
||||
SKIP_BACKUP_BEFORE_MIGRATE: z.stringbool().default(false),
|
||||
TZ: z.string().default('America/Chicago'),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import pg from 'pg';
|
||||
@@ -19,3 +22,21 @@ export type Db = ReturnType<typeof createDb>['db'];
|
||||
export async function runMigrations(db: Db): Promise<void> {
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
}
|
||||
|
||||
/**
|
||||
* This image has migrations the database hasn't run yet. False for an empty database (a fresh
|
||||
* install: nothing to lose). Reads drizzle's own bookkeeping, `drizzle.__drizzle_migrations`.
|
||||
*/
|
||||
export async function migrationsPending(db: Db, folder = MIGRATIONS_DIR): Promise<boolean> {
|
||||
const journal = JSON.parse(await readFile(join(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
const t = await db.execute<{ t: string | null }>(
|
||||
sql`select to_regclass('drizzle.__drizzle_migrations')::text as t`,
|
||||
);
|
||||
if (!t.rows[0]?.t) return false;
|
||||
const r = await db.execute<{ n: number }>(
|
||||
sql`select count(*)::int as n from drizzle.__drizzle_migrations`,
|
||||
);
|
||||
return r.rows[0]!.n < journal.entries.length;
|
||||
}
|
||||
+20
-1
@@ -5,7 +5,8 @@ import { AmiClient } from './ami/client.ts';
|
||||
import { buildApp } from './app.ts';
|
||||
import { ConfigError, loadConfig } from './config.ts';
|
||||
import { setSecretKey } from './crypto/secrets.ts';
|
||||
import { createDb, runMigrations } from './db/client.ts';
|
||||
import { createDb, migrationsPending, runMigrations } from './db/client.ts';
|
||||
import { backupBeforeMigrate } from './services/backup.ts';
|
||||
import { clearRestartIfRestarted, createGenerator } from './generator/index.ts';
|
||||
import { ensureInitialAdmin } from './services/bootstrap.ts';
|
||||
import { LiveState } from './live/state.ts';
|
||||
@@ -82,6 +83,24 @@ app.decorate('live', live);
|
||||
watchWebhooks(db, live, app.log);
|
||||
watchEvents(db, live, ami, app.log);
|
||||
|
||||
// A new version with database changes: back up first, so a bad update can be undone (08 §3.1).
|
||||
if (await migrationsPending(db)) {
|
||||
if (config.SKIP_BACKUP_BEFORE_MIGRATE)
|
||||
app.log.warn('database changes pending; SKIP_BACKUP_BEFORE_MIGRATE is set, so no backup first');
|
||||
else {
|
||||
app.log.info('database changes pending: backing up first');
|
||||
const backup = await backupBeforeMigrate(config, db).catch((err: unknown) => {
|
||||
app.log.fatal(
|
||||
{ err, dir: config.BACKUPS_DIR },
|
||||
'backup before migrating failed, so the database was left as it is. Fix the cause ' +
|
||||
'(the backups folder must exist and be writable, with free space), or set ' +
|
||||
'SKIP_BACKUP_BEFORE_MIGRATE=true to migrate without one',
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
app.log.info({ backup: backup.name }, 'backup before migrating made');
|
||||
}
|
||||
}
|
||||
await runMigrations(db);
|
||||
app.log.info('database migrated');
|
||||
const admin = await ensureInitialAdmin(
|
||||
|
||||
@@ -151,6 +151,30 @@ export function runBackup(config: Config, db: Db): Promise<BackupDto> {
|
||||
return running;
|
||||
}
|
||||
|
||||
/**
|
||||
* Before a new version migrates the database (index.ts): the database and the folders the daily
|
||||
* backup has, without recordings (big, and no migration touches them). Prunes nothing, so it
|
||||
* doesn't push a daily backup out; the daily job's pruning removes it in time. Reads as little of
|
||||
* the old schema as it can: the time zone only, falling back to TZ.
|
||||
*/
|
||||
export async function backupBeforeMigrate(config: Config, db: Db): Promise<BackupDto> {
|
||||
const timezone = await getSetting(db, 'general.timezone').catch(() => config.TZ);
|
||||
return createBackup(
|
||||
{
|
||||
databaseUrl: config.DATABASE_URL,
|
||||
appSecret: config.APP_SECRET,
|
||||
outDir: resolve(config.BACKUPS_DIR),
|
||||
timezone,
|
||||
dirs: {
|
||||
voicemail: resolve(config.VOICEMAIL_DIR),
|
||||
'sounds-custom': resolve(config.SOUNDS_DIR),
|
||||
moh: resolve(config.MOH_DIR),
|
||||
},
|
||||
},
|
||||
{ keep: Infinity },
|
||||
);
|
||||
}
|
||||
|
||||
/** The daily backup is due: enabled, past `backup.time` here, and none made today yet. */
|
||||
export async function backupDue(config: Config, db: Db, now = new Date()): Promise<boolean> {
|
||||
if (!(await getSetting(db, 'backup.enabled'))) return false;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// migrationsPending (08 §3.1): the api backs up before running new migrations, not on every start.
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { migrationsPending } from '../src/db/client.ts';
|
||||
import { createTestDb, hasTestDb } from './db.ts';
|
||||
|
||||
const MIGRATIONS = fileURLToPath(new URL('../src/db/migrations', import.meta.url));
|
||||
|
||||
describe.skipIf(!hasTestDb)('migrationsPending', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestDb>>;
|
||||
let newer: string;
|
||||
beforeAll(async () => {
|
||||
t = await createTestDb();
|
||||
// This image's migrations plus one the database hasn't run: a newer version.
|
||||
newer = await mkdtemp(join(tmpdir(), 'sp-mig-'));
|
||||
await cp(MIGRATIONS, newer, { recursive: true });
|
||||
const file = join(newer, 'meta', '_journal.json');
|
||||
const j = JSON.parse(await readFile(file, 'utf8'));
|
||||
j.entries.push({ ...j.entries.at(-1), idx: j.entries.length, tag: '9999_next' });
|
||||
await writeFile(file, JSON.stringify(j));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t?.drop();
|
||||
await rm(newer, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('false when up to date, true when the image has more, false for an empty database', async () => {
|
||||
expect(await migrationsPending(t.db)).toBe(false);
|
||||
expect(await migrationsPending(t.db, newer)).toBe(true);
|
||||
await t.db.execute(sql`drop schema drizzle cascade`);
|
||||
expect(await migrationsPending(t.db, newer)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user