"use strict"; /* * PostgreSQL(Pleasanter) → LINE WORKS共有ドライブ 自動バックアップ * 詳細仕様は SPEC.md 参照。 * * 使い方: * node backup.js … バックアップ実行 (run と同じ) * node backup.js run [--database=] … バックアップ実行(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=] | create-test-db [dbName] | drop-test-db [dbName]]" ); } } main().catch((err) => { console.error(err.message); process.exitCode = 1; });