From e41921ebfe51c3f4d602d0e47771cf638e348301 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 29 Aug 2026 11:58:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(snapshot,status):=20=E3=82=B9=E3=83=8A?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7=E3=83=83=E3=83=88=E3=81=AE?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E5=AF=BE=E5=BF=9C=E3=81=A8?= =?UTF-8?q?=E5=8F=AF=E8=A6=96=E5=8C=96=E3=83=BB=E3=83=89=E3=82=AD=E3=83=A5?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=20(PLAN39=20PR4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit スナップショットの対象を「共通 + アカウントグループ」の 2 本にし、いま自分が どのグループにいるのかを status と起動ログで分かるようにする。 - snapshot — 対象ボリュームを固定値から 2 本へ。1 つのアーカイブにまとめるため コンテナ内では /source/ai と /source/group に並べてマウントする。メタデータへ 対象ボリューム名を記録し、`devbase snapshot list` にも表示する - 旧スナップショット (volume: devbase_home_ubuntu のみ) は共通ボリュームを ルートへ直接マウントする旧レイアウトとして**そのまま復元できる** - 対象ボリュームの構成が変わったら新しい世代を作る。旧世代の snar は別の レイアウトを記録しており、そこへ差分を積むと全ファイルが移動したものとして 扱われて差分が壊れるため。明示的に古い世代を指定された場合は理由を出して止める - 復元時のクリアはマウントポイント自身ではなく**各マウントの直下**を消す (busy) - `devbase status` の [環境] にアカウントグループとボリューム名を出す。グループ名が 不正でも例外にせず表示に留める (status は状態を見るコマンドで、設定の誤りで 一覧全体を出せなくする必要はない) - entrypoint の起動ログにグループと gcloud のアカウントを 1 行出す。未ログインや gcloud 不在で `set -e` の起動を落とさないようフォールバックする - ドキュメント — ボリューム構造の表、アカウントグループの説明、2 層の永続化、 初回シード、スナップショットの対象と世代分割、README / CHANGELOG Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S5oA2PqY6UX2Ca3t78886t --- CHANGELOG.md | 63 +++++ README.md | 3 +- containers/base/entrypoint.sh | 19 ++ docs/plugin-dev/compose-yml-guidelines.md | 14 +- docs/plugin-dev/quickstart.md | 6 +- docs/user/container-operations.md | 109 +++++++- docs/user/environment-variables.md | 30 +++ docs/user/snapshot-guide.md | 51 +++- .../PLAN39_account-group-volume-separation.md | 25 +- lib/devbase/commands/snapshot.py | 9 +- lib/devbase/commands/status.py | 42 ++- lib/devbase/snapshot/manager.py | 131 ++++++++- tests/commands/test_status_account_group.py | 61 +++++ .../containers/test_entrypoint_startup_log.py | 93 +++++++ tests/snapshot/test_manager_volumes.py | 253 ++++++++++++++++++ 15 files changed, 860 insertions(+), 49 deletions(-) create mode 100644 tests/commands/test_status_account_group.py create mode 100644 tests/containers/test_entrypoint_startup_log.py create mode 100644 tests/snapshot/test_manager_volumes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 04eb13bf..5125c2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ ## [Unreleased] ### Added +- **永続化ボリュームをアカウントグループ単位に分離**しました (PLAN39 / #116)。 + これまで認証情報と会話ログは全コンテナ共通の `devbase_home_ubuntu` に置かれていたため、 + nyle.co.jp で認証した Claude Code / gcloud を kk-generation.com のプロジェクトが + そのまま引き継いでしまい、企業テナントの境界を越えていました。`DEVBASE_ACCOUNT_GROUP` + (未設定なら `default`) で使用する Google / AWS アカウントの単位を宣言すると、 + グループごとに `devbase_home_` が作られ `/persistent/group` としてマウントされます。 + + | 分類 | 置き場 | 内容 | + |---|---|---| + | 共通 | `/persistent/ai` (`devbase_home_ubuntu`) | `~/.claude/plugins` / `skills` / `commands` / `CLAUDE.md` / `settings.json`、`.codex` / `.serena` / `.kiro` / `.ssh` / `share` | + | グループ別 | `/persistent/group` (`devbase_home_`) | `.claude.json`、`~/.claude` 本体 (認証・会話ログ)、`.gemini`、gcloud / gws の設定ディレクトリ | + + `~/.claude/plugins` (238MB) のような共通資産はグループ数だけ重複しません。 + `default` グループでは初回起動時に既存データを**コピー**してシードするため、 + Claude Code の再ログインは発生しません (gcloud / gws はシード元が無いため + 全グループで初回 1 回の認証が要ります)。使えないグループ名 (Docker のボリューム名に + できないもの・`ubuntu`・数字だけ) は `devbase up` の前にエラーで弾きます。 + 詳細は [コンテナ操作ガイド](docs/user/container-operations.md#アカウントグループ) を参照してください。 + +- **gcloud / gws の設定ディレクトリをアカウントグループ単位に永続化**しました。 + `CLOUDSDK_CONFIG` / `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` を `/persistent/group` 配下へ + 向けることで、`gcloud auth login` / `gws auth login` のユーザー OAuth が + **コンテナを作り直しても保たれ**、かつグループをまたいで共有されなくなります。 + `CLOUDSDK_CONFIG` は gcloud CLI 専用ではなく `google.auth` の探索経路そのものなので、 + BigQuery クライアント等も同じ場所を見ます。あわせて `@googleworkspace/cli` (`gws`) を + base イメージへ追加しました (これまでどのコンテナにも入っておらず、設定だけ永続化しても + 復旧しませんでした)。 + +- **`GCP_AUTH_MODE` を新設**しました。`adc` でサービスアカウント鍵を使わず + `gcloud auth application-default login` によるユーザー認証 (ADC) を使い、`key` で + 従来どおり鍵を使います。未設定なら鍵の env の有無で自動判定するため、既存プロジェクトは + これまでどおり動きます。`adc` では `GOOGLE_APPLICATION_CREDENTIALS` と `BIGQUERY_KEY_FILE` を + **コンテナへ渡しません** (値だけ残して実体が無いと ADC はユーザー認証へフォールバックせず + `DefaultCredentialsError` で落ちるため)。 + + > **Warning:** `CLOUDSDK_CONFIG` の導入により、`~/.config/gcloud` は + > **gcloud の設定ディレクトリではなくなりました**。鍵モードで書き出される + > サービスアカウント鍵の置き場でしかなく、コンテナ層 (揮発) に残ります。設定を見たい + > ときは `$CLOUDSDK_CONFIG` を参照してください。 + +- **`devbase status` に解決されたアカウントグループ**を表示するようにしました。 + コンテナの起動ログにも、グループ名と gcloud のアカウントが 1 行出ます。 + + > **Note:** 上記のうち entrypoint と Dockerfile に関わる変更は、反映に + > `devbase build --no-cache` によるイメージの再ビルドとコンテナの作り直しが要ります。 + - **tmux の既定設定 (`/etc/tmux.conf`) を base イメージへ焼き込む**ようにしました。tmux は 起動時に端末の代替画面へ切り替わるため、出力履歴は VS Code のスクロールバックではなく tmux 自身のバッファに入ります。これまでコンテナの tmux は素の初期状態 (履歴 2000 行・ @@ -51,6 +97,23 @@ > 再ビルドしていないイメージでは、これまでどおり全フォルダを載せたワークスペースが > 書き出されます (機能が黙って失われることはありません)。 +- **スナップショットの対象が 2 ボリューム**になりました (共通 + アカウントグループ)。 + メタデータに対象ボリューム名を記録し、`devbase snapshot list` にも表示します。 + 分離前に作られた既存スナップショットは**そのまま復元できます**。対象ボリュームの構成が + 変わったときは、旧世代へ壊れた差分を積まないよう新しい世代を作ります (旧世代の差分状態 + ファイルは別のレイアウトを記録しているため、そこへ差分を積むと差分が壊れます)。 +- **`devbase env init` は鍵を登録したときだけ** `GOOGLE_APPLICATION_CREDENTIALS` / + `BIGQUERY_KEY_FILE` を書くようにしました (従来は鍵の有無に関係なく書いていました)。 + 実体の無いパスが `env` に残っていると ADC がユーザー認証へフォールバックできません。 + +### Fixed +- entrypoint の symlink 生成で、**入れ子パスの親ディレクトリが作られていなかった**不具合を + 直しました。`~/.claude/.credentials.json` は永続領域側の作成が + `No such file or directory` で落ちて壊れた symlink になり、`~/.claude/history.jsonl` は + ファイル判定が `*.json` グロブだったため `.jsonl` にマッチせず**ディレクトリとして** + 作られ、Claude Code が追記できませんでした。ファイルとして作るエントリは拡張子ではなく + 明示の一覧で判定するようにしています。 + - **`plugin.yml` の `requires.devbase` をインストール時に検証**するようにしました。要件を 満たさない Plugin は `devbase plugin install` が中止します。これまでは値を読むだけで 比較しておらず、`project.yml` 形式の Plugin を 2.x へ入れられてしまい、`devbase up` の diff --git a/README.md b/README.md index d0d486fc..82004333 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ devbaseは、Docker Composeを使った再現性の高い開発環境を提供 - **豊富なツールセット**: Docker CLI、AWS CLI、gcloud SDK、Terraform、Node.js、AI CLIツールがプリインストール - **複数コンテナの並行開発**: `devbase project scale`で既存コンテナを再起動せずにスケール可能 - **データ永続化**: 名前付きボリュームでコンテナ再起動後もデータを保持 -- **スナップショット管理**: 共通ボリューム `devbase_home_ubuntu`(コンテナ内 `/persistent/ai`。AI 設定・共有ファイル)の増分バックアップ・復元・世代管理 +- **アカウントグループ**: 認証情報と会話ログを `DEVBASE_ACCOUNT_GROUP` 単位のボリュームへ分離(共通資産は重複させない) +- **スナップショット管理**: 共通ボリューム `devbase_home_ubuntu`(`/persistent/ai`)とグループボリューム `devbase_home_`(`/persistent/group`)の増分バックアップ・復元・世代管理 - **環境変数の自動収集**: `devbase env init`でAWS/Git/GCP認証情報を対話的に設定 - **階層メニュー TUI**: `devbase list` のプロジェクト一覧(矢印キー移動・名前絞り込み対応)から起動・操作(up / down / login / ps / logs / scale / build / rebuild)を選択。画面最下部の常設メニュー(環境変数 / プラグイン / スナップショット / ステータス)へは ←→ キーで移動できます - **イメージ再ビルド**: `devbase build [name] --no-cache` でキャッシュ無効の完全再ビルド。`devbase rebuild [name]`(= `build --expires=7`)はイメージが既定 7 日より古いときのみ再ビルドします diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index a1b0ef8c..c112037b 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -496,6 +496,24 @@ devbase_setup_gcp_credentials() { export BIGQUERY_KEY_FILE="$bq_path" } +# 起動時に「どのグループで、どのアカウントとして動いているか」を 1 行出す。 +# +# entrypoint は `set -e` で動くため、未ログインで gcloud が非 0 を返しても起動を +# 落とさないようフォールバックする。gcloud を含まないイメージもあるので存在確認も行う。 +devbase_log_account_group() { + local group="${1:-default}" + local account + + if command -v gcloud >/dev/null 2>&1; then + account="$(gcloud config get account 2>/dev/null || echo unset)" + [ -n "$account" ] || account="unset" + else + account="gcloud not installed" + fi + + echo "Account group: ${group} (gcloud account: ${account}, CLOUDSDK_CONFIG: ${CLOUDSDK_CONFIG:-unset})" +} + # テストは関数定義だけを使う (source 時のみ有効な return で以降を読み飛ばす)。 if [ -n "${DEVBASE_ENTRYPOINT_LIB_ONLY:-}" ]; then return 0 2>/dev/null || exit 0 @@ -682,6 +700,7 @@ devbase_setup_ai_settings \ "/home/${USERNAME}" "$AI_PERSISTENT_DIR" "$GROUP_PERSISTENT_DIR" \ "$DEVBASE_ACCOUNT_GROUP" "$USERNAME" echo "AI agent settings symlinks setup completed" +devbase_log_account_group "$DEVBASE_ACCOUNT_GROUP" # ======================================== # Repository setup (PLAN32: 1 project = 複数リポジトリ) diff --git a/docs/plugin-dev/compose-yml-guidelines.md b/docs/plugin-dev/compose-yml-guidelines.md index cb885d3c..8a40d988 100644 --- a/docs/plugin-dev/compose-yml-guidelines.md +++ b/docs/plugin-dev/compose-yml-guidelines.md @@ -98,22 +98,26 @@ flowchart TB ### 3.1 標準ボリューム -devbaseでは2種類のボリュームパターンを使い分けます。 +devbaseでは3種類のボリュームパターンを使い分けます。 ```yaml volumes: - - devbase_home_ubuntu:/persistent/ai # 全コンテナ共有(AI設定) + - devbase_home_ubuntu:/persistent/ai # 全コンテナ共有(共通AI資産) + - devbase_home_default:/persistent/group # アカウントグループ単位(認証・履歴) - ${COMPOSE_PROJECT_NAME}_work_${CONTAINER_INDEX:-1}:/work # コンテナ専用 ``` | ボリューム | マウント先 | 共有範囲 | 用途 | |-----------|-----------|----------|------| -| `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナ | AI CLI 設定(`.claude` 等)、SSH鍵、共有ファイル置き場(`share`)。`~/.claude` 等は entrypoint が symlink | +| `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナ | 契約に紐づかない共通資産(`~/.claude/plugins` 等)、SSH鍵、共有ファイル置き場(`share`)。entrypoint が symlink | +| `devbase_home_` | `/persistent/group` | 同じアカウントグループのコンテナ | 認証情報と会話ログ(`~/.claude` 本体、`.gemini`、gcloud / gws の設定) | | `${COMPOSE_PROJECT_NAME}_work_${CONTAINER_INDEX:-1}` | `/work` | コンテナ専用 | ソースコード、ビルド成果物 | -> **Note:** マウント先は **`/persistent/ai`** です(旧 `/home/ubuntu` 直接マウントは廃止)。 +> **Note:** マウント先は **`/persistent/ai`** と **`/persistent/group`** です(旧 `/home/ubuntu` 直接マウントは廃止)。 > これらの標準ボリュームは compose.yml に明記しなくても devbase がスケール用 compose 生成時に -> 自動注入します。明記する場合も必ず `/persistent/ai` を使ってください。 +> 自動注入します。**グループボリュームの名前は `DEVBASE_ACCOUNT_GROUP` から devbase が決める**ので、 +> プロジェクト側で書く必要はありません(書いた場合も生成時に正しい名前へ差し替えられます)。 +> 明記する場合も必ず `/persistent/ai` / `/persistent/group` を使ってください。 ### 3.2 Docker Socketのマウント diff --git a/docs/plugin-dev/quickstart.md b/docs/plugin-dev/quickstart.md index 13c43574..175c4021 100644 --- a/docs/plugin-dev/quickstart.md +++ b/docs/plugin-dev/quickstart.md @@ -318,10 +318,12 @@ flowchart LR | 用途 | ボリューム名パターン | マウント先 | 共有範囲 | |------|---------------------|-----------|----------| -| AI 設定・共有ファイル | `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナ共有 | +| 共通 AI 資産・共有ファイル | `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナ共有 | +| 認証・会話ログ | `devbase_home_` | `/persistent/group` | 同じアカウントグループ | | 作業ディレクトリ | `${COMPOSE_PROJECT_NAME}_work_${CONTAINER_INDEX:-1}` | `/work` | コンテナ専用 | -- `devbase_home_ubuntu`(`/persistent/ai`)は AI CLI 設定・SSH 鍵・共有ファイルなど、コンテナ横断で共有したい設定の永続化に使用(`~/.claude` 等は entrypoint が symlink。旧 `/home/ubuntu` 直接マウントは廃止) +- `devbase_home_ubuntu`(`/persistent/ai`)は SSH 鍵・共有ファイル・`~/.claude/plugins` など、契約に紐づかずコンテナ横断で共有したい資産の永続化に使用(entrypoint が symlink。旧 `/home/ubuntu` 直接マウントは廃止) +- `devbase_home_`(`/persistent/group`)は認証情報と会話ログ。`` は `DEVBASE_ACCOUNT_GROUP`(未設定なら `default`)で決まり、devbase が生成 compose へ自動注入する - 作業ディレクトリボリュームはプロジェクトごと・コンテナインデックスごとに独立 ### 5.4 コンテナイメージの選択 diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index fe27ee9e..8303ccc3 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -120,28 +120,69 @@ graph LR subgraph devbase C[コンテナ 1
/work 専用] D[コンテナ 2
/work 専用] - E[共有AI設定
/persistent/ai] + E[共通AI設定
/persistent/ai] + F[グループ別の認証・履歴
/persistent/group] end A --> C B --> D C --> E D --> E + C --> F + D --> F ``` - 各コンテナは独立した `/work` ボリュームを持つ -- `/persistent/ai`(AI 設定・共有ファイル)は全コンテナで共有される(`/home/ubuntu` 直下のうち永続化されるのは symlink 対象のみ。下記「AI 設定の永続化」参照) +- `/persistent/ai`(共通の AI 資産・共有ファイル)は全コンテナで共有される +- `/persistent/group`(認証情報・会話履歴)は**同じアカウントグループのコンテナだけ**で共有される +- `/home/ubuntu` 直下のうち永続化されるのは symlink 対象のみ(下記「AI 設定の永続化」参照) - 異なるブランチでの並行作業に便利 ## ボリューム構造 -devbase のコンテナは 2 種類のボリュームを使用します。 +devbase のコンテナは 3 種類のボリュームを使用します。 | ボリューム名 | マウント先 | 共有範囲 | 用途 | |-------------|-----------|---------|------| -| `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナで共有 | AI CLI 設定(`.claude` / `.codex` / `.gemini` 等)、SSH 鍵、共有ファイル置き場(`share`)。詳細は「AI 設定の永続化」参照 | +| `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナで共有 | 契約やテナントに紐づかない共通資産(`~/.claude/plugins` / `skills` / `commands` / `CLAUDE.md` / `settings.json`、`.codex` / `.serena` / `.kiro`、SSH 鍵、共有ファイル置き場 `share`)| +| `devbase_home_{group}` | `/persistent/group` | 同じアカウントグループのコンテナで共有 | 企業テナントに紐づくもの(Claude Code の認証と会話ログ、`.gemini`、gcloud / gws の設定ディレクトリ)| | `devbase_work_{index}` | `/work` | 同じ index のコンテナで共有(プロジェクト間も共有) | プロジェクトのソースコード、作業ファイル | -> **Note:** `devbase_home_ubuntu` は **`/persistent/ai`** にマウントされます(`/home/ubuntu` への直接マウントは廃止)。`/home/ubuntu` 直下はコンテナ層(揮発)で、永続化されるのは entrypoint が `/persistent/ai` 配下へ symlink する設定ファイルのみです。シェル履歴など symlink 対象外のファイルは再生成で失われます。 +> **Note:** `devbase_home_ubuntu` は **`/persistent/ai`** にマウントされます(`/home/ubuntu` への直接マウントは廃止)。`/home/ubuntu` 直下はコンテナ層(揮発)で、永続化されるのは entrypoint が `/persistent/ai` / `/persistent/group` 配下へ symlink する設定ファイルのみです。シェル履歴など symlink 対象外のファイルは再生成で失われます。 + +### アカウントグループ + +`devbase_home_{group}` の `{group}` は `DEVBASE_ACCOUNT_GROUP` で宣言します。 +**使用する Google / AWS アカウントの単位**で、未設定なら `default` です。 + +```bash +# projects//env +DEVBASE_ACCOUNT_GROUP=kkg +``` + +これは「nyle.co.jp で認証した gcloud を kk-generation.com のプロジェクトが引き継がない」 +ようにするための仕切りです。同じグループのコンテナは認証を共有し、違うグループのコンテナは +互いの認証に到達できません。一方で `~/.claude/plugins`(238MB)のような共通資産は +`/persistent/ai` に置かれるため、グループを増やしても重複しません。 + +いま自分がどのグループにいるかは `devbase status` の `[環境]` セクションで確認できます。 + +``` +[環境] + devbase/.env 42変数 (最終更新: 2026-08-29) + アカウントグループ kkg (devbase_home_kkg / env) +``` + +末尾の `env` / `既定` は、値が `env` 由来か未設定によるフォールバックかを示します。 + +グループ名には次の 3 つが使えません。`devbase up` の前にエラーになります。 + +| 使えない名前 | 理由 | +|---|---| +| `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` に合わないもの | Docker のボリューム名にできない | +| `ubuntu` | 共通ボリューム `devbase_home_ubuntu` と同名になる | +| 数字だけの名前(`1` / `042`) | インスタンス番号のボリューム `devbase_home_` と同名になる | + +Google 認証の具体的な手順は [Google 認証ガイド](google-auth.md) を参照してください。 ### ボリュームの永続性 @@ -161,28 +202,68 @@ docker volume ls | grep devbase docker volume inspect devbase_home_ubuntu ``` -> **Warning:** `devbase_home_ubuntu` ボリューム(`/persistent/ai`、および symlink 経由でアクセスする `~/.claude` / `~/share` 等)は全プロジェクトで共有されます。ここにプロジェクト固有のファイルを置くと、他のプロジェクトにも影響します。プロジェクト固有のファイルは `/work` に配置してください。 +> **Warning:** `devbase_home_ubuntu` ボリューム(`/persistent/ai`、および symlink 経由でアクセスする `~/.claude/plugins` / `~/share` 等)は全プロジェクトで共有されます。ここにプロジェクト固有のファイルを置くと、他のプロジェクトにも影響します。プロジェクト固有のファイルは `/work` に配置してください。 ## AI 設定の永続化 AI CLI ツールの設定や認証情報は、コンテナを再生成しても保持されるよう -`devbase_home_ubuntu` ボリューム(`/persistent/ai`)に永続化されます。 +2 つのボリュームに永続化されます。 仕組みは **symlink** です。コンテナ起動時、entrypoint(`containers/base/entrypoint.sh`)が -以下の各エントリについて `/home/ubuntu/ -> /persistent/ai/` の symlink を作成します。 +以下の各エントリについて symlink を作成します。 + +**全コンテナ共通(`/persistent/ai`)** | エントリ | 内容 | |---------|------| -| `.claude.json` / `.claude` | Claude Code の設定・認証 | -| `.codex` | Codex CLI の設定 | -| `.gemini` | Gemini CLI の設定 | +| `.codex` | Codex CLI の設定(ChatGPT アカウントで分離済み)| | `.serena` | Serena MCP の設定 | -| `.kiro` | Kiro CLI の設定 | +| `.kiro` | Kiro CLI の設定(AWS 側で分離済み)| | `.ssh` | SSH 鍵 | -| `share` | 全コンテナ共有のファイル置き場(任意用途) | +| `share` | 全コンテナ共有のファイル置き場(任意用途)| +| `.claude/plugins` `.claude/skills` `.claude/commands` `.claude/CLAUDE.md` `.claude/settings.json` | Claude Code の共通資産 | + +**アカウントグループ単位(`/persistent/group`)** + +| エントリ | 内容 | +|---------|------| +| `.claude.json` | Claude Code の設定(`oauthAccount` を含む)| +| `.claude` | Claude Code の認証・会話ログ・セッション状態(上表の共通資産を除く**すべて**)| +| `.gemini` | Gemini CLI の設定(`vertex-ai` は GCP プロジェクトに紐づく)| + +`~/.claude` は `/persistent/group/.claude` への symlink で、**その配下の既定はグループ側**です。 +共通資産だけがその中から `/persistent/ai/.claude/` へ張り直されます。既定をグループ側に +倒しているのは、Claude Code が `projects` / `sessions` / `tasks` のようなディレクトリを随時作るため、 +永続化するものを列挙する方式だと**列挙漏れが黙って揮発する**からです。 + +```console +$ readlink -f ~/.claude # グループ側 +/persistent/group/.claude +$ readlink -f ~/.claude/plugins # 共通側(どのグループから見ても同じ実体) +/persistent/ai/.claude/plugins +``` - `/persistent/ai` は全コンテナ共通の `devbase_home_ubuntu` ボリュームなので、**どのコンテナからも同じ実体**を参照します(例: `~/share` は全コンテナで共有)。 -- symlink **対象外**のホーム配下ファイル(シェル履歴など)はコンテナ層に置かれ、再生成で失われます。永続化したいものは `/persistent/ai` 配下(= 上記 symlink 先)か `/work` に置いてください。 +- `/persistent/group` は `devbase_home_{group}` で、**同じアカウントグループのコンテナだけ**が同じ実体を参照します。 +- symlink **対象外**のホーム配下ファイル(シェル履歴など)はコンテナ層に置かれ、再生成で失われます。永続化したいものは `/persistent/ai` / `/persistent/group` 配下(= 上記 symlink 先)か `/work` に置いてください。 + +### 既存環境からの移行(初回シード) + +`default` グループでは、初回起動時に `/persistent/ai` にある分類 B のデータ +(`.claude.json` / 認証 / 会話ログ / `.gemini`)が `/persistent/group` へ**コピー**されます。 +そのため既存環境で Claude Code の再ログインは発生しません。 + +- コピーであって移動ではないので、切り戻すときは元データがそのまま残っています +- 実行されるのは**グループ側にまだ実体が無いときだけ**です(2 回目以降は何もしません) +- 実測で 1.3GB 程度あるため**初回だけ起動が伸びます** +- 非 `default` グループではシードしません(分離の意味が失われるため) +- `gcloud` / `gws` はシード元が存在しないため、`default` を含む**全グループで初回 1 回の認証**が必要です + +起動ログの 1 行で、どのグループとしてどのアカウントで動いているかを確認できます。 + +``` +Account group: kkg (gcloud account: someone@kk-generation.com, CLOUDSDK_CONFIG: /persistent/group/gcloud) +``` - `share` 配下に置いた VS Code ワークスペースファイルは `DEVBASE_WORKSPACE` で開けます(リポジトリ 1 件の構成のみ。[環境変数](environment-variables.md) 参照)。 > **Note:** symlink 対象は entrypoint にビルド時 `COPY` で焼き込まれます。エントリを増減した場合は diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 76a81472..3963f6d7 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -192,6 +192,36 @@ devbase project up ユーザー名のみで秘密情報ではありません。SSH 鍵やリモートログインの有効化はホスト側でユーザーが別途設定する前提です。`devbase env sync` 実行時には、未設定のキーのみ既定値で補完されます(既存値は上書きしません)。 +## アカウントグループ (`DEVBASE_ACCOUNT_GROUP`) + +**使用する Google / AWS アカウントの単位**を宣言します。`devbase env init` の収集対象では +なく、`$DEVBASE_ROOT/env` かプロジェクトの `env` に手書きする devbase 動作設定です。 + +| キー | 説明 | +|------|------| +| `DEVBASE_ACCOUNT_GROUP` | アカウントグループ名。未設定なら `default`。グループごとに `devbase_home_` ボリュームが作られ、コンテナへ `/persistent/group` としてマウントされる | + +```bash +# projects//env +DEVBASE_ACCOUNT_GROUP=kkg +``` + +同じグループのコンテナは Claude Code / gcloud / gws の認証と会話ログを共有し、 +違うグループのコンテナは互いの認証に到達できません。`~/.claude/plugins` のような +共通資産は別ボリューム (`/persistent/ai`) に残るため、グループを増やしても重複しません。 + +グループ名には次の 3 つが使えません(`devbase up` の前にエラーになります)。 + +| 使えない名前 | 理由 | +|---|---| +| `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` に合わないもの | Docker のボリューム名にできない | +| `ubuntu` | 共通ボリューム `devbase_home_ubuntu` と同名になる | +| 数字だけの名前(`1` / `042`)| インスタンス番号のボリューム `devbase_home_` と同名になる | + +解決結果は `devbase status` の `[環境]` セクションに出ます。ボリューム構造の全体は +[コンテナ運用ガイド](container-operations.md)、Google 認証の手順は +[Google 認証ガイド](google-auth.md) を参照してください。 + ## `devbase up` 後のエディタ自動オープン `devbase up` 完了後、dev コンテナへ接続した VS Code を自動で開けます(VS Code の「Attach to Running Container」を CLI から起動)。 diff --git a/docs/user/snapshot-guide.md b/docs/user/snapshot-guide.md index 4d3dfaed..85f026c4 100644 --- a/docs/user/snapshot-guide.md +++ b/docs/user/snapshot-guide.md @@ -1,6 +1,19 @@ # スナップショットガイド -devbase のスナップショット機能は、共通ボリューム `devbase_home_ubuntu`(コンテナ内では `/persistent/ai` にマウント。AI CLI 設定や共有ファイルを保持)を増分バックアップし、世代管理と復元を提供します。`/work` 配下のプロジェクト作業ファイルはバックアップ対象外なので、重要なファイルは Git に push するか別途バックアップを取ってください。 +devbase のスナップショット機能は、永続化ボリュームを増分バックアップし、世代管理と復元を提供します。 +`/work` 配下のプロジェクト作業ファイルはバックアップ対象外なので、重要なファイルは Git に push するか別途バックアップを取ってください。 + +対象は次の 2 本です。 + +| ボリューム | コンテナ内 | 内容 | +|---|---|---| +| `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナ共通の AI 資産・共有ファイル | +| `devbase_home_{group}` | `/persistent/group` | アカウントグループ単位の認証・会話ログ・gcloud / gws の設定 | + +`{group}` は実行時の `DEVBASE_ACCOUNT_GROUP` の解決結果です(未設定なら `default`)。 +プロジェクトディレクトリで実行すればそのプロジェクトのグループが、devbase ルートで実行すれば +グローバル `env` の値(無ければ `default`)が対象になります。詳細は +[コンテナ運用ガイド](container-operations.md) の「アカウントグループ」を参照してください。 ## 仕組み @@ -20,7 +33,7 @@ graph LR style D fill:#e8f4e8 ``` -- **フルバックアップ**: 共通ボリューム `devbase_home_ubuntu`(`/persistent/ai`)全体をアーカイブ +- **フルバックアップ**: 対象ボリューム 2 本の全体をアーカイブ(アーカイブ内では `ai/` と `group/` に分かれます) - **差分バックアップ**: 前回からの変更分のみをアーカイブ - **圧縮**: zstd `-1 -T0`(圧縮レベル 1、全 CPU コア使用)で高速圧縮 @@ -117,8 +130,8 @@ backups/ | ファイル | 内容 | |---------|------| -| `snapshot.yml` | 全世代のインデックス情報 | -| `meta.yml` | 世代ごとの作成日時、バックアップポイント数、サイズ等 | +| `snapshot.yml` | 全世代のインデックス情報(対象ボリューム名を含む)| +| `meta.yml` | 世代ごとの作成日時、バックアップポイント数、サイズ、**対象ボリューム**等 | | `full.tar.zst` | フルバックアップアーカイブ | | `incr-NNN.tar.zst` | 差分バックアップアーカイブ(NNN は連番) | @@ -164,10 +177,30 @@ devbase snapshot list 出力例: ``` -Name Points Size Created -20260218-080000 3 1.2 GB 2026-02-18 08:00:00 -20260220-103000 2 850 MB 2026-02-20 10:30:00 -before-upgrade 1 2.1 GB 2026-02-21 14:00:00 +名前 作成日時 差分数 サイズ 対象ボリューム +------------------------------------------------------------------------------------------ +20260218-080000 2026-02-18 08:00:00 3 1.2GB devbase_home_ubuntu +20260220-103000 2026-02-20 10:30:00 2 850.0MB devbase_home_ubuntu, devbase_home_default +before-upgrade 2026-02-21 14:00:00 1 2.1GB devbase_home_ubuntu, devbase_home_kkg +``` + +「対象ボリューム」が `devbase_home_ubuntu` だけの世代は、アカウントグループ分離より**前**に +作られた世代です。そのまま共通ボリュームへ復元できます。 + +### 対象ボリュームが変わったとき + +アカウントグループを切り替えたり、分離前の環境から更新したりすると、対象ボリュームの構成が +変わります。このとき devbase は**新しい世代を作ります**。旧世代の差分状態ファイル +(`snapshot.snar`)は別のレイアウトを記録しているため、そこへ差分を積むと全ファイルが +移動したものとして扱われ、差分が壊れるからです。世代を分けることで旧世代はそのまま復元できます。 + +構成の違う世代を明示的に指定して差分を作ろうとした場合は、理由を示して中断します。 + +```console +$ devbase snapshot create --name 20260218-080000 +スナップショット操作に失敗: スナップショット '20260218-080000' は別のボリューム構成 +(devbase_home_ubuntu) で作られています。現在の対象は devbase_home_ubuntu, +devbase_home_default です。新しい世代を作成してください (devbase snapshot create) ``` ### スナップショットからの復元 @@ -200,7 +233,7 @@ graph LR #### 復元の安全性 -復元を実行する前に、現在の共通ボリューム `devbase_home_ubuntu`(`/persistent/ai`)の状態が `pre-restore-` という名前で自動バックアップされます。 +復元を実行する前に、現在の対象ボリュームの状態が `pre-restore-` という名前で自動バックアップされます。 ```bash # 復元前に自動作成されるバックアップ diff --git a/issues/PLAN39_account-group-volume-separation.md b/issues/PLAN39_account-group-volume-separation.md index 1d545ce9..ba763636 100644 --- a/issues/PLAN39_account-group-volume-separation.md +++ b/issues/PLAN39_account-group-volume-separation.md @@ -153,6 +153,14 @@ issue #116 が `standard` 相当の Phase 分割で書かれていても、判 SQLite 自体は named volume 上で正常に動く(同じく実機で `create table` / `insert` を確認)ので `credentials.db` の置き場としては問題ない(並行実行は前提 13 の別件)。 +- 前提 21: **entrypoint の `export` / `unset` は `docker exec` のシェルに届かない。** + コンテナの環境変数はホスト側(生成 compose の `environment:` と `env_file`)が決めるもので、 + entrypoint が変更できるのは自分の子プロセス(`exec "$@"` で起動する PID 1 の子孫)だけである。 + 実機確認: `docker exec carmo-ai-dev-1 sh -c 'echo $GOOGLE_APPLICATION_CREDENTIALS'` は + entrypoint の外側の値をそのまま返す。したがって `CLOUDSDK_CONFIG` の設定も、 + `adc` モードでの 2 変数の除去も、**ホスト側で行う必要がある**(AC12 は `docker exec` で + 検証する条件なので、entrypoint だけでは満たせない)。 + - 前提 20: **`~/.claude` の子要素は 30 件あり、プランが分類表で名指ししているのは 7 件だけ** (実機 `carmo-ai-dev-1` で確認。`.credentials.json` / `.last-cleanup` / `.last-update-result.json` / `.ndf-retention-checked` / `.ndf-retention.lock` / @@ -303,6 +311,7 @@ issue #116 の「検討が必要な点」3 件は次のとおり決定した。 ## 修正対象 - `lib/devbase/env/keys.py` — `DEVBASE_ACCOUNT_GROUP` / `GCP_AUTH_MODE` の定義 +- `lib/devbase/env/gcp_auth.py`(新規) — 認証モードの解決と、鍵モード専用変数の除外(前提 21) - `lib/devbase/env/collectors/google.py` — `GOOGLE_APPLICATION_CREDENTIALS` / `BIGQUERY_KEY_FILE` を無条件に書かないようにする(前提 11) - `lib/devbase/volume/manager.py` — グループ名の解決・検証、グループボリュームの作成 - `lib/devbase/volume/compose.py` — `/persistent/group` のマウントとボリューム宣言、dev サービスへの env 受け渡し @@ -405,14 +414,16 @@ issue #116 は「Phase 1・2 を入れずに Phase 3 だけを適用すると問 - **変更内容:** `.config/gcloud` / `.config/gws` を symlink 対象にはせず、**設定ディレクトリごと グループボリュームへ向ける**(前提 8 / 12)。あわせて認証モードを切り替え可能にする。 - ```sh - export CLOUDSDK_CONFIG="/persistent/group/gcloud" - export GOOGLE_WORKSPACE_CLI_CONFIG_DIR="/persistent/group/gws" + ``` + CLOUDSDK_CONFIG=/persistent/group/gcloud + GOOGLE_WORKSPACE_CLI_CONFIG_DIR=/persistent/group/gws ``` - この 2 行だけで、`credentials.db` / `access_tokens.db` / `legacy_credentials/` / + この 2 つだけで、`credentials.db` / `access_tokens.db` / `legacy_credentials/` / `configurations/` / `application_default_credentials.json`(= ADC ファイル)と gws の `credentials.enc` / `.encryption_key` がグループボリュームへ移る。 + + **渡すのは entrypoint の `export` ではなくホスト側の生成 compose である**(前提 21)。 あわせて **`containers/base/Dockerfile:138` の npm グローバル行へ `@googleworkspace/cli` を足す**。 前提 17 のとおり gws はどのコンテナにも入っておらず、設定だけ永続化しても復旧しないため。 `@google/gemini-cli` / `@openai/codex` と同じ扱いにする(`bin` は `gws`)。ディレクトリは entrypoint が @@ -426,8 +437,12 @@ issue #116 は「Phase 1・2 を入れずに Phase 3 だけを適用すると問 | `key` | 現行どおり `GCP_CREDENTIALS_BASE64__` を復号して書き、2 変数を export する | | 未設定(auto) | 鍵の env があれば `key`、無ければ `adc` | - `adc` で 2 変数を **unset する**のが要点である。値だけ残して実体が無いと ADC は + `adc` で 2 変数を **コンテナへ渡さない**のが要点である。値だけ残して実体が無いと ADC は ユーザー認証へフォールバックせず `DefaultCredentialsError` で落ちる(前提 10)。 + 前提 21 のとおり entrypoint の `unset` は `docker exec` のシェルへ届かないため、 + **ホスト側で生成 compose の `environment:` の列挙から外す**。名前が載らなければ + Compose はその変数をコンテナへ渡さない。entrypoint 側の `unset` は、古いホストから + 起動された場合とプロジェクト `env` 直書きに対する保険として残す。 現状 `_collect_common_settings` は鍵の有無に関係なく 2 変数を書く(前提 11)ため、 `devbase env init` 側も鍵を登録したときだけ書くよう直す。 diff --git a/lib/devbase/commands/snapshot.py b/lib/devbase/commands/snapshot.py index 7db62960..d24f208c 100644 --- a/lib/devbase/commands/snapshot.py +++ b/lib/devbase/commands/snapshot.py @@ -62,14 +62,17 @@ def _snapshot_list(mgr) -> int: if not snapshots: print("スナップショットはありません") return 0 - print(f"{'名前':<24} {'作成日時':<24} {'差分数':>6} {'サイズ':>10}") - print("-" * 68) + print(f"{'名前':<24} {'作成日時':<24} {'差分数':>6} {'サイズ':>10} 対象ボリューム") + print("-" * 90) for s in snapshots: + # 対象ボリュームは PLAN39 以降に記録される。旧世代は共通ボリュームのみ。 + volumes = ', '.join((s.get('volumes') or {}).values()) or 'devbase_home_ubuntu' print( f"{s['name']:<24} " f"{s.get('created_at', 'N/A')[:19]:<24} " f"{s.get('incremental_count', 0):>6} " - f"{_format_size(s.get('size_bytes', 0)):>10}" + f"{_format_size(s.get('size_bytes', 0)):>10} " + f"{volumes}" ) return 0 diff --git a/lib/devbase/commands/status.py b/lib/devbase/commands/status.py index 65d104e3..fd647db7 100644 --- a/lib/devbase/commands/status.py +++ b/lib/devbase/commands/status.py @@ -1,5 +1,6 @@ """devbase status - 環境ステータスの一覧表示""" +import os import subprocess from datetime import datetime from pathlib import Path @@ -160,6 +161,34 @@ def _get_env_info(devbase_root: Path) -> dict | None: return None +def _get_account_group() -> dict | None: + """解決されたアカウントグループとボリューム名を返す (PLAN39)。 + + ``devbase status`` は devbase ルートで実行されることが多く、その場合 + ``DEVBASE_ACCOUNT_GROUP`` はグローバル ``env`` 由来の値 (無ければ ``default``) + になる。プロジェクトディレクトリで実行すればそのプロジェクトの解決結果になる。 + どちらの値を見ているのかが分かるよう、判定の出どころも返す。 + + グループ名が不正な場合はここで例外にせず ``None`` を返す。``status`` は + 状態を見るためのコマンドで、設定の誤りで一覧全体を出せなくする必要はない。 + """ + from devbase.errors import DevbaseError + from devbase.volume.manager import get_group_volume, resolve_account_group + + declared = os.environ.get("DEVBASE_ACCOUNT_GROUP") + try: + group = resolve_account_group() + volume = get_group_volume(group) + except DevbaseError as e: + return {"group": None, "volume": None, "error": str(e)} + return { + "group": group, + "volume": volume, + "source": "env" if (declared or "").strip() else "既定", + "error": None, + } + + def _get_snapshot_info(devbase_root: Path) -> dict | None: """スナップショットの概要情報を取得する""" try: @@ -213,14 +242,25 @@ def cmd_status(devbase_root: Path) -> int: # --- 環境セクション --- try: env_info = _get_env_info(devbase_root) - if env_info: + group_info = _get_account_group() + if env_info or group_info: print() print("[環境]") + if env_info: print( f" {'devbase/.env':<24}" f"{env_info['var_count']}変数 " f"(最終更新: {env_info['last_modified']})" ) + if group_info: + if group_info["error"]: + print(f" {'アカウントグループ':<20}(設定エラー) {group_info['error']}") + else: + print( + f" {'アカウントグループ':<20}" + f"{group_info['group']} " + f"({group_info['volume']} / {group_info['source']})" + ) except Exception: logger.debug("環境情報の取得に失敗しました", exc_info=True) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index aee46ffd..4585e571 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -11,10 +11,17 @@ from devbase.errors import SnapshotError from devbase.log import get_logger +from devbase.volume.manager import HOME_UBUNTU_VOLUME, get_group_volume logger = get_logger(__name__) -VOLUME_NAME = 'devbase_home_ubuntu' +# 後方互換のために残す旧定数 (共通ボリューム 1 本だった頃の対象) +VOLUME_NAME = HOME_UBUNTU_VOLUME +# 対象ボリュームのマウント先サブディレクトリ (PLAN39)。 +# 共通ボリュームとアカウントグループのボリュームを 1 つのアーカイブへまとめるため、 +# コンテナ内では /source/ に並べて置く。 +SHARED_MOUNT = 'ai' +GROUP_MOUNT = 'group' SNAPSHOT_IMAGE = 'devbase-snapshot:latest' DEFAULT_MAX_GENERATIONS = 3 DEFAULT_MAX_INCREMENTALS = 10 @@ -25,11 +32,36 @@ class SnapshotManager: """Docker volumeのスナップショット管理""" - def __init__(self, devbase_root: Path): + def __init__(self, devbase_root: Path, group: Optional[str] = None): + """ + Args: + devbase_root: devbase のルート + group: 対象のアカウントグループ (省略時は環境から解決) + """ self.devbase_root = devbase_root self.backups_dir = devbase_root / 'backups' self.backups_dir.mkdir(exist_ok=True) self._metadata_path = self.backups_dir / METADATA_FILE + self._group = group + self._volumes: Optional[dict] = None + + @property + def volumes(self) -> dict: + """作成時の対象ボリューム (初回参照時に解決する)。 + + 復元時は**スナップショット自身のメタデータ**を見るので、ここでの解決結果は + 使わない (別グループのスナップショットを取り違えないため)。 + + 解決はグループ名の検証を伴い、不正な名前なら ``DevbaseError`` になる。 + 一覧・コピー・削除のように対象ボリュームを必要としない操作まで倒さないよう、 + 参照されるまで遅延させる。 + """ + if self._volumes is None: + self._volumes = { + SHARED_MOUNT: HOME_UBUNTU_VOLUME, + GROUP_MOUNT: get_group_volume(self._group), + } + return self._volumes # ------------------------------------------------------------------ # Public API @@ -159,12 +191,17 @@ def restore(self, name: str, point: int | None = None) -> None: except Exception as e: logger.warning("復元前バックアップに失敗しましたが続行します: %s", e) + volumes = self.snapshot_volumes(snap_dir) + logger.info("復元先のボリューム: %s", ', '.join(volumes.values())) + # フルバックアップの復元 logger.info("フルバックアップを復元中...") self._run_docker_tar( snap_dir, 'restore', - "cd /target && find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null; " - "zstd -d /backup/full.tar.zst -c | tar --listed-incremental=/dev/null -xf -" + self.clear_command(volumes) + + "zstd -d /backup/full.tar.zst -c | " + "tar --listed-incremental=/dev/null -xf - -C /target", + volumes=volumes, ) # 差分バックアップを順番に適用(pointが指定されていればそこまで) @@ -180,7 +217,9 @@ def restore(self, name: str, point: int | None = None) -> None: logger.info("差分バックアップを適用中: %s", incr.name) self._run_docker_tar( snap_dir, 'restore', - f"cd /target && zstd -d /backup/{incr.name} -c | tar --listed-incremental=/dev/null -xf -" + f"zstd -d /backup/{incr.name} -c | " + f"tar --listed-incremental=/dev/null -xf - -C /target", + volumes=volumes, ) if point is not None: @@ -276,6 +315,20 @@ def should_start_new_generation( if not snapshots: return True latest = snapshots[-1] + + # 対象ボリュームの構成が変わったら新世代にする (PLAN39 の移行やグループ + # 切替)。旧世代の snar は別のレイアウトを記録しているので、そこへ差分を + # 積むと全ファイルが移動したものとして扱われ差分が壊れる。世代を分ければ + # 旧世代はそのまま復元できる。 + snap_dir = self.backups_dir / latest.get('name', '') + if snap_dir.is_dir() and self.snapshot_volumes(snap_dir) != self.volumes: + logger.info( + "対象ボリュームの構成が変わったため新しい世代を作成します " + "(旧: %s / 新: %s)", + ', '.join(self.snapshot_volumes(snap_dir).values()), + ', '.join(self.volumes.values())) + return True + return latest.get('incremental_count', 0) >= max_incrementals # ------------------------------------------------------------------ @@ -321,23 +374,54 @@ def _ensure_snapshot_image(self) -> str: logger.info("devbase-snapshotイメージのビルド完了") return SNAPSHOT_IMAGE - def _run_docker_tar(self, snap_dir: Path, mode: str, command: str) -> None: + @staticmethod + def volume_mount_args(volumes: dict, mode: str) -> list: + """対象ボリュームの ``docker run -v`` 引数を組み立てる。 + + サブディレクトリ名が空文字のエントリは、旧レイアウト (共通ボリューム 1 本を + ルートへ直接マウント) を表す。旧スナップショットを復元するために残している。 + """ + root = '/source' if mode == 'backup' else '/target' + suffix = ':ro' if mode == 'backup' else '' + args = [] + for sub, name in volumes.items(): + target = f'{root}/{sub}' if sub else root + args.extend(['-v', f'{name}:{target}{suffix}']) + return args + + @staticmethod + def clear_command(volumes: dict) -> str: + """復元前に対象ボリュームの中身を空にするコマンドを組み立てる。 + + マウントポイント自身は消せない (busy) ので、**各マウントの直下**を消す。 + 旧レイアウトも同じ形で扱える。 + """ + roots = ' '.join( + f'/target/{sub}' if sub else '/target' for sub in volumes) + return ( + 'for d in ' + roots + '; do ' + 'find "$d" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null; ' + 'done; ' + ) + + def _run_docker_tar(self, snap_dir: Path, mode: str, command: str, + volumes: Optional[dict] = None) -> None: """Docker経由でtar操作を実行する。 Args: snap_dir: スナップショットディレクトリ mode: 'backup' or 'restore' command: コンテナ内で実行するコマンド + volumes: 対象ボリューム (省略時は作成時の対象) """ image = self._ensure_snapshot_image() abs_snap_dir = snap_dir.resolve() - volume_mount = f'{VOLUME_NAME}:/source:ro' if mode == 'backup' else f'{VOLUME_NAME}:/target' backup_mount = f'{abs_snap_dir}:/backup:ro' if mode == 'restore' else f'{abs_snap_dir}:/backup' cmd = [ 'docker', 'run', '--rm', - '-v', volume_mount, + *self.volume_mount_args(volumes or self.volumes, mode), '-v', backup_mount, image, 'bash', '-c', command, @@ -366,7 +450,7 @@ def _create_full(self, name: str, snap_dir: Path) -> None: 'name': name, 'created_at': datetime.now().isoformat(), 'type': 'full', - 'volume': VOLUME_NAME, + 'volumes': dict(self.volumes), 'files': ['full.tar.zst'], 'incremental_count': 0, } @@ -374,6 +458,18 @@ def _create_full(self, name: str, snap_dir: Path) -> None: def _create_incremental(self, name: str, snap_dir: Path) -> None: """差分バックアップを作成""" + recorded = self.snapshot_volumes(snap_dir) + if recorded != self.volumes: + # 通常はここへ来ない (should_start_new_generation が新世代へ倒す)。 + # 明示的に古い世代を指定されたときだけ到達する。黙って壊れた差分を + # 積むより、理由を出して止める方がよい。 + raise SnapshotError( + f"スナップショット '{name}' は別のボリューム構成 " + f"({', '.join(recorded.values())}) で作られています。" + f"現在の対象は {', '.join(self.volumes.values())} です。" + "新しい世代を作成してください (devbase snapshot create)" + ) + snar_file = snap_dir / 'snapshot.snar' if not snar_file.exists(): # snarファイルがなければフルバックアップにフォールバック @@ -411,10 +507,13 @@ def _update_global_metadata(self, name: str, snap_dir: Path) -> None: # 既存エントリを探す found = False + volumes = snap_meta.get('volumes') or self.snapshot_volumes(snap_dir) + for snap in meta.get('snapshots', []): if snap['name'] == name: snap['updated_at'] = now snap['incremental_count'] = snap_meta.get('incremental_count', 0) + snap['volumes'] = dict(volumes) found = True break @@ -424,6 +523,7 @@ def _update_global_metadata(self, name: str, snap_dir: Path) -> None: 'created_at': now, 'updated_at': now, 'incremental_count': snap_meta.get('incremental_count', 0), + 'volumes': dict(volumes), }) self._save_metadata(meta) @@ -440,6 +540,19 @@ def _save_metadata(self, meta: dict) -> None: with open(self._metadata_path, 'w') as f: yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) + def snapshot_volumes(self, snap_dir: Path) -> dict: + """スナップショットの対象ボリュームを、そのメタデータから解決する。 + + 新しいメタデータは ``volumes`` (サブディレクトリ名 → ボリューム名) を持つ。 + 持たない旧スナップショットは共通ボリューム 1 本をルートへ直接マウントする + レイアウトなので、サブディレクトリ名を空文字にした 1 件として返す。 + """ + meta = self._load_snap_meta(snap_dir) + volumes = meta.get('volumes') + if isinstance(volumes, dict) and volumes: + return dict(volumes) + return {'': meta.get('volume', HOME_UBUNTU_VOLUME)} + def _load_snap_meta(self, snap_dir: Path) -> dict: """個別スナップショットのmeta.ymlを読み込む""" meta_path = snap_dir / 'meta.yml' diff --git a/tests/commands/test_status_account_group.py b/tests/commands/test_status_account_group.py new file mode 100644 index 00000000..7bb1c7eb --- /dev/null +++ b/tests/commands/test_status_account_group.py @@ -0,0 +1,61 @@ +"""``devbase status`` のアカウントグループ表示 (PLAN39 Task 7 / AC10)""" + +from __future__ import annotations + +import pytest + +from devbase.commands import status + + +@pytest.fixture(autouse=True) +def _clean_group_env(monkeypatch): + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + + +def test_default_group_is_reported_as_a_fallback(): + info = status._get_account_group() + + assert info["group"] == "default" + assert info["volume"] == "devbase_home_default" + assert info["source"] == "既定" + assert info["error"] is None + + +def test_declared_group_is_reported_as_env(monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + + info = status._get_account_group() + + assert info["group"] == "kkg" + assert info["volume"] == "devbase_home_kkg" + assert info["source"] == "env" + + +def test_invalid_group_is_reported_without_raising(monkeypatch): + """設定の誤りで status 全体を出せなくしない。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "ubuntu") + + info = status._get_account_group() + + assert info["group"] is None + assert "ubuntu" in info["error"] + + +def test_status_prints_the_account_group(tmp_path, capsys, monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + + status.cmd_status(tmp_path) + + out = capsys.readouterr().out + assert "アカウントグループ" in out + assert "kkg" in out + assert "devbase_home_kkg" in out + + +def test_status_prints_the_error_for_an_invalid_group(tmp_path, capsys, monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "1") + + status.cmd_status(tmp_path) + + out = capsys.readouterr().out + assert "設定エラー" in out diff --git a/tests/containers/test_entrypoint_startup_log.py b/tests/containers/test_entrypoint_startup_log.py new file mode 100644 index 00000000..36372507 --- /dev/null +++ b/tests/containers/test_entrypoint_startup_log.py @@ -0,0 +1,93 @@ +"""起動ログの 1 行出力 (PLAN39 Task 7 / AC10) + +「どのグループで、どのアカウントとして動いているか」を起動時に 1 行出す。 +entrypoint は ``set -e`` で動くため、未ログインや gcloud 不在で**起動が落ちない** +ことが要点になる。 +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + + +# gcloud を含まない最小の PATH。`/nonexistent` にすると bash 自体も見つからない。 +MINIMAL_PATH = "/usr/bin:/bin:/usr/sbin:/sbin" + + +def run(script: str, cwd: Path, path: str | None = None): + base = {k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_", "CLOUDSDK_"))} + if path is not None: + base["PATH"] = path + full = f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n{script}\n' + return subprocess.run(["bash", "-c", full], cwd=cwd, + env=base, capture_output=True, text=True) + + +@pytest.fixture +def fake_bin(tmp_path: Path): + """``gcloud`` を差し替えるための PATH を組み立てる。""" + d = tmp_path / "bin" + d.mkdir() + + def install(script: str): + path = d / "gcloud" + path.write_text(f"#!/bin/bash\n{script}\n") + path.chmod(0o755) + return f"{d}:{os.environ['PATH']}" + + return install + + +def test_group_and_account_are_reported(tmp_path, fake_bin): + path = fake_bin('echo "someone@example.com"') + + result = run('CLOUDSDK_CONFIG=/persistent/group/gcloud ' + 'devbase_log_account_group "kkg"', tmp_path, path) + + assert result.returncode == 0, result.stderr + assert "Account group: kkg" in result.stdout + assert "gcloud account: someone@example.com" in result.stdout + assert "CLOUDSDK_CONFIG: /persistent/group/gcloud" in result.stdout + + +def test_unauthenticated_gcloud_does_not_stop_startup(tmp_path, fake_bin): + """未ログインだと gcloud は非 0 を返す。set -e で起動を落とさない。""" + path = fake_bin('echo "ERROR: unset" >&2; exit 1') + + result = run('devbase_log_account_group "default"', tmp_path, path) + + assert result.returncode == 0, result.stderr + assert "gcloud account: unset" in result.stdout + + +def test_empty_account_is_reported_as_unset(tmp_path, fake_bin): + """`gcloud config get account` は未設定でも終了コード 0 で空を返すことがある。""" + path = fake_bin('exit 0') + + result = run('devbase_log_account_group "default"', tmp_path, path) + + assert result.returncode == 0, result.stderr + assert "gcloud account: unset" in result.stdout + + +def test_missing_gcloud_is_reported(tmp_path): + """gcloud を含まないイメージでも落ちない。""" + result = run('devbase_log_account_group "default"', tmp_path, path=MINIMAL_PATH) + + assert result.returncode == 0, result.stderr + assert "gcloud not installed" in result.stdout + + +def test_group_defaults_when_omitted(tmp_path): + result = run('devbase_log_account_group', tmp_path, path=MINIMAL_PATH) + + assert result.returncode == 0, result.stderr + assert "Account group: default" in result.stdout + assert "CLOUDSDK_CONFIG: unset" in result.stdout diff --git a/tests/snapshot/test_manager_volumes.py b/tests/snapshot/test_manager_volumes.py new file mode 100644 index 00000000..4150b902 --- /dev/null +++ b/tests/snapshot/test_manager_volumes.py @@ -0,0 +1,253 @@ +"""スナップショットの対象ボリューム (PLAN39 Task 6) + +対象が共通ボリューム 1 本から「共通 + アカウントグループ」の 2 本になる。 +Docker は起動せず、``_run_docker_tar`` を差し替えて **何をどこへマウントするか**と +**旧メタデータの互換**を固定する。 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from devbase.errors import SnapshotError +from devbase.snapshot.manager import SnapshotManager + + +@pytest.fixture(autouse=True) +def _clean_group_env(monkeypatch): + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + return tmp_path + + +class RecordingManager(SnapshotManager): + """``docker run`` を実行せず、渡された引数だけを記録する。""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.calls: list[dict] = [] + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + self.calls.append({ + "mode": mode, + "command": command, + "volumes": dict(volumes or self.volumes), + "mounts": self.volume_mount_args(volumes or self.volumes, mode), + }) + # フルバックアップの実体が無いと restore が止まるので、印だけ作る + if mode == "backup": + (snap_dir / "full.tar.zst").write_text("archive") + (snap_dir / "snapshot.snar").write_text("snar") + + +# --------------------------------------------------------------------------- +# 対象ボリューム (AC9) +# --------------------------------------------------------------------------- + +def test_both_volumes_are_targeted(root): + mgr = RecordingManager(root) + + assert mgr.volumes == { + "ai": "devbase_home_ubuntu", + "group": "devbase_home_default", + } + + +def test_group_volume_follows_the_account_group(root, monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + mgr = RecordingManager(root) + + assert mgr.volumes["group"] == "devbase_home_kkg" + + +def test_explicit_group_wins(root, monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + mgr = RecordingManager(root, group="with") + + assert mgr.volumes["group"] == "devbase_home_with" + + +def test_backup_mounts_both_volumes_read_only(root): + mgr = RecordingManager(root) + + mgr.create(name="snap1") + + mounts = mgr.calls[0]["mounts"] + assert mounts == [ + "-v", "devbase_home_ubuntu:/source/ai:ro", + "-v", "devbase_home_default:/source/group:ro", + ] + + +def test_restore_mounts_both_volumes_writable(root): + mgr = RecordingManager(root) + mgr.create(name="snap1") + mgr.calls.clear() + + mgr.restore("snap1") + + restore_calls = [c for c in mgr.calls if c["mode"] == "restore"] + assert restore_calls[0]["mounts"] == [ + "-v", "devbase_home_ubuntu:/target/ai", + "-v", "devbase_home_default:/target/group", + ] + + +def test_restore_clears_each_mount_not_the_mount_points(root): + """マウントポイント自身は消せない (busy)。各マウントの直下を消す。""" + mgr = RecordingManager(root) + mgr.create(name="snap1") + mgr.calls.clear() + + mgr.restore("snap1") + + command = [c for c in mgr.calls if c["mode"] == "restore"][0]["command"] + assert "for d in /target/ai /target/group;" in command + assert "-C /target" in command + + +# --------------------------------------------------------------------------- +# メタデータ +# --------------------------------------------------------------------------- + +def test_metadata_records_the_target_volumes(root): + mgr = RecordingManager(root) + + mgr.create(name="snap1") + + meta = yaml.safe_load((root / "backups" / "snap1" / "meta.yml").read_text()) + assert meta["volumes"] == { + "ai": "devbase_home_ubuntu", + "group": "devbase_home_default", + } + + +def test_global_metadata_records_the_target_volumes(root): + mgr = RecordingManager(root) + + mgr.create(name="snap1") + + meta = yaml.safe_load((root / "backups" / "snapshot.yml").read_text()) + assert meta["snapshots"][0]["volumes"]["group"] == "devbase_home_default" + + +# --------------------------------------------------------------------------- +# 旧スナップショットの互換 (AC9) +# --------------------------------------------------------------------------- + +def _write_legacy_snapshot(root: Path, name: str = "old") -> Path: + """PLAN39 以前のスナップショット (共通ボリューム 1 本) を作る。""" + snap_dir = root / "backups" / name + snap_dir.mkdir(parents=True) + (snap_dir / "full.tar.zst").write_text("archive") + (snap_dir / "snapshot.snar").write_text("snar") + (snap_dir / "meta.yml").write_text(yaml.safe_dump({ + "name": name, + "type": "full", + "volume": "devbase_home_ubuntu", + "files": ["full.tar.zst"], + "incremental_count": 0, + })) + (root / "backups" / "snapshot.yml").write_text(yaml.safe_dump({ + "max_generations": 3, + "snapshots": [{"name": name, "created_at": "2026-01-01T00:00:00", + "updated_at": "2026-01-01T00:00:00", + "incremental_count": 0}], + })) + return snap_dir + + +def test_legacy_snapshot_layout_is_recognised(root): + snap_dir = _write_legacy_snapshot(root) + mgr = RecordingManager(root) + + assert mgr.snapshot_volumes(snap_dir) == {"": "devbase_home_ubuntu"} + + +def test_legacy_snapshot_restores_into_the_shared_volume(root): + """旧世代は共通ボリュームをルートへ直接マウントして復元する。""" + _write_legacy_snapshot(root) + mgr = RecordingManager(root) + + mgr.restore("old") + + restore_calls = [c for c in mgr.calls if c["mode"] == "restore"] + assert restore_calls[0]["mounts"] == ["-v", "devbase_home_ubuntu:/target"] + assert "for d in /target;" in restore_calls[0]["command"] + + +def test_snapshot_without_metadata_falls_back_to_the_shared_volume(root): + """meta.yml が壊れている / 無い世代でも復元先を見失わない。""" + snap_dir = root / "backups" / "broken" + snap_dir.mkdir(parents=True) + mgr = RecordingManager(root) + + assert mgr.snapshot_volumes(snap_dir) == {"": "devbase_home_ubuntu"} + + +# --------------------------------------------------------------------------- +# レイアウト変更時の世代分割 +# --------------------------------------------------------------------------- + +def test_layout_change_starts_a_new_generation(root): + """旧世代へ差分を積むと snar のレイアウトが違うため差分が壊れる。""" + _write_legacy_snapshot(root) + mgr = RecordingManager(root) + + assert mgr.should_start_new_generation() is True + + +def test_group_change_starts_a_new_generation(root, monkeypatch): + mgr = RecordingManager(root) + mgr.create(name="snap1") + + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + other = RecordingManager(root) + + assert other.should_start_new_generation() is True + + +def test_same_layout_keeps_appending_increments(root): + mgr = RecordingManager(root) + mgr.create(name="snap1") + + assert mgr.should_start_new_generation() is False + + +def test_incremental_on_a_different_layout_is_refused(root): + """明示的に古い世代を指定されたときは、壊れた差分を積まず理由を出す。""" + _write_legacy_snapshot(root) + mgr = RecordingManager(root) + + with pytest.raises(SnapshotError) as excinfo: + mgr.create(name="old", full=False) + + message = str(excinfo.value) + assert "devbase_home_ubuntu" in message + assert "devbase_home_default" in message + + +def test_invalid_group_does_not_break_read_only_operations(root, monkeypatch): + """一覧のように対象ボリュームを要さない操作は、グループ名が不正でも通る。""" + _write_legacy_snapshot(root) + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "ubuntu") + + mgr = SnapshotManager(root) + + assert [s["name"] for s in mgr.list()] == ["old"] + + +def test_invalid_group_is_rejected_when_volumes_are_needed(root, monkeypatch): + from devbase.errors import DevbaseError + + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "ubuntu") + mgr = SnapshotManager(root) + + with pytest.raises(DevbaseError): + _ = mgr.volumes