import url from "url"; import querystring from "querystring"; import { spawn } from "child_process"; import { PassThrough } from "stream"; import { getConfig } from "blitz"; import AWS from "aws-sdk"; import { sendEmail } from "../integrations/ses"; const { serverRuntimeConfig } = getConfig(); const s3 = new AWS.S3({ credentials: new AWS.Credentials({ accessKeyId: serverRuntimeConfig.awsS3.accessKeyId, secretAccessKey: serverRuntimeConfig.awsS3.secretAccessKey, }), region: serverRuntimeConfig.awsS3.region, }); export default async function backup(schedule: "daily" | "weekly" | "monthly") { const s3Bucket = `shellphone-${schedule}-backup`; const { database, host, port, user, password } = parseDatabaseUrl(process.env.DATABASE_URL!); const fileName = getFileName(database); 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({ body: `${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({ body: `${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) => sendEmail({ body: `${schedule} backup failed: ${error}`, subject: `${schedule} backup failed: ${error}`, recipients: ["error@shellphone.app"], }), ); }); } function pad(number: number) { return number.toString().padStart(2, "0"); } function getFileName(database: string) { const now = new Date(); const year = now.getUTCFullYear(); const month = pad(now.getUTCMonth() + 1); const day = pad(now.getUTCDate()); const hours = pad(now.getUTCHours()); const minutes = pad(now.getUTCMinutes()); const seconds = pad(now.getUTCSeconds()); return `${database}-${year}-${month}-${day}_${hours}-${minutes}-${seconds}.sql.gz`; // 2021-09-15_16-00-02.sql.gz } type DatabaseUrl = { readonly user: string; readonly password: string; readonly host: string; readonly port: number; readonly database: string; }; function parseDatabaseUrl(databaseUrl: string): DatabaseUrl { const parsedUrl = url.parse(databaseUrl, false, true); const config = querystring.parse(parsedUrl.query!); if (parsedUrl.auth) { const userPassword = parsedUrl.auth.split(":", 2); config.user = userPassword[0]; if (userPassword.length > 1) { config.password = userPassword[1]; } } if (parsedUrl.pathname) { config.database = parsedUrl.pathname.replace(/^\//, "").replace(/\/$/, ""); } if (parsedUrl.hostname) { config.host = parsedUrl.hostname; } if (parsedUrl.port) { config.port = parsedUrl.port; } return { user: config.user as string, password: config.password as string, host: config.host as string, port: Number.parseInt(config.port as string, 10), database: config.database as string, }; }