"use strict"; /* * リストア * LINE WORKS共有ドライブから、このサーバー(ホスト名)の最新バックアップを取得し復元する。 * DBリストアと設定ファイルリストアは別コマンド(サブコマンド)として独立している。 * * 使い方: * node restore.js [database] [--database=] [--yes] … DBリストア(省略時のデフォルト) * node restore.js config [--yes] … 設定ファイルリストア * * --database (databaseサブコマンドのみ)復元先DB名を一時的に上書き(省略時は.envのPGDATABASE) * --yes 確認プロンプトをスキップする(自動化・検証用途。通常は付けずに対話確認すること) * * --- database サブコマンドの処理順序(復元前バックアップが復元対象に混ざらないよう、この順序を厳守する) --- * 1. 復元対象(このホスト名の最新バックアップ)を共有ドライブ上で特定・ダウンロード * 2. Webサービス(既定 pleasanter, .envのWEB_SERVICE_NAMEで変更可)を停止(DBへの接続を切る) * 3. 現在のデータベースを安全のため別名でバックアップ(pre-restore-, 通常のpostgres-とは別名のため * 次回以降の「最新バックアップ検索」には混ざらない) * 4. データベースをドロップ→再作成 * 5. ダウンロード済みのバックアップ(1.)からpg_restore * 6. Webサービスを再起動(成功・失敗いずれの場合も、2.で実際に停止していた場合のみ) * * --- config サブコマンドの処理順序 --- * (Pleasanter自体のインストール済みであることが前提。dotnet本体やパッケージ一式は対象外) * 1. 最新の設定アーカイブ([hostname]config-.tar.gz)を共有ドライブ上で特定・ダウンロード * 2. 関連サービス(.envのCONFIG_RESTORE_SERVICES、既定 nginx/postgresql-18/pleasanter)を事前に全停止 * 3. CONFIG_BACKUP_PATHSの各パスについて、既存のファイル/ディレクトリがあれば * ".pre-restore-" に必ずリネームして保持してから、アーカイブの内容を配置 * 4. サービスの自動再起動は行わない。手動で実行すべきsystemctlコマンドをログ・画面に表示するのみ */ require("./lib/env").loadEnv(); const fs = require("fs"); const os = require("os"); const path = require("path"); const readline = require("readline"); const logger = require("./lib/logger"); const { getUserAccessToken } = require("./lib/lineworksUserAuth"); const drive = require("./lib/lineworksDrive"); const { findLatestBackupFileSet, findLatestConfigArchive } = require("./lib/backupLocator"); const { runPgDump, formatTimestamp, formatDateFolderName } = require("./lib/pgDump"); const { runPgRestore } = require("./lib/pgRestore"); const { createDatabase, dropDatabase } = require("./lib/testDb"); const { uploadLogFile } = require("./lib/logUpload"); const { isServiceActive, stopService, startService } = require("./lib/serviceControl"); const { parseConfigPaths } = require("./lib/configBackup"); const { extractArchive, restoreConfigPaths } = require("./lib/configRestore"); function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }); }); } function parseArgs(argv) { const rest = argv.slice(2); const rawCommand = rest[0]; const hasExplicitCommand = rawCommand && !rawCommand.startsWith("--"); const command = hasExplicitCommand ? rawCommand : "database"; const remaining = hasExplicitCommand ? rest.slice(1) : rest; const options = {}; for (const arg of remaining) { const m = /^--([^=]+)=(.*)$/.exec(arg); if (m) { options[m[1]] = m[2]; } else if (arg.startsWith("--")) { options[arg.slice(2)] = true; } } return { command, options }; } async function restoreDatabase(options) { const startedAt = Date.now(); const pgDumpBin = process.env.PG_DUMP_BIN || "pg_dump"; const pgRestoreBin = process.env.PG_RESTORE_BIN || "pg_restore"; 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 = options.database || process.env.PGDATABASE || "Implem.Pleasanter"; const serviceName = process.env.WEB_SERVICE_NAME || "pleasanter"; const hostname = os.hostname(); const localTmpDir = process.env.BACKUP_LOCAL_DIR ? path.resolve(process.env.BACKUP_LOCAL_DIR) : path.join(__dirname, "dump_tmp"); logger.info("===== リストア開始 ====="); logger.info(`対象データベース=${targetDatabase} ホスト名=${hostname}`); let accessToken = null; let sharedriveId = null; let backupFolderId = null; let restoreTmpDir = null; let serviceWasStopped = false; try { logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)"); accessToken = await getUserAccessToken(); 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; // 1. 復元対象を特定 logger.info(`最新バックアップ検索開始: hostname=${hostname}`); const backupSet = await findLatestBackupFileSet( accessToken, sharedriveId, backupFolderId, hostname, drive, logger ); if (!backupSet) { throw new Error(`hostname=${hostname} のバックアップが共有ドライブ上に見つかりませんでした`); } logger.info( `最新バックアップ特定: 日付フォルダ=${backupSet.dateFolderName} timestamp=${backupSet.timestamp} ` + `ファイル数=${backupSet.files.length}` ); console.log(""); console.log("以下の内容でリストアを実行します:"); console.log( ` 復元元: ${rootFolderName}/${backupSet.dateFolderName}/ ` + `(timestamp=${backupSet.timestamp}, ${backupSet.files.length}ファイル, hostname=${hostname})` ); console.log(` 復元先データベース: ${targetDatabase} (host=${host} port=${port} user=${user})`); console.log(` 復元前に現在の「${targetDatabase}」は安全のため別途バックアップした上でドロップされます。`); if (options.database) { console.log(` (--database指定のため「${serviceName}」サービスの停止・再起動は行いません)`); } else { console.log(` 復元中は「${serviceName}」サービスを停止します(完了後に自動で再起動します)。`); } console.log(" この操作は取り消せません。"); console.log(""); if (!options.yes) { const answer = await prompt(`本当に実行しますか?続行するには "${targetDatabase}" と入力してEnter: `); if (answer !== targetDatabase) { logger.info("ユーザーによりキャンセルされました(入力不一致)。"); console.log("入力が一致しなかったため中断しました。"); return; } } // 1. (続き) ダウンロード・再結合 fs.mkdirSync(localTmpDir, { recursive: true }); restoreTmpDir = path.join(localTmpDir, `restore-${backupSet.timestamp}`); fs.mkdirSync(restoreTmpDir, { recursive: true }); const downloadedParts = []; for (const file of backupSet.files) { const destPath = path.join(restoreTmpDir, file.fileName); await drive.downloadFile(accessToken, sharedriveId, file.fileId, destPath, logger); downloadedParts.push(destPath); } const reassembledPath = path.join(restoreTmpDir, `restore-${backupSet.timestamp}.dmp`); const writeStream = fs.createWriteStream(reassembledPath); for (const part of downloadedParts) { writeStream.write(fs.readFileSync(part)); } await new Promise((resolve, reject) => { writeStream.end((err) => (err ? reject(err) : resolve())); }); logger.info( `バックアップファイル再結合完了: ${reassembledPath} (${fs.statSync(reassembledPath).size}bytes)` ); // 2. Webサービス停止 // --database で本番以外(テスト用DB等)を指定した場合は、本番サービスを巻き込まないよう停止処理自体を行わない。 if (options.database) { logger.info( `--database指定によるDB上書き実行のため、Webサービス(${serviceName})の停止・再起動はスキップします。` ); } else { const serviceActiveBefore = await isServiceActive(serviceName); if (serviceActiveBefore) { logger.info(`Webサービス停止開始: ${serviceName}`); await stopService(serviceName); serviceWasStopped = true; logger.info(`Webサービス停止完了: ${serviceName}`); } else { logger.warn(`Webサービス「${serviceName}」は既に停止していたため、停止処理はスキップしました(完了後の自動再起動も行いません)。`); } } // 3. 現在のDBを安全のため別途バックアップ logger.info(`復元前の安全バックアップ開始: database=${targetDatabase}`); const safetyTimestamp = formatTimestamp(); const { files: safetyDumpFiles } = await runPgDump({ pgDumpBin, host, port, user, password, database: targetDatabase, outputDir: localTmpDir, splitSizeMb: Number(process.env.PG_DUMP_SPLIT_SIZE_MB || 3072), timestamp: safetyTimestamp, prefix: `[${hostname}]pre-restore-`, }); logger.info(`復元前の安全バックアップ完了(pg_dump): ファイル数=${safetyDumpFiles.length}`); const safetyDateFolder = await drive.ensureFolder( accessToken, sharedriveId, backupFolderId, formatDateFolderName(), logger ); for (const filePath of safetyDumpFiles) { await drive.uploadFile(accessToken, sharedriveId, safetyDateFolder.fileId, filePath, { overwrite: false }, logger); } for (const filePath of safetyDumpFiles) { fs.unlinkSync(filePath); } logger.info("復元前の安全バックアップのアップロード完了・ローカル一時ファイル削除完了"); // 4. ドロップ→再作成 logger.info(`データベースドロップ開始: ${targetDatabase}`); await dropDatabase(targetDatabase, { ifExists: false }); logger.info(`データベースドロップ完了: ${targetDatabase}`); logger.info(`データベース再作成開始: ${targetDatabase}`); await createDatabase(targetDatabase); logger.info(`データベース再作成完了: ${targetDatabase}`); // 5. pg_restore logger.info(`pg_restore開始: ${reassembledPath} → ${targetDatabase}`); const restoreStartedAt = Date.now(); const { stderrOutput } = await runPgRestore({ pgRestoreBin, host, port, user, password, database: targetDatabase, dumpFilePath: reassembledPath, }); const restoreElapsedSec = ((Date.now() - restoreStartedAt) / 1000).toFixed(1); if (stderrOutput) { logger.warn(`pg_restore警告出力: ${stderrOutput}`); } logger.info(`pg_restore終了: 所要時間=${restoreElapsedSec}秒`); fs.rmSync(restoreTmpDir, { recursive: true, force: true }); logger.info("ダウンロード分のローカル一時ファイル削除完了"); const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger.info(`===== リストア終了(成功) ===== 所要時間=${elapsedSec}秒`); } catch (err) { const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger.error(`===== リストア終了(失敗) ===== 所要時間=${elapsedSec}秒 理由=${err.message}`); if (restoreTmpDir) { fs.rmSync(restoreTmpDir, { recursive: true, force: true }); } process.exitCode = 1; } finally { if (serviceWasStopped) { try { logger.info(`Webサービス再起動開始: ${serviceName}`); await startService(serviceName); logger.info(`Webサービス再起動完了: ${serviceName}`); } catch (err) { logger.error( `Webサービス再起動失敗: ${serviceName} 理由=${err.message} ` + `(手動で 'systemctl start ${serviceName}' を実行してください)` ); } } await uploadLogFile({ accessToken, sharedriveId, backupFolderId }); } } async function restoreConfig(options) { const startedAt = Date.now(); const hostname = os.hostname(); const targetPaths = parseConfigPaths(); const serviceNames = (process.env.CONFIG_RESTORE_SERVICES || "nginx,postgresql-18,pleasanter") .split(",") .map((s) => s.trim()) .filter(Boolean); const localTmpDir = process.env.BACKUP_LOCAL_DIR ? path.resolve(process.env.BACKUP_LOCAL_DIR) : path.join(__dirname, "dump_tmp"); logger.info("===== 設定ファイルリストア開始 ====="); logger.info(`ホスト名=${hostname} 対象パス数=${targetPaths.length} 対象サービス=${serviceNames.join(",")}`); let accessToken = null; let sharedriveId = null; let backupFolderId = null; let extractDir = null; const stoppedServices = []; try { if (targetPaths.length === 0) { throw new Error("CONFIG_BACKUP_PATHSが未設定です"); } logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)"); accessToken = await getUserAccessToken(); 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; logger.info(`最新設定アーカイブ検索開始: hostname=${hostname}`); const configArchive = await findLatestConfigArchive( accessToken, sharedriveId, backupFolderId, hostname, drive, logger ); if (!configArchive) { throw new Error(`hostname=${hostname} の設定アーカイブが共有ドライブ上に見つかりませんでした`); } logger.info( `最新設定アーカイブ特定: 日付フォルダ=${configArchive.dateFolderName} timestamp=${configArchive.timestamp}` ); console.log(""); console.log("以下の内容で設定ファイルリストアを実行します:"); console.log( ` 復元元: ${rootFolderName}/${configArchive.dateFolderName}/${configArchive.file.fileName}` ); console.log(` 復元先パス(${targetPaths.length}件):`); for (const p of targetPaths) { console.log(` - ${p}`); } console.log(" 既存のファイル/ディレクトリは削除せず、必ず \".pre-restore-\" にリネームして保持します。"); console.log(` 復元前に以下のサービスをすべて停止します(完了後の自動再起動は行いません): ${serviceNames.join(", ")}`); console.log(" Pleasanter自体のインストール(dotnet本体・パッケージ一式)は対象外です。"); console.log(" この操作は取り消せません。"); console.log(""); if (!options.yes) { const answer = await prompt(`本当に実行しますか?続行するには "${hostname}" と入力してEnter: `); if (answer !== hostname) { logger.info("ユーザーによりキャンセルされました(入力不一致)。"); console.log("入力が一致しなかったため中断しました。"); return; } } // 1. ダウンロード fs.mkdirSync(localTmpDir, { recursive: true }); const archiveLocalPath = path.join(localTmpDir, configArchive.file.fileName); await drive.downloadFile(accessToken, sharedriveId, configArchive.file.fileId, archiveLocalPath, logger); // 2. サービス事前停止(全対象。個別のパスとの対応付けはせず、常に全部止める) for (const serviceName of serviceNames) { const activeBefore = await isServiceActive(serviceName); if (activeBefore) { logger.info(`サービス停止開始: ${serviceName}`); await stopService(serviceName); stoppedServices.push(serviceName); logger.info(`サービス停止完了: ${serviceName}`); } else { logger.warn(`サービス「${serviceName}」は既に停止していたため、停止処理はスキップしました。`); } } // 3. 展開→各パスへ「退避してから配置」 extractDir = path.join(localTmpDir, `config-restore-${configArchive.timestamp}`); await extractArchive(archiveLocalPath, extractDir); logger.info(`設定アーカイブ展開完了: ${extractDir}`); const restoreTimestamp = formatTimestamp(); const results = await restoreConfigPaths({ extractDir, targetPaths, timestamp: restoreTimestamp, logger, }); fs.unlinkSync(archiveLocalPath); fs.rmSync(extractDir, { recursive: true, force: true }); logger.info("ローカル一時ファイル(アーカイブ・展開分)削除完了"); const restoredCount = results.filter((r) => r.status === "restored").length; const skippedCount = results.filter((r) => r.status === "skipped").length; logger.info(`設定ファイル配置結果: 復元${restoredCount}件 / スキップ${skippedCount}件`); const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger.info(`===== 設定ファイルリストア終了(成功) ===== 所要時間=${elapsedSec}秒`); } catch (err) { const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger.error(`===== 設定ファイルリストア終了(失敗) ===== 所要時間=${elapsedSec}秒 理由=${err.message}`); if (extractDir) { fs.rmSync(extractDir, { recursive: true, force: true }); } process.exitCode = 1; } finally { if (stoppedServices.length > 0) { console.log(""); console.log("以下のサービスを停止したままにしています。確認の上、手動で起動してください:"); for (const serviceName of stoppedServices) { if (serviceName === "nginx") { console.log(` nginx -t && systemctl start nginx`); } else { console.log(` systemctl start ${serviceName}`); } } console.log(""); logger.warn( `以下のサービスを停止したままです。手動で起動してください: ${stoppedServices.join(", ")}` ); } await uploadLogFile({ accessToken, sharedriveId, backupFolderId }); } } async function main() { const { command, options } = parseArgs(process.argv); if (command === "database") { await restoreDatabase(options); } else if (command === "config") { await restoreConfig(options); } else { console.log("使い方: node restore.js [database [--database=] | config] [--yes]"); process.exitCode = 1; } } main().catch((err) => { console.error(err.message); process.exitCode = 1; });