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

207 lines
8.6 KiB
JavaScript

"use strict";
/*
* PostgreSQL(Pleasanter) → LINE WORKS共有ドライブ 自動バックアップ
* 詳細仕様は SPEC.md 参照。
*
* 使い方:
* node backup.js … バックアップ実行 (run と同じ)
* node backup.js run [--database=<name>] … バックアップ実行(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");
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 };
}
async function runBackup({ database } = {}) {
const startedAt = Date.now();
logger.info("===== バックアップ開始 =====");
let accessToken = null;
let sharedriveId = null;
let backupFolderId = null;
try {
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();
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: `[${os.hostname()}]postgres-`,
});
const dumpElapsedSec = ((Date.now() - dumpStartedAt) / 1000).toFixed(1);
logger.info(
`pg_dump終了: 合計サイズ=${totalBytes}bytes ファイル数=${dumpFiles.length} 所要時間=${dumpElapsedSec}`
);
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;
const dateFolderName = formatDateFolderName();
const dateFolder = await drive.ensureFolder(
accessToken,
sharedriveId,
backupFolderId,
dateFolderName,
logger
);
for (const filePath of dumpFiles) {
await drive.uploadFile(accessToken, sharedriveId, dateFolder.fileId, filePath, { overwrite: false }, logger);
}
for (const filePath of dumpFiles) {
fs.unlinkSync(filePath);
}
logger.info("ローカル一時ファイル削除完了");
// DB以外の環境設定ファイル(.envのCONFIG_BACKUP_PATHS)もあわせてバックアップする。
// サーバー消失時の復元に必要なだけで、失敗してもDBバックアップ自体は成功扱いのまま続行する。
try {
const configPaths = parseConfigPaths();
if (configPaths.length === 0) {
logger.info("CONFIG_BACKUP_PATHS未設定のため、設定ファイルバックアップはスキップします");
} else {
logger.info(`設定ファイルバックアップ開始: 対象${configPaths.length}`);
const { archivePath, includedPaths } = await createConfigArchive({
paths: configPaths,
outputDir,
timestamp,
hostname: os.hostname(),
logger,
});
logger.info(
`設定アーカイブ作成完了: ${path.basename(archivePath)} ` +
`(${includedPaths.length}件, ${fs.statSync(archivePath).size}bytes)`
);
await drive.uploadFile(accessToken, sharedriveId, dateFolder.fileId, archivePath, { overwrite: false }, logger);
fs.unlinkSync(archivePath);
logger.info("設定アーカイブのアップロード完了・ローカル一時ファイル削除完了");
}
} catch (err) {
logger.error(`設定ファイルバックアップ失敗(DBバックアップ自体は継続): ${err.message}`);
}
await rotateOldBackups(accessToken, sharedriveId, backupFolderId);
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}`);
process.exitCode = 1;
} finally {
await uploadLogFile({ accessToken, sharedriveId, backupFolderId });
}
}
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 {
console.log(
"使い方: node backup.js [run [--database=<dbName>] | create-test-db [dbName] | drop-test-db [dbName]]"
);
}
}
main().catch((err) => {
console.error(err.message);
process.exitCode = 1;
});