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

355 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PostgreSQL → LINE WORKS 共有ドライブ 自動バックアップ (Node.js)
## 実装・デプロイ状況(2026-07-11時点)
- 実装完了・`/opt/dbbackup/` に配置済み(本ドキュメント内の`dbbackup/`という記載は配置先ルートを指す)。
- Node.js v22.23.1(dnfのAppStreamリポジトリから導入。要件`>=18.13.0`は満たす。追加npm依存なし)。
- User Account認証(`kenichiro.nogi@next-hd.co.jp`)によるセットアップ完了、`refresh_token`取得・保存済み。
- `PGPASSWORD`はPleasanter本体の設定ファイル(`/web/pleasanter/Implem.Pleasanter/App_Data/Parameters/Rds.json`の`SaConnectionString`内`PWD=`)から転記して設定済み。
- `create-test-db``run --database=...``drop-test-db` によるエンドツーエンド検証済み(pg_dump、共有ドライブへのフォルダ作成・アップロード、ローテーション、ログアップロードすべて成功を確認)。
- 本番`PGDATABASE=Implem.Pleasanter`に対する実行はまだ未実施(検証はテスト用DBのみ)。cronなどへの自動実行登録も未着手。
- サーバーホスト名(このサーバーでは`nextoffice2`)をバックアップファイル名の先頭に`[nextoffice2]`として自動付与する機能を追加済み(詳細は後述「複数サーバーでの運用について」)。
## Context
現行の `backup.sh``pg_dump` でローカルにダンプを作成するところまでは動いているが、転送処理(`sftp.sh` 経由)はコメントアウトされて使われていない。転送先を独自VPN上のSFTPサーバーから **LINE WORKS ドライブの共有ドライブ**に変更したいというのが今回の要望。
**認証方式について**: 当初 `get-token.js` / 姉妹プロジェクトの `lineworks-anythingllm.js`(LINE WORKS 掲示板連携)と同じ「Service AccountのJWT自己署名 → OAuth2トークン取得」を共有ドライブAPIにも流用する想定だったが、実装・検証の結果、**Drive/共有ドライブAPIはService Account認証(JWT)に対応しておらず、User Account認証(OAuth 2.0 認可コードフロー)が必須**であることが判明した(公式ドキュメント `https://developers.worksmobile.com/jp/docs/drive` に明記、かつ実機検証でも「スコープ不足」とは異なるエラー`Not allowed api`を確認)。そのため:
- 掲示板API等、既存のService Account認証で動くもの → 従来通り `get-token.js` / `lib/lineworksAuth.js`
- Drive/共有ドライブAPI(backup.jsが使うもの) → 新設の `lib/lineworksUserAuth.js`(User Account認証、OAuth認可コードフロー)
を使い分ける。User Account認証は実ユーザー(kenichiro.nogi@next-hd.co.jp)による一度きりのブラウザ認可が必要なため、`lineworks-authorize.js` という一回限りのセットアップスクリプトを用意し、そこで取得した `refresh_token``.env` に保存、以降 `backup.js` は無人で `refresh_token` から `access_token` を再発行する。
保存先は 共有ドライブ「システムバックアップ」 > `Pleasanter_DB_Backup` > 日付フォルダ(`YYYYMMDD`) で、フォルダが無ければ都度作成。ファイル名側に秒単位まで含むタイムスタンプを付与し、同日複数回実行しても上書きされないようにする。アップロード後は日付フォルダの個数を数え、保持回数(既定100・`.env`で変更可)を超えた古いフォルダをLINE WORKS側から削除してローテーションする。
さらに、バックアップ開始・終了、アップロード開始・終了、LINE WORKSログイン(トークン取得)からディレクトリ取得・作成までの各過程を秒単位のタイムスタンプ付きで詳細ログに記録する。ログは同じ共有ドライブの `Pleasanter_DB_Backup/logs/` 配下に月1回ローテーション(月ごとに1ファイル)で保存し、実行のたびに上書きアップロードする。通常の経過はINFO、リトライ等の異常系はWARN/ERRORで記録する。
`sftp.sh`(旧転送手段)および旧 `backup.sh`(node版移行前のシェル版バックアップ)は、node版(`backup.js`)への移行が完了し不要となったため、2026-07-11に削除した。`.env`内の旧backup.sh専用変数(`DATE`/`REMOTEDIR`/`SAVEPATH`/`PREFIX`/`EXT`/`FNAME`/`DBNAME`/`PASSWORD`。特に平文パスワードを含んでいた)も同時に削除済み。
任意にパイプライン全体(pg_dump→分割→アップロード→ローテーション→ログ)を試せるよう、本番DBに触れずに使えるテスト用DBの作成・削除コマンドも `backup.js` のサブコマンドとして用意する。
## 参照した公式ドキュメント(エンドポイント確定分)
| 操作 | Method | URL |
|---|---|---|
| 共有ドライブ一覧 | GET | `/v1.0/sharedrives``{ sharedrives: [{ sharedriveId, name, ... }] }` |
| ルート直下 一覧 | GET | `/v1.0/sharedrives/{sharedriveId}/files``{ files: [...], responseMetaData: { nextCursor } }` |
| フォルダ配下 一覧 | GET | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}/children` (同形式) |
| ルート直下 フォルダ作成 | POST | `/v1.0/sharedrives/{sharedriveId}/files/createfolder` body `{ fileName }` → 201 `{ fileId, fileType:"FOLDER", ... }` |
| フォルダ配下 フォルダ作成 | POST | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}/createfolder` body `{ fileName }` |
| アップロードURL発行(段階1) | POST | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}` body `{ fileName, fileSize, modifiedTime, overwrite, resume, suffixOnDuplicate }``{ uploadUrl, offset }` |
| 実バイト送信(段階2) | POST | `uploadUrl` (別ホスト `apis-storage.worksmobile.com`) — `multipart/form-data`, フィールド名 `Filedata`, `Authorization: Bearer` 同梱 → 201 `{ fileId, fileName, fileSize }` |
| 削除(ファイル/フォルダ) | DELETE | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}` → 204 |
必要スコープ: `file`(読み書き全般)。`.env` の `LW_SCOPE``get-token.js`(Service Account認証)専用に残し、バックアップ用(User Account認証)には別変数 `LW_SCOPE_DRIVE` を新設して混同しないようにする。
### 認可コードフロー / リフレッシュ関連エンドポイント
| 操作 | Method | URL |
|---|---|---|
| 認可リクエスト | GET(ブラウザ) | `https://auth.worksmobile.com/oauth2/v2.0/authorize?client_id=...&redirect_uri=...&response_type=code&scope=file&state=...` |
| 認可コード→トークン交換 | POST | `https://auth.worksmobile.com/oauth2/v2.0/token` (`grant_type=authorization_code`) |
| リフレッシュ | POST | 同上 (`grant_type=refresh_token`) |
Refresh Token Rotation が ON のため、リフレッシュのたびに新しい `refresh_token` が発行され古い方は無効化される。取得の都度 `.env``LW_USER_REFRESH_TOKEN` を書き換えて保存する。
## 新規ファイル構成 (dbbackup/ 配下)
```
dbbackup/
backup.js … エントリポイント (node backup.js)
lineworks-authorize.js … User Account認証の一回限りのセットアップ(ブラウザ認可→refresh_token取得・.env保存)
lib/
env.js … .env の読み込み(process.envへ反映。.envが常に正、実行環境変数より優先)・LW_SHAREDRIVE_ID/LW_USER_REFRESH_TOKEN書き戻し
logger.js … 月次ローテーションするローカルログファイルへの書き込み(INFO/WARN/ERROR、時分秒付き)
lineworksAuth.js … get-token.js が使うService Account認証(JWT)。コード内にデフォルト値は持たず、.env未設定なら例外
lineworksUserAuth.js … backup.jsが使うUser Account認証(OAuth認可コードフロー)。認可URL組立て・コード交換・refresh_tokenによる再発行(rotation対応)
lineworksDrive.js … 共有ドライブAPI一覧のラッパー(共有ドライブID解決・フォルダ確保・アップロード・一覧・削除)、429リトライ付きfetch。logger連携で各API呼び出しをINFO/WARNログに記録
pgDump.js … pg_dump をspawnし、標準出力を3072MB単位でローカルファイルに分割書き出し
testDb.js … createdb/dropdb をspawnしてテスト用DBを作成・削除するヘルパー
logs/ … ログのローカル置き場(月次ファイル、既定 backup-YYYYMM.log)
package.json … 依存ゼロ(fetch/FormData/Blob/fs.openAsBlob等 Node標準のみ)。engines: node>=18.13
get-token.js … Service Account認証の動作確認用CLI。内部実装は lib/lineworksAuth.js を呼ぶ薄いラッパー。コード内にデフォルト値を持たず、必要な環境変数が.envに無ければ実行不可
.env … 下記キーを追記
SPEC.md … 本仕様書(このplanの内容)をそのまま保存。実装の最初のステップとしてコミットする
```
`sftp.sh`・旧`backup.sh`は2026-07-11に削除済み(前述)。
### `.env` 追記内容
```
# --- Drive(共有ドライブ)バックアップ関連 (User Account認証) ---
LW_SCOPE_DRIVE=file
LW_REDIRECT_URI=https://example.com/callback
LW_USER_REFRESH_TOKEN=
LW_SHAREDRIVE_NAME=システムバックアップ
LW_SHAREDRIVE_ID=
LW_BACKUP_ROOT_FOLDER_NAME=Pleasanter_DB_Backup
# --- PostgreSQL接続 ---
PG_DUMP_BIN=/usr/bin/pg_dump
PGHOST=localhost
PGPORT=5432
PGUSER=postgres
PGPASSWORD=
PGDATABASE=Implem.Pleasanter
# --- ローカル一時ファイル・分割・ローテーション ---
BACKUP_LOCAL_DIR=/var/lib/pgsql/backup/dmp/
PG_DUMP_SPLIT_SIZE_MB=3072
BACKUP_RETENTION_COUNT=100
# --- ログ ---
LOG_LOCAL_DIR=./logs
LW_LOG_FOLDER_NAME=logs
# --- テスト用DB(create-test-db / drop-test-db) ---
PG_CREATEDB_BIN=createdb
PG_DROPDB_BIN=dropdb
TEST_DB_NAME=pleasanter_backup_test
```
`LW_SHAREDRIVE_ID` は初回実行時に名前(`LW_SHAREDRIVE_NAME`)から解決し、`lib/env.js` が `.env` ファイルの当該行を書き換えて永続化する。2回目以降はID解決APIを呼ばずキャッシュ値を使う。
## 処理フロー (`backup.js`)
全体を `try { ... } catch { ...ERRORログ... } finally { ...ログファイルのアップロード... }` で包む。`accessToken` / `sharedriveId` / `backupFolderId` / `logsFolderId` は関数スコープの変数に保持し、`finally` 内でログアップロード時に(まだ未取得なら)再利用・再取得する。
1. `.env` 読み込み(`lib/env.js`、`process.env` に無いキーだけ反映=実環境変数を優先)。`logger.info("===== バックアップ開始 =====")`。
2. `lib/pgDump.js`: `logger.info("pg_dump開始: host=... db=...")``PG_DUMP_BIN -h $PGHOST -p $PGPORT -U $PGUSER -Fc $PGDATABASE` を spawn(`PGPASSWORD` 設定時のみ子プロセスのenvに渡す)。標準出力を `BACKUP_LOCAL_DIR` 配下に `[<os.hostname()>]postgres-<YYYYMMDD-HHMMSS>.dmp000`, `.dmp001`, … として `PG_DUMP_SPLIT_SIZE_MB` ごとに書き出す(`split -d -a 3` と同じ3桁ゼロ埋め連番)。ホスト名プレフィックスは`backup.js`側で`os.hostname()`から取得して`prefix`引数として渡す(`.env`等の追加設定は不要、サーバーごとに自動判別)。完了時 `logger.info("pg_dump終了: 合計サイズ=... 所要時間=...")`。exit code非0またはstderrで失敗時は `logger.error(...)` を出して例外を投げここで中断(アップロード・ローテーションは行わない)。
3. `logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)")``lib/lineworksUserAuth.js``getUserAccessToken()``LW_USER_REFRESH_TOKEN` から `access_token` を再発行(rotationで返る新しい`refresh_token`は即座に`.env`へ保存) → 成功で `logger.info("アクセストークン取得成功")`、失敗で `logger.error(...)`(`LW_USER_REFRESH_TOKEN`未設定時は「先に`lineworks-authorize.js`を実行してください」という内容の例外)。
4. `lib/lineworksDrive.js`(各呼び出し前後でINFOログ、429リトライはWARNログを`logger`経由で出力):
- `resolveSharedDriveId()`: `.env``LW_SHAREDRIVE_ID` があれば `logger.info("共有ドライブID: キャッシュ値を使用 (...)")`。無ければ一覧取得→`LW_SHAREDRIVE_NAME`と一致するものを検索→`.env`へ書き戻し、`logger.info("共有ドライブID解決: name=... id=...")`。見つからなければ`logger.error`してエラー終了。
- `ensureFolder(parentFileId, name)`: 一覧を見て同名の`FOLDER`があれば `logger.info("フォルダ確認: 既存を使用 name=... id=...")`、無ければ作成し `logger.info("フォルダ確認: 新規作成 name=... id=...")`。これを `Pleasanter_DB_Backup`(ルート直下)→ 日付フォルダ`YYYYMMDD`(その配下)の2階層分呼ぶ。
- 分割されたダンプファイルを順番に `uploadFile(sharedriveId, dateFolderId, filePath)` でアップロード。各ファイルについて `logger.info("アップロード開始: ファイル名=... サイズ=...")` → 段階1でuploadUrl取得→段階2でPOST → 成功で `logger.info("アップロード成功: ファイル名=... fileId=... 所要時間=...")`、失敗で `logger.error(...)` して例外を投げ中断(ローカルファイルは削除しない=再実行できるように残す)。大きいファイルは `fs.openAsBlob()` でメモリに全展開せず送信。
5. 全パートのアップロードに成功したら `BACKUP_LOCAL_DIR` 上の当該一時ファイルを削除し `logger.info("ローカル一時ファイル削除完了")`
6. ローテーション: `logger.info("ローテーション開始 (保持件数=...)")``Pleasanter_DB_Backup` 配下のフォルダ一覧を取得し、フォルダ名(=日付文字列なので辞書順=時系列順)でソート。`BACKUP_RETENTION_COUNT` を超えた古い方から `deleteFile(sharedriveId, fileId)` で削除しつつ `logger.info("削除: フォルダ名=... fileId=...")`。`logger.info("ローテーション終了: 削除件数=...")`。
7. `logger.info("===== バックアップ終了(成功) ===== 所要時間=...")`。失敗時は catch 節で `logger.error("===== バックアップ終了(失敗): ...")` を出し `process.exitCode = 1`
8. `finally` 節: `logger.info("ログファイルアップロード開始")` → 4.で解決済みの `sharedriveId` を使って(未解決なら改めて解決を試み、それも失敗したらログアップロードは諦めローカルログのみ残す)`Pleasanter_DB_Backup/logs` フォルダを確保し、当月ログファイル(`backup-YYYYMM.log`)を `overwrite: true` でアップロード(段階1リクエストの`overwrite`フラグをtrueにして同名ファイルを上書き)。成功/失敗いずれも最後にコンソールへも出力。
## 環境設定ファイルバックアップ(2026-07-11追加)
DBのみでは「サーバーが完全に失われた場合に同じ環境を再構築できない」ため、DB以外の設定ファイル一式もbackup.js実行のたびに合わせてバックアップする機能を追加。
**対象(`.env`の`CONFIG_BACKUP_PATHS`、カンマ区切りで変更可能)**
| パス | 理由 |
|---|---|
| `/etc/nginx` | リバースプロキシ設定一式 |
| `/web/pleasanter/Implem.Pleasanter/App_Data/Parameters` | Pleasanterの環境固有設定(ユーザー指定) |
| `/web/pleasanter/Implem.Pleasanter/wwwroot` | 静的ファイル・添付ファイル等(ユーザー指定) |
| `/web/pleasanter/Implem.CodeDefiner/Implem.License.dll` | ライセンスファイル、再ダウンロード不可(ユーザー指定) |
| `/web/pleasanter/Implem.Pleasanter/Implem.License.dll` | 同上(ユーザー指定) |
| `/etc/letsencrypt` | HTTPS証明書一式(調査の上追加。無いと証明書の再発行が必要になる) |
| `/etc/systemd/system/pleasanter.service` | Pleasanterを起動するための自作systemdユニット(調査の上追加。パッケージ由来ではないため他に控えが無い) |
| `/var/lib/pgsql/18/data/postgresql.conf` | PostgreSQLサーバー設定(調査の上追加。pg_dumpはデータのみでサーバー設定は含まない) |
| `/var/lib/pgsql/18/data/pg_hba.conf` | PostgreSQL接続認証設定(同上) |
`/web/pleasanter/Implem.Pleasanter/`配下の実行バイナリ本体(dotnet依存関係含め434MB)は、Pleasanter公式配布物から再構築可能なため対象外とした(ユーザー指定の対象からも除外されていた)。
**検討したが対象外としたもの**
- `/opt/dbbackup`自体(このバックアップ/リストアの仕組み一式): `.env`にLINE WORKSのクライアントシークレットやDBパスワード、秘密鍵ファイルが含まれるため、**それらの認証情報をアップロード先であるLINE WORKS共有ドライブ自身に保存することはセキュリティ上推奨しない**と判断し、あえて含めなかった。この一式は別途安全な方法(パスワード管理ツール、社内の別のセキュアな保管場所等)で保管することを推奨。
- firewalldの設定: 確認したところ本サーバーではfirewalld自体が起動していないため対象外。
- cronの設定: 2026-07-11時点でまだcron未登録のため対象なし。今後cron登録した場合は`/etc/cron.d/dbbackup`等を追加で対象に含めることを推奨。
- SSHホスト鍵: 新サーバーでは通常再生成すれば足りるため対象外。
**処理内容(`lib/configBackup.js`、`backup.js`から呼び出し)**
- 対象パスのうち実在するものだけを`tar -czf`で1つの`.tar.gz`にまとめる(存在しないパスは警告ログのみでスキップ)。
- ファイル名は`[hostname]config-<timestamp>.tar.gz`(DBダンプと同じ日付フォルダへアップロード。ローテーション対象にも自動的に含まれる)。
- DBバックアップとは独立した`try/catch`で実行するため、**設定ファイルバックアップが失敗してもDBバックアップ自体の成否には影響しない**(エラーはログに記録されるのみ)。
- `--database`指定時(テスト実行)でもスキップせず毎回実行する(読み取り専用でサーバーへの影響が無いため)。
**動作検証(2026-07-11)**
- テスト用DBでのバックアップ実行時に、9件すべてのパスが検出されアーカイブ化・アップロード成功を確認(約21.7MB)。
- 手動で同じロジックを実行し、tar内に9件すべてのパスが正しく含まれていることを確認済み。
## cron登録コマンド(未登録・参考用、2026-07-11時点)
運用方針: 週1回、毎週水曜日の午前2時にバックアップを実行する。**現時点ではまだcron登録は行っていない**。実際に登録する際は以下のいずれかの方法で設定する。
**方法1: `/etc/cron.d/dbbackup` を新規作成する場合**
```
0 2 * * 3 root cd /opt/dbbackup && /usr/bin/node backup.js run >> /opt/dbbackup/logs/cron-stdout.log 2>&1
```
**方法2: `crontab -e`(rootのcrontab)に追記する場合**
```
0 2 * * 3 cd /opt/dbbackup && /usr/bin/node backup.js run >> /opt/dbbackup/logs/cron-stdout.log 2>&1
```
- `0 2 * * 3`: 分=0, 時=2, 日=任意, 月=任意, 曜日=3(水曜日。cronの曜日は0=日曜始まり)
- `node`のパスは`/usr/bin/node`(本サーバーへの`dnf install nodejs`によるインストール先)。
- `backup.js`自体が`lib/logger.js`でのローカルログ記録・LINE WORKS共有ドライブへのログアップロードを行うため、`cron-stdout.log`へのリダイレクトは「nodeプロセス自体が起動できなかった」等の想定外の異常のみを拾うための保険。通常の実行結果は共有ドライブの`Pleasanter_DB_Backup/logs/backup-<年月>.log`、およびローカルの`/opt/dbbackup/logs/backup-<年月>.log`を参照すること。
- 登録前に`logs`ディレクトリの書き込み権限があることを確認(`/opt/dbbackup/logs`はrootで作成される想定)。
## リストア機能 (`restore.js`, 2026-07-11追加)
`node restore.js [--database=<dbName>] [--yes]` で実行する、共有ドライブからのデータベース復元機能。実装・テストDBでの検証済み。
**処理内容**
1. このサーバーのホスト名(`os.hostname()`、例: `nextoffice2`)に一致する`[hostname]postgres-...`形式の最新バックアップを、共有ドライブの`Pleasanter_DB_Backup`配下から特定する(`lib/backupLocator.js`)。日付フォルダを新しい順に探索し、対象ホスト名のファイルが無いフォルダは遡ってスキップする(複数サーバー運用でその日はまだ対象サーバーの分が無いケースに対応)。同一タイムスタンプの分割ファイル(`.dmp000`, `.dmp001`, …)をすべて特定する。
2. 対話確認: 復元元・復元先DB名・Webサービス停止有無を表示し、復元先DB名そのものを入力させて一致した場合のみ続行(`--yes`指定時はスキップ、検証・自動化用途)。
3. 特定したバックアップの各パートをダウンロードし(`lib/lineworksDrive.js`の`downloadFile()`。`/download`エンドポイントの302リダイレクト先URLも含め、両方のリクエストに`Authorization: Bearer`ヘッダーが必要 — 片方だけだと401になることを実機で確認済み)、パート順に結合して1つの`-Fc`形式ダンプファイルに復元する(分割は単純なバイト分割のため、結合するだけで元のpg_dump出力に戻る)。
4. **Webサービス停止**(`lib/serviceControl.js`、既定`pleasanter`、`.env`の`WEB_SERVICE_NAME`で変更可): DBへの接続を切るため、ドロップ前に停止する。**`--database`でDB名を上書きした場合(テスト用DB等)は、本番サービスを巻き込まないよう停止処理自体をスキップする**(`--database`未指定=本番DB復元の場合のみ実施)。停止前に稼働中か確認し、既に停止していた場合は「対象外」として記録するのみで、完了後の自動再起動も行わない(=元の状態を変えない)。
5. **復元対象をダウンロードし終えた後で**、現在のデータベースを`[hostname]pre-restore-<timestamp>`という別名(通常の`postgres-`とは異なるプレフィックス)でpg_dump・共有ドライブへアップロードする。この順序(ダウンロード→現状バックアップ)を守ることで、この安全バックアップ自体が次回の「最新バックアップ検索」に混入しない(`postgres-`プレフィックスではないため`lib/backupLocator.js`の検索対象外)。
6. データベースをドロップ(`--if-exists`なし。対象DBが実在することを前提とし、無い場合はエラーで中断)→同名で再作成。
7. 結合済みのダンプファイルから`pg_restore -h ... -U ... -d <dbName> --no-owner <dumpfile>`で復元。
8. ローカル一時ファイル(ダウンロード分)を削除。**Webサービスを再起動**(4.で実際に停止していた場合のみ。成功・失敗いずれの場合も`finally`節で実行し、再起動自体が失敗した場合は手動での`systemctl start`実行を促すエラーログを出す)。成否に関わらず`lib/logUpload.js`(backup.jsと共用)で当月ログを共有ドライブへアップロード。
**新規/変更ファイル**
- `restore.js` … エントリポイント
- `lib/backupLocator.js` … 最新バックアップ特定ロジック
- `lib/pgRestore.js``pg_restore`のspawnラッパー
- `lib/serviceControl.js``systemctl is-active/stop/start`のラッパー(Webサービスの安全な停止・再起動用)
- `lib/lineworksDrive.js``downloadFile()`を追加
- `lib/testDb.js` … 汎用の`createDatabase()`/`dropDatabase()`を追加(既存の`createTestDb`/`dropTestDb`はそのまま維持)
- `lib/pgDump.js``formatDateFolderName()`を追加(backup.js/restore.js共用)
- `lib/logUpload.js` … backup.js内にあった`uploadLogFile()`を切り出し、backup.js/restore.js共用に変更
- `.env``PG_RESTORE_BIN`, `WEB_SERVICE_NAME`(既定`pleasanter`)を追加
**動作検証(2026-07-11、テストDBにて実施。本番DBには一切触れていない)**
- テスト用DBにマーカー行を入れてバックアップ→内容を変更→`node restore.js --database=pleasanter_backup_test --yes`を実行→復元後にバックアップ時点の値が正しく戻っていることを2回確認(=最新バックアップからの復元であり、直前の状態ではないことを確認)。
- 確認プロンプトで誤った入力をした場合、ドロップされずに中断されることを確認。
- ダウンロードAPIの2段階目(実際のファイル取得URL)でも`Authorization`ヘッダーが必須であること(無いと401)を実機で確認済み。
- **`--database`指定時は`pleasanter.service`の状態が実行前後で一切変化しない**ことを確認(本番サービスを巻き込まない設計の検証)。
- `lib/serviceControl.js`の`isServiceActive`/`stopService`/`startService`自体は、systemdのtransientユニット(`systemd-run --collect`、実行後は自動的に消える使い捨てユニットで本番には一切影響しない)を使って個別に動作検証済み(起動確認→停止→停止後にis-active=false→存在しないユニットへのstart失敗時のエラーメッセージも確認)。
**未検証・注意事項**
- 本番DB(`Implem.Pleasanter`)に対する`--database`未指定でのフルリストア(実際のサービス停止・再起動を伴う本番実行)はまだ行っていない。
- pg_restoreは`--no-owner`のみ付与(`--clean`等は付けていない。ドロップ→再作成した空DBへの復元のため不要と判断)。
- テスト中に本番バックアップの「最新」ポインタが一時的にテストDBのものに上書きされたため、検証後に本番DB(`Implem.Pleasanter`)への`node backup.js run`を再実行し、最新バックアップが本番データに戻っていることを確認済み(2026-07-11 12:29)。**`--database`でテスト実行した直後は、本番の`node backup.js run`を必ず1回実行してから運用に戻すこと**(そうしないと`--database`無指定のrestore.jsが誤ってテストDBのデータを最新として拾ってしまう)。
## 設定ファイルリストア機能 (`restore.js config`, 2026-07-11追加)
DB以外の環境設定ファイル(前述「環境設定ファイルバックアップ」参照)を復元するための機能。**DBリストア(`restore.js database`)とは独立した別コマンド**として実装した(`node restore.js config [--yes]`)。**Pleasanter自体は既にインストール済みであることが前提**(dotnet本体・パッケージ一式434MBは対象外、Parameters/wwwroot/ライセンスDLL等の設定・データ部分のみが対象)。
**処理内容**
1. このホスト名に一致する最新の設定アーカイブ(`[hostname]config-<timestamp>.tar.gz`)を`lib/backupLocator.js`の`findLatestConfigArchive()`で特定・ダウンロード。
2. 対話確認: 復元元・復元先パス一覧・退避方式・停止対象サービスを表示し、**ホスト名そのものを入力させて**一致した場合のみ続行(`--yes`でスキップ可)。
3. **`.env`の`CONFIG_RESTORE_SERVICES`(既定`nginx,postgresql-18,pleasanter`)に列挙された全サービスを、個々のパスとの対応関係を考慮せず一律に事前停止する**(ユーザー指示による。DBリストアと異なり「一部だけ止める」判定はしない)。
4. `CONFIG_BACKUP_PATHS`の各パスについて、**復元先に既存のファイル/ディレクトリがあれば削除せず、必ず`<path>.pre-restore-<timestamp>`にリネームして保持してから**、アーカイブ側の内容を配置する(`lib/configRestore.js`の`restoreConfigPaths()`)。アーカイブ側にそのパスが存在しない場合は警告ログのみでスキップ(退避も配置も行わない)。
5. **サービスの自動再起動は行わない**(ユーザー指示による)。3.で実際に停止したサービスの一覧をログ・コンソールに表示し、手動で実行すべき`systemctl start ...`コマンド(nginxのみ`nginx -t && systemctl start nginx`)を案内する。
6. 成否に関わらず`lib/logUpload.js`で当月ログを共有ドライブへアップロード。
**新規ファイル**
- `lib/configRestore.js` … tar展開(`extractArchive`)、退避+配置(`restoreConfigPaths`)
- `lib/backupLocator.js``findLatestConfigArchive()`を追加(既存の`findLatestBackupFileSet`とは別関数。設定アーカイブは分割されない単一ファイルのため)
- `.env``CONFIG_RESTORE_SERVICES`を追加
**動作検証(2026-07-11)**
- サンドボックスでの事前検証: `extractArchive`/`restoreConfigPaths`のロジックのみを検証(バックアップ時点の内容で固めたアーカイブに対し、現在の内容を意図的に変更(ドリフトを模擬)した状態から復元し、復元先が正しく戻ること・退避ファイルにドリフト後の内容が保持されること・アーカイブ非含有パスは無変更であることを確認)。
**本番環境での実行・インシデントと修正(2026-07-11)**
ユーザーの指示により、本番の`nginx`/`postgresql-18`/`pleasanter`を実際に停止した上で`node restore.js config`をフル実行(確認プロンプトにホスト名を入力して実施)。結果:
- 9パス中8パス(nginx設定・Parameters・wwwroot・ライセンスDLL×2・systemdユニット・postgresql.conf・pg_hba.conf)は正常に復元完了。
- **`/etc/letsencrypt`のみ、nginxが起動失敗**。原因は、`live/*.pem`が持つ相対シンボリックリンク(例: `fullchain.pem -> ../../archive/<domain>/fullchain1.pem`、certbotの標準構成)を配置する際に使っていた`fs.cpSync(source, dest, {recursive:true})`が、**相対リンクをコピー元ディレクトリ(tar展開用の一時ディレクトリ)を基準とした絶対パスに書き換えてしまう**という挙動を持っていたため(Node.js の既知の挙動。サンドボックスで再現・確認済み)。tar自体(`extractArchive`)はシンボリックリンクを正しく相対パスのまま保存・展開できていた。
- **復旧**: 退避してあった`/etc/letsencrypt.pre-restore-<timestamp>`(正常な状態)を`/etc/letsencrypt`にスワップして即座に復旧 → `postgresql-18`→`pleasanter`→`nginx`の順で起動 → HTTP/HTTPS双方の応答・DBテーブル数(93件)を確認し、正常復旧を確認済み。
- **恒久対応**: `lib/configRestore.js`の配置処理を、シンボリックリンクをそのまま複製する`cp -a`(spawn経由)に変更(`restoreConfigPaths`は非同期関数に変更)。修正後、サンドボックスで同様の相対シンボリックリンク構成を再現し、復元後もリンクが`../archive/cert1.pem`のような相対パスのまま正しく保持されることを再検証済み。
- 本番環境での再実行によるフル(サービス停止込み)なエンドツーエンド再検証は、サンドボックス検証で十分と判断し実施していない(ユーザー確認の上で見送り)。
- インシデント調査用に一時的に残していた壊れた`/etc/letsencrypt.broken-cpsync-symlink-bug`は削除済み。設計通りの退避ファイル(`*.pre-restore-20260711-132523`、7件)はそのまま保持している。
**教訓・今後の注意点**
- 対象パスにシンボリックリンクを含む可能性がある場合(今回は`/etc/letsencrypt`)、Node.jsの`fs.cpSync`/`fs.cp`の`recursive`コピーは要注意。シンボリックリンクを正確に複製する必要がある処理は`cp -a`等のOSコマンドを使うこと。
- 今後`CONFIG_BACKUP_PATHS`に新しいパスを追加する際は、そのパス配下にシンボリックリンクが無いか(`find <path> -type l`)を事前に確認することが望ましい。
## 複数サーバーでの運用について
このバックアップシステムは、複数のサーバーが同一の共有ドライブ「システムバックアップ」へ並行してアップロードする運用を想定している。区別のため、ダンプファイル名先頭に`[<サーバーのホスト名>]`が自動付与される(例: `nextoffice2`というホスト名のサーバーなら`[nextoffice2]postgres-....dmp000`)。
**サーバーごとに個別のUser Account認証(`refresh_token`)が必要な理由**
- `refresh_token`はLINE WORKSアプリ(`LW_CLIENT_ID`)+ユーザーアカウント(`kenichiro.nogi@next-hd.co.jp`)に紐づくものであり、サーバー自体に紐づくものではない。そのため`.env`ごとコピーしてそのまま使うこと自体はLINE WORKS側の制約としては禁止されていない。
- ただし本アプリはRefresh Token Rotationが有効なため、「リフレッシュのたびに新しい`refresh_token`が発行され、古い方は無効化される」(公式ドキュメント・SPEC内既述の通り)。
- 複数のサーバーが**同一の`refresh_token`をコピーして持った状態**で、それぞれ独立して`backup.js`を実行し続けると、どちらか一方が先にローテーションした時点で、もう一方が持つ`refresh_token`は無効化済みとなり、次回実行時に認証エラーで失敗する。
- そのため、**恒久的に並行稼働させる各サーバーは、それぞれ個別に`node lineworks-authorize.js`を実行し、サーバーごとに独立してローテーションする`refresh_token`を持たせること**。`LW_CLIENT_ID` / `LW_CLIENT_SECRET` / `LW_SHAREDRIVE_ID` はアプリ・共有ドライブ自体を指す値なので、全サーバーで共通の値をコピーして問題ない。
- 一時的な移行(旧サーバーを廃止し新サーバーへ完全に切り替える場合など、今後`backup.js`を実行するサーバーが常に1台のみ)であれば、`.env`をそのままコピーしてよい。
**`refresh_token`の有効期限**
- `refresh_token`自体の有効期限は90日間(LINE WORKS公式ドキュメントによる)。
- Refresh Token Rotationが有効なため、`getUserAccessToken()`実行のたびに新しい90日間有効な`refresh_token`が発行され`.env`に書き戻される。**90日以内に最低1回`backup.js`を実行し続ける限り、有効期限は自動更新され続け、再度ブラウザでの手動認証は不要**。
- 逆に、90日以上`backup.js`を一度も実行しない期間ができると、その時点の`refresh_token`が失効し、次回実行時に認証エラーとなる。その場合は`node lineworks-authorize.js`による再認証が必要。cron等で定期実行を止めないことが実質的な前提条件。
## 初回セットアップ (`lineworks-authorize.js`)
Drive API利用に必須のUser Account認証を一度だけ行うスクリプト。
1. 事前にDeveloper Consoleの「User Account認証」欄のRedirect URLに `.env``LW_REDIRECT_URI` と完全一致する値を登録しておく。
2. `node lineworks-authorize.js` を実行すると認可URLが表示されるので、ブラウザで開き対象アカウント(例: kenichiro.nogi@next-hd.co.jp)でログイン・同意する。**パスワードなど認証情報はこちらのスクリプトや対話には一切入力しない**(ブラウザ上のLINE WORKSログイン画面でのみ入力する)。
3. 同意後リダイレクトされた画面のURL(またはcodeパラメータの値)をコンソールに貼り付ける。
4. `code`を`access_token`/`refresh_token`に交換し、`refresh_token`を`.env`の`LW_USER_REFRESH_TOKEN`に保存する。
5. 以降 `backup.js` は無人でこの`refresh_token`から`access_token`を再発行する。Refresh Token Rotationにより毎回新しい`refresh_token`が発行されるため、`lib/lineworksUserAuth.js`の`getUserAccessToken()`は取得の都度`.env`へ書き戻す。
## CLIサブコマンド (`backup.js`)
`process.argv[2]` をコマンド名として単純にディスパッチする(追加npm依存なし)。
| コマンド | 内容 |
|---|---|
| `node backup.js` または `node backup.js run [--database=<dbName>]` | 通常のバックアップフロー(上記1〜8)を実行。`--database`指定時は同プロセス内で`PGDATABASE`をその値に上書きしてから実行(`.env`は書き換えない=使い捨ての一時上書き)。 |
| `node backup.js create-test-db [dbName]` | テスト用DBを作成(`lib/testDb.js`)。省略時は`.env`の`TEST_DB_NAME`を使用。 |
| `node backup.js drop-test-db [dbName]` | テスト用DBを削除(`lib/testDb.js`)。省略時は`.env`の`TEST_DB_NAME`を使用。 |
これにより、本番DBに触れず以下のようなセルフテストが可能になる:
```
node backup.js create-test-db
node backup.js run --database=pleasanter_backup_test
node backup.js drop-test-db
```
### `lib/testDb.js`
- `createTestDb(dbName)`: `PG_CREATEDB_BIN -h $PGHOST -p $PGPORT -U $PGUSER <dbName>` を spawn(`PGPASSWORD`設定時は子プロセスのenvへ渡す)。既に存在する場合はcreatedbのエラーをそのまま表示して終了(先に`drop-test-db`を促すメッセージを添える)。
- `dropTestDb(dbName)`: `PG_DROPDB_BIN -h $PGHOST -p $PGPORT -U $PGUSER --if-exists <dbName>` を spawn。`--if-exists`により未作成でもエラーにしない。
- どちらも接続先ホスト/ポート/ユーザーは通常バックアップと同じ`.env`の`PGHOST`/`PGPORT`/`PGUSER`/`PGPASSWORD`を再利用する(接続先サーバーは同じ、対象DB名だけが違う)。
- 実行結果は`console.log`/`console.error`のみ(通常バックアップの月次ログ・LINE WORKSへのログアップロードとは無関係な開発用ユーティリティのため、`logger`/Driveアップロードには連携しない)。
## ログ設計 (`lib/logger.js`)
- 出力先はローカルの `LOG_LOCAL_DIR/backup-YYYYMM.log`(月が変わったら自動的に別ファイルに切り替わる=月1回ローテーション)。プロセス内でファイルハンドルを使い回さず、書き込みの都度対象月のパスを計算して追記(`fs.appendFileSync`)することで日付またぎにも自然に対応する。
- 1行フォーマット: `YYYY-MM-DD HH:mm:ss [LEVEL] message`(時分秒まで必須)。
- `logger.info(msg)` / `logger.warn(msg)` / `logger.error(msg)` の3レベル。すべて `console.log`/`console.warn`/`console.error` にも同時出力し、標準出力からも経過が追えるようにする。
- 記録対象(すべて時分秒付きINFO、異常系はWARN/ERROR):
- バックアップ処理全体の開始・終了(成功/失敗、所要時間)
- pg_dumpの開始・終了(失敗時はERRORでstderr内容も記録)
- LINE WORKSアクセストークン取得(scope、開始・成功・失敗)
- 共有ドライブID解決(キャッシュ使用/新規検索の別)
- `Pleasanter_DB_Backup`・日付フォルダ・`logs`フォルダそれぞれの取得/作成(既存流用か新規作成か、fileId)
- 各ダンプファイルのアップロード開始・成功・失敗
- 429レートリミット時のリトライ(WARN、待機秒数)
- ローカル一時ファイル削除
- ローテーションで削除したフォルダ一覧
- ログファイル自体のアップロード開始・成功・失敗
- `lib/lineworksDrive.js` の各関数は `logger` を受け取って(または直接requireして)上記のログを出す。`lwFetch`内の429リトライ箇所も `console.warn` ではなく `logger.warn` に統一する。
## 再利用・共通化する既存ロジック
- JWT組立て・トークン取得: `get-token.js` / `lineworks-anythingllm.js``createJwtAssertion` / `getAccessToken` をほぼそのまま `lib/lineworksAuth.js` に切り出す(scopeを引数化するだけで、`get-token.js`の board向けデフォルトはCLI側に残す)。
- 429リトライ付きfetch: `lineworks-anythingllm.js``lwFetch`(スロットリング+Retry-After対応)と同じ実装を `lib/lineworksDrive.js` 内に置く(ロジックはコピーだが依存関係を増やしたくないため別ファイル化はしない)。
- ファイル名の日時フォーマットは旧`backup.sh`(2026-07-11削除)で使われていた`date '+%Y%m%d-%H%M%S'`相当をJSで再現。
## 動作確認方法
`nextoffice2`サーバーの`/opt/dbbackup`にて、以下1〜3を実施し成功を確認済み(2026-07-11)。4〜7は未実施(必要に応じて追加で実施)。
1. ~~`node dbbackup/get-token.js` でService Account認証~~ … 今回は未実施(Drive/バックアップ用途では使用しないため)。
2. `node lineworks-authorize.js` を実行し、User Account認証(kenichiro.nogi@next-hd.co.jp)でrefresh_tokenが`.env`の`LW_USER_REFRESH_TOKEN`に保存されることを確認 → **OK**
3. `node backup.js create-test-db` でテスト用DBを作成し、`node backup.js run --database=pleasanter_backup_test`(本番`PGDATABASE`には触れない)を実行して、
- LINE WORKSの「システムバックアップ」共有ドライブに `Pleasanter_DB_Backup/<today>/` が作成され、`[nextoffice2]postgres-....dmp000` としてダンプファイルが格納されること → **OK**
- `Pleasanter_DB_Backup/logs/backup-<今月>.log` がアップロードされ、開始〜終了までの各ステップが時分秒付き・INFOで記録されていること → **OK**
- `.env``LW_SHAREDRIVE_ID`(`@2101000001789666`)が書き込まれること → **OK**
- ローカル `logs/backup-<今月>.log` にも同内容が残っていること → **OK**
を確認。その後 `node backup.js drop-test-db` でテスト用DB・ローカル一時ファイルを後始末済み。
**以下、未実施(今後必要になった際の参考手順として残す)**
4. 同日にもう一度 `run --database=pleasanter_backup_test` を実行し、同じ日付フォルダ・同じ月次ログファイルが上書き(ログは追記)されること、ダンプファイル名は秒まで異なるため衝突しないことを確認。
5. `BACKUP_RETENTION_COUNT` を小さい値(例: 2)に一時変更して連続実行し、古い日付フォルダが削除されローテーションが機能すること、ログに削除対象フォルダがINFOで記録されることを確認。
6. 異常系: DB接続情報を意図的に間違え、アップロード・ローテーションに進まず失敗終了すること、ローカルログにERRORが記録されつつ`finally`でログファイル自体はアップロードされることを確認。
7. 本番`PGDATABASE=Implem.Pleasanter`に対する実運用実行、およびcronへの登録(未着手)。