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

193 lines
21 KiB
Markdown

# PostgreSQL → LINE WORKS 共有ドライブ 自動バックアップ (Node.js)
## 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` は「以前の手段」なので使用しない・参照もしない。
任意にパイプライン全体(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` は変更・参照しない。
### `.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接続 (backup.sh のデフォルトを踏襲) ---
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` 配下に `postgres-<YYYYMMDD-HHMMSS>.dmp000`, `.dmp001`, … として `PG_DUMP_SPLIT_SIZE_MB` ごとに書き出す(`split -d -a 3` と同じ3桁ゼロ埋め連番)。完了時 `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にして同名ファイルを上書き)。成功/失敗いずれも最後にコンソールへも出力。
## 初回セットアップ (`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``date '+%Y%m%d-%H%M%S'` 相当をJSで再現。
## 動作確認方法
1. `node dbbackup/get-token.js` でService Account認証のトークン取得自体が引き続き動くことを確認(リファクタ後の非破壊確認。`.env`に`LW_CLIENT_ID`等が無ければ実行不可になることも確認)。
2. `node dbbackup/lineworks-authorize.js` を一度実行し、User Account認証(kenichiro.nogi@next-hd.co.jp)でrefresh_tokenが`.env`の`LW_USER_REFRESH_TOKEN`に保存されることを確認。
3. `node dbbackup/backup.js create-test-db` でテスト用DBを作成し、`node dbbackup/backup.js run --database=pleasanter_backup_test`(本番`PGDATABASE`には触れない)を一度実行して、
- LINE WORKSの「システムバックアップ」共有ドライブに `Pleasanter_DB_Backup/<today>/` が作成され、ダンプファイルが格納されること
- `Pleasanter_DB_Backup/logs/backup-<今月>.log` がアップロードされ、開始〜終了までの各ステップが時分秒付き・INFOで記録されていること
- `.env``LW_SHAREDRIVE_ID` が書き込まれること
- ローカル `dbbackup/logs/backup-<今月>.log` にも同内容が残っていること
を目視確認。
4. 同日にもう一度 `run --database=pleasanter_backup_test` を実行し、同じ日付フォルダ・同じ月次ログファイルが上書き(ログは追記)されること、ダンプファイル名は秒まで異なるため衝突しないことを確認。
5. `BACKUP_RETENTION_COUNT` を小さい値(例: 2)に一時変更して連続実行し、古い日付フォルダが削除されローテーションが機能すること、ログに削除対象フォルダがINFOで記録されることを確認。
6. 異常系: DB接続情報を意図的に間違え、アップロード・ローテーションに進まず失敗終了すること、ローカルログにERRORが記録されつつ`finally`でログファイル自体はアップロードされることを確認。
7. `node dbbackup/backup.js drop-test-db` でテスト用DBを削除し、後始末できることを確認。