diff --git a/.github/workflows/be-deploy.yml b/.github/workflows/be-deploy.yml index c27803d..3e739fe 100644 --- a/.github/workflows/be-deploy.yml +++ b/.github/workflows/be-deploy.yml @@ -9,12 +9,12 @@ on: push: paths: - apps/backend/**/*.ts + - apps/backend/**/*.sql tags: - v*.*.* - branches: - - master jobs: + # TODO: Implement Database Migrations and data backup deploy_backend: name: Trigger Render Deployment runs-on: ubuntu-latest diff --git a/.github/workflows/fe-deploy.yml b/.github/workflows/fe-deploy.yml index c393aa0..21df25b 100644 --- a/.github/workflows/fe-deploy.yml +++ b/.github/workflows/fe-deploy.yml @@ -11,8 +11,6 @@ on: - "apps/web/**/*.ts" tags: - v*.*.* - branches: - - master jobs: build_artifacts: diff --git a/apps/backend/scripts/migrate-backup.ts b/apps/backend/scripts/migrate-backup.ts new file mode 100644 index 0000000..a996ca0 --- /dev/null +++ b/apps/backend/scripts/migrate-backup.ts @@ -0,0 +1,202 @@ +import { execSync, spawn } from 'node:child_process'; +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + chmodSync, + unlinkSync, + statSync, +} from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import { createGzip } from 'node:zlib'; +import path from 'node:path'; +import os from 'node:os'; + +// --- Configuration --- +const DB_URL = process.env.DATABASE_URL; +const UPLOAD_URL = process.env.BACKUP_UPLOAD_URL; +const UPLOAD_METHOD = process.env.BACKUP_UPLOAD_METHOD || 'PUT'; +const MIGRATION_CMD = process.env.MIGRATION_CMD || 'pnpm drizzle-kit migrate'; + +const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-'); +const TEMP_BACKUP_PATH = path.join(process.cwd(), `backup-${TIMESTAMP}.sql.gz`); +const BIN_DIR = path.join(process.cwd(), '.bin'); + +// --- Pre-checks --- + +/** + * Ensures the script is running with Administrative / Sudo privileges + */ +function ensureAdminPrivileges(): void { + const isWindows = process.platform === 'win32'; + + if (!isWindows) { + if (process.getuid && process.getuid() !== 0) { + console.error( + 'โŒ Error: This script must be run with root/sudo privileges.', + ); + process.exit(1); + } + } else { + try { + execSync('net session', { stdio: 'ignore' }); + } catch { + console.error( + 'โŒ Error: This script must be run in an Administrator command prompt.', + ); + process.exit(1); + } + } +} + +/** + * Checks if pg_dump is installed, otherwise downloads static binaries into a local directory + */ +async function ensurePgDumpBinary(): Promise { + // Check system PATH first + try { + execSync('pg_dump --version', { stdio: 'ignore' }); + return 'pg_dump'; + } catch { + console.log( + 'โš ๏ธ pg_dump not found in system PATH. Checking local binaries...', + ); + } + + const localPgDumpPath = path.join( + BIN_DIR, + process.platform === 'win32' ? 'pg_dump.exe' : 'pg_dump', + ); + + if (existsSync(localPgDumpPath)) { + return localPgDumpPath; + } + + console.log('๐Ÿ“ฆ Downloading standalone pg_dump binary...'); + mkdirSync(BIN_DIR, { recursive: true }); + + const platform = os.platform(); + const arch = os.arch(); + + // URL source for static pg_dump binaries (using standard prebuilt static binaries) + let downloadUrl = ''; + if (platform === 'linux' && arch === 'x64') { + downloadUrl = + 'https://github.com/eeacms/postgresql-client/raw/master/bin/linux/x86_64/pg_dump'; + } else { + throw new Error( + `Automatic pg_dump download is not configured for platform: ${platform}-${arch}. Please install postgresql-client manually.`, + ); + } + + const res = await fetch(downloadUrl); + if (!res.ok || !res.body) { + throw new Error(`Failed to download pg_dump binary from ${downloadUrl}`); + } + + const fileStream = createWriteStream(localPgDumpPath); + await pipeline(res.body, fileStream); + + if (platform !== ('win32' as string)) { + chmodSync(localPgDumpPath, 0o755); + } + + console.log('โœ… pg_dump binary downloaded successfully.'); + return localPgDumpPath; +} + +// --- Operational Steps --- + +function runMigrations(): void { + console.log('๐Ÿ”„ Running database migrations...'); + try { + execSync(MIGRATION_CMD, { stdio: 'inherit', env: process.env }); + console.log('โœ… Migrations completed successfully.'); + } catch (error) { + console.error('โŒ Migration step failed.'); + throw error; + } +} + +async function createCompressedDump( + pgDumpBinary: string, + connectionString: string, + outputPath: string, +): Promise { + console.log(`๐Ÿ“ฆ Creating compressed database dump: ${outputPath}`); + + const pgDump = spawn( + pgDumpBinary, + ['--dbname=' + connectionString, '--clean', '--if-exists'], + { stdio: ['ignore', 'pipe', 'inherit'] }, + ); + + const gzip = createGzip(); + const outputStream = createWriteStream(outputPath); + + await pipeline(pgDump.stdout, gzip, outputStream); + console.log('โœ… Compressed dump created successfully.'); +} + +async function uploadBackup( + filePath: string, + uploadUrl: string, +): Promise { + console.log('๐Ÿš€ Uploading backup to remote endpoint...'); + const fileStats = statSync(filePath); + const fileStream = createReadStream(filePath); + + const response = await fetch(uploadUrl, { + method: UPLOAD_METHOD, + headers: { + 'Content-Type': 'application/gzip', + 'Content-Length': fileStats.size.toString(), + }, + // @ts-expect-error Node fetch supports ReadableStream in body + body: fileStream, + duplex: 'half', + }); + + if (!response.ok) { + throw new Error( + `Upload failed with status ${response.status}: ${response.statusText}`, + ); + } + + console.log('โœ… Backup successfully uploaded.'); +} + +// --- Main Execution --- + +async function main(): Promise { + ensureAdminPrivileges(); + + if (!DB_URL) { + console.error('โŒ Error: DATABASE_URL environment variable is required.'); + process.exit(1); + } + + try { + const pgDumpExecutable = await ensurePgDumpBinary(); + + runMigrations(); + await createCompressedDump(pgDumpExecutable, DB_URL, TEMP_BACKUP_PATH); + + if (UPLOAD_URL) { + await uploadBackup(TEMP_BACKUP_PATH, UPLOAD_URL); + } else { + console.log('โš ๏ธ BACKUP_UPLOAD_URL not set. Skipping upload step.'); + } + } catch (error) { + console.error('๐Ÿ’ฅ Database operations failed:', error); + process.exitCode = 1; + } finally { + if (existsSync(TEMP_BACKUP_PATH)) { + console.log('๐Ÿงน Cleaning up temporary backup file...'); + unlinkSync(TEMP_BACKUP_PATH); + } + } +} + +main(); diff --git a/apps/backend/scripts/tsconfig.json b/apps/backend/scripts/tsconfig.json new file mode 100644 index 0000000..9a243c2 --- /dev/null +++ b/apps/backend/scripts/tsconfig.json @@ -0,0 +1,6 @@ +{ + "files": ["migrate-backup.ts"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 1c09aa4..281e0a6 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -1,5 +1,6 @@ { "extends": ["nitro/tsconfig"], + "exclude": ["./scripts"], "compilerOptions": { "paths": { "~/*": ["./server/*"]