Skip to content

Commit a613ab9

Browse files
committed
Add a script to restore the production database from a backup.
1 parent dfb0b96 commit a613ab9

File tree

4 files changed

+103
-15
lines changed

4 files changed

+103
-15
lines changed

webserver/fly-release-command.mjs

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execSync } from 'node:child_process';
2-
import { replaceDbWithBackup } from './replaceDbWithBackup.mjs';
2+
import { replaceStagingDbWithBackup } from './replaceDbWithBackup.mjs';
33

44
// Turn on swap to avoid out-of-memory errors during `prisma migrate deploy`
55
console.log(execSync('fallocate -l 256M /swapfile', {encoding: 'utf-8'}));
@@ -11,7 +11,7 @@ console.log(execSync('swapon /swapfile', {encoding: 'utf-8'}));
1111
if (process.env.FLY_APP_NAME === 'vlach-cookbook-staging') {
1212
// Copy the latest backup over the staging database before running the Prisma
1313
// migration, to make sure the migration will work when the app is released.
14-
await replaceDbWithBackup({
14+
await replaceStagingDbWithBackup({
1515
DATABASE_URL: process.env.ADMIN_DATABASE_URL,
1616
dbName: "cookbook_staging",
1717
GOOGLE_CREDENTIALS: process.env.MIGRATION_GOOGLE_SERVICE_ACCOUNT_CREDENTIALS,

webserver/prisma/seed.mjs

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { replaceDbWithBackup } from '../replaceDbWithBackup.mjs';
1+
import { replaceStagingDbWithBackup } from '../replaceDbWithBackup.mjs';
22

33
// Load the production database into the local database.
44

55
const DATABASE_URL = new URL(process.env.DATABASE_URL);
66
// Remove a parameter that psql doesn't understand.
77
DATABASE_URL.searchParams.delete('statement_cache_size');
88

9-
await replaceDbWithBackup({
9+
await replaceStagingDbWithBackup({
1010
DATABASE_URL: DATABASE_URL.href,
1111
dbName: "cookbook",
1212
GOOGLE_CREDENTIALS: process.env.COOKBOOK_GOOGLE_SERVICE_ACCOUNT_CREDENTIALS,

webserver/replaceDbWithBackup.mjs

+63-11
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Storage } from '@google-cloud/storage';
1+
import { Storage } from "@google-cloud/storage";
22
import * as age from "age-encryption";
3-
import { execSync } from 'node:child_process';
4-
import zlib from 'node:zlib';
3+
import { execSync } from "node:child_process";
4+
import zlib from "node:zlib";
55

66
/**
77
* Fetches the newest backup of the production database from gs://vlach-cookbook-backup, and
@@ -16,18 +16,47 @@ import zlib from 'node:zlib';
1616
* service account key.
1717
* @param {string} options.AGE_IDENTITY The https://age-encryption.org/ identity file to decrypt the
1818
* backup.
19+
*/
20+
export async function replaceStagingDbWithBackup({
21+
DATABASE_URL,
22+
dbName,
23+
GOOGLE_CREDENTIALS,
24+
AGE_IDENTITY,
25+
}) {
26+
const dump = await fetchLatestBackup({ GOOGLE_CREDENTIALS, AGE_IDENTITY });
27+
replaceDbWithBackupContents({
28+
dump,
29+
dbName,
30+
DATABASE_URL,
31+
webserverRole: "cookbook_staging_webserver",
32+
});
33+
}
34+
35+
/**
36+
* Fetches, decrypts, and returns the newest backup of the production database from
37+
* gs://vlach-cookbook-backup.
1938
*
39+
* @param {Object} options
40+
* @param {string} options.GOOGLE_CREDENTIALS The JSON-serialized contents of the file expected by
41+
* https://cloud.google.com/docs/authentication/application-default-credentials#GAC. Usually a
42+
* service account key.
43+
* @param {string} options.AGE_IDENTITY The https://age-encryption.org/ identity file to decrypt the
44+
* backup.
45+
*
46+
* @returns {Promise<string>}
2047
*/
21-
export async function replaceDbWithBackup({DATABASE_URL, dbName, GOOGLE_CREDENTIALS, AGE_IDENTITY}) {
48+
async function fetchLatestBackup({ GOOGLE_CREDENTIALS, AGE_IDENTITY }) {
2249
const storage = new Storage({
23-
projectId: 'vlach-cookbook',
24-
credentials: JSON.parse(GOOGLE_CREDENTIALS)
50+
projectId: "vlach-cookbook",
51+
credentials: JSON.parse(GOOGLE_CREDENTIALS),
2552
});
2653

27-
const bucket = storage.bucket('vlach-cookbook-backup');
54+
const bucket = storage.bucket("vlach-cookbook-backup");
2855
// Look for backups starting a month ago.
2956
let earliestBackupTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
30-
const [recentBackups] = await bucket.getFiles({ startOffset: `backup-${earliestBackupTime.toISOString()}` });
57+
const [recentBackups] = await bucket.getFiles({
58+
startOffset: `backup-${earliestBackupTime.toISOString()}`,
59+
});
3160
if (recentBackups.length === 0) {
3261
throw new Error(`Couldn't find a backup since ${earliestBackupTime}.`);
3362
}
@@ -37,18 +66,41 @@ export async function replaceDbWithBackup({DATABASE_URL, dbName, GOOGLE_CREDENTI
3766
d.addIdentity(AGE_IDENTITY);
3867
const decrypted = await d.decrypt(contents);
3968

40-
const dump = zlib.brotliDecompressSync(decrypted).toString('utf-8');
69+
const dump = zlib.brotliDecompressSync(decrypted).toString("utf-8");
70+
return dump;
71+
}
4172

73+
/**
74+
* Replaces the database at {@link DATABASE_URL} with the SQL {@link dump}.
75+
*
76+
* @param {Object} options
77+
* @param {string} options.dump Decrypted contents of a backup.
78+
* @param {string} options.DATABASE_URL A postgres:// URL with credentials.
79+
* @param {string} options.dbName The name of the database to write into. This should match the end
80+
* of DATABASE_URL.
81+
* @param {string} options.webserverRole The database role name that the webserver uses.
82+
*/
83+
export function replaceDbWithBackupContents({
84+
dump,
85+
dbName,
86+
DATABASE_URL,
87+
webserverRole,
88+
}) {
4289
// Clear the whole database without actually deleting it. Otherwise, when we
4390
// add new tables that aren't in Prod yet, the *second* deployment to Staging
4491
// will fail because the above `pg_dump` won't know to remove those tables.
4592
const restore = `DROP SCHEMA IF EXISTS public CASCADE;
4693
CREATE SCHEMA public;
47-
GRANT USAGE ON SCHEMA public TO cookbook_staging_webserver;
94+
GRANT USAGE ON SCHEMA public TO ${webserverRole};
4895
${dump.replaceAll(/cookbook_prod/g, dbName)}`;
4996

5097
try {
51-
console.log(execSync(`psql -d "${DATABASE_URL}"`, { input: restore, encoding: 'utf-8' }));
98+
console.log(
99+
execSync(`psql -d "${DATABASE_URL}"`, {
100+
input: restore,
101+
encoding: "utf-8",
102+
})
103+
);
52104
} catch (e) {
53105
console.error(`Restoring failed with\n>>>>>\n${restore}\n<<<<<`);
54106
throw e;
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import assert from "node:assert";
2+
import { execSync } from "node:child_process";
3+
import { readFileSync } from "node:fs";
4+
import { replaceDbWithBackupContents } from "./replaceDbWithBackup.mjs";
5+
6+
assert.equal(process.env.FLY_APP_NAME, "vlach-cookbook");
7+
8+
// Turn on swap to avoid out-of-memory errors during `prisma migrate deploy`
9+
console.log(execSync("fallocate -l 256M /swapfile", { encoding: "utf-8" }));
10+
console.log(execSync("chmod 0600 /swapfile", { encoding: "utf-8" }));
11+
console.log(execSync("mkswap /swapfile", { encoding: "utf-8" }));
12+
console.log(
13+
execSync("echo 10 > /proc/sys/vm/swappiness", { encoding: "utf-8" })
14+
);
15+
console.log(execSync("swapon /swapfile", { encoding: "utf-8" }));
16+
17+
// Expect the admin to have manually downloaded and decrypted a backup.
18+
const dump = readFileSync(process.env.BACKUP_FILE, { encoding: "utf8" });
19+
20+
// Replace the production database with the loaded backup.
21+
replaceDbWithBackupContents({
22+
DATABASE_URL: process.env.ADMIN_DATABASE_URL,
23+
dbName: "cookbook_prod",
24+
dump,
25+
webserverRole: "cookbook_prod_webserver",
26+
});
27+
28+
// Run the Prisma migration, in case the backup is older than the latest release.
29+
process.env.DATABASE_URL = process.env.ADMIN_DATABASE_URL;
30+
console.log(execSync("prisma migrate deploy", { encoding: "utf-8" }));
31+
console.log(
32+
execSync(
33+
"node prisma/migrations/20240916002444_cooking_history/migrate-history-categories.mjs"
34+
),
35+
{ encoding: "utf-8" }
36+
);

0 commit comments

Comments
 (0)