207 lines
8.6 KiB
JavaScript
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;
|
|
});
|