db backups

This commit is contained in:
m5r 2022-07-03 15:42:31 +02:00
parent e22841062a
commit 3021f0b6f3
7 changed files with 132 additions and 11 deletions

View File

@ -6,7 +6,15 @@ invariant(
`Please define the "INVITATION_TOKEN_SECRET" environment variable`, `Please define the "INVITATION_TOKEN_SECRET" environment variable`,
); );
invariant(typeof process.env.SESSION_SECRET === "string", `Please define the "SESSION_SECRET" environment variable`); invariant(typeof process.env.SESSION_SECRET === "string", `Please define the "SESSION_SECRET" environment variable`);
invariant(typeof process.env.AWS_SES_REGION === "string", `Please define the "AWS_SES_REGION" environment variable`); invariant(typeof process.env.AWS_REGION === "string", `Please define the "AWS_REGION" environment variable`);
invariant(
typeof process.env.AWS_S3_ACCESS_KEY_ID === "string",
`Please define the "AWS_S3_ACCESS_KEY_ID" environment variable`,
);
invariant(
typeof process.env.AWS_S3_ACCESS_KEY_SECRET === "string",
`Please define the "AWS_S3_ACCESS_KEY_SECRET" environment variable`,
);
invariant( invariant(
typeof process.env.AWS_SES_ACCESS_KEY_ID === "string", typeof process.env.AWS_SES_ACCESS_KEY_ID === "string",
`Please define the "AWS_SES_ACCESS_KEY_ID" environment variable`, `Please define the "AWS_SES_ACCESS_KEY_ID" environment variable`,
@ -41,11 +49,17 @@ export default {
sessionSecret: process.env.SESSION_SECRET, sessionSecret: process.env.SESSION_SECRET,
encryptionKey: process.env.MASTER_ENCRYPTION_KEY, encryptionKey: process.env.MASTER_ENCRYPTION_KEY,
}, },
awsSes: { aws: {
awsRegion: process.env.AWS_SES_REGION, region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID, ses: {
secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET, accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
fromEmail: process.env.AWS_SES_FROM_EMAIL, secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET,
fromEmail: process.env.AWS_SES_FROM_EMAIL,
},
s3: {
accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_S3_ACCESS_KEY_SECRET,
},
}, },
fathom: { fathom: {
siteId: process.env.FATHOM_SITE_ID, siteId: process.env.FATHOM_SITE_ID,

View File

@ -0,0 +1,4 @@
import { CronJob } from "~/utils/queue.server";
import backup from "~/utils/backup-db.server";
export default CronJob("daily db backup", () => backup("daily"), "0 0 * * *");

View File

@ -0,0 +1,4 @@
import { CronJob } from "~/utils/queue.server";
import backup from "~/utils/backup-db.server";
export default CronJob("monthly db backup", () => backup("monthly"), "0 0 1 * *");

View File

@ -0,0 +1,4 @@
import { CronJob } from "~/utils/queue.server";
import backup from "~/utils/backup-db.server";
export default CronJob("weekly db backup", () => backup("weekly"), "0 0 * * 0");

View File

@ -0,0 +1,95 @@
import { spawn } from "child_process";
import { PassThrough } from "stream";
import logger from "~/utils/logger.server";
import config from "~/config/config.server";
import { Credentials, S3 } from "aws-sdk";
import sendEmail from "~/utils/mailer.server";
const credentials = new Credentials({
accessKeyId: config.aws.s3.accessKeyId,
secretAccessKey: config.aws.s3.secretAccessKey,
});
export const s3 = new S3({ region: config.aws.region, credentials });
export default async function backup(schedule: "daily" | "weekly" | "monthly") {
const s3Bucket = "shellphone-backups";
const { database, host, port, user, password } = parseDatabaseUrl(process.env.DATABASE_URL!);
const fileName = `${schedule}-${database}.sql.gz`;
console.log(`Dumping database ${database}`);
const pgDumpChild = spawn("pg_dump", [`-U${user}`, `-d${database}`], {
env: {
...process.env,
PGPASSWORD: password,
PGHOST: host,
PGPORT: port.toString(),
},
stdio: ["ignore", "pipe", "inherit"],
});
console.log(`Compressing dump "${fileName}"`);
const gzippedDumpStream = new PassThrough();
const gzipChild = spawn("gzip", { stdio: ["pipe", "pipe", "inherit"] });
gzipChild.on("exit", (code) => {
if (code !== 0) {
return sendEmail({
text: `${schedule} backup failed: gzip: Bad exit code (${code})`,
html: `${schedule} backup failed: gzip: Bad exit code (${code})`,
subject: `${schedule} backup failed: gzip: Bad exit code (${code})`,
recipients: ["error@shellphone.app"],
});
}
});
pgDumpChild.stdout.pipe(gzipChild.stdin);
gzipChild.stdout.pipe(gzippedDumpStream);
pgDumpChild.on("exit", (code) => {
if (code !== 0) {
console.log("pg_dump failed, upload aborted");
return sendEmail({
text: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
html: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
subject: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
recipients: ["error@shellphone.app"],
});
}
console.log(`Uploading "${fileName}" to S3 bucket "${s3Bucket}"`);
const uploadPromise = s3
.upload({
Bucket: s3Bucket,
Key: fileName,
ACL: "private",
ContentType: "text/plain",
ContentEncoding: "gzip",
Body: gzippedDumpStream,
})
.promise();
uploadPromise
.then(() => console.log(`Successfully uploaded "${fileName}"`))
.catch((error) => {
logger.error(error);
return sendEmail({
text: `${schedule} backup failed: ${error}`,
html: `${schedule} backup failed: ${error}`,
subject: `${schedule} backup failed: ${error}`,
recipients: ["error@shellphone.app"],
});
});
});
}
function parseDatabaseUrl(databaseUrl: string) {
const url = new URL(databaseUrl);
return {
user: url.username,
password: url.password,
host: url.host,
port: Number.parseInt(url.port),
database: url.pathname.replace(/^\//, "").replace(/\/$/, ""),
} as const;
}

View File

@ -18,7 +18,7 @@ export default async function sendEmail({ text, html, subject, recipients }: Sen
subject, subject,
encoding: "UTF-8", encoding: "UTF-8",
to: recipients, to: recipients,
from: serverConfig.awsSes.fromEmail, from: serverConfig.aws.ses.fromEmail,
}; };
if (process.env.NODE_ENV !== "production" || process.env.CI) { if (process.env.NODE_ENV !== "production" || process.env.CI) {
@ -27,10 +27,10 @@ export default async function sendEmail({ text, html, subject, recipients }: Sen
const transporter = createTransport({ const transporter = createTransport({
SES: new SES({ SES: new SES({
region: serverConfig.awsSes.awsRegion, region: serverConfig.aws.region,
credentials: new Credentials({ credentials: new Credentials({
accessKeyId: serverConfig.awsSes.accessKeyId, accessKeyId: serverConfig.aws.ses.accessKeyId,
secretAccessKey: serverConfig.awsSes.secretAccessKey, secretAccessKey: serverConfig.aws.ses.secretAccessKey,
}), }),
}), }),
}); });

View File

@ -9,7 +9,7 @@ processes = []
[env] [env]
APP_BASE_URL = "https://www.shellphone.app" APP_BASE_URL = "https://www.shellphone.app"
AWS_SES_FROM_EMAIL = "\"Mokhtar from Shellphone\" <mokhtar@shellphone.app>" AWS_SES_FROM_EMAIL = "\"Mokhtar from Shellphone\" <mokhtar@shellphone.app>"
AWS_SES_REGION = "eu-central-1" AWS_REGION = "eu-central-1"
NODE_ENV = "production" NODE_ENV = "production"
PORT = "8080" PORT = "8080"