ken_nogi/dbbackup/restore.js
Kenichiro NOGI 67cc3da6c8 up
2026-07-13 19:02:04 +09:00

451 lines
21 KiB
JavaScript

"use strict";
/*
* リストア
* LINE WORKS共有ドライブから、このサーバー(ホスト名)の最新バックアップを取得し復元する。
* DBリストアと設定ファイルリストアは別コマンド(サブコマンド)として独立している。
*
* 使い方:
* node restore.js [database] [--database=<dbName>] [--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-<timestamp>.tar.gz)を共有ドライブ上で特定・ダウンロード
* 2. 関連サービス(.envのCONFIG_RESTORE_SERVICES、既定 nginx/postgresql-18/pleasanter)を事前に全停止
* 3. CONFIG_BACKUP_PATHSの各パスについて、既存のファイル/ディレクトリがあれば
* "<path>.pre-restore-<timestamp>" に必ずリネームして保持してから、アーカイブの内容を配置
* 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(" 既存のファイル/ディレクトリは削除せず、必ず \"<path>.pre-restore-<timestamp>\" にリネームして保持します。");
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=<dbName>] | config] [--yes]");
process.exitCode = 1;
}
}
main().catch((err) => {
console.error(err.message);
process.exitCode = 1;
});