ken_nogi/PleasanterSystem/dbbackup_XXX/backup.js
Kenichiro NOGI ed33892f08 chore: 作業中の変更を整理しコミット(複数プロジェクト分)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 11:09:50 +09:00

217 lines
8.4 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 path = require("path");
const logger = require("./lib/logger");
const { getUserAccessToken } = require("./lib/lineworksUserAuth");
const drive = require("./lib/lineworksDrive");
const { runPgDump, formatTimestamp } = require("./lib/pgDump");
const { createTestDb, dropTestDb } = require("./lib/testDb");
function pad2(n) {
return String(n).padStart(2, "0");
}
function formatDateFolderName(date = new Date()) {
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`;
}
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,
});
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("ローカル一時ファイル削除完了");
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}`);
}
// バックアップ成否に関わらず、当月ログファイルを Pleasanter_DB_Backup/logs 配下へ上書きアップロードする。
async function uploadLogFile({ accessToken, sharedriveId, backupFolderId }) {
logger.info("ログファイルアップロード開始");
try {
let token = accessToken;
let driveId = sharedriveId;
let folderId = backupFolderId;
if (!token) {
token = await getUserAccessToken();
}
if (!driveId) {
driveId = await drive.resolveSharedDriveId(token, logger);
}
if (!folderId) {
const rootFolderName = process.env.LW_BACKUP_ROOT_FOLDER_NAME || "Pleasanter_DB_Backup";
const backupFolder = await drive.ensureFolder(token, driveId, null, rootFolderName, logger);
folderId = backupFolder.fileId;
}
const logFolderName = process.env.LW_LOG_FOLDER_NAME || "logs";
const logsFolder = await drive.ensureFolder(token, driveId, folderId, logFolderName, logger);
const logFilePath = logger.currentLogFilePath();
if (fs.existsSync(logFilePath)) {
await drive.uploadFile(token, driveId, logsFolder.fileId, logFilePath, { overwrite: true }, logger);
logger.info("ログファイルアップロード成功");
}
} catch (err) {
logger.error(`ログファイルアップロード失敗: ${err.message}`);
}
}
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;
});