451 lines
21 KiB
JavaScript
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;
|
|
});
|