"use strict"; /* * PostgreSQL(Pleasanter) → LINE WORKS共有ドライブ 自動バックアップ * 詳細仕様は SPEC.md 参照。 * * 使い方: * node backup.js … バックアップ実行 (run と同じ) * node backup.js run [--database=] … バックアップ実行(DB名を一時的に上書き可能) * node backup.js create-test-db [dbName] … テスト用DB作成 * node backup.js drop-test-db [dbName] … テスト用DB削除 * * 保存先: 共有ドライブ「システムバックアップ」> Pleasanter_DB_Backup > 日付フォルダ(YYYYMMDD) * ログ: 同ドライブの Pleasanter_DB_Backup/logs 配下に月次ファイルとして上書きアップロード */ require("./lib/env").loadEnv(); const fs = require("fs"); const os = require("os"); const path = require("path"); const logger = require("./lib/logger"); const { getUserAccessToken } = require("./lib/lineworksUserAuth"); const drive = require("./lib/lineworksDrive"); const { runPgDump, formatTimestamp, formatDateFolderName } = require("./lib/pgDump"); const { createTestDb, dropTestDb } = require("./lib/testDb"); const { uploadLogFile } = require("./lib/logUpload"); const { parseConfigPaths, createConfigArchive } = require("./lib/configBackup"); const { cleanupStaleLocalFiles } = require("./lib/cleanupStale"); const { createTokenRefresher } = require("./lib/tokenRefresher"); const { trySafely } = require("./lib/trySafely"); const { sendBackupReportMail, sendTestMail } = require("./lib/mailNotify"); function parseArgs(argv) { const rest = argv.slice(2); const rawCommand = rest[0]; const hasExplicitCommand = rawCommand && !rawCommand.startsWith("--"); const command = hasExplicitCommand ? rawCommand : "run"; const remaining = hasExplicitCommand ? rest.slice(1) : rest; const options = {}; const positional = []; for (const arg of remaining) { const m = /^--([^=]+)=(.*)$/.exec(arg); if (m) { options[m[1]] = m[2]; } else { positional.push(arg); } } return { command, options, positional }; } // pg_dump実行と、分割ファイルの共有ドライブへのアップロードを行う。 // アップロードは長時間かかるため、ファイルごとにtokenManager経由でトークンの鮮度を確認する。 async function runDbDumpAndUpload({ pgDumpBin, host, port, user, password, targetDatabase, outputDir, splitSizeMb, timestamp, hostname, tokenManager, sharedriveId, dateFolderId, }) { logger.info( `pg_dump開始: host=${host} port=${port} user=${user} db=${targetDatabase} 出力先=${outputDir}` ); const dumpStartedAt = Date.now(); const { files: dumpFiles, totalBytes } = await runPgDump({ pgDumpBin, host, port, user, password, database: targetDatabase, outputDir, splitSizeMb, timestamp, prefix: `[${hostname}]postgres-`, }); const dumpElapsedSec = ((Date.now() - dumpStartedAt) / 1000).toFixed(1); logger.info( `pg_dump終了: 合計サイズ=${totalBytes}bytes ファイル数=${dumpFiles.length} 所要時間=${dumpElapsedSec}秒` ); for (const filePath of dumpFiles) { const token = await tokenManager.getToken(); await drive.uploadFile(token, sharedriveId, dateFolderId, filePath, { overwrite: false }, logger); } for (const filePath of dumpFiles) { fs.unlinkSync(filePath); } logger.info("ローカル一時ファイル削除完了"); } // DB以外の環境設定ファイル(.envのCONFIG_BACKUP_PATHS)もあわせてバックアップする。 // DBバックアップの成否とは無関係に、常に独立して試行する(runBackup側でtrySafely経由で呼ぶ)。 async function runConfigBackupAndUpload({ outputDir, timestamp, hostname, tokenManager, sharedriveId, dateFolderId }) { const configPaths = parseConfigPaths(); if (configPaths.length === 0) { logger.info("CONFIG_BACKUP_PATHS未設定のため、設定ファイルバックアップはスキップします"); return; } logger.info(`設定ファイルバックアップ開始: 対象${configPaths.length}件`); const { archivePath, includedPaths } = await createConfigArchive({ paths: configPaths, outputDir, timestamp, hostname, logger, }); logger.info( `設定アーカイブ作成完了: ${path.basename(archivePath)} ` + `(${includedPaths.length}件, ${fs.statSync(archivePath).size}bytes)` ); const token = await tokenManager.getToken(); await drive.uploadFile(token, sharedriveId, dateFolderId, archivePath, { overwrite: false }, logger); fs.unlinkSync(archivePath); logger.info("設定アーカイブのアップロード完了・ローカル一時ファイル削除完了"); } async function runBackup({ database } = {}) { const startedAt = Date.now(); logger.info("===== バックアップ開始 ====="); const pgDumpBin = process.env.PG_DUMP_BIN || "pg_dump"; const host = process.env.PGHOST || "localhost"; const port = process.env.PGPORT || "5432"; const user = process.env.PGUSER || "postgres"; const password = process.env.PGPASSWORD || ""; const targetDatabase = database || process.env.PGDATABASE || "Implem.Pleasanter"; const outputDir = process.env.BACKUP_LOCAL_DIR ? path.resolve(process.env.BACKUP_LOCAL_DIR) : path.join(__dirname, "dump_tmp"); const splitSizeMb = Number(process.env.PG_DUMP_SPLIT_SIZE_MB || 3072); const timestamp = formatTimestamp(); const hostname = os.hostname(); const tokenManager = createTokenRefresher({ getUserAccessToken }); let accessToken = null; let sharedriveId = null; let backupFolderId = null; let failureMessage = null; try { // 前回実行が失敗して残ったアップロード未完了のローカルファイルを起動時に削除する。 cleanupStaleLocalFiles({ outputDir, hostname, logger }); logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)"); accessToken = await tokenManager.getToken(); logger.info("アクセストークン取得成功"); sharedriveId = await drive.resolveSharedDriveId(accessToken, logger); const rootFolderName = process.env.LW_BACKUP_ROOT_FOLDER_NAME || "Pleasanter_DB_Backup"; const backupFolder = await drive.ensureFolder(accessToken, sharedriveId, null, rootFolderName, logger); backupFolderId = backupFolder.fileId; const dateFolderName = formatDateFolderName(); const dateFolder = await drive.ensureFolder( accessToken, sharedriveId, backupFolderId, dateFolderName, logger ); // DBバックアップと設定ファイルバックアップは、互いの成否に関わらず両方とも必ず試行する。 const dbResult = await trySafely( () => runDbDumpAndUpload({ pgDumpBin, host, port, user, password, targetDatabase, outputDir, splitSizeMb, timestamp, hostname, tokenManager, sharedriveId, dateFolderId: dateFolder.fileId, }), (err) => logger.error(`DBバックアップ失敗(設定ファイルバックアップは継続): ${err.message}`) ); const configResult = await trySafely( () => runConfigBackupAndUpload({ outputDir, timestamp, hostname, tokenManager, sharedriveId, dateFolderId: dateFolder.fileId, }), (err) => logger.error(`設定ファイルバックアップ失敗(DBバックアップ自体は継続): ${err.message}`) ); const rotationToken = await tokenManager.getToken(); await rotateOldBackups(rotationToken, sharedriveId, backupFolderId); const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); if (!dbResult.ok || !configResult.ok) { logger.error(`===== バックアップ終了(失敗) ===== 所要時間=${elapsedSec}秒`); process.exitCode = 1; failureMessage = [ dbResult.ok ? null : `DBバックアップ: ${dbResult.error.message}`, configResult.ok ? null : `設定ファイルバックアップ: ${configResult.error.message}`, ] .filter(Boolean) .join(" / "); } else { logger.info(`===== バックアップ終了(成功) ===== 所要時間=${elapsedSec}秒`); } } catch (err) { const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger.error(`===== バックアップ終了(失敗) ===== 所要時間=${elapsedSec}秒 理由=${err.message}`); process.exitCode = 1; failureMessage = err.message; } finally { await uploadLogFile({ accessToken, sharedriveId, backupFolderId }); const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); await sendBackupReportMail({ success: !failureMessage, hostname, database: targetDatabase, elapsedSec, errorMessage: failureMessage, logFilePath: logger.currentLogFilePath(), }); } } async function rotateOldBackups(accessToken, sharedriveId, backupFolderId) { const retentionCount = Number(process.env.BACKUP_RETENTION_COUNT || 100); logger.info(`ローテーション開始 (保持件数=${retentionCount})`); const children = await drive.listChildren(accessToken, sharedriveId, backupFolderId, logger); const dateFolders = children .filter((f) => f.fileType === "FOLDER" && /^\d{8}$/.test(f.fileName)) .sort((a, b) => (a.fileName < b.fileName ? -1 : a.fileName > b.fileName ? 1 : 0)); const excess = dateFolders.length - retentionCount; if (excess <= 0) { logger.info(`ローテーション終了: 削除件数=0 (現在${dateFolders.length}件)`); return; } const toDelete = dateFolders.slice(0, excess); for (const folder of toDelete) { await drive.deleteFile(accessToken, sharedriveId, folder.fileId, logger); logger.info(`削除: フォルダ名=${folder.fileName} fileId=${folder.fileId}`); } logger.info(`ローテーション終了: 削除件数=${toDelete.length}`); } async function main() { const { command, options, positional } = parseArgs(process.argv); if (command === "run") { await runBackup({ database: options.database }); } else if (command === "create-test-db") { const dbName = positional[0] || process.env.TEST_DB_NAME || "pleasanter_backup_test"; await createTestDb(dbName); } else if (command === "drop-test-db") { const dbName = positional[0] || process.env.TEST_DB_NAME || "pleasanter_backup_test"; await dropTestDb(dbName); } else if (command === "test-mail") { await sendTestMail(); } else { console.log( "使い方: node backup.js [run [--database=] | create-test-db [dbName] | drop-test-db [dbName] | test-mail]" ); } } main().catch((err) => { console.error(err.message); process.exitCode = 1; });