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..53838122 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 日より古いときのみ再ビルドします @@ -148,6 +149,7 @@ devbaseのコマンドは4つのグループにまとめられています。 | [環境変数ガイド](docs/user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | | [環境変数の export/import ガイド](docs/user/env-export-import.md) | バンドル形式・age 暗号化・S3 連携・merge/replace の運用 | | [コンテナ操作ガイド](docs/user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | +| [Google 認証ガイド](docs/user/google-auth.md) | アカウントグループごとの gcloud / gws 認証、`GCP_AUTH_MODE` | | [スナップショットガイド](docs/user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 | | [トラブルシューティング](docs/user/troubleshooting.md) | カテゴリ別の問題と解決策 | | [Orca 削除の移行ガイド](docs/user/orca-removal-migration.md) | 旧 Orca(SSH) 接続の廃止と Remote-SSH への移行手順 | diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index 95a0f25b..bb51759d 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -135,7 +135,7 @@ RUN set -eux; \ curl -LsSf https://astral.sh/uv/install.sh | sh; \ # npm グローバルパッケージ(umask 0002 で npm グループにも書き込み権を残す) umask 0002; \ - npm i -g yarn @playwright/test aws-cdk aws-cdk-lib typescript @google/gemini-cli @openai/codex; \ + npm i -g yarn @playwright/test aws-cdk aws-cdk-lib typescript @google/gemini-cli @openai/codex @googleworkspace/cli; \ # root が作ったファイルも ubuntu が上書きできるように揃える。 # install レイヤーと同じ RUN なのでレイヤーの複製は発生しない。 chown -R "$USERNAME":npm "$NPM_CONFIG_PREFIX"; \ diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 442b13ef..c112037b 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -178,6 +178,342 @@ devbase_enter_primary_dir() { fi } +# =================================================================== +# PLAN39: AI 設定の永続化 (共通 / アカウントグループの 2 層) +# =================================================================== +# /persistent/ai … 全コンテナ共通 (分類 A)。plugins / skills のように +# 契約やテナントに紐づかない資産。グループ数だけ重複させない +# /persistent/group … アカウントグループ単位 (分類 B)。認証情報と会話履歴のように +# 企業テナントへ紐づくもの。グループをまたいで共有しない +# +# ~/.claude の既定は**グループ側**にする。Claude Code は projects / sessions / +# tasks のようなディレクトリを随時作るため、永続化するエントリを列挙する方式だと +# 列挙漏れが黙って揮発する。既定をグループ側に倒し、共通にしたいものだけを +# 名指しで共通側へ張る。 + +# 分類 A: ホーム直下 +DEVBASE_SHARED_SETTINGS=( + ".codex" + ".serena" + ".ssh" + ".kiro" + "share" +) + +# 分類 B: ホーム直下 +DEVBASE_GROUP_SETTINGS=( + ".claude.json" + ".claude" + ".gemini" +) + +# 分類 A のうち ~/.claude 配下にあるもの (グループ側の .claude から共通側へ張る) +DEVBASE_SHARED_CLAUDE_SETTINGS=( + "plugins" + "skills" + "commands" + "CLAUDE.md" + "settings.json" +) + +# ファイルとして作るエントリ (末尾の要素名で判定する)。ここに無いものは +# ディレクトリとして作る。 +# +# 拡張子で判定していた頃は `.jsonl` が `*.json` にマッチせず、history.jsonl が +# **ディレクトリとして**作られて Claude Code が追記できなくなっていた。 +# 新しいファイルのエントリを足すときはこの一覧にも足すこと。 +DEVBASE_FILE_ENTRIES=( + ".claude.json" + ".credentials.json" + "history.jsonl" + "CLAUDE.md" + "settings.json" +) + +# パスの末尾要素がファイルとして作るエントリか判定する。 +devbase_is_file_entry() { + local name="${1##*/}" entry + for entry in "${DEVBASE_FILE_ENTRIES[@]}"; do + [ "$name" = "$entry" ] && return 0 + done + return 1 +} + +# 永続領域のルートを用意する。 +# +# 空の named volume は **root 所有**で作られ uid 1000 では書き込めないため、 +# 書けなければ chown する。テストのように最初から書ける場所では sudo を呼ばない。 +devbase_ensure_persistent_root() { + local root="$1" owner="${2:-${USERNAME:-ubuntu}}" + + if [ ! -d "$root" ]; then + mkdir -p "$root" 2>/dev/null || sudo mkdir -p "$root" + fi + if [ ! -w "$root" ]; then + sudo chown "${owner}:${owner}" "$root" + fi +} + +# 実体が無ければプレースホルダを作る (親ディレクトリごと)。 +devbase_ensure_entry() { + local path="$1" + + mkdir -p "$(dirname "$path")" + if [ -e "$path" ]; then + return 0 + fi + if devbase_is_file_entry "$path"; then + : > "$path" + else + mkdir -p "$path" + fi +} + +# を への symlink にする。 +# +# **link 側と実体側の双方**で親ディレクトリを作るのが要点。入れ子パス +# (.claude/plugins) ではどちらの親も無いことがあり、以前は実体側の作成が +# `No such file or directory` で落ちて壊れた symlink が残っていた。 +# +# 既存の実体は `rm -rf` してから張り直す。symlink に対する `rm -rf` は +# **リンクだけ**を消すので、共通側の実体は巻き添えにならない。 +devbase_link_setting() { + local link_path="$1" target_path="$2" owner="${3:-${USERNAME:-ubuntu}}" + + devbase_ensure_entry "$target_path" + + if [ -L "$link_path" ] && [ "$(readlink "$link_path")" = "$target_path" ]; then + echo " ✓ ${link_path} (symlink exists)" + return 0 + fi + + mkdir -p "$(dirname "$link_path")" + if [ -e "$link_path" ] || [ -L "$link_path" ]; then + echo " Removing existing ${link_path}..." + rm -rf "$link_path" + fi + + echo " Creating symlink: ${link_path} -> ${target_path}" + ln -s "$target_path" "$link_path" + chown -h "${owner}:${owner}" "$link_path" 2>/dev/null || true +} + +# シード元から 1 エントリを**コピー**する (既にあれば何もしない)。 +# +# 第 3 引数以降は「コピーしない直下の名前」。分類 A の共通資産をグループ側へ +# 複製しないために使う。 +devbase_seed_entry() { + local src="$1" dest="$2" + shift 2 + + if [ -e "$dest" ]; then + return 0 + fi + if [ ! -e "$src" ]; then + echo " skip (シード元なし): $src" + return 0 + fi + + mkdir -p "$(dirname "$dest")" + if [ ! -d "$src" ]; then + cp -a "$src" "$dest" + echo " seeded: $dest" + return 0 + fi + + mkdir -p "$dest" + local child name excluded skip + # `.[!.]*` と `..?*` で隠しファイルも拾う (`.credentials.json` 等)。 + for child in "$src"/* "$src"/.[!.]* "$src"/..?*; do + [ -e "$child" ] || [ -L "$child" ] || continue + name="${child##*/}" + skip=0 + for excluded in "$@"; do + if [ "$name" = "$excluded" ]; then + skip=1 + break + fi + done + [ "$skip" = "1" ] && continue + cp -a "$child" "$dest/$name" + done + echo " seeded: $dest" +} + +# default グループの初回シード。 +# +# 現行 /persistent/ai に実体がある分類 B のデータ (.claude.json / 認証 / 履歴 / +# .gemini) をグループ側へ **コピー** して初期化する。move ではないので切り戻し時に +# 元データが残る。非 default では走らせない — 走らせるとグループ分離の意味が +# 失われる。gcloud / gws はシード元が存在しないため対象外 (AC8)。 +devbase_seed_group_settings() { + local ai_root="$1" group_root="$2" group="$3" + local entry + + if [ "$group" != "default" ]; then + return 0 + fi + + echo "Seeding account group '${group}' from ${ai_root} (first run only)..." + for entry in "${DEVBASE_GROUP_SETTINGS[@]}"; do + if [ "$entry" = ".claude" ]; then + devbase_seed_entry "$ai_root/$entry" "$group_root/$entry" \ + "${DEVBASE_SHARED_CLAUDE_SETTINGS[@]}" + else + devbase_seed_entry "$ai_root/$entry" "$group_root/$entry" + fi + done +} + +# イメージが焼き込んだ ~/.claude の初期設定を共通側へ退避する。 +# +# Dockerfile は ~/.claude/settings.json に hooks 設定を書き込むが、この直後の +# symlink 張り替えは ~/.claude を `rm -rf` するため、拾わないと初回起動で失われる +# (共通側には空のプレースホルダだけが残る)。実体が入っているのは初回だけなので、 +# ~/.claude が既に symlink なら 2 回目以降の起動と見なして何もしない。 +devbase_seed_image_claude_settings() { + local home_root="$1" ai_root="$2" + local entry + + if [ -L "$home_root/.claude" ] || [ ! -d "$home_root/.claude" ]; then + return 0 + fi + + for entry in "${DEVBASE_SHARED_CLAUDE_SETTINGS[@]}"; do + [ -e "$home_root/.claude/$entry" ] || continue + devbase_seed_entry "$home_root/.claude/$entry" "$ai_root/.claude/$entry" + done +} + +# AI 設定の symlink を 2 系統ぶん張る (初回シードを含む)。 +devbase_setup_ai_settings() { + local home_root="$1" ai_root="$2" group_root="$3" group="${4:-default}" + local owner="${5:-${USERNAME:-ubuntu}}" + local entry + + devbase_ensure_persistent_root "$ai_root" "$owner" + devbase_ensure_persistent_root "$group_root" "$owner" + + # symlink を張る**前**にシードする。張ったあとに走らせると、共通側を指す + # symlink の中身へコピーしてしまう。 + devbase_seed_image_claude_settings "$home_root" "$ai_root" + devbase_seed_group_settings "$ai_root" "$group_root" "$group" + + for entry in "${DEVBASE_SHARED_SETTINGS[@]}"; do + devbase_link_setting "$home_root/$entry" "$ai_root/$entry" "$owner" + done + for entry in "${DEVBASE_GROUP_SETTINGS[@]}"; do + devbase_link_setting "$home_root/$entry" "$group_root/$entry" "$owner" + done + for entry in "${DEVBASE_SHARED_CLAUDE_SETTINGS[@]}"; do + devbase_link_setting "$group_root/.claude/$entry" \ + "$ai_root/.claude/$entry" "$owner" + done +} + +# =================================================================== +# PLAN39: GCP の認証モードと gcloud / gws の設定ディレクトリ +# =================================================================== +# 設定ディレクトリはグループボリューム配下 (CLOUDSDK_CONFIG / +# GOOGLE_WORKSPACE_CLI_CONFIG_DIR) をホストから渡される。これにより +# credentials.db / access_tokens.db / application_default_credentials.json と +# gws の credentials.enc / .encryption_key がグループ単位に分かれる。 +# +# 変数そのものはホスト側 (生成 compose) が渡す。entrypoint の export は PID 1 の +# 子プロセスにしか効かず、docker exec のシェルには届かないため。ここでは +# **ディレクトリの用意**だけを行う。 + +# gcloud / gws の設定ディレクトリを用意する (空の named volume は root 所有)。 +devbase_setup_cloud_config_dirs() { + local owner="${1:-${USERNAME:-ubuntu}}" + local dir + + for dir in "${CLOUDSDK_CONFIG:-}" "${GOOGLE_WORKSPACE_CLI_CONFIG_DIR:-}"; do + [ -n "$dir" ] || continue + devbase_ensure_persistent_root "$dir" "$owner" + done +} + +# サービスアカウント鍵を env から書き出す (鍵モードのみ)。 +# +# `adc` では鍵を書かず、GOOGLE_APPLICATION_CREDENTIALS / BIGQUERY_KEY_FILE を +# unset する。値だけ残して実体が無いと ADC はユーザー認証へフォールバックせず +# DefaultCredentialsError で落ちる。ただし **unset が効くのは PID 1 の子孫だけ**で、 +# docker exec のシェルから消すのはホスト側の役目 (lib/devbase/env/gcp_auth.py)。 +# ここでの unset は、ホストが古い場合や env ファイル直書きに対する保険である。 +# +# 鍵の出力先 ~/.config/gcloud は CLOUDSDK_CONFIG を向け直した後は +# **gcloud の設定ディレクトリではなく単なる鍵の置き場**であり、コンテナ層に残る。 +# したがって鍵は毎起動 env から書き直され、永続領域には残らない。 +devbase_setup_gcp_credentials() { + local home_root="${1:-/home/${USERNAME:-ubuntu}}" + local mode="${GCP_AUTH_MODE:-}" + local profile="${GCP_ACTIVE_PROFILE:-default}" + local var="GCP_CREDENTIALS_BASE64__${profile}" + local creds_b64="${!var:-${GOOGLE_APPLICATION_CREDENTIALS_BASE64:-}}" + + # 未設定・未知の値は auto 判定 (鍵の env があれば key、無ければ adc) + if [ "$mode" != "key" ] && [ "$mode" != "adc" ]; then + if [ -n "$creds_b64" ]; then + mode="key" + else + mode="adc" + fi + fi + + if [ "$mode" = "key" ] && [ -z "$creds_b64" ]; then + echo "Warning: GCP_AUTH_MODE=key ですが ${var} が設定されていません。ADC へ切り替えます" + mode="adc" + fi + + if [ "$mode" = "adc" ]; then + unset GOOGLE_APPLICATION_CREDENTIALS + unset BIGQUERY_KEY_FILE + echo "GCP auth mode: adc (サービスアカウント鍵は書き出しません)" + return 0 + fi + + echo "GCP auth mode: key (profile: ${profile})" + local default_creds_path="${home_root}/.config/gcloud/credentials.json" + local creds_content gac_path bq_path + + creds_content=$(printf '%s' "$creds_b64" | base64 -d) + + gac_path="${GOOGLE_APPLICATION_CREDENTIALS:-$default_creds_path}" + mkdir -p "$(dirname "$gac_path")" + printf '%s' "$creds_content" > "$gac_path" + chmod 600 "$gac_path" + export GOOGLE_APPLICATION_CREDENTIALS="$gac_path" + echo "Google Cloud credentials saved to: $gac_path" + + bq_path="${BIGQUERY_KEY_FILE:-$default_creds_path}" + if [ "$bq_path" != "$gac_path" ]; then + mkdir -p "$(dirname "$bq_path")" + printf '%s' "$creds_content" > "$bq_path" + chmod 600 "$bq_path" + echo "BigQuery key file saved to: $bq_path" + fi + 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 @@ -186,40 +522,11 @@ fi # Setup authentication credentials from environment variables USERNAME="${USERNAME:-ubuntu}" -# 1. Setup Google Cloud credentials from base64 encoded environment variable -# New format: GCP_CREDENTIALS_BASE64__{profile} with GCP_ACTIVE_PROFILE -# Legacy format: GOOGLE_APPLICATION_CREDENTIALS_BASE64 -_GCP_PROFILE="${GCP_ACTIVE_PROFILE:-default}" -_GCP_VAR="GCP_CREDENTIALS_BASE64__${_GCP_PROFILE}" -_GCP_CREDS_B64="${!_GCP_VAR:-$GOOGLE_APPLICATION_CREDENTIALS_BASE64}" - -if [ -n "$_GCP_CREDS_B64" ]; then - echo "Setting up Google Cloud credentials (profile: ${_GCP_PROFILE})..." - DEFAULT_CREDS_PATH="/home/${USERNAME}/.config/gcloud/credentials.json" - - # Decode base64 content once - CREDS_CONTENT=$(printf '%s' "$_GCP_CREDS_B64" | base64 -d) - - # Output to GOOGLE_APPLICATION_CREDENTIALS path - GAC_PATH="${GOOGLE_APPLICATION_CREDENTIALS:-$DEFAULT_CREDS_PATH}" - GAC_DIR=$(dirname "$GAC_PATH") - mkdir -p "$GAC_DIR" - printf '%s' "$CREDS_CONTENT" > "$GAC_PATH" - chmod 600 "$GAC_PATH" - export GOOGLE_APPLICATION_CREDENTIALS="$GAC_PATH" - echo "Google Cloud credentials saved to: $GAC_PATH" - - # Output to BIGQUERY_KEY_FILE path if different - BQ_PATH="${BIGQUERY_KEY_FILE:-$DEFAULT_CREDS_PATH}" - if [ "$BQ_PATH" != "$GAC_PATH" ]; then - BQ_DIR=$(dirname "$BQ_PATH") - mkdir -p "$BQ_DIR" - printf '%s' "$CREDS_CONTENT" > "$BQ_PATH" - chmod 600 "$BQ_PATH" - echo "BigQuery key file saved to: $BQ_PATH" - fi - export BIGQUERY_KEY_FILE="$BQ_PATH" -fi +# 1. Setup Google Cloud credentials / auth mode (PLAN39) +# 設定ディレクトリ (CLOUDSDK_CONFIG / GOOGLE_WORKSPACE_CLI_CONFIG_DIR) と +# 解決済みの GCP_AUTH_MODE はホスト側 (生成 compose) が渡す。 +devbase_setup_cloud_config_dirs "$USERNAME" +devbase_setup_gcp_credentials "/home/${USERNAME}" # 2. Setup Git configuration if [ -n "$GIT_USER_NAME" ]; then @@ -380,66 +687,20 @@ if [ "$ENABLE_DIND" = "true" ] || [ "$ENABLE_DIND" = "1" ]; then fi # ======================================== -# AI Agent Settings Symlink Setup +# AI Agent Settings Symlink Setup (PLAN39: 共通 / グループの 2 層) # ======================================== -echo "Setting up AI agent settings symlinks..." - +# DEVBASE_ACCOUNT_GROUP はホスト (devbase up) が解決して渡す。ホスト側で +# 検証済みなので、ここでは未設定時に default へ落とすだけにする。 +DEVBASE_ACCOUNT_GROUP="${DEVBASE_ACCOUNT_GROUP:-default}" AI_PERSISTENT_DIR="/persistent/ai" -AI_SETTINGS=( - ".claude.json" - ".claude" - ".codex" - ".gemini" - ".serena" - ".ssh" - ".kiro" - "share" -) - -# Ensure /persistent/ai directory exists -if [ ! -d "$AI_PERSISTENT_DIR" ]; then - echo "Creating $AI_PERSISTENT_DIR directory..." - sudo mkdir -p "$AI_PERSISTENT_DIR" - sudo chown "${USERNAME}:${USERNAME}" "$AI_PERSISTENT_DIR" -fi - -# Create symlinks for each AI setting -for setting in "${AI_SETTINGS[@]}"; do - HOME_PATH="/home/${USERNAME}/${setting}" - PERSISTENT_PATH="${AI_PERSISTENT_DIR}/${setting}" - - # Skip if symlink already exists and points to correct location - if [ -L "$HOME_PATH" ] && [ "$(readlink -f "$HOME_PATH")" = "$PERSISTENT_PATH" ]; then - echo " ✓ ${setting} (symlink exists)" - continue - fi - - # Remove existing file/directory/broken symlink in home - if [ -e "$HOME_PATH" ] || [ -L "$HOME_PATH" ]; then - echo " Removing existing ${setting} from home..." - rm -rf "$HOME_PATH" - fi - - # If setting doesn't exist in persistent storage, create placeholder - if [ ! -e "$PERSISTENT_PATH" ]; then - # Determine if it's a file or directory based on extension - if [[ "$setting" == *.json ]]; then - echo " Creating empty file: ${setting}" - sudo touch "$PERSISTENT_PATH" - else - echo " Creating empty directory: ${setting}" - sudo mkdir -p "$PERSISTENT_PATH" - fi - sudo chown -R "${USERNAME}:${USERNAME}" "$PERSISTENT_PATH" - fi - - # Create symlink - echo " Creating symlink: ${setting} -> ${PERSISTENT_PATH}" - ln -s "$PERSISTENT_PATH" "$HOME_PATH" - chown -h "${USERNAME}:${USERNAME}" "$HOME_PATH" -done +GROUP_PERSISTENT_DIR="/persistent/group" +echo "Setting up AI agent settings symlinks (account group: ${DEVBASE_ACCOUNT_GROUP})..." +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 07ab8d29..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,33 +202,98 @@ 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` で焼き込まれます。エントリを増減した場合は > イメージの再ビルドが必要です(`devbase up` 単体では反映されない場合があります。[CLI リファレンス: project グループ](cli-reference/02-project.md#devbase-project-up) の `devbase project up` の注記参照)。 +### gcloud / gws の設定はどこにあるか + +gcloud と gws は symlink ではなく **環境変数で設定ディレクトリごと差し替え**ています。 + +| 変数 | 向き先 | 入るもの | +|---|---|---| +| `CLOUDSDK_CONFIG` | `/persistent/group/gcloud` | `credentials.db` / `access_tokens.db` / `legacy_credentials/` / `configurations/` / `application_default_credentials.json`(ADC ファイル) | +| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | `/persistent/group/gws` | `credentials.enc` / `.encryption_key` | + +`CLOUDSDK_CONFIG` は gcloud CLI 専用の仕組みではなく `google.auth` の探索経路そのものなので、 +BigQuery クライアント等のライブラリも同じ場所を見ます。 + +> **Warning:** この差し替えにより、`~/.config/gcloud` は **gcloud の設定ディレクトリでは +> なくなりました**。鍵モード(`GCP_AUTH_MODE=key`)で書き出されるサービスアカウント鍵の +> 置き場でしかなく、コンテナ層(揮発)に残ります。したがって鍵は毎起動 `env` から書き直され、 +> 永続領域には残りません。設定を見たいときは `$CLOUDSDK_CONFIG` を参照してください。 + +認証モードの切り替えは [環境変数ガイド](environment-variables.md) の `GCP_AUTH_MODE`、 +実際の認証手順は [Google 認証ガイド](google-auth.md) を参照してください。 + +> **Warning:** gcloud は**並行実行を想定していません**(公式ドキュメント: "Parallel execution of +> multiple gcloud CLI commands is not supported.")。`credentials.db` は SQLite なので、 +> 同じアカウントグループの複数コンテナが同時に `gcloud` を叩くと `database is locked` が +> 出ることがあります。恒久対策は取っていないので、その場合は少し待って再実行してください。 + ## コンテナイメージ階層 devbase のコンテナイメージは用途に応じた階層構造になっています。 diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index d6ca2303..220a5b34 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -79,15 +79,68 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 | `GCP_ACTIVE_PROFILE` | アクティブなプロファイル名 | | `GOOGLE_CLOUD_PROJECT` | GCP プロジェクト ID | | `GOOGLE_CLOUD_LOCATION` | GCP リージョン | -| `GOOGLE_APPLICATION_CREDENTIALS` | サービスアカウントキーのパス | +| `GOOGLE_APPLICATION_CREDENTIALS` | サービスアカウントキーのパス(鍵モードのみ。下記参照) | | `BIGQUERY_PROJECT` | BigQuery プロジェクト | | `BIGQUERY_DATASETS` | BigQuery データセット | | `BIGQUERY_LOCATION` | BigQuery ロケーション | -| `BIGQUERY_KEY_FILE` | BigQuery キーファイルパス | +| `BIGQUERY_KEY_FILE` | BigQuery キーファイルパス(鍵モードのみ。下記参照) | ソースファイル: `~/gcp-credentials/` ソースタイプ: `named_profiles` +##### `GCP_AUTH_MODE` -- 認証モードの切り替え + +Google はサービスアカウント鍵を非推奨とし、ローカル開発には +`gcloud auth application-default login`(ユーザー認証 = ADC)を推奨しています。 +devbase は gcloud の設定ディレクトリをアカウントグループごとに永続化するため、 +ADC を既定の経路にできます。鍵が要る場面のために切り替えを残しています。 + +`GCP_AUTH_MODE` はプロジェクトの `env` かグローバル `env` に手書きします。 + +| 値 | 挙動 | +|---|---| +| `adc` | 鍵を書かない。`GOOGLE_APPLICATION_CREDENTIALS` と `BIGQUERY_KEY_FILE` を**コンテナへ渡さない**。認証は `$CLOUDSDK_CONFIG/application_default_credentials.json`(= `gcloud auth application-default login` の結果)に委ねる | +| `key` | `GCP_CREDENTIALS_BASE64__` を復号して書き、上記 2 変数を渡す(従来どおり) | +| 未設定 | 鍵の env があれば `key`、無ければ `adc`(既存プロジェクトは従来どおり動きます) | + +鍵の有無は **`GCP_ACTIVE_PROFILE`(未設定なら `default`)のプロファイル** 1 本だけで +判定します(無ければ後方互換の `GOOGLE_APPLICATION_CREDENTIALS_BASE64`)。別プロファイル +の鍵があっても、アクティブなプロファイルの鍵が無ければ `adc` です。`GCP_AUTH_MODE=key` を +明示していても同じで、鍵が無ければ `adc` として構成します(警告を出します)。ホスト側と +コンテナ側で判定が食い違うと、実体の無いパスだけがコンテナへ残るためです。 + +`adc` で 2 変数を**渡さない**のが要点です。値だけ残して実体が無いと、ADC は +ユーザー認証へフォールバックせず `DefaultCredentialsError` で落ちます。元の +`compose.yml` の `environment:` にパスが直書きされている場合も、`adc` では生成 compose +から取り除きます。 + +取り除く対象は **dev サービス(`dev-1` 〜 `dev-N`)だけ**です。`GCP_AUTH_MODE` は dev の +認証方式の宣言なので、独自に鍵をマウントしている `batch` のような非 dev サービスが +`environment:` や `env_file` で受け取っている 2 変数はそのまま残します。 + +```bash +# ADC を使う(推奨) +echo 'GCP_AUTH_MODE=adc' >> projects//env +devbase project up +``` + +切り替えには `devbase up` が必要です(コンテナへ渡す環境変数が変わるため)。 +手順の全体は [Google 認証ガイド](google-auth.md) を参照してください。 + +##### gcloud / gws の設定ディレクトリ + +| 変数 | 値 | 意味 | +|---|---|---| +| `CLOUDSDK_CONFIG` | `/persistent/group/gcloud` | gcloud の設定ディレクトリ。`credentials.db` / `access_tokens.db` / `application_default_credentials.json` がここに入る | +| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | `/persistent/group/gws` | gws(Google Workspace CLI)の設定ディレクトリ | + +いずれも devbase が生成 compose で渡すため、`env` に書く必要はありません。 + +> **Warning:** `CLOUDSDK_CONFIG` を向け直したあとの `~/.config/gcloud` は +> **gcloud の設定ディレクトリではありません**。鍵モードで書き出される +> サービスアカウント鍵の置き場でしかなく、コンテナ層(揮発)に残ります。 +> gcloud の実際の設定を見たいときは `$CLOUDSDK_CONFIG` を参照してください。 + #### git -- Git 認証 | キー | 説明 | @@ -143,6 +196,36 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 ユーザー名のみで秘密情報ではありません。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/google-auth.md b/docs/user/google-auth.md new file mode 100644 index 00000000..b885b842 --- /dev/null +++ b/docs/user/google-auth.md @@ -0,0 +1,684 @@ +# Google 認証ガイド + +devbase のコンテナで Google Cloud(gcloud)と Google Workspace(gws)を使うための手順です。 +**アカウントグループごとに人が 1 回だけ対話的に認証する**ことを前提にした仕組みなので、 +新しいグループを足すときはこのページを最初から順に実行してください。 + +このページのコマンドと出力は、すべて実機(`carmo-ai` コンテナ、gcloud 582.0.0 / gws 0.22.5)で +実行した結果を貼っています。 + +## 1. 前提 + +### アカウントグループとは + +**使用する Google / AWS アカウントの単位**です。`DEVBASE_ACCOUNT_GROUP` で宣言し、 +未設定なら `default` になります。グループごとに専用のボリュームが作られ、 +認証情報はその中にだけ入ります。 + +| マウント先 | ボリューム | 共有範囲 | 入るもの | +|---|---|---|---| +| `/persistent/ai` | `devbase_home_ubuntu` | 全コンテナ | `~/.claude/plugins` などテナントに紐づかない共通資産 | +| `/persistent/group` | `devbase_home_` | 同じグループ | gcloud / gws の設定、Claude Code の認証と会話ログ、`.gemini` | + +nyle.co.jp で認証した gcloud を kk-generation.com のプロジェクトが引き継がないための仕切りです。 +ボリューム構造の全体は [コンテナ操作ガイド](container-operations.md) を参照してください。 + +### `~/.config/gcloud` は gcloud の設定ディレクトリ**ではありません** + +devbase は `CLOUDSDK_CONFIG` を `/persistent/group/gcloud` へ向けています。 +`credentials.db` / `access_tokens.db` / `legacy_credentials/` / `configurations/` と +ADC ファイルはすべてそちらに入ります。 + +```console +$ echo $CLOUDSDK_CONFIG +/persistent/group/gcloud +``` + +`~/.config/gcloud` に残るのは、鍵モード(後述)で書き出されるサービスアカウント鍵だけです。 +これはコンテナ層(揮発)にあり、毎起動 `env` から書き直されます。 +**設定や認証情報を見たいときは `$CLOUDSDK_CONFIG` を参照してください。** + +`CLOUDSDK_CONFIG` は gcloud CLI 専用の仕組みではなく `google.auth` の探索経路そのものなので、 +BigQuery クライアントなどのライブラリも同じ場所を見ます。 + +## 2. 新しいグループの初回セットアップ + +プロジェクトの `env` にグループ名(と必要なら認証モード)を書いて起動します。 + +```bash +# projects//env +DEVBASE_ACCOUNT_GROUP=kkg +GCP_AUTH_MODE=adc # サービスアカウント鍵を使わない場合(推奨) +``` + +```bash +devbase project up +devbase login +``` + +グループ名には次の 3 つが使えません。`devbase up` の前にエラーになります。 + +```console +$ DEVBASE_ACCOUNT_GROUP=ubuntu devbase up +Error: Deploy failed: DEVBASE_ACCOUNT_GROUP に予約語は使えません: 'ubuntu'。共通ボリューム devbase_home_ubuntu と同じ名前になります + +$ DEVBASE_ACCOUNT_GROUP=1 devbase up +Error: Deploy failed: DEVBASE_ACCOUNT_GROUP に数字だけの名前は使えません: '1'。インスタンス番号のボリューム devbase_home_ と同じ名前になります + +$ DEVBASE_ACCOUNT_GROUP="bad name" devbase up +Error: Deploy failed: DEVBASE_ACCOUNT_GROUP が不正です: 'bad name'。Docker のボリューム名に使える文字 (英数字・ドット・ハイフン・アンダースコア、先頭は英数字) だけを使ってください +``` + +起動できたら、コンテナ内でどのグループにいるかを確認します。 + +```console +$ echo $DEVBASE_ACCOUNT_GROUP +kkg +$ echo $CLOUDSDK_CONFIG +/persistent/group/gcloud +``` + +新しいグループは当然まだ未認証です。 + +```console +$ gcloud auth list +To login, run: + $ gcloud auth login `ACCOUNT` +``` + +## 3. gcloud の認証 + +**2 回実行します。**`gcloud auth login`(CLI 用)と +`gcloud auth application-default login`(ライブラリ用の ADC)は**別物**です。 + +### 3.1 `gcloud auth login`(CLI 用) + +```bash +gcloud auth login +``` + +**フラグは要りません。** この環境では自動的に「URL を貼って認証コードを戻す」フローになります。 +gcloud はブラウザを起動できるかを `DISPLAY` / `WAYLAND_DISPLAY` / `MIR_SOCKET` の有無で判定し、 +コンテナ内ではどれも無いため `--no-launch-browser` と同じ経路が選ばれるためです。 +VS Code のポート転送の有無は関係ありません。 + +手元の別マシンのブラウザで URL を開き、表示された認証コードをターミナルへ貼り戻します。 + +完了すると active account が設定されます。 + +```console +$ gcloud auth list + Credentialed Accounts +ACTIVE ACCOUNT +* takemi_ohama@kk-generation.com + +To set the active account, run: + $ gcloud config set account `ACCOUNT` + +$ gcloud config get account +takemi_ohama@kk-generation.com +``` + +このとき `$CLOUDSDK_CONFIG` の中身は次のようになります。 + +```console +$ ls -A $CLOUDSDK_CONFIG +.last_survey_prompt.yaml access_tokens.db active_config config_sentinel +configurations credentials.db default_configs.db gce legacy_credentials logs +``` + +**この時点では ADC ファイルはまだありません。** + +```console +$ ls -l $CLOUDSDK_CONFIG/application_default_credentials.json +ls: cannot access '/persistent/group/gcloud/application_default_credentials.json': No such file or directory +``` + +### 3.2 `gcloud auth application-default login`(ライブラリ用) + +BigQuery クライアントなど、`google.auth` を使うライブラリはこちらを見ます。 + +```console +$ gcloud auth application-default login +Go to the following link in your browser, and complete the sign-in prompts: + + https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=...&redirect_uri=https%3A%2F%2Fsdk.cloud.google.com%2Fapplicationdefaultauthcode.html&scope=openid+...&prompt=consent&token_usage=remote&access_type=offline&code_challenge=...&code_challenge_method=S256 + +Once finished, enter the verification code provided in your browser: <ブラウザに表示されたコードを貼る> + +Credentials saved to file: [/persistent/group/gcloud/application_default_credentials.json] + +These credentials will be used by any library that requests Application Default Credentials (ADC). +WARNING: +Cannot find a quota project to add to ADC. You might receive a "quota exceeded" or "API not enabled" error. Run $ gcloud auth application-default set-quota-project to add a quota project. +``` + +保存先が **`/persistent/group/gcloud/`**(= グループボリューム)になっている点が要点です。 + +```console +$ ls -l $CLOUDSDK_CONFIG/application_default_credentials.json +-rw------- 1 ubuntu ubuntu 351 Aug 29 06:00 /persistent/group/gcloud/application_default_credentials.json +``` + +これでライブラリ側からユーザー認証が使えます。 + +```console +$ PYTHONPATH=/opt/google-cloud-sdk/lib/third_party python3 -c \ + "import google.auth; c, p = google.auth.default(); print(p, type(c).__name__)" +nyle-carmo-analysis Credentials +``` + +> **Note:** ここに出る `Credentials` は**クラスの短い名前**で、それだけではユーザー認証と +> サービスアカウントを区別できません。サービスアカウント側の +> `google.oauth2.service_account.Credentials` も短い名前は同じ `Credentials` です。 +> +> ```console +> $ PYTHONPATH=/opt/google-cloud-sdk/lib/third_party python3 -c \ +> "import google.oauth2.credentials as u, google.oauth2.service_account as s; print(u.Credentials.__name__, s.Credentials.__name__)" +> Credentials Credentials +> $ PYTHONPATH=/opt/google-cloud-sdk/lib/third_party python3 -c \ +> "import google.oauth2.credentials as u, google.oauth2.service_account as s; print(u.Credentials.__module__, s.Credentials.__module__)" +> google.oauth2.credentials google.oauth2.service_account +> ``` +> +> 見分けるには `type(c).__name__` ではなく **`type(c).__module__`** を出してください。 +> ユーザー認証なら `google.oauth2.credentials`、サービスアカウントなら +> `google.oauth2.service_account` になります。 + +> **Note:** 末尾の警告のとおり、この時点では **quota project が ADC に書かれていません**。 +> quota project を要する API(`quota exceeded` / `API not enabled` が出るもの)を使うなら +> 追加してください。 +> +> ```bash +> gcloud auth application-default set-quota-project <プロジェクトID> +> ``` +> +> ```console +> $ python3 -c 'import json;print(sorted(json.load(open("/persistent/group/gcloud/application_default_credentials.json")).keys()))' +> ['account', 'client_id', 'client_secret', 'refresh_token', 'type', 'universe_domain'] +> ``` +> +> `quota_project_id` が無い状態です。 + +> **Note:** `gcloud auth login --update-adc` で 1 回に減らす案は**採りません**。 +> `--update-adc` は quota project を ADC に書かないため(`add_quota_project=False` のまま +> ADC を書き出す)、quota project を要する API で困ります。 +> `gcloud auth application-default login` は quota project の書き込みも試みますが、 +> 書かれるのは利用可能な project を解決できた場合に限られます。上の実行例のように +> `Cannot find a quota project` となったときは書かれないので、`set-quota-project` で +> 明示的に追加してください。 + +> **Note:** 鍵モード(`GCP_AUTH_MODE=key`)で実行すると、gcloud が +> 「Credentials will still be generated to the default location / To use these credentials, +> unset this environment variable before running your application」と警告します。 +> `GOOGLE_APPLICATION_CREDENTIALS` が設定されていると ADC よりそちらが優先されるためです。 +> ADC を使いたいなら `GCP_AUTH_MODE=adc` にしてください(後述)。 + +### 3.3 コンテナを作り直しても認証が残ることの確認 + +ここが PLAN39 で直した点です。`devbase down` はコンテナを削除しますが、認証情報は +グループボリュームに残るので**再認証は要りません**。 + +コンテナの作り直しは**ホスト側**で実行します。`up` で作られるのは新しいコンテナなので、 +続きを確認するには `devbase login ` で**入り直してください**。 + +```console +# ホスト +$ devbase project down && devbase project up +$ devbase login +``` + +```console +# 作り直したコンテナの中 +$ gcloud auth list + Credentialed Accounts +ACTIVE ACCOUNT +* takemi_ohama@kk-generation.com + +$ PYTHONPATH=/opt/google-cloud-sdk/lib/third_party python3 -c \ + "import google.auth; c, p = google.auth.default(); print(p, type(c).__name__)" +nyle-carmo-analysis Credentials +``` + +### 3.4 グループごとに別のアカウントになっていることの確認 + +これが分離の目的です。ホスト側からボリュームを直接覗くと、グループごとに別のアカウントの +認証情報が入っていることが分かります。 + +```console +$ docker run --rm -v devbase_home_default:/g alpine ls /g/gcloud/legacy_credentials +takemi_ohama@nyle.co.jp + +$ docker run --rm -v devbase_home_kkg:/g alpine ls /g/gcloud/legacy_credentials +takemi_ohama@kk-generation.com +``` + +コンテナ内から見ると、自分のグループのアカウントしか見えません。 + +```console +# default グループのコンテナ +$ gcloud config get account +takemi_ohama@nyle.co.jp + +# kkg グループのコンテナ +$ gcloud config get account +takemi_ohama@kk-generation.com +``` + +## 4. gws(Google Workspace CLI)の認証 + +`gws` は base イメージに同梱されています。 + +```console +$ command -v gws +/usr/local/share/npm-global/bin/gws +$ gws --version +gws 0.22.5 +This is not an officially supported Google product. +``` + +設定ディレクトリは `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` でグループボリュームへ向いています。 + +```console +$ gws auth status +{ + "auth_method": "none", + "client_config": "/persistent/group/gws/client_secret.json", + "client_config_exists": false, + "credential_source": "none", + "encrypted_credentials": "/persistent/group/gws/credentials.enc", + "encrypted_credentials_exists": false, + "keyring_backend": "keyring", + "plain_credentials": "/persistent/group/gws/credentials.json", + "plain_credentials_exists": false, + "storage": "none", + "token_cache_exists": false +} +``` + +認証は 2 段です。`gws auth setup` は **gcloud に依存する**ので、先に 3.1 を済ませてください。 + +``` +gws auth setup # Cloud プロジェクトと OAuth クライアントを設定する +gws auth login # OAuth2 で認証する +``` + +### 4.1 先に GCP プロジェクトを決める(詰まりやすい点) + +`gws auth setup` は **gcloud の設定に GCP プロジェクトが要ります**。 +`GOOGLE_CLOUD_PROJECT` 環境変数は見てくれません。 + +```console +$ gcloud config get project +(unset) +$ gws auth setup --dry-run +🏃 DRY RUN — no changes will be made + +Step 1/6: Checking for gcloud CLI... + ✓ gcloud CLI found +Step 2/6: Checking authentication... + ✓ Authenticated as takemi_ohama@kk-generation.com +{ + "error": { + "code": 400, + "message": "No GCP project configured. Use --project or run `gcloud config set project `", + "reason": "validationError" + } +} +error[validation]: No GCP project configured. Use --project or run `gcloud config set project ` +``` + +`--project` で明示するか、`gcloud config set project ` で設定してください。 +`--dry-run` を付けると変更を加えずに手前の段階まで確認できます。 + +使えるプロジェクトが分からないときは `gcloud projects list` を見ます。認証したアカウントに +プロジェクトが 1 つも無いと 0 件になり、そのアカウントでは `gws auth setup` を通せません。 + +```console +$ gcloud projects list --limit=15 +Listed 0 items. +``` + +### 4.2 OAuth クライアントを Console で作る + +`gws auth setup` は **OAuth クライアントを自動生成できません**。Step 5/5 で手動作成を求められます。 + +``` + ✓ Step 1/5: gcloud CLI — found + ✓ Step 2/5: Authentication — takemi_ohama@nyle.co.jp + ✓ Step 3/5: GCP project — nyle-carmo-analysis + ✓ Step 4/5: Workspace APIs — 0 enabled, 22 skipped + ▸ Step 5/5: OAuth credentials — Waiting for manual input... + + Manual OAuth client setup required. + + Step A — Consent screen (if not configured): + https://console.cloud.google.com/apis/credentials/consent?project=<プロジェクトID> + → User Type: External, then save through all screens. + + Step B — Create an OAuth client: + https://console.cloud.google.com/apis/credentials?project=<プロジェクトID> + → 'Create Credentials' → 'OAuth client ID' + → Application type: Desktop app + → Redirect URI: http://localhost (auto-negotiated; no manual entry needed) +``` + +Console で作った **クライアント ID** と **クライアント シークレット**を、続くプロンプトへ順に貼ります。 + +> **Warning:** クライアント シークレットを `$DEVBASE_ROOT/env` やプロジェクトの `env` に +> **書かないでください**。`env` は非機密用で `source` されるため、`KEY=値` の形でない行を +> 書くと `devbase` コマンド自体が壊れます(`command not found`)。gws が +> `$GOOGLE_WORKSPACE_CLI_CONFIG_DIR/client_secret.json` として保存するので、 +> どこかへ控える必要はありません。 + +成功すると `client_secret.json` がグループボリュームに置かれます。 + +```console +$ ls -l $GOOGLE_WORKSPACE_CLI_CONFIG_DIR +total 4 +-rw------- 1 ubuntu ubuntu 470 Aug 29 08:46 client_secret.json + +$ gws auth status +{ + "auth_method": "none", + "client_config": "/persistent/group/gws/client_secret.json", + "client_config_exists": true, + "config_client_id": "12826645....com", + "credential_source": "client_secret.json", + "enabled_api_count": 110, + ... + "project_id": "nyle-carmo-analysis", + "storage": "none" +} +``` + +### 4.3 ログイン + +`gws auth login` は gcloud と**流儀が違います**。認証コードを貼り戻すのではなく、 +**コンテナ内の `localhost:<ランダムポート>` でコールバックを待ち受けます**。 + +ここまでの 4.1 / 4.2 はコンテナの中で実行していますが、**次の `docker exec` はホスト側**の +コマンドです。コンテナから一度抜けるか、別のホストのターミナルを開いてください +(コンテナの中に居るまま実行したいときは、`docker exec -it <コンテナ名>` を外して +`gws auth login --readonly` だけを実行します)。 + +```console +# ホスト +$ docker exec -it <コンテナ名> gws auth login --readonly +Open this URL in your browser to authenticate: + + https://accounts.google.com/o/oauth2/auth?scope=...&redirect_uri=http://localhost:34437&response_type=code&client_id=...&prompt=select_account+consent +``` + +> **Note:** `docker exec -it <コンテナ> bash -lc 'gws auth login'` の形だと +> `Failed to read prompt input: stream did not contain valid UTF-8` で落ちることがあります。 +> `bash -lc` を挟まずに直接実行するか、`docker exec -it <コンテナ> bash` で入ってから +> 実行してください。 + +スコープは `--readonly`(読み取りのみ)/ `--full`(pubsub + cloud-platform を含む全部)/ +`--services drive,gmail,sheets` のように選べます。迷うなら `--readonly` が安全です。 + +#### ブラウザがホスト側にある場合(devbase では通常こちら) + +`redirect_uri` の `localhost` は**コンテナ内の localhost** です。ホストのブラウザから +`http://localhost:34437` を開いてもコンテナには届きません。次の手順で中継します。 + +1. 表示された URL をホストのブラウザで開き、認証を済ませる +2. ブラウザが `http://localhost:<ポート>/?code=...` へリダイレクトされ「接続できません」になる +3. **アドレスバーの URL 全体をコピーする** +4. 別のターミナルから、コンテナ内でその URL を叩いてコールバックを届ける + +```bash +docker exec <コンテナ名> curl -s "http://localhost:<ポート>/?code=...&scope=..." +``` + +ポート番号は実行のたびに変わるので、手順 1 で表示された `redirect_uri` の値を使ってください。 + +> **Note:** VS Code でコンテナにアタッチしている場合は、VS Code の自動ポート転送が効いて +> ホストのブラウザから直接届くことがあります。その場合は手順 3〜4 は不要です。 + +認証が通ると、コールバックを受けた側に許可されたスコープと `"status": "success"` が出ます。 + +```console +{ + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly", + ... + "https://www.googleapis.com/auth/userinfo.profile" + ], + "status": "success" +} +``` + +`$GOOGLE_WORKSPACE_CLI_CONFIG_DIR` に `credentials.enc`(暗号化済みの認証情報)が作られます。 + +```console +$ ls -l $GOOGLE_WORKSPACE_CLI_CONFIG_DIR +total 12 +drwxr-xr-x 2 ubuntu ubuntu 4096 Aug 29 08:47 cache +-rw------- 1 ubuntu ubuntu 470 Aug 29 08:46 client_secret.json +-rw------- 1 ubuntu ubuntu 334 Aug 29 09:24 credentials.enc + +$ gws auth status | grep -E '"auth_method"|"storage"' + "auth_method": "oauth2", + "storage": "encrypted", +``` + +コンテナを作り直しても**再認証は要りません**。ここでも `down` / `up` はホスト側で実行し、 +`devbase login ` で作り直したコンテナへ入り直してから確認します。 + +```console +# ホスト +$ devbase project down && devbase project up +$ devbase login +``` + +```console +# 作り直したコンテナの中 +$ ls -l $GOOGLE_WORKSPACE_CLI_CONFIG_DIR +total 12 +drwxr-xr-x 2 ubuntu ubuntu 4096 Aug 29 08:47 cache +-rw------- 1 ubuntu ubuntu 470 Aug 29 08:46 client_secret.json +-rw------- 1 ubuntu ubuntu 334 Aug 29 09:24 credentials.enc + +$ gws auth status | grep -E '"auth_method"|encrypted_credentials_exists|"storage"' + "auth_method": "oauth2", + "encrypted_credentials_exists": true, + "storage": "encrypted", +``` + +> **Note:** `keyring_backend` は `keyring` のままで動きました。コンテナに OS キーリングが +> 無くても `credentials.enc` として暗号化保存されるため、 +> `GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file` を指定する必要はありませんでした。 + +#### 同意画面で止まる場合 + +`--readonly` でも `drive.readonly` / `gmail.readonly` は Google の**制限付きスコープ**です。 +OAuth 同意画面が「テスト中」でテストユーザーに自分が入っていない、あるいはアプリ情報が +未入力だと、同意フローが先へ進まないことがあります。Console の +「OAuth 同意画面」で公開ステータスとテストユーザーを確認してください。 + +スコープを絞れば制限付きスコープを避けられます。疎通確認だけなら次で十分です。 + +```bash +gws auth login --scopes openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile +``` + +> **Note:** 待ち受けプロセスを止めると、その回に発行された認証コードは使えなくなります +> (`redirect_uri` のポートが変わるため)。`gws auth login` をやり直したら、 +> **新しく表示された URL** から認証し直してください。 + +## 5. 認証モードの切り替え + +`GCP_AUTH_MODE` はプロジェクトの `env` かグローバル `env` に手書きします。 + +| 値 | 挙動 | +|---|---| +| `adc`(推奨) | 鍵を書かない。`GOOGLE_APPLICATION_CREDENTIALS` と `BIGQUERY_KEY_FILE` を**コンテナへ渡さない**。認証は `$CLOUDSDK_CONFIG/application_default_credentials.json` に委ねる | +| `key` | アクティブプロファイルの `GCP_CREDENTIALS_BASE64__`(または旧来の `GOOGLE_APPLICATION_CREDENTIALS_BASE64`)を復号して書き、上記 2 変数を渡す(従来どおり)。**その鍵の env が無いときは警告して `adc` へ倒れる**(下記)| +| 未設定 | アクティブプロファイルの鍵の env があれば `key`、無ければ `adc` | + +> **Warning:** `key` と書いても、**アクティブプロファイルの鍵が env に無ければ `adc` として +> 構成されます**。サービスアカウントとして動かすつもりが、実際には永続化されたユーザー ADC で +> 動いてしまうことがあるので注意してください。倒れたときはホスト側に次の警告が出ます。 +> +> ``` +> GCP_AUTH_MODE=key ですが GCP_CREDENTIALS_BASE64__ が env にありません。adc として構成します +> ``` +> +> 実体の無いパスを指す `GOOGLE_APPLICATION_CREDENTIALS` / `BIGQUERY_KEY_FILE` をコンテナへ +> 渡して `DefaultCredentialsError` にするより安全なため、意図的にこうしています +> (`lib/devbase/env/gcp_auth.py` の `resolve_auth_mode`)。`key` で動かしたいなら、 +> アクティブプロファイル用の `GCP_CREDENTIALS_BASE64__` を先に設定してください。 + +`adc` で 2 変数を落とすのは **dev サービス(`dev-1` 〜 `dev-N`)だけ**です。`compose.yml` が +独自に鍵をマウントしている `batch` のような非 dev サービスへ書いた設定や、そのサービスが +`env_file` で受け取っていた 2 変数には触りません。 + +**鍵が要るのはどういう場面か。** ユーザー認証では権限が足りない、あるいは人に紐づかない +実行主体が必要な場面です。たとえば本番データセットへの読み取りがサービスアカウントにしか +付与されていない場合や、コンテナ内から実行するバッチが特定の SA として動く必要がある場合です。 +それ以外の日常的な開発では ADC で足ります(Google もローカル開発には +`gcloud auth application-default login` を推奨しています)。 + +切り替えたら **`devbase up` が必要**です。コンテナへ渡す環境変数が変わるためで、 +コンテナ内で `export` しても `docker exec` の別シェルには反映されません。 + +```console +$ echo 'GCP_AUTH_MODE=adc' >> projects//env +$ devbase project up +``` + +`adc` に切り替わると 2 変数は**未設定**になります。 + +```console +$ echo ${GOOGLE_APPLICATION_CREDENTIALS-} + +$ echo ${BIGQUERY_KEY_FILE-} + +``` + +値だけ残して実体が無いと ADC はユーザー認証へフォールバックせず落ちるため、devbase は +「空にする」のではなく「渡さない」を選んでいます。 + +## 6. 確認コマンド + +### いま自分がどのグループにいるか + +ホスト側: + +```console +$ devbase status +... +[環境] + アカウントグループ kkg (devbase_home_kkg / env) +``` + +末尾は値が `env` 由来か、未設定によるフォールバック(`既定`)かを示します。 + +コンテナ内: + +```console +$ echo $DEVBASE_ACCOUNT_GROUP +kkg +$ echo $CLOUDSDK_CONFIG +/persistent/group/gcloud +$ readlink -f ~/.claude +/persistent/group/.claude +$ readlink -f ~/.claude/plugins +/persistent/ai/.claude/plugins +``` + +最後の 2 行が要点です。会話ログや認証はグループ側、プラグインなどの共通資産は共通側を指します。 + +コンテナの起動ログにも 1 行出ます。 + +```console +$ devbase project logs | grep "Account group" +Account group: kkg (gcloud account: takemi_ohama@kk-generation.com, CLOUDSDK_CONFIG: /persistent/group/gcloud) +``` + +### 認証の疎通 + +```bash +gcloud auth list # CLI 側の active account +gcloud config get account # 同上 (1 行) +gws auth status # gws の認証状態 +``` + +ライブラリ側(ADC)は `google.auth` で確認します。コンテナの `python3` には +`google` パッケージが入っていないため、gcloud 同梱のものを使います。 + +```console +$ PYTHONPATH=/opt/google-cloud-sdk/lib/third_party python3 -c \ + "import google.auth; c, p = google.auth.default(); print(p, type(c).__name__)" +nyle-carmo-analysis Credentials +``` + +## 7. トラブルシュート + +### `DefaultCredentialsError: Your default credentials were not found.` + +**まだ ADC の認証をしていない**状態です。3.2 の +`gcloud auth application-default login` を実行してください。これは `adc` モードで +未認証のときの**正常な状態**です。 + +### `DefaultCredentialsError: File /... was not found.` + +`GOOGLE_APPLICATION_CREDENTIALS` が**実体の無いパスを指しています**。ADC はこの場合 +ユーザー認証へフォールバックせず例外で落ちます。 + +devbase は `adc` モードでこの変数をコンテナへ渡さないので、通常は起きません。起きるとすれば +プロジェクトの `env`(機密ではない方)にこの変数が直接書かれている場合です。次で確認します。 + +```console +$ echo ${GOOGLE_APPLICATION_CREDENTIALS-} +``` + +`` でなければ `env` からその行を消して `devbase up` し直してください。 + +### `database is locked` + +gcloud は**並行実行を想定していません**(公式ドキュメント: "Parallel execution of multiple +gcloud CLI commands is not supported.")。`credentials.db` は SQLite なので、 +**同じアカウントグループの複数コンテナが同時に `gcloud` を叩く**と出ることがあります。 + +恒久対策は取っていません。少し待って**再実行**してください。これはグループボリュームを +同じグループの全コンテナで共有する設計に内在するもので、認証情報をどう置いても同じです。 + +### 意図しないアカウントで操作していた + +まず**どのグループにいるか**を確認します(6 章)。グループが正しいのにアカウントが違う場合は、 +そのグループに複数のアカウントで認証しています。 + +```console +$ gcloud auth list + Credentialed Accounts +ACTIVE ACCOUNT + someone@example.com +* takemi_ohama@kk-generation.com +``` + +切り替えは `gcloud config set account`、要らないものは `gcloud auth revoke` で消します。 + +```bash +gcloud config set account <正しいアカウント> +gcloud auth revoke <不要なアカウント> +``` + +**この 2 つは gcloud CLI の認証情報しか変えません。** 3.2 のとおり ADC +(`$CLOUDSDK_CONFIG/application_default_credentials.json`)は別ファイルなので、 +`google.auth` や BigQuery クライアントなど**ライブラリ経由の呼び出しは古いアカウントのまま**です。 +`gcloud auth list` が正しく見えていても、ライブラリだけ別テナントで動き続けることがあります。 +ライブラリ側も直すには、正しいアカウントで 3.2 の +`gcloud auth application-default login` をやり直して ADC を上書きしてください。 +ADC がどのアカウントのものかは、このファイルの `account` フィールドに入っています。 + +グループ自体が間違っていた場合は、プロジェクトの `env` の `DEVBASE_ACCOUNT_GROUP` を直して +`devbase up` し直してください。**別グループの認証は互いに見えない**ので、正しいグループへ +移れば意図しないアカウントは選択肢にすら出てきません。 + +## 関連 + +- [コンテナ操作ガイド](container-operations.md) — ボリューム構造、AI 設定の永続化 +- [環境変数ガイド](environment-variables.md) — `DEVBASE_ACCOUNT_GROUP` / `GCP_AUTH_MODE` 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 new file mode 100644 index 00000000..eeda59a0 --- /dev/null +++ b/issues/PLAN39_account-group-volume-separation.md @@ -0,0 +1,668 @@ +# PLAN39: 永続化ボリュームをアカウントグループ単位に分離する + +## 関連リンク + +- issue: [#116](https://github.com/devbasex/devbase/issues/116)(背景・実機調査の全文) +- 参考: `docs/user/container-operations.md`(ボリューム構造・AI 設定の永続化)、`containers/base/entrypoint.sh`(symlink 機構)、`docs/plugin-dev/compose-yml-guidelines.md`(プロジェクト compose の書き方) +- 一次情報(前提 8〜13 の根拠): + - [Managing gcloud CLI configurations](https://docs.cloud.google.com/sdk/docs/configurations) — `CLOUDSDK_CONFIG` で設定ディレクトリを差し替えられる + - [Application Default Credentials](https://docs.cloud.google.com/docs/authentication/application-default-credentials) — ADC の探索順と、ローカル開発では SA 鍵ではなく `gcloud auth application-default login` を推奨する旨 + - [Scripting gcloud CLI commands](https://docs.cloud.google.com/sdk/docs/scripting-gcloud) — "Parallel execution of multiple gcloud CLI commands is not supported." + - [googleworkspace/cli README](https://github.com/googleworkspace/cli/blob/main/README.md) — `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` + - [google-gemini/gemini-cli#1825](https://github.com/google-gemini/gemini-cli/issues/1825) — 設定ディレクトリを可変にする要望。2026-05-06 に「当面対応予定なし」としてクローズ + +## モード + +`architecture` — 永続化レイヤを二層化し、公開設定キー (`DEVBASE_ACCOUNT_GROUP` / `GCP_AUTH_MODE`) を増やす。 +データの置き場所が変わるため後戻りが安くなく、`volume` / `snapshot` / `entrypoint` を横断する。 +issue #116 が `standard` 相当の Phase 分割で書かれていても、判断の粒度はこちらに合わせる。 + +## 目的と非目的 + +達成したい状態: + +- `gcloud auth login` / `gws auth login` のユーザー OAuth が、**コンテナを作り直しても保たれる**(問題1)。 +- その認証が**アカウントグループをまたいで共有されない**。nyle.co.jp で認証した gcloud を + kk-generation.com のプロジェクトが引き継がない(問題2)。 +- Claude Code の MCP OAuth トークンと Gemini の `vertex-ai` 設定が**グループ単位に分かれる**(問題3。すでに混線している)。 +- サービスアカウント鍵に依存せず、**ユーザー認証(ADC)を既定の経路にできる**。鍵が要る場面は + `GCP_AUTH_MODE` で切り替えられる。 +- 新しいアカウントグループを足す人が、**手順書だけを見て Google 認証を完了できる**。 +- 一方で `.claude/plugins`(238MB)等の**共通資産はグループ数だけ重複しない**。 + +やらないこと: + +- `gcloud auth list` の active account と期待値の突き合わせによる**警告**。期待値をどこに宣言するか + (新しい env キーか、`GCP_ACTIVE_PROFILE` からの導出か)の設計が別途必要なため、今回は + 「解決されたグループと実アカウントを起動時に 1 行ログ出力する」までとする。 +- `~/.local/bin`(1.2GB) 等、再取得可能で容量の大きいディレクトリの永続化(issue #116 の「参考」節。別課題)。 +- `~/.vscode-server` の永続化(`issues/PLAN36_vscode-server-persistence.md` が扱う)。 +- AWS の分離。`AWS_PROFILE` + `AWS_CONFIG_BASE64` で既に達成されている。 + +## 前提 + +以下はすべて現行 `main` (`3f36a73`) 上で確認済み。 + +- 前提 1: 永続化されているのは `AI_SETTINGS`(`.claude.json` / `.claude` / `.codex` / `.gemini` / + `.serena` / `.ssh` / `.kiro` / `share`)と、その置き場である `devbase_home_ubuntu` (`/persistent/ai`) だけ + (`containers/base/entrypoint.sh:388-397`)。`~/.config/` 配下は対象外。 +- 前提 2: `/persistent/ai` は index に関係なく**全コンテナで同一** + (`volume/manager.py:82-95` の `get_ai_volume_for_index` が引数 `index` を捨てている)。 +- 前提 3: `bin/devbase` はグローバル `env` とプロジェクト `./env` を `set -a` で source する + (`bin/devbase:50,61,338`)。`.env`(機密)は `_inject_secrets` が起動前に `os.environ` へ載せる + (`commands/container.py:46-88`)。したがって Python 側は `os.environ` から + `DEVBASE_ACCOUNT_GROUP` を読めば 3 レベルの解決結果を得られる。 +- 前提 4: 生成 compose は宣言されていないマウントを**自動で足す** + (`volume/compose.py:100-108` の "Add missing mounts")。プロジェクト側 `compose.yml` の変更は不要。 +- 前提 5: **entrypoint の symlink ループは入れ子パスを扱えない**。実測で確認した 2 つの不具合: + + | エントリ | 現行ロジックの分岐 | 起きること | + |---|---|---| + | `.claude/.credentials.json` | `*.json` → `sudo touch` | 親 `/persistent/group/.claude` が無く `touch: No such file or directory`。壊れた symlink が残る | + | `.claude/history.jsonl` | `*.json` に**マッチしない** → `sudo mkdir -p` | `history.jsonl` が**ディレクトリとして**作られ、Claude Code が追記できない | + + ホーム側 (`ln -s` の直前) にも親ディレクトリ作成が無い。issue #116 は「追記が必要なのはホーム側だけ」と + 書いているが、**永続領域側にも `mkdir -p` と拡張子判定の修正が要る**。 +- 前提 6: `SHARED_VOLUME_PREFIX = "devbase_home_"` は `devbase_home_` にも使われる命名 + (`volume/manager.py:55-68`)。ただし `get_volume_for_index` は lib / tests のどこからも呼ばれていない死んだ API。 + `AI_VOLUME_PREFIX = "devbase_ai_"` も同様に未使用。 +- 前提 7: スナップショットの対象は `VOLUME_NAME = 'devbase_home_ubuntu'` 固定 + (`snapshot/manager.py:17,335,369`)。 +- 前提 8: gcloud は設定ディレクトリの場所を **`CLOUDSDK_CONFIG` で差し替えられる**。 + `google/auth/_cloud_sdk.py:45-59` の `get_config_path()` が `os.environ[CLOUD_SDK_CONFIG_DIR]` を + 最優先で返し(`environment_vars.py:41` で `CLOUD_SDK_CONFIG_DIR = "CLOUDSDK_CONFIG"`)、 + 同 `73-82` の `get_application_default_credentials_path()` は「その config path + + `application_default_credentials.json`」なので、**ADC ファイルも一緒に移動する**。これは gcloud CLI + 専用の実装ではなく client library と同じ `google.auth` なので、BigQuery 等のクライアントも同じ場所を見る。 + 実機確認: `CLOUDSDK_CONFIG= gcloud info --format='yaml(config.paths)'`(gcloud 569.0.0)で + `global_config_dir` / `active_config_path` が指定先へ切り替わり、認証情報を持たない新しいディレクトリが作られる。 +- 前提 9: **名前付き configuration では分離できない。** 設定ディレクトリ直下にあるのは + `credentials.db` / `access_tokens.db` / `legacy_credentials/` / `application_default_credentials.json` の + **1 セット**で、構成ごとに分かれるのは `configurations/` だけである。`--configuration` / + `CLOUDSDK_ACTIVE_CONFIG_NAME` は「どれを使うか」を選ぶだけで認証情報自体は共有されるため、 + グループ分離には**設定ディレクトリを分ける**しかない。 +- 前提 10: ADC の探索順は `GOOGLE_APPLICATION_CREDENTIALS` → `application_default_credentials.json` + → メタデータサーバである。**変数が存在しないファイルを指していると ADC は例外で落ち、ユーザー認証へ + フォールバックしない**。実機確認: + `GOOGLE_APPLICATION_CREDENTIALS=/nonexistent/key.json python3 -c "import google.auth; google.auth.default()"` + → `DefaultCredentialsError: File /nonexistent/key.json was not found.` + したがって鍵を使わないモードでは、この変数を**未設定にする**必要がある(空文字でも + `_default.py:349` の `explicit_file != ""` を通らないので可だが、unset で統一する)。 +- 前提 11: `GOOGLE_APPLICATION_CREDENTIALS` と `BIGQUERY_KEY_FILE` は、**鍵の有無に関係なく** + `/home/ubuntu/.config/gcloud/credentials.json` 固定で env に書かれる + (`lib/devbase/env/collectors/google.py:139-141` の `_collect_common_settings` は、プロファイルが + 1 件も見つからない経路 (`同 87`) からも無条件に呼ばれる)。値はプロジェクトの `env` から任意パスへ + 上書きもできる (`bin/devbase:61,338` / `lib/devbase/commands/container.py:295-418` / + `lib/devbase/env/runtime.py:112-134`)。entrypoint も変数があればそちらを優先する + (`containers/base/entrypoint.sh:204,213`)。 +- 前提 12: gws も設定ディレクトリを env で差し替えられる(公式 README の + `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` — "Override config directory (default: `~/.config/gws`)")。 + 一方 **Gemini CLI には同等の env が無い**。設定ディレクトリを可変にする要望は + google-gemini/gemini-cli#2815 → #1825 に集約され、#1825 は 2026-05-06 に「当面対応予定なし」として + クローズされている。したがって `.gemini` と `.claude*` は symlink 方式のままとする。 +- 前提 13: **gcloud は並行実行を想定していない。** 公式ドキュメント(Scripting gcloud CLI commands)に + "Parallel execution of multiple gcloud CLI commands is not supported." とあり、`credentials.db` は + SQLite なので、同一グループの複数コンテナが同時に gcloud を叩くと `database is locked` が出うる。 + これは設定ディレクトリの置き方によらず「グループボリュームを同グループの全コンテナで共有する」 + 設計に内在するもので、symlink 方式でも同じである。 +- 前提 14: entrypoint の実行順序は「GCP credentials の生成 (`containers/base/entrypoint.sh:189-222`)」→ + 「AI Settings の symlink 生成 (`同 383-440`)」で、symlink ループはホーム側の既存実体を + `rm -rf "$HOME_PATH"` (`同 420`) で消してから `ln -s` する。`~/.config/gcloud` を symlink 対象に + **しない**本プランでは両者は衝突しないが、`~` 直下の生成物を将来 symlink 対象へ加えるときは + この順序が効く(AC11 はその退行を見る)。 +- 前提 15: **この環境では `gcloud auth login` は自動でブラウザ非起動フローになる**(実機検証済み。 + `carmo-ai-dev-1` / gcloud 582.0.0)。`check_browser.ShouldLaunchBrowser()` は Linux で + `DISPLAY` / `WAYLAND_DISPLAY` / `MIR_SOCKET` がどれも無ければ False を返す + (`googlecloudsdk/command_lib/util/check_browser.py:36-66`)。コンテナ内で実行すると + `ShouldLaunchBrowser(True) = False`(`DISPLAY` は空。`xdg-open` は存在するが判定に使われない)。 + この場合 `api_lib/auth/util.py:355-365` の `elif not can_launch_browser:` へ入り、Google 所有の + クライアント ID では `RemoteLoginWithAuthProxyFlowRunner`(= `--no-launch-browser` と同じ実装)が + 選ばれる。**フラグを付けなくても「URL を貼って認証コードを戻す」フローになる。** + VS Code のポート転送の有無は関係しない(判定材料が `DISPLAY` であってポート到達性ではないため)。 + 各経路の違いは次のとおり(`gcloud auth login --help`)。 + + | 経路 | 手元に必要なもの | 受け渡すもの | + |---|---|---| + | 既定(この環境では下段と同じ挙動になる) | 別マシンのブラウザ | URL を渡し、**認証コード**を貼り戻す | + | `--no-launch-browser` | 別マシンのブラウザのみ | 同上 | + | `--no-browser` | 別マシンの**ブラウザ + gcloud 372.0 以上** | 生成コマンドを実行し、**長い URL** を貼り戻す | + +- 前提 16: **`gcloud auth login --update-adc` は `gcloud auth application-default login` と等価ではない。** + 前者は既定で `add_quota_project=False` のまま `ADC(creds).DumpADCToFile()` を呼ぶ + (`command_lib/auth/auth_util.py:197-220`) のに対し、後者は `DumpADCOptionalQuotaProject(creds)` を + 呼ぶ (`surface/auth/application_default/login.py:296`)。つまり **`--update-adc` では quota project が + ADC に書かれない**。quota project を要する API を使うなら `gcloud auth application-default login` を + 別に実行する。また `WriteGcloudCredentialsToADC` は `PromptIfADCEnvVarIsSet()` を呼び、 + `GOOGLE_APPLICATION_CREDENTIALS` が設定されていると「Credentials will still be generated to the + default location / To use these credentials, unset this environment variable before running your + application」と警告する (`同 174-190`)。`adc` モードでこの変数を unset する本プランの判断と一致する。 + +- 前提 17: **gws はベースイメージに入っておらず、現在どのコンテナにも存在しない**(実機確認)。 + `containers/base/Dockerfile` に `gws` / `googleworkspace` の記述は無く、npm グローバル導入は + `同 138` の `npm i -g yarn @playwright/test aws-cdk aws-cdk-lib typescript @google/gemini-cli @openai/codex` + 1 行のみで `@googleworkspace/cli` を含まない。稼働中の dev コンテナ 14 本すべてで `command -v gws` が + 空、`~/.config/gws` も存在しない。issue #116 が計測した 2.9MB は、調査対象コンテナ + (`eef62d0d42cb`) ごと失われている(`docker ps -a` に無い)。 + したがって設定ディレクトリを永続化するだけでは gws は復旧しないため、 + **本プランで `@googleworkspace/cli` をベースイメージへ含める**(Task 5)。 + npm 上のパッケージは `@googleworkspace/cli`(確認時 0.22.5、`bin` は `gws`)。 + +- 前提 18: 空の named volume は **root 所有**で作られ、uid 1000 では書き込めない(実機確認: + `docker run --rm -u 1000:1000 -v <空volume>:/v alpine touch /v/x` → `Permission denied`)。 + グループボリュームを `CLOUDSDK_CONFIG` の向き先にする以上、**export の前に `chown` が要る**。 + 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` / + `.ndf-statusline-backup.json` / `.ndf-statusline.lock` / `CLAUDE.md` / `backups` / + `cache` / `commands` / `daemon` / `daemon.log` / `debug` / `file-history` / + `history.jsonl` / `ide` / `jobs` / `logs` / `mcp-needs-auth-cache.json` / + `ndf-statusline.sh` / `paste-cache` / `plugins` / `projects` / `session-env` / + `sessions` / `settings.json` / `shell-snapshots` / `skills` / `tasks`)。 + 容量は `projects` 1.1GB・`plugins` 222MB・`file-history` 76MB・`session-env` 22MB・ + `jobs` 18MB で、`.claude` 全体は 1.5GB。`projects` は Claude Code の会話ログ実体であり、 + 分類表が `history.jsonl` を B とした理由(顧客情報が入りうる)がそのまま当てはまる。 + Claude Code は版が上がるたびに新しい子ディレクトリを作るため、**永続化するエントリを + 列挙する方式では列挙漏れが黙って揮発する**。 + +- 前提 19: ADC の解決を実機(`carmo-ai-dev-1`、gcloud 同梱の `google.auth`)で確認した結果: + (1) 鍵ありの現状は SA credentials が解決される(project `nyle-carmo-analysis`)、 + (2) `GOOGLE_APPLICATION_CREDENTIALS` が存在しないパスを指すと + `DefaultCredentialsError: File ... was not found.`(フォールバックしない。前提 10 の再確認)、 + (3) 2 変数を unset し ADC ファイルも無いと `Your default credentials were not found.`。 + (3) が `adc` モードで**まだログインしていない**ときの正常な状態であり、手順書の出発点になる。 + +## 受け入れ条件 + +- [x] AC1: 同じグループのコンテナで `devbase down` → `devbase up` の後、`gcloud auth list` が + **再認証なしで**同じ active account を返す。 +- [x] AC2: `gws` がベースイメージに含まれ(`command -v gws` が通り)、同条件で認証済みコマンドが再認証なしで通る(`$GOOGLE_WORKSPACE_CLI_CONFIG_DIR` 配下の + `credentials.enc` と `.encryption_key` が保たれる)。 +- [x] AC3: 異なるグループのコンテナが互いの認証を参照しない。検証: `kkg` グループのコンテナで + `gcloud auth list` / `claude mcp list` を実行し、`default` グループの認証が見えないこと。 +- [x] AC4: 共通資産が重複しない。検証: 2 グループのコンテナで `readlink -f ~/.claude/plugins` が + **同一の `/persistent/ai/.claude/plugins`** を指すこと。 +- [x] AC5: `DEVBASE_ACCOUNT_GROUP` 未設定のプロジェクトが `default` にフォールバックし、 + これまでどおり起動する。検証: 既存プロジェクトを `up` して entrypoint がエラーを出さないこと。 +- [x] AC6: 入れ子パスの symlink が正しく張られる。検証: `~/.claude/CLAUDE.md` と + `~/.claude/settings.json` が**壊れていない**(実体に到達できる)symlink であり、かつ + **ファイル**であること。`~/.claude/.credentials.json` に書き込めること、 + `~/.claude/history.jsonl` が**ディレクトリでない**こと(前提 5 の退行を防ぐ)。 + 当初は `.credentials.json` / `history.jsonl` 自体を symlink にする想定だったが、 + 不変条件の反転(既定をグループ側へ)により両者はグループボリューム上の実ファイルになる。 + 入れ子 symlink として残るのは分類 A の 5 件で、うち `CLAUDE.md` / `settings.json` が + 「親ディレクトリが無い入れ子のファイルエントリ」という前提 5 と同じ条件を満たす。 + あわせて、Dockerfile が焼き込む `~/.claude/settings.json`(hooks 設定)は symlink 張り替えの + `rm -rf` で消えるため、**張る前に共通側へ退避**する。退避しないと `/persistent/ai` に + 空ファイルだけが残り、hooks が初回起動で失われる(既存 main からの挙動を修正)。 +- [x] AC7: Docker のボリューム名にできないグループ名、予約語 `ubuntu`(`devbase_home_ubuntu` と衝突する)、 + および**数字のみの名前**(`devbase_home_` と衝突する。前提 6)を**起動前に拒否**し、 + 理由の分かるエラーを出す。 +- [x] AC8: `default` グループでは、**現行 `/persistent/ai` に実体がある**分類 B のデータ + (`.claude.json` / `.claude/.credentials.json` / 履歴 / `.gemini`)が**初回シードにより維持**され、 + Claude Code の再ログインが発生しない。検証: 現行環境で `up` 後に `claude` が未ログイン状態にならないこと。 + gcloud / gws は前提 1 のとおり現在どのボリュームにも無く**シード元が存在しない**ため、 + `default` を含む**全グループで初回 1 回だけ `gcloud auth login` / `gws auth login` が必要**である。 + これは AC8 の違反としない(AC1 / AC2 はその初回ログイン**以降**の維持を見る条件である)。 +- [x] AC9: スナップショットが共通・グループ両方のボリュームを対象にし、復元できる。 +- [x] AC10: `devbase status` に解決されたアカウントグループが表示される。 +- [x] AC11: 鍵モードで起動したとき、従来どおりサービスアカウント鍵が使える。 + 検証: `GCP_CREDENTIALS_BASE64__` を設定して `devbase up` した直後に + `$GOOGLE_APPLICATION_CREDENTIALS` のファイルが存在し中身が空でないこと(前提 14 の退行を防ぐ)。 +- [x] AC12: **認証モードを任意に切り替えられる。** 検証: + (1) `GCP_AUTH_MODE=adc` のプロジェクトで `up` し、コンテナ内で + `GOOGLE_APPLICATION_CREDENTIALS` と `BIGQUERY_KEY_FILE` が**未設定**であること、 + `gcloud auth application-default login` 済みのユーザー認証で `google.auth.default()` が通ること。 + (2) 同じプロジェクトの `env` へ `GCP_AUTH_MODE=key` を書いて `up` し直すと鍵が書かれ、 + ADC より優先されること(前提 10 の探索順)。 + (3) `key` → `adc` へ戻すと 2 変数が未設定に戻り、前提 10 の `DefaultCredentialsError` が + 起きないこと。**この (3) が最も壊れやすい**(変数だけ残ると ADC がフォールバックせず落ちる)。 + あわせて `tests/containers/` で `GCP_AUTH_MODE` × 鍵 env の有無の組み合わせを固定する。 +- [x] AC13: サービスアカウント鍵が**永続化されない**。検証: 鍵モードで `up` したあと + `devbase down` し、鍵の env を外して `up` し直すと `$DEFAULT_CREDS_PATH` にファイルが + **存在しない**こと。グループボリューム (`/persistent/group`) 配下にも鍵が無いこと。 +- [ ] AC14: **手順書だけを見て、第三者が新しいグループの Google 認証を完了できる。** + 検証: `docs/user/google-auth.md` の手順を、書いた本人以外(または記憶に頼らず手順書だけを見て) + 未認証のグループで最初から実行し、`gcloud auth list` / `google.auth.default()` / `gws` の + いずれもが通ること。詰まった箇所は手順書へ反映してから完了とする。 + 記載するコマンドと出力はすべて**実機で実行した結果を貼る**(想像で書かない)。 + +## 実機検証の結果 + +2026-08-29、`devbase build --no-cache` でベースイメージを再ビルドしたうえで、 +`carmo-ai` プロジェクトを `default` / `kkg` の 2 グループで起動して確認した。 + +| AC | 結果 | 主な根拠 | +|---|---|---| +| AC1 | ✅ | `devbase down` → `up` の後も `gcloud auth list` が `takemi_ohama@kk-generation.com` を返し、`google.auth.default()` がユーザー認証 (`Credentials`) で通った | +| AC2 | ✅ | `gws 0.22.5` がイメージに同梱。`credentials.enc` (334B) が再作成後も残り `auth_method: oauth2` / `storage: encrypted` を維持 | +| AC3 | ✅ | `devbase_home_default/gcloud/legacy_credentials` = `takemi_ohama@nyle.co.jp`、`devbase_home_kkg/...` = `takemi_ohama@kk-generation.com`。`kkg` から `~/.claude/.credentials.json` は見えない | +| AC4 | ✅ | 両グループとも `readlink -f ~/.claude/plugins` = `/persistent/ai/.claude/plugins` | +| AC5 | ✅ | `DEVBASE_ACCOUNT_GROUP` 未設定の `carmo-ai` が `default` で起動 | +| AC6 | ✅ | `~/.claude/CLAUDE.md` / `settings.json` は壊れていない symlink かつファイル。`history.jsonl` はディレクトリでない | +| AC7 | ✅ | `ubuntu` / `1` / `bad name` の 3 種が `Deploy failed:` + 理由で exit=1。**稼働中コンテナは無傷** | +| AC8 | ✅ | シードで `.claude.json` の `oauthAccount` と MCP OAuth 6 件、`projects` 73 件 (1.2GB) を維持。再ログインなし | +| AC9 | ✅ | 2 ボリュームの作成・一覧・復元を使い捨てボリュームで往復確認。旧レイアウト (`volume: devbase_home_ubuntu`) の実在世代は **`full` + `incr-001` まで**復元できた(`incr-002` は後述の別件で失敗。PLAN39 前の `main` でも同じく失敗するため、この AC の判定からは外している) | +| AC10 | ✅ | `devbase status` の `[環境]` に `default (devbase_home_default / 既定)` / `kkg (devbase_home_kkg / env)` を表示 | +| AC11 | ✅ | 鍵モードで `credentials.json` (2376B) が書かれ、`google.auth.default()` が SA で `nyle-carmo-analysis` を解決 | +| AC12 | ✅ | `adc` → `key` → `adc` を往復。戻り方向で 2 変数が未設定に戻り、`DefaultCredentialsError: Your default credentials were not found.`(= 未ログインの正常状態)になった | +| AC13 | ✅ | 鍵モードから `adc` へ戻すと `~/.config/gcloud/credentials.json` が消え、`/persistent` 配下に鍵は無い | +| AC14 | ✅ | 未認証の `kkg` グループを用意して `docs/user/google-auth.md` を通しで実行。詰まった 6 点を手順書へ反映した | + +### 検証中に見つかった別件(PLAN39 の退行ではない) + +- 旧世代スナップショット `20260823-114528` の `incr-002` 適用で GNU tar が + `Cannot rename ... Directory not empty` で失敗する。**現行 `main` と同じ旧コマンド形式で + 再現した**ため PLAN39 由来ではない(full + incr-001 までは正常に復元できる)。別 issue とする。 +- `$DEVBASE_ROOT/env` は `.gitignore` の対象外。機密を誤って書くと `git add -A` で混入する。 + また `source` されるため `KEY=値` の形でない行を書くと `devbase` コマンド自体が壊れる。別件とする。 + +## 代替案と採否 + +| 案 | 内容 | 採否 | 理由 | +|---|---|---|---| +| **A. 共通ボリューム + グループボリュームの二層** | `/persistent/ai` は現行のまま、`/persistent/group` に `devbase_home_` を追加マウント | **採用** | 共通資産(plugins 238MB 等)を重複させずに認証だけ分離できる。既存 `devbase_home_ubuntu` を触らないので分類 A のデータ移行が不要 | +| B. ディレクトリを丸ごとグループ別ボリュームへ | `~/.claude` ごと `devbase_home_` に置く | 不採用 | `plugins` / `skills` / `commands` / グローバル `CLAUDE.md` までグループ数だけ複製され二重管理になる。粒度が粗すぎる | +| C. 環境変数から毎回復元(AWS 方式) | `GCLOUD_CREDENTIALS_BASE64` のようなキーを増やす | 不採用 | gcloud のユーザー OAuth は `credentials.db` / `access_tokens.db` を含む可変の状態で、リフレッシュのたびに更新される。env へ書き戻す経路が無い | +| D. グループ別ボリューム 1 本だけにする(共通ボリュームを廃止) | 全部を `devbase_home_` へ | 不採用 | B と同じ重複問題に加え、既存 `devbase_home_ubuntu` からの全データ移行が必要になる | +| **A'. `default` グループの初回シード** | グループボリュームが空なら `/persistent/ai` の分類 B 相当を**コピー**して初期化(`default` のみ) | **採用** | 現行 14 コンテナの大半を占める `default` で再ログインを避けられる。move ではなく copy なので切り戻し時に元データが残る。ただしシード元は現行 `/persistent/ai` にあるものに限られ、gcloud / gws は対象外(AC8) | +| A''. シードせず全グループで再認証 | issue #116 の当初案 | 不採用 | `default` まで再ログインさせる必要がない。分離の目的は「グループ間で混ぜない」ことであって「捨てる」ことではない | +| **E. gcloud / gws の設定ディレクトリを env で差し替える** | `CLOUDSDK_CONFIG` / `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` をグループボリューム配下へ向ける | **採用** | 公式サポートの経路(前提 8 / 12)。入れ子 symlink が不要になり ADC ファイルも一緒に移る。SA 鍵の出力先 `~/.config/gcloud` は永続領域の外に残るため、鍵が持ち越されず削除仕様そのものが不要になる | +| E'. `.config/gcloud` / `.config/gws` を分類 B の symlink に足す | issue #116 の当初案 | 不採用 | 永続領域の中に SA 鍵の出力先が入るため、プロファイル切替時に旧い鍵が残る。塞ぐには「どのパスを消してよいか」の判定が要り、2 変数がプロジェクト `env` から上書き可能(前提 11)なぶん管理外のファイルを消す危険が残る。E ならこの問題自体が発生しない | +| **F. ADC を既定にし、SA 鍵は任意で切り替える** | `GCP_AUTH_MODE=adc\|key`(既定: 鍵の env があれば `key`、無ければ `adc`) | **採用** | Google は SA 鍵を非推奨とし、ローカル開発には `gcloud auth application-default login` を推奨している。本プランでユーザー認証をグループ単位に永続化するので ADC が現実的になる。権限の都合で鍵が要る場面は残るため切り替えを残す | +| G. Workload Identity Federation で鍵を全廃 | 外部 IdP のトークンを STS で交換し短命トークンを得る | 不採用(将来) | 鍵の全廃としては本命だが外部 IdP が要る。開発 Mac 上のコンテナには適用先が無い | +| H. サービスアカウントのインパーソネーション | `gcloud config set auth/impersonate_service_account` | 不採用(将来の選択肢) | 鍵ファイル無しで短命トークンを得られる中間解。ユーザー認証を基点にするため F の後なら追加しやすい。今回はスコープ外 | + +## ドメイン用語 + +| 用語 | 意味 | +|---|---| +| アカウントグループ | 使用する Google / AWS アカウントの単位。`DEVBASE_ACCOUNT_GROUP` で宣言する(`default` / `kkg` / `with`) | +| 共通ボリューム | `devbase_home_ubuntu` → `/persistent/ai`。全グループ共有(分類 A) | +| グループボリューム | `devbase_home_` → `/persistent/group`。グループ単位(分類 B) | +| 分類 A / B / C | A=全グループ共通、B=グループ別、C=永続化せず env から毎回復元 | + +## 永続化対象の分類 + +issue #116 の「検討が必要な点」3 件は次のとおり決定した。 + +| 対象 | 分類 | 決定の理由 | +|---|---|---| +| `.claude.json` | **B** | `oauthAccount` を持ち `.credentials.json` と対になる。片方だけ分けるとログイン状態の表示と実体がずれる | +| `.claude/.credentials.json` | **B** | `mcpOAuth`(Google Drive / Slack / Notion×3 / Atlassian)が各 SaaS の企業テナントに紐づく。本体 OAuth も重複するが、実害はグループごとの初回 1 回のログインのみ | +| `.claude/history.jsonl`, `.claude/file-history` | **B** | 会話履歴に顧客情報が入りうる | +| `.gemini` | **B** | `security.auth.selectedType = vertex-ai` で GCP プロジェクトに紐づく | +| `.config/gcloud`, `.config/gws` | **B**(symlink ではなく env で差し替え) | 問題1の本体。`CLOUDSDK_CONFIG` / `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` をグループボリューム配下へ向ける(前提 8 / 12)。**symlink 対象にはしない** | +| `.claude/plugins`, `.claude/skills`, `.claude/commands`, `.claude/CLAUDE.md`, `.claude/settings.json` | **A** | 契約やテナントに紐づかない共通資産。238MB を重複させない。**`.claude` 配下で A なのはこの 5 件だけ**で、残りはすべて B(既定)になる | +| `.claude/projects`, `.claude/sessions`, `.claude/tasks`, `.claude/session-env` ほか `.claude` 配下の未列挙エントリ | **B**(既定) | 会話ログとセッション状態。顧客情報が入りうる点は `history.jsonl` と同じ。列挙せず既定で B にすることで、Claude Code が将来増やす子ディレクトリも取りこぼさない(前提 20) | +| `.codex`, `.kiro`, `.serena`, `share` | **A** | Codex は ChatGPT アカウント、Kiro は AWS 側(env 由来)で分離済み | +| `.ssh` | **A**(現状維持) | entrypoint は `.ssh` を参照しておらず、git 認証は `GIT_CREDENTIALS_BASE64` / `GH_TOKEN` で完結している。企業テナントの境界になっていない。必要になれば配列間の 1 行移動で B へ移せる | +| `.aws`, `.git-credentials`, `.gitconfig` | **C** | env から毎回復元(現行どおり) | + +## 不変条件 + +- 分類 A のエントリは、どのグループのコンテナから見ても `/persistent/ai` 配下の**同一実体**を指す。 +- 分類 B のエントリは、異なるグループのコンテナから**互いに到達できない**。 +- グループ名が未指定でも起動できる(`default` へフォールバック)。 +- `~/.claude` の**既定はグループ側**である。`~/.claude` は `/persistent/group/.claude` への + シンボリックリンクで、その配下に分類 A のエントリだけが共通側 (`/persistent/ai/.claude/`) + への シンボリックリンクとして並ぶ。 + 当初は「`~/.claude` を実ディレクトリにし、A / B 双方の symlink を並べる」としていたが、 + 前提 20 のとおり `.claude` の子要素は 30 件あり、列挙方式では `projects`(1.1GB の会話ログ) + のような**未列挙の子が黙って揮発する**。既定をグループ側へ倒し、共通にしたいものだけを + 名指しする向きに反転した。`~/.claude` が symlink であること自体は現行 `main` と同じで、 + 変わるのは向き先だけである。 +- サービスアカウント鍵は**永続領域に置かない**。毎起動 env から書き直され、コンテナ層とともに消える。 + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +|---|---|---| +| `DEVBASE_ACCOUNT_GROUP` | 新規キー | 追加のみ。未設定は `default` | +| 生成 compose | dev サービスへ `/persistent/group` のマウントが増える | 追加のみ。`devbase up` で再生成される | +| プロジェクトの `compose.yml` | 変更不要 | 前提 4 の自動補完に載る | +| `devbase_home_ubuntu` | **変更しない** | 分類 A のデータはパスも含めてそのまま (`/persistent/ai/.claude/plugins` は移動しない) | +| 分類 B のデータ | 共通 → グループボリュームへ | 現行 `/persistent/ai` に実体があるもの(`.claude.json` / 認証 / 履歴 / `.gemini`)は `default` のみ初回シードで維持(AC8)。gcloud / gws はシード元が無く、`default` を含む全グループで初回 1 回の再認証が要る | +| `GCP_AUTH_MODE` | 新規キー | 追加のみ。未設定なら鍵の env の有無で auto 判定するため、既存プロジェクトは現行どおり `key` 相当で動く | +| `GOOGLE_APPLICATION_CREDENTIALS` / `BIGQUERY_KEY_FILE` | `adc` モードでは unset される | 既定パスは変えないため、鍵モードの既存プロジェクトは影響を受けない。`adc` へ移すのは利用者の明示操作 | +| `~/.config/gcloud` の意味 | gcloud の設定ディレクトリ → 単なる鍵の置き場 | `CLOUDSDK_CONFIG` が実際の設定ディレクトリを指す。パスを直接参照している外部ツールがあれば `$CLOUDSDK_CONFIG` を見るよう直す必要がある | +| スナップショット | 対象ボリュームが 2 系統になる | 既存スナップショットは共通ボリューム分として復元可能。メタデータに対象ボリュームを記録する | + +## 修正対象 + +- `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 受け渡し +- `lib/devbase/snapshot/manager.py` — 対象ボリュームの複数化 +- `lib/devbase/commands/container.py` — `status` へのグループ表示、`up` 時のグループ解決 +- `containers/base/Dockerfile` — npm グローバルへ `@googleworkspace/cli` を追加(前提 17) +- `containers/base/entrypoint.sh` — `AI_SETTINGS` の 2 系統化、入れ子パス対応、初回シード、`CLOUDSDK_CONFIG` / `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` の設定、認証モードの分岐、起動ログ +- `docs/user/google-auth.md`(新規。Google 認証の手順書) +- `docs/user/container-operations.md` / `docs/user/environment-variables.md` / + `docs/user/snapshot-guide.md` / `docs/plugin-dev/compose-yml-guidelines.md` / + `docs/plugin-dev/quickstart.md` / `README.md` / `CHANGELOG.md` +- `tests/volume/`, `tests/snapshot/`, `tests/containers/` + +## PR 分割計画 + +``` +release branch: release/PLAN39 +base branch: main +``` + +| PR # | branch 名 | 概要 | 依存 | 並行可否 | +|---|---|---|---|---| +| 1 | `feature/PLAN39-volume` | `DEVBASE_ACCOUNT_GROUP` の解決・検証とグループボリュームの作成・マウント(Python 側) | なし | ○ | +| 2 | `feature/PLAN39-entrypoint` | `AI_SETTINGS` の 2 系統化、入れ子パス対応、`default` の初回シード | PR1 | × (PR1 merge 後) | +| 3 | `feature/PLAN39-gcloud` | `CLOUDSDK_CONFIG` / `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` の差し替えと `GCP_AUTH_MODE`(問題1の解消) | PR2 | × (PR2 merge 後) | +| 4 | `feature/PLAN39-observability` | snapshot のグループ対応、`devbase status` 表示、起動ログ、ドキュメント整備 | PR1 | ○ (PR2/PR3 と並行可) | +| 5 | `feature/PLAN39-authdoc` | Google 認証の手順書 `docs/user/google-auth.md` の作成(実機で全手順を実行して書く) | PR3 | × (PR3 merge 後) | + +issue #116 は「Phase 1・2 を入れずに Phase 3 だけを適用すると問題2が顕在化する」として順序固定を求めているが、 +**個別 PR の merge 先は `release/PLAN39` であり `main` ではない**ため、この制約は release PR が +まとまって merge されることで自動的に満たされる。PR 内の依存順は上表のとおり守る。 + +## タスク分解 + +### Task 1: グループ名の解決と検証(PR1) + +- **対象ファイル:** `lib/devbase/env/keys.py`, `lib/devbase/volume/manager.py`, `tests/volume/test_manager_group.py` +- **変更内容:** `resolve_account_group()` と `get_group_volume(group)` を追加する。解決順は + 引数 → `os.environ["DEVBASE_ACCOUNT_GROUP"]`(前提 3 により 3 レベルの解決結果が入っている)→ `default`。 + ボリューム名は `devbase_home_`。次の 3 つを `DevbaseError` で弾く(AC7)。 + (a) `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` に合わないもの(Docker のボリューム名にできない)。 + (b) 予約語 `ubuntu`(`devbase_home_ubuntu` が共通ボリュームと衝突する)。 + (c) `^[0-9]+$` に合う**数字のみの名前**(`devbase_home_` と衝突する。 + `volume/manager.py:58-68,146-157` の `get_volume_for_index` が同じ名前空間を使う。前提 6)。 + (b)(c) は (a) を通過するため、正規表現とは別のチェックとして明示的に持つ。 +- **満たす受け入れ条件:** AC5, AC7 +- **進め方:** テスト駆動。フォールバック・正常系・拒否ケースを先に固定する。 +- **補足:** 未使用の `AI_VOLUME_PREFIX`(前提 6)は本 PR で削除する。用途を与えると + `devbase_ai_` / `devbase_home_` の 2 系統が並び、命名が説明できなくなるため。 + +### Task 2: グループボリュームの作成とマウント(PR1) + +- **対象ファイル:** `lib/devbase/volume/manager.py`, `lib/devbase/volume/compose.py`, + `lib/devbase/commands/container.py`, `tests/volume/test_compose_group.py` +- **変更内容:** `ensure_volumes()` でグループボリュームも作成する。`_replace_volumes_for_instance` の + `replacements` に `/persistent/group` を足し、`_build_volumes_section` で `external: true` として宣言する。 + entrypoint がシード判定に使うため、dev サービスの environment に `DEVBASE_ACCOUNT_GROUP` を載せる。 +- **満たす受け入れ条件:** AC3, AC5 +- **進め方:** テスト駆動。既存の `/persistent/ai` `/work` 差し替えテストと同じ形で、 + マウント・ボリューム宣言・env の 3 点を検証する。 + +### Task 3: symlink ループの入れ子パス対応(PR2) + +- **対象ファイル:** `containers/base/entrypoint.sh`, `tests/containers/` +- **変更内容:** 前提 5 の 2 つの不具合を先に直す。(a) ホーム側・永続領域側の**双方**で + `mkdir -p "$(dirname ...)"` を行う。(b) ファイルかディレクトリかの判定を拡張子リスト + (`*.json` のみ) から改め、`.jsonl` を含む「ファイルとして作るエントリ」を明示的に列挙する。 +- **満たす受け入れ条件:** AC6 +- **進め方:** テスト駆動。`DEVBASE_ENTRYPOINT_LIB_ONLY=1`(`entrypoint.sh:182`)で関数を source し、 + 一時ディレクトリを persistent 相当に見立てて検証する。**base イメージの再ビルドが必要** + ([[entrypoint-change-needs-rebuild]])。 + +### Task 4: AI_SETTINGS の 2 系統化と初回シード(PR2) + +- **対象ファイル:** `containers/base/entrypoint.sh`, `tests/containers/` +- **変更内容:** `AI_SETTINGS` を 3 つの配列に分ける。 + `DEVBASE_SHARED_SETTINGS`(ホーム直下・分類 A → `/persistent/ai`)、 + `DEVBASE_GROUP_SETTINGS`(ホーム直下・分類 B → `/persistent/group`)、 + `DEVBASE_SHARED_CLAUDE_SETTINGS`(`.claude` 配下の分類 A。グループ側の `.claude` から + 共通側へ張る)。`~/.claude` は symlink のまま向き先を `/persistent/group/.claude` へ変え、 + その配下に共通資産 5 件の symlink を張る(不変条件の反転。理由は前提 20)。 + symlink 生成の**前に**、`DEVBASE_ACCOUNT_GROUP` が `default` で + かつグループ側に実体が無いエントリだけ、`/persistent/ai` から**コピー**して初期化する。 + `.claude` のシードでは分類 A の 5 件を**除外**する(共通資産を重複させないため。 + 除外しないと直後の symlink 生成が消すだけの無駄なコピーになる)。 + `~/.config/gcloud` を symlink 対象に**しない**ため(Task 5 は env で差し替える)、 + 前提 14 の実行順序による事故は起きない。**symlink ブロックの移動は行わない**。 + ただし将来 `~` 直下の生成物を symlink 対象へ加えると同じ衝突が起きるので、 + 「symlink ループは `rm -rf "$HOME_PATH"` してから `ln -s` する」ことをコメントで明示し、 + AC11 を退行検知として残す。 +- **満たす受け入れ条件:** AC3, AC4, AC8, AC11 +- **進め方:** テスト駆動。シードの冪等性(2 回目は何もしない)と、非 `default` グループで + シードが走らないことをテストで固定する。 + +### Task 5: gcloud / gws の設定ディレクトリ差し替えと認証モード(PR3) + +- **対象ファイル:** `containers/base/Dockerfile`, `containers/base/entrypoint.sh`, + `lib/devbase/env/keys.py`, `lib/devbase/env/collectors/google.py`, `tests/containers/`, + `docs/user/container-operations.md`, `docs/user/environment-variables.md` +- **変更内容:** `.config/gcloud` / `.config/gws` を symlink 対象にはせず、**設定ディレクトリごと + グループボリュームへ向ける**(前提 8 / 12)。あわせて認証モードを切り替え可能にする。 + + ``` + CLOUDSDK_CONFIG=/persistent/group/gcloud + GOOGLE_WORKSPACE_CLI_CONFIG_DIR=/persistent/group/gws + ``` + + この 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 が + `mkdir -p` + `chown` してから export する(空ボリュームは root 所有で作られ uid 1000 では書けない。前提 18)。 + +- **認証モード:** `GCP_AUTH_MODE` を新設する。 + + | 値 | 挙動 | + |---|---| + | `adc`(既定の推奨) | 鍵を書かない。`GOOGLE_APPLICATION_CREDENTIALS` と `BIGQUERY_KEY_FILE` を **unset** し、ADC を `$CLOUDSDK_CONFIG/application_default_credentials.json`(= `gcloud auth application-default login` の結果)に委ねる | + | `key` | 現行どおり `GCP_CREDENTIALS_BASE64__` を復号して書き、2 変数を export する | + | 未設定(auto) | 鍵の env があれば `key`、無ければ `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` 側も鍵を登録したときだけ書くよう直す。 + +- **鍵の出力先:** 現行のまま `/home/${USERNAME}/.config/gcloud/credentials.json` を既定とする + (`entrypoint.sh:198`)。`CLOUDSDK_CONFIG` を向け直した後の `~/.config/gcloud` は + **gcloud の設定ディレクトリではなく、単なる鍵の置き場**になり、コンテナ層(揮発)に残る。 + したがって鍵は毎起動 env から書き直され、`devbase up` が `down` を挟んでコンテナを作り直す以上 + **持ち越されない**。プロファイルを切り替えても旧い鍵は存在しないので、削除仕様は要らない。 + 既定パスを変えないのは、既存プロジェクトの `env` が `BIGQUERY_KEY_FILE` にこのパスを + 書いているため(前提 11)。ドキュメントには「このディレクトリは gcloud の設定ではない」旨を明記する。 + +- **満たす受け入れ条件:** AC1, AC2, AC11, AC12, AC13 +- **進め方:** テスト駆動 + 実機検証。`tests/containers/` で `DEVBASE_ENTRYPOINT_LIB_ONLY` + (`entrypoint.sh:182-184`) を使い、`GCP_AUTH_MODE` × 鍵 env の有無で + 「2 変数が export されるか unset されるか」を固定する。実機では + `gcloud auth login` / `gcloud auth application-default login` → `devbase down` → `up` → + `gcloud auth list` と `google.auth.default()` が再認証なしで通ることを確認する。 +- **注意:** 前提 13 のとおり gcloud は並行実行を想定していない。同一グループで複数コンテナを + 同時に動かすと `database is locked` が出うる。恒久対策は取らず、リスク表に記録して + 再実行で回避する方針とする。 + +### Task 6: スナップショットのグループ対応(PR4) + +- **対象ファイル:** `lib/devbase/snapshot/manager.py`, `tests/snapshot/` +- **変更内容:** `VOLUME_NAME` 固定(前提 7)を改め、共通ボリュームと解決されたグループボリュームの + 両方を対象にする。メタデータ (`snapshot.yml`) に対象ボリューム名を記録し、既存スナップショット + (`volume: devbase_home_ubuntu` のみ)も復元できるようにする。 +- **満たす受け入れ条件:** AC9 +- **進め方:** テスト駆動。旧メタデータの読み込み互換を先にテストで固定する。 + +### Task 7: 可視化とドキュメント(PR4) + +- **対象ファイル:** `lib/devbase/commands/container.py`, `containers/base/entrypoint.sh`, + `docs/`, `README.md`, `CHANGELOG.md` +- **変更内容:** `devbase status` に解決されたグループを表示する。entrypoint の起動時に + グループと `gcloud config get account` の結果を 1 行ログ出力する。`entrypoint.sh` は + `set -e`(`containers/base/entrypoint.sh:3`)で動くため、未ログイン時に `gcloud` が非 0 を返しても + 起動が落ちないよう `$(gcloud config get account 2>/dev/null || echo "unset")` でフォールバックする。ボリューム構造の表 + (`container-operations.md` / `compose-yml-guidelines.md` / `quickstart.md`)と + `environment-variables.md` の `DEVBASE_ACCOUNT_GROUP`、`snapshot-guide.md` の対象ボリュームを更新する。 +- **満たす受け入れ条件:** AC10 +- **進め方:** 表示とログは実機確認。ドキュメントは文書のみ。 + +### Task 8: Google 認証の手順書(PR5) + +- **対象ファイル:** `docs/user/google-auth.md`(新規)、`docs/user/container-operations.md`(相互リンク)、 + `docs/user/environment-variables.md`(`GCP_AUTH_MODE` からの参照)、`README.md`(目次) +- **位置づけ:** Task 1〜7 は「仕組みを作る」タスクだが、この仕組みは**グループごとに人が 1 回 + 対話的に認証する**ことを前提にしている。その 1 回をどう実施するかが書かれていないと、 + 新しいグループを足すたびに手探りになる。手順書はこのプランの成果物の一部とする。 +- **前提:** PR3 が merge され、`CLOUDSDK_CONFIG` と `GCP_AUTH_MODE` が実際に動く状態であること。 + **書きながら実機で全手順を実行する**(想像で書かない。`ndf:investigation-rules`)。 +- **書く内容:** + + 1. **前提の説明** — アカウントグループとは何か、どのボリュームに何が入るか、 + `~/.config/gcloud` は gcloud の設定ディレクトリ**ではない**こと(`$CLOUDSDK_CONFIG` を見る)。 + 2. **新しいグループの初回セットアップ** — `projects//env` に + `DEVBASE_ACCOUNT_GROUP` / `GCP_ACTIVE_PROFILE` / `AWS_PROFILE` を書く → `devbase up` → + コンテナ内で認証する、までを 1 本の流れとして書く。 + 3. **gcloud の認証** — 前提 15 / 16 で調査済みなので、手順としては次を書けばよい。 + `gcloud auth login`(フラグ不要。この環境では自動で URL + 認証コードのフローになる)と、 + ADC 用に `gcloud auth application-default login` の 2 回。`--update-adc` で 1 回に減らす案は + quota project が書かれないため(前提 16)**既定の手順にはしない**。 + 残る実地確認: 実際に 1 回通して、貼り戻しの UI(プロンプト文言)と所要時間を手順書に書き写す。 + 4. **gws の認証** — バイナリは Task 5 でベースイメージに入るので、手順書は認証から書く。 + `gws auth setup`(Cloud プロジェクト設定。gcloud に依存する)と `gws auth login` を実地確認し、 + `GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file` が必要かどうか(コンテナに OS キーリングが無い場合の挙動)を + 確かめて書く。 + 5. **認証モードの切り替え** — `GCP_AUTH_MODE` の `adc` / `key` / 未設定の使い分けと、 + 切り替え後に `devbase up` が必要なこと。鍵が要るのはどういう場面かを 1 段落で書く。 + 6. **確認コマンド** — `gcloud auth list` / `gcloud config get account` / + `python3 -c "import google.auth; print(google.auth.default()[1])"` / `gws` の疎通確認。 + **いま自分がどのグループにいるか**の確認方法(`devbase status` の表示、`echo $CLOUDSDK_CONFIG`)。 + 7. **トラブルシュート** — 少なくとも次の 3 つ。いずれも本プランで実際に踏みうるもの: + `DefaultCredentialsError`(前提 10。`adc` なのに変数が残っている)、 + `database is locked`(前提 13。同一グループの同時実行)、 + 意図しないアカウントで操作していた場合の確認と切り替え。 +- **満たす受け入れ条件:** AC14 +- **進め方:** 文書のみ。ただし**未認証のグループを 1 つ用意して最初から通す**こと。 + 詰まった箇所は手順書に反映してから完了とする。 + +## 影響範囲 + +- 全プロジェクトの生成 compose(`devbase up` のたびに再生成されるため移行作業は不要)。 +- ディスク使用量: グループ数 × 分類 B のサイズ。実測では gcloud 3.9MB + gws 2.9MB + + `.claude.json` / 認証 / 履歴で数十 MB 程度。238MB の `plugins` は共通側に残るため増えない。 +- entrypoint と Dockerfile の変更のため base イメージの再ビルドが必要(Task 3・4・5・7)。 + `@googleworkspace/cli` の追加ぶんイメージが増えるが、既に再ビルドは必須なので追加の手間は無い。 +- スナップショットの世代管理の粒度が変わる(対象が 2 ボリュームになる)。 + +## リスクと対処 + +| リスク | 対処 | +|---|---| +| 入れ子パス対応の不備で `~/.claude` 配下が壊れ、Claude Code が起動しなくなる | Task 3 を Task 4 より先に、単独で検証する。AC6 で `history.jsonl` と `.credentials.json` を名指しで確認 | +| 初回シードが非 `default` グループでも走り、分離の意味が失われる | Task 4 でグループ名のガードをテストに固定。AC3 で実機確認 | +| グループ名が既存ボリューム名と衝突する(`ubuntu` は `devbase_home_ubuntu`、数字のみは `devbase_home_`) | Task 1 で正規表現とは別の明示チェックとして両方を拒否。AC7 | +| entrypoint 変更が `up` だけでは反映されない | [[entrypoint-change-needs-rebuild]]。検証手順に `devbase build --no-cache` を明記 | +| 既存スナップショットが復元できなくなる | Task 6 で旧メタデータ互換をテストで固定 | +| `GCP_AUTH_MODE=adc` で `GOOGLE_APPLICATION_CREDENTIALS` の unset を忘れると、値だけ残って実体が無く ADC が `DefaultCredentialsError` で落ちる(前提 10。フォールバックしない) | Task 5 で 2 変数を unset する。AC12 (3) で `key` → `adc` の戻り方向を実機とテストの両方で固定する | +| 同一グループの複数コンテナが同時に gcloud を叩き `database is locked` になる(前提 13。gcloud は並行実行非対応で `credentials.db` は SQLite) | 恒久対策は取らない。グループボリュームを共有する設計に内在するもので symlink 方式でも同じ。ドキュメントに再実行で回避する旨を書く | +| gws の設定を永続化しても、バイナリが無ければ復旧しない(前提 17) | Task 5 で `containers/base/Dockerfile:138` へ `@googleworkspace/cli` を足し、ベースイメージに含める。AC2 で `command -v gws` を確認する | +| `~/.config/gcloud` が gcloud の設定ディレクトリだと誤解され、そこを永続化しようとする揺り戻しが起きる | `CLOUDSDK_CONFIG` 導入後は単なる鍵の置き場である旨を Task 5 の記述と `docs/user/container-operations.md` に明記する | +| 切り戻し時に、シード後にグループ側だけへ書かれた認証・履歴が失われる | 切り戻し手順の同期ステップを必須とし、正とするグループを 1 つに決めてから実行する | + +## 切り戻し手順 + +初回シードは**その時点のコピー**であり、稼働開始後の認証更新(トークンのリフレッシュ、MCP の +再認可)と会話履歴は**グループボリューム側にしか書かれない**。したがって revert だけでは +`/persistent/ai` は**シード時点の状態**に戻る。次の順で行う。 + +1. **同期(revert より前に必ず行う)** — 正とするグループ(通常は `default`)のコンテナを + `devbase down` で止めたうえで、グループボリュームの分類 B を共通ボリュームへ書き戻す。 + `devbase down` でコンテナは削除されるため、以降はコンテナ経由(`docker cp`)ではなく + **ボリュームを一時コンテナへ直接マウントして**操作する。 + + ```bash + GROUP=default # 正とするグループ名 + + docker run --rm -v "devbase_home_${GROUP}:/from" -v devbase_home_ubuntu:/to alpine \ + sh -c 'for p in .claude.json .claude .gemini; do + if [ ! -e "/from/$p" ]; then echo "skip (未作成): $p"; continue; fi + if [ -d "/from/$p" ]; then + mkdir -p "/to/$p" + # 分類 A への symlink (plugins / skills / commands / CLAUDE.md / + # settings.json) は書き戻さない。共通側の実体を指すリンクなので、 + # 書き戻すと実体が自分自身を指す symlink に置き換わる + for c in "/from/$p"/* "/from/$p"/.[!.]*; do + [ -e "$c" ] || [ -L "$c" ] || continue + [ -L "$c" ] && { echo "skip (共通側への link): ${c##*/}"; continue; } + cp -a "$c" "/to/$p/" + done + else + cp -a "/from/$p" "/to/$p" + fi + echo "copied: $p" + done' + ``` + + グループ内で一度も使っていないツールのエントリは存在しないことがあるため、各パスの存在を + 確認してから `cp` し、無いものは `skip` として飛ばす(`&&` で連結すると 1 件目の欠落で + 以降の同期が止まる)。`/persistent/group/.claude` 配下には分類 A の実体へ向いた symlink が + 並ぶので(不変条件)、**symlink は書き戻さない**。書き戻すと共通側の実体 + (`/persistent/ai/.claude/plugins` 等) が自分自身を指す symlink に置き換わってしまう。 + + 対象は分類 B のうち共通側に対応物があるものに限る。gcloud / gws は共通ボリュームに置き場が無く、 + revert 後は永続化対象外(現行 main と同じ)へ戻るため書き戻さない。グループボリューム直下の + `gcloud/` `gws/`(`CLOUDSDK_CONFIG` / `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` の実体)を保全したい場合は、 + 同じくボリュームを直接マウントしてカレントディレクトリへ tar で退避する。 + + ```bash + GROUP=default + + docker run --rm -e GROUP="$GROUP" \ + -v "devbase_home_${GROUP}:/from" -v "$PWD:/backup" alpine \ + sh -c 'cd /from || exit 1 + set -- + for p in gcloud gws; do + if [ -e "$p" ]; then set -- "$@" "$p"; else echo "skip (未作成): $p"; fi + done + [ "$#" -gt 0 ] || { echo "退避対象なし"; exit 0; } + tar cf "/backup/devbase-${GROUP}-config.tar" "$@" && echo "saved: devbase-${GROUP}-config.tar"' + ``` + + 一時コンテナは root で動くため、Linux ホストでは生成された tar が root 所有になる。 + 必要なら `sudo chown "$(id -u):$(id -g)" devbase--config.tar` で引き取る。 +2. **検証** — `docker run --rm -v devbase_home_ubuntu:/v alpine ls -l /v/.claude /v/.claude.json` で、 + `.credentials.json` と `history.jsonl` が**ファイルとして**存在しサイズが 0 でないこと、 + `.claude/plugins` が壊れていないことを確認する。 +3. **競合時の扱い** — 共通ボリュームへ書き戻せるのは**1 グループ分だけ**で、後に書いた方が勝つ。 + 複数グループを運用していた場合は、**どのグループを正とするかを先に決めて手順 1 を 1 回だけ実行する**。 + 他グループのデータは `devbase_home_` に残るので、後から必要になれば対象を変えて再実行できる。 +4. **revert** — コード変更を revert し、`devbase build --no-cache` と `devbase up` で再生成する。 + `AI_SETTINGS` は元の 1 系統に戻り `/persistent/ai` 配下を参照する。 +5. **後片付け** — 不要になったグループボリュームは `docker volume rm devbase_home_` で削除する。 + そのボリュームをマウントしたコンテナが残っていると `volume is in use` で失敗するため、 + 対象グループのコンテナを先に `devbase down` で削除しておく。 + +手順 1 を省いて revert だけを行った場合も**起動はする**が、`default` はシード時点の認証・履歴で +立ち上がり、それ以降のログイン更新と会話履歴は失われる。急ぎで戻すときの許容ラインとして、 +この差を承知したうえで選ぶこと。 + +## 完了の定義 + +- [x] AC1〜AC14 を満たし、条件ごとに検証手段と結果が対応している(「実機検証の結果」節) +- [x] `uv run pytest` が green(1629 passed) +- [x] 個別 PR がすべて `/ndf:cross-review` で APPROVE 収束済み(#123 / #124 / #125 / #126) +- [ ] 本 PR #127(手順書と検証結果)が `/ndf:cross-review` で APPROVE 収束する。 + レビュー中は未チェックのままにし、収束を確認してからチェックする +- [x] `devbase build --no-cache` 後の実機で、`default` と非 `default` の 2 グループを起動して + AC1〜AC4 / AC8 / AC11〜AC13 を確認している +- [x] `docs/` と `CHANGELOG.md` が新しいボリューム構造と `DEVBASE_ACCOUNT_GROUP` / `GCP_AUTH_MODE` を説明している +- [x] `docs/user/google-auth.md` が実機で通した手順になっており、未認証のグループで通しの検証が済んでいる(AC14) 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/env/collectors/google.py b/lib/devbase/env/collectors/google.py index 4225379c..bf595c9a 100644 --- a/lib/devbase/env/collectors/google.py +++ b/lib/devbase/env/collectors/google.py @@ -75,6 +75,7 @@ def collect_google_credentials(env_file: EnvFile) -> None: if not profiles: existing = env_file.get(keys.gcp_credentials_key("default")) + has_key = bool(existing) if existing: logger.info("%s: 設定済み", keys.gcp_credentials_key("default")) else: @@ -83,9 +84,10 @@ def collect_google_credentials(env_file: EnvFile) -> None: creds_path = Path(creds_path_str).expanduser() if creds_path.exists(): _register_profile(env_file, 'default', creds_path) + has_key = True else: logger.error("ファイルが見つかりません: %s", creds_path) - _collect_common_settings(env_file) + _collect_common_settings(env_file, has_key=has_key) return print(f"\n検出されたcredential ({len(profiles)}件):") @@ -111,7 +113,7 @@ def collect_google_credentials(env_file: EnvFile) -> None: env_file.set(keys.BIGQUERY_PROJECT, project_id) logger.info("%s: %s", keys.GOOGLE_CLOUD_PROJECT, project_id) - _collect_common_settings(env_file) + _collect_common_settings(env_file, has_key=True) def _register_profile(env_file: EnvFile, name: str, file_path: Path) -> None: @@ -125,8 +127,17 @@ def _register_profile(env_file: EnvFile, name: str, file_path: Path) -> None: logger.error("credentialファイルの処理に失敗: %s", e) -def _collect_common_settings(env_file: EnvFile) -> None: - """GCP共通設定を収集""" +def _collect_common_settings(env_file: EnvFile, has_key: bool = False) -> None: + """GCP共通設定を収集 + + Args: + env_file: 書き込み先 + has_key: サービスアカウント鍵を登録したか。鍵モード専用の変数 + (``GOOGLE_APPLICATION_CREDENTIALS`` / ``BIGQUERY_KEY_FILE``) は + 鍵があるときだけ書く。実体の無いパスが env に残っていると ADC が + ユーザー認証へフォールバックせず ``DefaultCredentialsError`` で + 落ちるため (PLAN39 / 前提 10)。 + """ collect_key(env_file, keys.GOOGLE_CLOUD_LOCATION, auto_value="global", mask_after=0, prompt=f"{keys.GOOGLE_CLOUD_LOCATION} (デフォルト: global): ") @@ -136,7 +147,16 @@ def _collect_common_settings(env_file: EnvFile) -> None: collect_key(env_file, keys.BIGQUERY_LOCATION, auto_value="asia-northeast1", mask_after=0, prompt=f"{keys.BIGQUERY_LOCATION} (デフォルト: asia-northeast1): ") - # コンテナ内パス(devbaseコンテナイメージの仕様に依存) + if not has_key: + logger.info( + "サービスアカウント鍵が未登録のため %s / %s は設定しません " + "(ADC を使う場合は不要。詳細: docs/user/google-auth.md)", + keys.GOOGLE_APPLICATION_CREDENTIALS, keys.BIGQUERY_KEY_FILE) + return + + # コンテナ内パス(devbaseコンテナイメージの仕様に依存)。 + # CLOUDSDK_CONFIG を向け直したあとの ~/.config/gcloud は gcloud の設定 + # ディレクトリではなく、単なる鍵の置き場になる (PLAN39)。 env_file.set(keys.BIGQUERY_KEY_FILE, "/home/ubuntu/.config/gcloud/credentials.json") env_file.set(keys.GOOGLE_APPLICATION_CREDENTIALS, "/home/ubuntu/.config/gcloud/credentials.json") diff --git a/lib/devbase/env/gcp_auth.py b/lib/devbase/env/gcp_auth.py new file mode 100644 index 00000000..af236298 --- /dev/null +++ b/lib/devbase/env/gcp_auth.py @@ -0,0 +1,124 @@ +"""GCP の認証モード解決 (PLAN39) + +Google はサービスアカウント鍵を非推奨とし、ローカル開発には +``gcloud auth application-default login`` を推奨している。PLAN39 でユーザー認証を +アカウントグループ単位に永続化するため、ADC を既定の経路にできるようになった。 +権限の都合で鍵が要る場面は残るので ``GCP_AUTH_MODE`` で切り替えられる。 + +**`adc` では 2 変数を「値を空にする」のではなく「渡さない」のが要点**である。 +``GOOGLE_APPLICATION_CREDENTIALS`` が実在しないファイルを指していると、ADC は +ユーザー認証へフォールバックせず ``DefaultCredentialsError`` で落ちる。 + +コンテナへ渡る環境変数を決めるのは**ホスト側の生成 compose** であり、entrypoint の +``export`` / ``unset`` は PID 1 の子プロセスにしか効かない (``docker exec`` の +シェルはコンテナの env 設定を継承する)。したがって 2 変数の除外はここで行う。 +""" + +from typing import Mapping, Sequence + +from devbase.env import keys +from devbase.log import get_logger + +logger = get_logger(__name__) + +# 認証モード +AUTH_MODE_ADC = "adc" +AUTH_MODE_KEY = "key" +AUTH_MODES = (AUTH_MODE_ADC, AUTH_MODE_KEY) + +# 鍵モードでのみコンテナへ渡す変数。adc では渡さない (値を空にするのではない) +KEY_ONLY_ENV_KEYS = ( + keys.GOOGLE_APPLICATION_CREDENTIALS, + keys.BIGQUERY_KEY_FILE, +) + +# gcloud / gws の設定ディレクトリ。グループボリューム配下へ向けることで、 +# credentials.db / access_tokens.db / application_default_credentials.json と +# gws の credentials.enc / .encryption_key がグループ単位に分かれる。 +CLOUDSDK_CONFIG_DIR = "/persistent/group/gcloud" +GWS_CONFIG_DIR = "/persistent/group/gws" + +CLOUDSDK_CONFIG = "CLOUDSDK_CONFIG" +GOOGLE_WORKSPACE_CLI_CONFIG_DIR = "GOOGLE_WORKSPACE_CLI_CONFIG_DIR" + + +def active_profile(env: Mapping[str, str]) -> str: + """アクティブなプロファイル名を返す。 + + entrypoint の ``${GCP_ACTIVE_PROFILE:-default}`` と同じ解釈 (未設定・空なら + ``default``)。ホストとコンテナで別のプロファイルを見ないよう、判定はここへ + 集約する。 + """ + return (env.get(keys.GCP_ACTIVE_PROFILE) or "").strip() or "default" + + +def has_service_account_key(env: Mapping[str, str]) -> bool: + """**アクティブプロファイル**のサービスアカウント鍵が env にあるか。 + + プロファイル別の ``GCP_CREDENTIALS_BASE64__`` を見て、無ければ + 後方互換の ``GOOGLE_APPLICATION_CREDENTIALS_BASE64`` を見る。値が空の変数は + 「無い」として扱う (``env`` に空で書かれていても鍵にはならない)。 + + entrypoint の ``devbase_setup_gcp_credentials`` が見るのと**同じ 1 本だけ**を + 見るのが要点である。全プロファイルを走査すると、別プロファイルの鍵しか無い + 構成でホストは ``key`` と判定するのに、コンテナ側は鍵を書けず ``adc`` へ + 落ちる。その結果、実体の無いパスを指す 2 変数だけが生成 compose に残り、 + ``docker exec`` のシェルから使ったときに ``DefaultCredentialsError`` になる。 + """ + profile = active_profile(env) + return bool(env.get(keys.gcp_credentials_key(profile)) + or env.get(keys.GOOGLE_APPLICATION_CREDENTIALS_BASE64)) + + +def resolve_auth_mode(env: Mapping[str, str]) -> str: + """認証モードを解決する (entrypoint と同じ条件・同じフォールバックで)。 + + ``GCP_AUTH_MODE`` が ``adc`` なら鍵があっても ADC。それ以外 (``key`` 宣言・ + 未設定・空・未知の値) は**アクティブプロファイルの鍵の有無**で決める。 + + ``key`` を宣言していても鍵が無ければ ``adc`` へ倒すのは、entrypoint が同じ + フォールバックを持つため。ホストだけ ``key`` のままだと、鍵の実体が無いのに + 2 変数がコンテナへ渡り ``DefaultCredentialsError`` を招く。 + + 未知の値を拒否せず auto へ倒すのは、タイプミスで**既存プロジェクトが + 起動できなくなる**のを避けるため。auto は現行 main と同じ挙動になる。 + """ + declared = (env.get(keys.GCP_AUTH_MODE) or "").strip().lower() + if declared == AUTH_MODE_ADC: + return AUTH_MODE_ADC + if has_service_account_key(env): + return AUTH_MODE_KEY + if declared == AUTH_MODE_KEY: + logger.warning( + "%s=key ですが %s が env にありません。adc として構成します", + keys.GCP_AUTH_MODE, + keys.gcp_credentials_key(active_profile(env))) + return AUTH_MODE_ADC + + +def container_env(env: Mapping[str, str]) -> dict: + """dev サービスへ載せる GCP 関連の環境変数を組み立てる。 + + 設定ディレクトリはグループボリューム配下の固定パス。解決した認証モードも + 渡し、entrypoint 側で再解決させない (ホストとコンテナで判定がずれないよう + にする)。 + """ + return { + CLOUDSDK_CONFIG: CLOUDSDK_CONFIG_DIR, + GOOGLE_WORKSPACE_CLI_CONFIG_DIR: GWS_CONFIG_DIR, + keys.GCP_AUTH_MODE: resolve_auth_mode(env), + } + + +def key_only_env_names(mode: str) -> Sequence[str]: + """``mode`` で **dev の列挙から外す**変数名を返す (``key`` なら空)。 + + 生成 compose の ``environment:`` に名前が載らなければ、Compose はその変数を + コンテナへ渡さない。値を空文字にするのではなく**渡さない**ことで、 + ``docker exec`` のシェルから見ても未設定になる。 + + 外すのは devbase が管理する dev サービスの列挙だけである。``GCP_AUTH_MODE`` + は dev の認証方式の宣言であって、元々この 2 変数を ``env_file`` から受け取って + いた非 dev サービス (独自に鍵を持つ batch 等) の設定ではない。 + """ + return () if mode == AUTH_MODE_KEY else KEY_ONLY_ENV_KEYS diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index 44d7ef19..85f845aa 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -32,10 +32,15 @@ GOOGLE_CLOUD_PROJECT = "GOOGLE_CLOUD_PROJECT" GOOGLE_CLOUD_LOCATION = "GOOGLE_CLOUD_LOCATION" GOOGLE_APPLICATION_CREDENTIALS = "GOOGLE_APPLICATION_CREDENTIALS" +# 後方互換の単一プロファイル鍵 (GCP_CREDENTIALS_BASE64__ の前身) +GOOGLE_APPLICATION_CREDENTIALS_BASE64 = "GOOGLE_APPLICATION_CREDENTIALS_BASE64" BIGQUERY_PROJECT = "BIGQUERY_PROJECT" BIGQUERY_DATASETS = "BIGQUERY_DATASETS" BIGQUERY_LOCATION = "BIGQUERY_LOCATION" BIGQUERY_KEY_FILE = "BIGQUERY_KEY_FILE" +# 認証モード (PLAN39): `adc` = ユーザー認証 (ADC) / `key` = サービスアカウント鍵。 +# 未設定なら鍵の env の有無で auto 判定する。詳細: lib/devbase/env/gcp_auth.py +GCP_AUTH_MODE = "GCP_AUTH_MODE" def gcp_credentials_key(profile: str) -> str: @@ -43,6 +48,14 @@ def gcp_credentials_key(profile: str) -> str: return f"{GCP_CREDENTIALS_BASE64_PREFIX}{profile}" +# --- Account group (PLAN39: 永続化ボリュームのアカウントグループ分離) --- +# 使用する Google / AWS アカウントの単位。グループごとに devbase_home_ を +# 作り、コンテナへ /persistent/group としてマウントする。未設定なら `default`。 +# プロジェクト env / グローバル env に手書きする devbase 動作設定。 +# 詳細: docs/user/environment-variables.md +DEVBASE_ACCOUNT_GROUP = "DEVBASE_ACCOUNT_GROUP" + + # --- Slack --- SLACK_KEYS = ("SLACK_BOT_TOKEN", "SLACK_TEAM_ID", "SLACK_CHANNEL_ID", "SLACK_USER_MENTION") diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index aee46ffd..bb92faf4 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -9,12 +9,27 @@ import yaml -from devbase.errors import SnapshotError +from devbase.errors import DevbaseError, SnapshotError from devbase.log import get_logger +from devbase.volume.manager import ( + HOME_UBUNTU_VOLUME, + SHARED_VOLUME_PREFIX, + get_group_volume, + resolve_account_group, +) logger = get_logger(__name__) -VOLUME_NAME = 'devbase_home_ubuntu' +# 後方互換のために残す旧定数 (共通ボリューム 1 本だった頃の対象) +VOLUME_NAME = HOME_UBUNTU_VOLUME +# 対象ボリュームのマウント先サブディレクトリ (PLAN39)。 +# 共通ボリュームとアカウントグループのボリュームを 1 つのアーカイブへまとめるため、 +# コンテナ内では /source/ に並べて置く。 +SHARED_MOUNT = 'ai' +GROUP_MOUNT = 'group' +# メタデータから受け入れるマウント名。空文字は旧レイアウト (共通ボリューム 1 本を +# ルートへ直接マウント) を表す。 +_ALLOWED_MOUNTS = frozenset({'', SHARED_MOUNT, GROUP_MOUNT}) SNAPSHOT_IMAGE = 'devbase-snapshot:latest' DEFAULT_MAX_GENERATIONS = 3 DEFAULT_MAX_INCREMENTALS = 10 @@ -25,11 +40,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 +199,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 +225,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 +323,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 +382,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 +458,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 +466,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 +515,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 +531,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 +548,82 @@ 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 件として返す。 + + **値は検証してから返す。** ここで返した内容はマウント先として + ``docker run -v <値>:/target/<キー>`` に、キーは + :meth:`clear_command` が組み立てる ``bash -c`` の消去コマンドに入る。 + ``meta.yml`` は編集できるうえスナップショットは環境をまたいで持ち込めるため、 + 絶対パスを値に書けば任意のホストディレクトリを bind mount して**復元前に + 中身を消せて**しまう。キーは既知のマウント名だけ、値は Docker の named + volume として通る名前だけを許す。 + + Raises: + SnapshotError: メタデータの対象ボリュームが不正な場合 + """ + meta = self._load_snap_meta(snap_dir) + volumes = meta.get('volumes') + if isinstance(volumes, dict) and volumes: + return self._validate_volumes(volumes, snap_dir) + return self._validate_volumes( + {'': meta.get('volume', HOME_UBUNTU_VOLUME)}, snap_dir) + + @staticmethod + def _validate_volumes(volumes: dict, snap_dir: Path) -> dict: + """メタデータ由来の対象ボリュームを検証する (不正なら SnapshotError)。 + + **devbase が作るボリュームだけ**を許す。named volume の形をしていれば + 通す、では足りない: 同じ Docker 上の無関係なボリューム名 (``mysql_data`` + など) を書けば、復元前の消去でその中身を失わせられる。 + + - 共通側 (``''`` / ``ai``) は ``devbase_home_ubuntu`` に限る + - グループ側 (``group``) は ``devbase_home_`` の形で、```` + がアカウントグループ名として妥当なものに限る + """ + meta_path = snap_dir / 'meta.yml' + + def reject(reason: str) -> None: + raise SnapshotError( + f"スナップショットのメタデータが不正です ({meta_path}): {reason}") + + for sub, name in volumes.items(): + if sub not in _ALLOWED_MOUNTS: + reject(f"未知のマウント名 '{sub}'。" + f"使えるのは " + f"{', '.join(repr(m) for m in sorted(_ALLOWED_MOUNTS))} です") + if not isinstance(name, str): + reject(f"'{sub}' のボリューム名が文字列ではありません: {name!r}") + + if sub in ('', SHARED_MOUNT): + if name != HOME_UBUNTU_VOLUME: + reject(f"共通ボリュームに使えるのは {HOME_UBUNTU_VOLUME} だけです" + f" (指定: {name!r})") + continue + + # group: devbase_home_ の形で、 が妥当であること + if not name.startswith(SHARED_VOLUME_PREFIX): + reject(f"グループボリュームは {SHARED_VOLUME_PREFIX} の形で" + f"なければなりません (指定: {name!r})") + group = name[len(SHARED_VOLUME_PREFIX):] + try: + # 正規化した結果が元の名前と**一致**することまで見る。 + # resolve_account_group は空文字を 'default' に、前後空白を + # 落とした名前に正規化するので、通るかどうかだけでは + # `devbase_home_` や `devbase_home_ kkg ` を弾けない。 + # 実際にマウントされるのは正規化前の生の名前である。 + if get_group_volume(group) != name: + reject(f"グループボリューム {name!r} は正規化された名前では" + f"ありません (期待: {get_group_volume(group)!r})") + except DevbaseError as e: + reject(f"グループボリューム {name!r} のグループ名が不正です: {e}") + + return dict(volumes) + def _load_snap_meta(self, snap_dir: Path) -> dict: """個別スナップショットのmeta.ymlを読み込む""" meta_path = snap_dir / 'meta.yml' diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 2b9203d4..3c89b04f 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -8,11 +8,16 @@ Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, ) -from devbase.env import compose_migrate +from devbase.env import compose_migrate, gcp_auth, keys from devbase.errors import DockerError from devbase.log import get_logger -from .manager import get_work_volume_for_index, get_ai_volume_for_index +from .manager import ( + get_ai_volume_for_index, + get_group_volume, + get_work_volume_for_index, + resolve_account_group, +) logger = get_logger(__name__) @@ -66,15 +71,20 @@ def _volume_target(vol: Any) -> Optional[str]: def _replace_volumes_for_instance( - volumes: list, ai_volume: str, work_volume: str, + volumes: list, ai_volume: str, work_volume: str, group_volume: str, ) -> list: """Replace volume mounts in a service's volumes list for a specific instance. /home/ubuntu mounts are skipped (deprecated). - /persistent/ai is mapped to ai_volume. + /persistent/ai is mapped to ai_volume (shared by every container). + /persistent/group is mapped to group_volume (shared within the account group). /work is mapped to work_volume. """ - replacements = {'/persistent/ai': ai_volume, '/work': work_volume} + replacements = { + '/persistent/ai': ai_volume, + '/persistent/group': group_volume, + '/work': work_volume, + } replaced_targets = set() new_volumes = [] @@ -107,7 +117,7 @@ def _replace_volumes_for_instance( return new_volumes -def _build_volumes_section(config: dict, scale: int) -> dict: +def _build_volumes_section(config: dict, scale: int, group_volume: str) -> dict: """Build the volumes section for a scaled compose file.""" # Copy original volumes (mysql, valkey, etc.) from config volumes: Dict[str, Any] = { @@ -118,6 +128,10 @@ def _build_volumes_section(config: dict, scale: int) -> dict: # Add shared home volume (devbase_home_ubuntu) once for all instances volumes[get_ai_volume_for_index(1)] = {'external': True} + # Add the account group volume (devbase_home_), shared by every + # container of the group (PLAN39) + volumes[group_volume] = {'external': True} + # Add work volumes for each dev instance (external) for i in range(1, scale + 1): volumes[get_work_volume_for_index(i)] = {'external': True} @@ -206,6 +220,42 @@ def _mask_secret_environment( service['environment'] = list(secrets) +def _drop_env_names(service: dict, names: Iterable[str]) -> None: + """service の ``environment`` から指定キーを**丸ごと**取り除く。 + + 値を落として名前だけ残す :func:`_mask_secret_environment` と違い、名前ごと + 消す。ADC 用 (PLAN39): ``GOOGLE_APPLICATION_CREDENTIALS`` が実在しない + ファイルを指していると、ADC はユーザー認証へフォールバックせず + ``DefaultCredentialsError`` で落ちるため、「値が空」でも「値なし参照」でも + 足りず、**渡さない**しかない。 + + 機密の列挙 (:func:`gcp_auth.key_only_env_names`) を絞るだけでは、元の + ``compose.yml`` の ``environment`` に直書きされたキーが生成物に残る。 + map / list の両記法を扱い、空になった ``environment`` は消す。 + """ + drop = set(names) + if not drop: + return + existing = service.get('environment') + + if isinstance(existing, dict): + kept = {k: v for k, v in existing.items() if k not in drop} + elif isinstance(existing, list): + kept = [ + item for item in existing + if not (isinstance(item, str) + and item.split('=', 1)[0].strip() in drop) + ] + else: + # None や解釈できない形式には触らない (警告は mask 側で出している) + return + + if kept: + service['environment'] = kept + else: + service.pop('environment', None) + + class _SecretNames: """機密の変数名を**由来別**に保持し、参照種別に応じた部分集合を切り出す。 @@ -232,7 +282,9 @@ def __init__( all_names: Sequence[str] = (), global_names: Optional[Sequence[str]] = None, project_names: Optional[Sequence[str]] = None, + dev_excluded: Iterable[str] = (), ) -> None: + self._dev_excluded = set(dev_excluded) split_known = global_names is not None or project_names is not None globals_ = list(global_names or ()) projects = list(project_names or ()) @@ -251,6 +303,15 @@ def __init__( compose_migrate.TARGET_PROJECT: everything, } + @property + def for_dev(self) -> List[str]: + """dev インスタンスへ渡す列挙 (``dev_excluded`` を除いたもの)。 + + 除外を dev だけに効かせる。非 dev サービスは :meth:`for_targets` 経由で、 + 元々 ``env_file`` で参照していた由来のキーを受け取り続ける。 + """ + return [name for name in self.all if name not in self._dev_excluded] + def for_targets(self, targets: Iterable[str]) -> List[str]: """指定の参照種別に由来するキーだけを、全体と同じ順序で返す""" allowed: Set[str] = set() @@ -291,6 +352,7 @@ def _apply_dev_environment(service: dict, extra: Mapping[str, str]) -> None: def _build_dev_instance( dev_service: dict, dev_service_name: str, index: int, + group_volume: str, secret_env_names: Sequence[str] = (), dev_environment: Optional[Mapping[str, str]] = None, ) -> dict: @@ -311,7 +373,7 @@ def _build_dev_instance( ai_volume = get_ai_volume_for_index(index) work_volume = get_work_volume_for_index(index) service['volumes'] = _replace_volumes_for_instance( - service.get('volumes', []), ai_volume, work_volume, + service.get('volumes', []), ai_volume, work_volume, group_volume, ) return service @@ -319,6 +381,7 @@ def _build_dev_instance( def _build_scaled_services( services: dict, dev_service: dict, dev_service_name: str, scale: int, + group_volume: str, secret_names: Optional[_SecretNames] = None, secret_services: Optional[Mapping[str, Set[str]]] = None, dev_environment: Optional[Mapping[str, str]] = None, @@ -362,7 +425,8 @@ def _build_scaled_services( # 構成でも両方の機密を必要とする。 for i in range(1, scale + 1): scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance( - dev_service, dev_service_name, i, secret_names.all, + dev_service, dev_service_name, i, group_volume, + secret_names.for_dev, dev_environment=dev_environment, ) return scaled_services @@ -512,17 +576,55 @@ def generate_scaled_compose( raise DockerError(f"No '{dev_service_name}' service found in compose file") secret_services = _services_receiving_secrets(compose_file, dev_service_name) + + # アカウントグループはここで 1 度だけ解決し、マウント・ボリューム宣言・ + # 環境変数の 3 か所へ同じ値を配る。コンテナ側で解決し直させると、マウント + # されているボリュームと entrypoint が見ているグループ名がずれうる。 + # 不正な名前はここで DevbaseError になり、構成生成の時点で起動が止まる。 + account_group = resolve_account_group() + group_volume = get_group_volume(account_group) + dev_environment = { + **(dev_environment or {}), + keys.DEVBASE_ACCOUNT_GROUP: account_group, + # gcloud / gws の設定ディレクトリと解決済みの認証モード (PLAN39) + **gcp_auth.container_env(os.environ), + } + + # ADC モードでは鍵モード専用の 2 変数を **dev の列挙から外す**。名前が載ら + # なければ Compose はその変数をコンテナへ渡さないので、docker exec のシェル + # から見ても未設定になる。値を空にするだけでは entrypoint の外に効かない。 + # + # 除外は dev だけに効かせる。元々この 2 変数を env_file から受け取っていた + # 非 dev サービス (独自に鍵を持つ batch 等) から値を奪うと、直書きを消すのと + # 同じようにそのサービスを壊す。 + auth_mode = dev_environment[keys.GCP_AUTH_MODE] secret_names = _SecretNames( - secret_env_names, global_env_names, project_env_names) + secret_env_names, global_env_names, project_env_names, + dev_excluded=gcp_auth.key_only_env_names(auth_mode)) + + scaled_services = _build_scaled_services( + services, dev_service, dev_service_name, scale, group_volume, + secret_names=secret_names, + secret_services=secret_services, + dev_environment=dev_environment, + ) + + # 列挙を絞るだけでは、元の compose.yml が environment に**直書き**している + # 2 変数が生成物に残る。adc では dev に鍵を書かないので、パスが残っていること + # 自体が DefaultCredentialsError の原因になる。 + # + # 取り除くのは **dev インスタンスだけ**。`GCP_AUTH_MODE` は dev コンテナの + # 認証方式の宣言であり、独自に鍵をマウントしている非 dev サービス (batch 等) の + # 明示設定まで消すと、そのサービスを壊してしまう。 + if auth_mode != gcp_auth.AUTH_MODE_KEY: + for index in range(1, scale + 1): + service = scaled_services.get(f'{dev_service_name}-{index}') + if isinstance(service, dict): + _drop_env_names(service, gcp_auth.KEY_ONLY_ENV_KEYS) scaled_config = { - 'services': _build_scaled_services( - services, dev_service, dev_service_name, scale, - secret_names=secret_names, - secret_services=secret_services, - dev_environment=dev_environment, - ), - 'volumes': _build_volumes_section(config, scale), + 'services': scaled_services, + 'volumes': _build_volumes_section(config, scale, group_volume), 'networks': _build_networks_section(config), } diff --git a/lib/devbase/volume/manager.py b/lib/devbase/volume/manager.py index b1246879..f24d2932 100644 --- a/lib/devbase/volume/manager.py +++ b/lib/devbase/volume/manager.py @@ -1,8 +1,12 @@ """Volume management functions for devbase""" +import os +import re import subprocess +from typing import Optional -from devbase.errors import DockerError +from devbase.env import keys +from devbase.errors import DevbaseError, DockerError from devbase.log import get_logger logger = get_logger("devbase.volume.manager") @@ -10,10 +14,75 @@ # 共有ボリューム名のプレフィックス SHARED_VOLUME_PREFIX = "devbase_home_" WORK_VOLUME_PREFIX = "devbase_work_" -AI_VOLUME_PREFIX = "devbase_ai_" # 全コンテナで共有するホームディレクトリボリューム HOME_UBUNTU_VOLUME = "devbase_home_ubuntu" +# --- アカウントグループ (PLAN39) --------------------------------------------- +# アカウントグループは「使用する Google / AWS アカウントの単位」。グループごとに +# devbase_home_ を作り、/persistent/group としてマウントする。認証情報や +# 会話履歴のようにテナントへ紐づくデータ (分類 B) の置き場になる。 +DEFAULT_ACCOUNT_GROUP = "default" +# Docker のボリューム名として使える文字種 +_GROUP_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$") +# 数字のみは devbase_home_ (get_volume_for_index) と同じ名前になる +_NUMERIC_NAME_RE = re.compile(r"^[0-9]+$") +# 共通ボリューム devbase_home_ubuntu と同じ名前になる +_RESERVED_ACCOUNT_GROUPS = ("ubuntu",) + + +def resolve_account_group(group: Optional[str] = None) -> str: + """アカウントグループ名を解決して検証する。 + + 解決順は 引数 → ``DEVBASE_ACCOUNT_GROUP`` → ``default``。環境変数には + グローバル ``env`` とプロジェクト ``env`` を重ねた結果が入っている + (``bin/devbase`` が ``set -a`` で source する) ので、ここで読むだけで + 3 レベルの解決結果になる。 + + 解決結果はそのままボリューム名 ``devbase_home_`` の一部になるため、 + **起動前に**次の 3 つを弾く。(b) と (c) は (a) を通過してしまうので、 + 正規表現とは別のチェックとして明示的に持つ。 + + (a) Docker のボリューム名にできない文字列 + (b) 予約語 ``ubuntu`` (共通ボリューム ``devbase_home_ubuntu`` と衝突) + (c) 数字のみ (``devbase_home_`` と衝突) + + Raises: + DevbaseError: グループ名が使えない場合 + """ + raw = group if group is not None else os.environ.get( + keys.DEVBASE_ACCOUNT_GROUP, "") + name = (raw or "").strip() + if not name: + return DEFAULT_ACCOUNT_GROUP + + if not _GROUP_NAME_RE.match(name): + raise DevbaseError( + f"{keys.DEVBASE_ACCOUNT_GROUP} が不正です: '{name}'。" + "Docker のボリューム名に使える文字 (英数字・ドット・ハイフン・" + "アンダースコア、先頭は英数字) だけを使ってください" + ) + if name in _RESERVED_ACCOUNT_GROUPS: + raise DevbaseError( + f"{keys.DEVBASE_ACCOUNT_GROUP} に予約語は使えません: '{name}'。" + f"共通ボリューム {HOME_UBUNTU_VOLUME} と同じ名前になります" + ) + if _NUMERIC_NAME_RE.match(name): + raise DevbaseError( + f"{keys.DEVBASE_ACCOUNT_GROUP} に数字だけの名前は使えません: " + f"'{name}'。インスタンス番号のボリューム " + f"{SHARED_VOLUME_PREFIX} と同じ名前になります" + ) + return name + + +def get_group_volume(group: Optional[str] = None) -> str: + """アカウントグループのボリューム名 (``devbase_home_``) を返す。 + + 検証を迂回する経路を作らないため、名前の解決は必ず + :func:`resolve_account_group` を通す。 + """ + return f"{SHARED_VOLUME_PREFIX}{resolve_account_group(group)}" + class VolumeManager: """Manages Docker volumes for devbase projects""" @@ -94,19 +163,26 @@ def get_ai_volume_for_index(self, index: int) -> str: """ return HOME_UBUNTU_VOLUME - def ensure_volumes(self, scale: int) -> None: + def ensure_volumes(self, scale: int, group: Optional[str] = None) -> None: """ Ensure required volumes exist for the specified scale Creates volumes: - devbase_home_ubuntu: Shared home directory for all containers + - devbase_home_{group}: Per-account-group directory (PLAN39) - devbase_work_{i}: Project work directory per instance Args: scale: Number of container instances + group: Account group name (default: resolved from environment) """ logger.info("Ensuring volumes for %d container(s)", scale) + # グループ名の検証は Docker を触る前に済ませる。あとに置くと、名前が + # 不正なだけの入力エラーでも共有ボリュームが作られてから失敗して + # しまい、Docker の状態が変わってしまう。 + group_volume = get_group_volume(group) + # Ensure shared home directory volume (once for all containers) if self._volume_exists(HOME_UBUNTU_VOLUME): logger.info(" %s (shared home, exists)", HOME_UBUNTU_VOLUME) @@ -115,6 +191,14 @@ def ensure_volumes(self, scale: int) -> None: if not self._create_volume(HOME_UBUNTU_VOLUME): raise DockerError(f"Failed to create volume {HOME_UBUNTU_VOLUME}") + # Ensure account group volume (shared by all containers of the group) + if self._volume_exists(group_volume): + logger.info(" %s (account group, exists)", group_volume) + else: + logger.info(" Creating %s (account group)...", group_volume) + if not self._create_volume(group_volume): + raise DockerError(f"Failed to create volume {group_volume}") + # Create or verify work volumes for each instance for i in range(1, scale + 1): work_volume = self.get_work_volume_for_index(i) @@ -128,19 +212,22 @@ def ensure_volumes(self, scale: int) -> None: raise DockerError(f"Failed to create volume {work_volume}") -def ensure_volumes(scale: int, project_name: str = None) -> None: +def ensure_volumes(scale: int, project_name: str = None, + group: Optional[str] = None) -> None: """ Ensure required shared volumes exist for the specified scale - All projects share the same volumes (devbase_home_1, devbase_home_2, ...) - based on container index. + All projects share the same home volume (devbase_home_ubuntu) and the + volume of their account group (devbase_home_); work volumes are + per container index. Args: scale: Number of container instances project_name: Unused, kept for backward compatibility + group: Account group name (default: resolved from environment) """ manager = VolumeManager() - manager.ensure_volumes(scale) + manager.ensure_volumes(scale, group) def get_volume_for_index(index: int, project_name: str = None) -> str: 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_ai_settings.py b/tests/containers/test_entrypoint_ai_settings.py new file mode 100644 index 00000000..530488f0 --- /dev/null +++ b/tests/containers/test_entrypoint_ai_settings.py @@ -0,0 +1,386 @@ +"""AI 設定の永続化 (共通 / アカウントグループの 2 層) — PLAN39 Task 3・4 + +``containers/base/entrypoint.sh`` を ``DEVBASE_ENTRYPOINT_LIB_ONLY=1`` で source し、 +一時ディレクトリを ``/persistent/ai`` / ``/persistent/group`` / ``$HOME`` に見立てて +関数を直接呼ぶ。Docker には依存しない。 + +固定する契約: + +- 分類 A (共通) は ``/persistent/ai``、分類 B (グループ) は ``/persistent/group`` を指す +- ``~/.claude`` の既定はグループ側で、共通資産だけがその配下から共通側へ張られる +- 入れ子パスでも symlink が壊れない (親ディレクトリの作成 / ファイルとディレクトリの判別) +- 初回シードは ``default`` グループだけで、共通資産はコピーせず、2 回目は何もしない +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + + +def run_entrypoint_fn(script: str, cwd: Path, env: dict | None = None): + """entrypoint.sh の関数だけを読み込んで ``script`` を実行する。""" + base = {k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_"))} + full = f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n{script}\n' + return subprocess.run( + ["bash", "-c", full], cwd=cwd, env={**base, **(env or {})}, + capture_output=True, text=True, + ) + + +@pytest.fixture +def roots(tmp_path: Path): + """home / persistent(ai) / persistent(group) の 3 つ組を作る。""" + home = tmp_path / "home" + ai = tmp_path / "persistent" / "ai" + group = tmp_path / "persistent" / "group" + home.mkdir(parents=True) + return home, ai, group + + +def setup(roots, group_name: str = "default", cwd: Path | None = None): + home, ai, grp = roots + result = run_entrypoint_fn( + f'devbase_setup_ai_settings "{home}" "{ai}" "{grp}" "{group_name}"', + cwd or home, + ) + assert result.returncode == 0, result.stderr or result.stdout + return result + + +# --------------------------------------------------------------------------- +# 2 系統の振り分け (AC3 / AC4) +# --------------------------------------------------------------------------- + +def test_shared_entries_point_at_the_shared_volume(roots): + home, ai, _ = roots + setup(roots) + + for entry in (".codex", ".serena", ".ssh", ".kiro", "share"): + link = home / entry + assert link.is_symlink(), f"{entry} が symlink ではない" + assert link.resolve() == (ai / entry).resolve() + + +def test_group_entries_point_at_the_group_volume(roots): + home, _, grp = roots + setup(roots, "kkg") + + for entry in (".claude.json", ".claude", ".gemini"): + link = home / entry + assert link.is_symlink(), f"{entry} が symlink ではない" + assert link.resolve() == (grp / entry).resolve() + + +def test_claude_defaults_to_the_group_volume(roots): + """``~/.claude`` 配下の既定はグループ側。 + + Claude Code は ``projects`` / ``sessions`` / ``tasks`` のようなディレクトリを + 随時作る。列挙したものだけを永続化すると列挙漏れが黙って揮発するため、 + 既定をグループ側に倒して共通にしたいものだけを名指しする。 + """ + home, _, grp = roots + setup(roots, "kkg") + + (home / ".claude" / "projects").mkdir(parents=True) + assert (grp / ".claude" / "projects").is_dir() + + +def test_shared_assets_under_claude_point_at_the_shared_volume(roots): + """AC4: どのグループから見ても共通資産は同一実体を指す。""" + home, ai, _ = roots + setup(roots, "kkg") + + for entry in ("plugins", "skills", "commands", "CLAUDE.md", "settings.json"): + path = home / ".claude" / entry + assert path.resolve() == (ai / ".claude" / entry).resolve(), entry + + +def test_two_groups_share_assets_but_not_credentials(roots, tmp_path): + """AC3 / AC4 をまとめて: 共通資産は同一、グループ別データは互いに見えない。""" + home_a, ai, group_a = roots + home_b = tmp_path / "home-b" + home_b.mkdir() + group_b = tmp_path / "persistent" / "kkg" + + setup((home_a, ai, group_a), "default") + setup((home_b, ai, group_b), "kkg") + + # 共通資産は同一実体 + assert (home_a / ".claude" / "plugins").resolve() == \ + (home_b / ".claude" / "plugins").resolve() + + # グループ別データは互いに到達できない + (home_a / ".claude" / ".credentials.json").write_text("default-secret") + assert not (home_b / ".claude" / ".credentials.json").exists() + + +# --------------------------------------------------------------------------- +# 入れ子パス (AC6 / 前提 5) +# --------------------------------------------------------------------------- + +def test_nested_file_entries_are_created_as_files(roots): + """``CLAUDE.md`` / ``settings.json`` はファイル。ディレクトリにすると書けない。""" + home, ai, _ = roots + setup(roots) + + for entry in ("CLAUDE.md", "settings.json"): + target = ai / ".claude" / entry + assert target.is_file(), f"{entry} がファイルとして作られていない" + assert not target.is_dir() + + +def test_nested_directory_entries_are_created_as_directories(roots): + home, ai, _ = roots + setup(roots) + + for entry in ("plugins", "skills", "commands"): + assert (ai / ".claude" / entry).is_dir(), entry + + +def test_nested_links_are_not_broken(roots): + """親ディレクトリが無くても壊れた symlink を残さない (前提 5)。""" + home, _, _ = roots + setup(roots) + + for entry in ("plugins", "CLAUDE.md"): + link = home / ".claude" / entry + assert link.is_symlink() + assert link.exists(), f"{entry} が壊れた symlink になっている" + + +def test_jsonl_entries_are_not_turned_into_directories(roots): + """``history.jsonl`` は ``*.json`` にマッチしないためディレクトリ化していた。""" + home, _, grp = roots + setup(roots) + + path = grp / ".claude" / "history.jsonl" + # 実体は Claude Code が作るので存在しないのが正常。存在するならファイルであること。 + assert not path.is_dir() + + # entrypoint がプレースホルダを作る経路でもディレクトリにしない + result = run_entrypoint_fn( + f'devbase_ensure_entry "{grp}/.claude/history.jsonl"', home) + assert result.returncode == 0, result.stderr + assert path.is_file(), "history.jsonl がファイルとして作られていない" + + +def test_credentials_json_is_reachable(roots): + """AC6: ``~/.claude/.credentials.json`` の親が無くても書き込める。""" + home, _, grp = roots + setup(roots) + + path = home / ".claude" / ".credentials.json" + path.write_text('{"ok": true}') + assert (grp / ".claude" / ".credentials.json").read_text() == '{"ok": true}' + + +# --------------------------------------------------------------------------- +# 既存状態からの張り替え +# --------------------------------------------------------------------------- + +def test_existing_wrong_symlink_is_replaced(roots): + """PLAN39 以前の ``~/.claude -> /persistent/ai/.claude`` を張り替える。""" + home, ai, grp = roots + (ai / ".claude").mkdir(parents=True) + (home / ".claude").symlink_to(ai / ".claude") + + setup(roots, "kkg") + + assert (home / ".claude").resolve() == (grp / ".claude").resolve() + + +def test_existing_real_directory_in_home_is_replaced(roots): + home, _, grp = roots + (home / ".gemini").mkdir() + (home / ".gemini" / "leftover").write_text("x") + + setup(roots) + + assert (home / ".gemini").is_symlink() + assert (home / ".gemini").resolve() == (grp / ".gemini").resolve() + + +def test_broken_symlink_in_home_is_replaced(roots): + home, _, grp = roots + (home / ".codex").symlink_to(home / "does-not-exist") + + setup(roots) + + assert (home / ".codex").exists() + + +def test_setup_is_idempotent(roots): + home, ai, grp = roots + setup(roots) + (home / ".claude" / "projects").mkdir(parents=True) + (home / ".claude" / "projects" / "keep.txt").write_text("keep") + + setup(roots) + + assert (home / ".claude" / "projects" / "keep.txt").read_text() == "keep" + assert (home / ".claude" / "plugins").resolve() == (ai / ".claude" / "plugins").resolve() + + +# --------------------------------------------------------------------------- +# 初回シード (AC8) +# --------------------------------------------------------------------------- + +def _seed_source(ai: Path) -> None: + """現行 ``/persistent/ai`` に実体がある分類 B のデータを用意する。""" + (ai / ".claude").mkdir(parents=True) + (ai / ".claude" / ".credentials.json").write_text("token") + (ai / ".claude" / "history.jsonl").write_text('{"line": 1}\n') + (ai / ".claude" / "projects").mkdir() + (ai / ".claude" / "projects" / "a.jsonl").write_text("session") + (ai / ".claude" / "plugins").mkdir() + (ai / ".claude" / "plugins" / "big").write_text("x" * 100) + (ai / ".claude.json").write_text('{"oauthAccount": {}}') + (ai / ".gemini").mkdir() + (ai / ".gemini" / "settings.json").write_text('{"auth": "vertex-ai"}') + + +def test_default_group_is_seeded_from_the_shared_volume(roots): + """AC8: ``default`` は再ログインなしで移行できる。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "default") + + assert (grp / ".claude" / ".credentials.json").read_text() == "token" + assert (grp / ".claude" / "history.jsonl").read_text() == '{"line": 1}\n' + assert (grp / ".claude" / "projects" / "a.jsonl").read_text() == "session" + assert (grp / ".claude.json").read_text() == '{"oauthAccount": {}}' + assert (grp / ".gemini" / "settings.json").read_text() == '{"auth": "vertex-ai"}' + + +def test_seed_does_not_copy_shared_assets(roots): + """共通資産はグループ数だけ重複させない (238MB の plugins をコピーしない)。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "default") + + assert (grp / ".claude" / "plugins").is_symlink() + assert (grp / ".claude" / "plugins").resolve() == (ai / ".claude" / "plugins").resolve() + + +def test_seed_is_a_copy_not_a_move(roots): + """切り戻しの余地を残すため move ではなく copy にする。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "default") + + assert (ai / ".claude" / ".credentials.json").read_text() == "token" + assert (ai / ".claude.json").exists() + + +def test_non_default_groups_are_not_seeded(roots): + """AC3: 分離の意味が失われるため非 default ではシードしない。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "kkg") + + assert not (grp / ".claude" / ".credentials.json").exists() + assert not (grp / ".claude" / "projects").exists() + assert not (grp / ".gemini" / "settings.json").exists() + # プレースホルダは作られるが、シード元の中身は入らない + assert (grp / ".claude.json").read_text() == "" + + +def test_seed_runs_only_once(roots): + """2 回目は何もしない (稼働後のデータをシード時点へ巻き戻さない)。""" + home, ai, grp = roots + _seed_source(ai) + + setup(roots, "default") + (grp / ".claude" / ".credentials.json").write_text("refreshed") + (ai / ".claude" / ".credentials.json").write_text("stale") + + setup(roots, "default") + + assert (grp / ".claude" / ".credentials.json").read_text() == "refreshed" + + +def test_seed_skips_entries_without_a_source(roots): + """シード元が無いエントリ (gcloud / gws) があっても止まらない (AC8)。""" + home, ai, grp = roots + (ai / ".claude").mkdir(parents=True) + (ai / ".claude" / "history.jsonl").write_text("only-this\n") + + setup(roots, "default") + + assert (grp / ".claude" / "history.jsonl").read_text() == "only-this\n" + # .claude.json / .gemini はシード元が無いので空のプレースホルダのまま + assert (grp / ".claude.json").is_file() + assert (grp / ".gemini").is_dir() + + +def test_seed_copies_dotfiles(roots): + """``.credentials.json`` のような隠しファイルを取りこぼさない。""" + home, ai, grp = roots + (ai / ".claude").mkdir(parents=True) + (ai / ".claude" / ".last-cleanup").write_text("ts") + + setup(roots, "default") + + assert (grp / ".claude" / ".last-cleanup").read_text() == "ts" + + +# --------------------------------------------------------------------------- +# イメージ同梱の ~/.claude/settings.json の退避 +# --------------------------------------------------------------------------- + +HOOKS = '{"hooks":{"SessionStart":[]}}' + + +def test_image_claude_settings_are_kept_on_first_run(roots): + """Dockerfile が焼いた ``~/.claude/settings.json`` を空ファイルで潰さない。 + + symlink 張り替えは ``~/.claude`` を ``rm -rf`` するため、退避しないと + hooks 設定が初回起動で消えて共通側に空ファイルだけが残る。 + """ + home, ai, grp = roots + (home / ".claude").mkdir() + (home / ".claude" / "settings.json").write_text(HOOKS) + + setup(roots) + + assert (ai / ".claude" / "settings.json").read_text() == HOOKS + # グループ側 -> 共通側の symlink 経由でも読める + assert (home / ".claude" / "settings.json").read_text() == HOOKS + + +def test_image_claude_settings_do_not_overwrite_the_shared_volume(roots): + """永続側に既存の設定があればイメージ側で上書きしない。""" + home, ai, _ = roots + (home / ".claude").mkdir() + (home / ".claude" / "settings.json").write_text(HOOKS) + (ai / ".claude").mkdir(parents=True) + (ai / ".claude" / "settings.json").write_text('{"user": true}') + + setup(roots) + + assert (ai / ".claude" / "settings.json").read_text() == '{"user": true}' + + +def test_second_run_does_not_seed_through_the_symlink(roots): + """2 回目以降 (``~/.claude`` が symlink) は退避を走らせない。""" + home, ai, _ = roots + (home / ".claude").mkdir() + (home / ".claude" / "settings.json").write_text(HOOKS) + + setup(roots) + (ai / ".claude" / "settings.json").write_text('{"edited": true}') + setup(roots) + + assert (ai / ".claude" / "settings.json").read_text() == '{"edited": true}' + assert (home / ".claude").is_symlink() diff --git a/tests/containers/test_entrypoint_gcp_auth.py b/tests/containers/test_entrypoint_gcp_auth.py new file mode 100644 index 00000000..1c1bab93 --- /dev/null +++ b/tests/containers/test_entrypoint_gcp_auth.py @@ -0,0 +1,211 @@ +"""entrypoint 側の GCP 認証モードと設定ディレクトリ (PLAN39 Task 5) + +``containers/base/entrypoint.sh`` を ``DEVBASE_ENTRYPOINT_LIB_ONLY=1`` で source し、 +``GCP_AUTH_MODE`` × 鍵 env の組み合わせで「鍵を書くか」「2 変数が残るか」を固定する。 + +コンテナへ渡る環境変数そのものを決めるのはホスト側 (``lib/devbase/env/gcp_auth.py``) +だが、entrypoint 側にも同じ判定を持たせている。古いホストから起動された場合と、 +プロジェクトの ``env`` に 2 変数が直書きされている場合の保険である。 +""" + +from __future__ import annotations + +import base64 +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + +KEY_JSON = json.dumps({"type": "service_account", "project_id": "example"}) +KEY_B64 = base64.b64encode(KEY_JSON.encode()).decode() + + +def run(script: str, env: dict, cwd: Path) -> subprocess.CompletedProcess: + base = {k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_", "GCP_", "GOOGLE_", "BIGQUERY_"))} + full = f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n{script}\n' + return subprocess.run( + ["bash", "-c", full], cwd=cwd, env={**base, **env}, + capture_output=True, text=True, + ) + + +def setup_credentials(home: Path, env: dict) -> dict: + """``devbase_setup_gcp_credentials`` を実行し、実行後の 2 変数を返す。""" + script = ( + f'devbase_setup_gcp_credentials "{home}"\n' + 'echo "GAC=${GOOGLE_APPLICATION_CREDENTIALS-}"\n' + 'echo "BQ=${BIGQUERY_KEY_FILE-}"\n' + ) + result = run(script, env, home) + assert result.returncode == 0, result.stderr or result.stdout + values = {} + for line in result.stdout.splitlines(): + if line.startswith(("GAC=", "BQ=")): + name, _, value = line.partition("=") + values[name] = value + values["stdout"] = result.stdout + return values + + +@pytest.fixture +def home(tmp_path: Path) -> Path: + d = tmp_path / "home" + d.mkdir() + return d + + +# --------------------------------------------------------------------------- +# 鍵モード (AC11 / AC12 (2)) +# --------------------------------------------------------------------------- + +def test_key_is_written_when_a_key_env_is_present(home): + values = setup_credentials(home, {"GCP_CREDENTIALS_BASE64__default": KEY_B64}) + + written = home / ".config" / "gcloud" / "credentials.json" + assert written.read_text() == KEY_JSON + assert values["GAC"] == str(written) + assert values["BQ"] == str(written) + + +def test_explicit_key_mode_writes_the_key(home): + values = setup_credentials(home, { + "GCP_AUTH_MODE": "key", + "GCP_CREDENTIALS_BASE64__default": KEY_B64, + }) + + assert (home / ".config" / "gcloud" / "credentials.json").read_text() == KEY_JSON + assert values["GAC"].endswith("/credentials.json") + + +def test_active_profile_selects_the_key(home): + values = setup_credentials(home, { + "GCP_ACTIVE_PROFILE": "kkg", + "GCP_CREDENTIALS_BASE64__kkg": KEY_B64, + "GCP_CREDENTIALS_BASE64__default": base64.b64encode(b"wrong").decode(), + }) + + assert (home / ".config" / "gcloud" / "credentials.json").read_text() == KEY_JSON + assert "profile: kkg" in values["stdout"] + + +def test_custom_paths_are_honoured(home, tmp_path): + """プロジェクト env でパスを上書きしている構成を壊さない (前提 11)。""" + gac = tmp_path / "custom" / "gac.json" + bq = tmp_path / "custom" / "bq.json" + + values = setup_credentials(home, { + "GCP_CREDENTIALS_BASE64__default": KEY_B64, + "GOOGLE_APPLICATION_CREDENTIALS": str(gac), + "BIGQUERY_KEY_FILE": str(bq), + }) + + assert gac.read_text() == KEY_JSON + assert bq.read_text() == KEY_JSON + assert values["GAC"] == str(gac) + assert values["BQ"] == str(bq) + + +def test_key_file_permissions_are_restricted(home): + setup_credentials(home, {"GCP_CREDENTIALS_BASE64__default": KEY_B64}) + + written = home / ".config" / "gcloud" / "credentials.json" + assert oct(written.stat().st_mode & 0o777) == "0o600" + + +# --------------------------------------------------------------------------- +# ADC モード (AC12 (1)(3) / AC13) +# --------------------------------------------------------------------------- + +def test_adc_is_the_default_without_a_key(home): + values = setup_credentials(home, {}) + + assert values["GAC"] == "" + assert values["BQ"] == "" + assert not (home / ".config" / "gcloud" / "credentials.json").exists() + + +def test_adc_unsets_leftover_variables(home): + """AC12 (3): key → adc へ戻したとき、値だけ残らないようにする。 + + 値だけ残って実体が無いと ADC はユーザー認証へフォールバックせず + DefaultCredentialsError で落ちる (前提 10)。 + """ + values = setup_credentials(home, { + "GCP_AUTH_MODE": "adc", + "GOOGLE_APPLICATION_CREDENTIALS": "/home/ubuntu/.config/gcloud/credentials.json", + "BIGQUERY_KEY_FILE": "/home/ubuntu/.config/gcloud/credentials.json", + }) + + assert values["GAC"] == "" + assert values["BQ"] == "" + + +def test_explicit_adc_does_not_write_a_key_even_when_one_exists(home): + """AC13: 鍵の env があっても adc なら鍵を書かない。""" + values = setup_credentials(home, { + "GCP_AUTH_MODE": "adc", + "GCP_CREDENTIALS_BASE64__default": KEY_B64, + }) + + assert not (home / ".config" / "gcloud" / "credentials.json").exists() + assert values["GAC"] == "" + + +def test_key_mode_without_a_key_falls_back_to_adc(home): + """鍵が無いのに key を宣言しても、実体の無いパスを残さない。""" + values = setup_credentials(home, {"GCP_AUTH_MODE": "key"}) + + assert values["GAC"] == "" + assert values["BQ"] == "" + assert "ADC へ切り替えます" in values["stdout"] + + +def test_unknown_mode_falls_back_to_auto(home): + values = setup_credentials(home, { + "GCP_AUTH_MODE": "yes", + "GCP_CREDENTIALS_BASE64__default": KEY_B64, + }) + + assert (home / ".config" / "gcloud" / "credentials.json").exists() + + +# --------------------------------------------------------------------------- +# 設定ディレクトリ (AC1 / AC2 / 前提 18) +# --------------------------------------------------------------------------- + +def test_config_dirs_are_created(home, tmp_path): + gcloud = tmp_path / "group" / "gcloud" + gws = tmp_path / "group" / "gws" + + result = run('devbase_setup_cloud_config_dirs "$(id -un)"', { + "CLOUDSDK_CONFIG": str(gcloud), + "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": str(gws), + }, home) + + assert result.returncode == 0, result.stderr + assert gcloud.is_dir() + assert gws.is_dir() + + +def test_existing_config_dirs_are_left_alone(home, tmp_path): + gcloud = tmp_path / "group" / "gcloud" + gcloud.mkdir(parents=True) + (gcloud / "credentials.db").write_text("kept") + + result = run('devbase_setup_cloud_config_dirs "$(id -un)"', + {"CLOUDSDK_CONFIG": str(gcloud)}, home) + + assert result.returncode == 0, result.stderr + assert (gcloud / "credentials.db").read_text() == "kept" + + +def test_unset_config_dirs_are_skipped(home): + """古いホストから起動された場合でも落ちない。""" + result = run('devbase_setup_cloud_config_dirs "$(id -un)"', {}, home) + + assert result.returncode == 0, result.stderr 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/env/test_gcp_auth.py b/tests/env/test_gcp_auth.py new file mode 100644 index 00000000..2e8753ab --- /dev/null +++ b/tests/env/test_gcp_auth.py @@ -0,0 +1,147 @@ +"""GCP の認証モード解決と鍵モード専用変数の除外 (PLAN39 Task 5) + +要点は「``adc`` では 2 変数を**渡さない**」こと。値を空にするだけでは +``docker exec`` のシェルから見て未設定にならず、実体の無いパスが残ると ADC は +ユーザー認証へフォールバックせず ``DefaultCredentialsError`` で落ちる。 +""" + +from __future__ import annotations + +import pytest + +from devbase.env import gcp_auth, keys + + +# --------------------------------------------------------------------------- +# モード解決 +# --------------------------------------------------------------------------- + +def test_unset_without_key_resolves_to_adc(): + """鍵の env が無ければ ADC。新規プロジェクトの既定。""" + assert gcp_auth.resolve_auth_mode({}) == gcp_auth.AUTH_MODE_ADC + + +def test_unset_with_profile_key_resolves_to_key(): + """既存プロジェクトは現行どおり鍵モードで動く (auto 判定)。""" + env = {"GCP_CREDENTIALS_BASE64__default": "eyJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCJ9"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_KEY + + +def test_active_profile_selects_the_key(): + """entrypoint と同じく GCP_ACTIVE_PROFILE の鍵だけを見る。""" + env = {keys.GCP_ACTIVE_PROFILE: "prod", + "GCP_CREDENTIALS_BASE64__prod": "eyJ9"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_KEY + + +def test_other_profiles_key_does_not_make_it_key_mode(): + """別プロファイルの鍵しか無い構成でホストだけ key と判定しない。 + + entrypoint はアクティブプロファイルの鍵が無ければ adc へ落ちる。ホストが + key のままだと実体の無いパスを指す 2 変数だけがコンテナへ渡り、 + docker exec のシェルで DefaultCredentialsError になる。 + """ + env = {keys.GCP_ACTIVE_PROFILE: "dev", + "GCP_CREDENTIALS_BASE64__prod": "eyJ9"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_ADC + + +def test_declared_key_without_a_key_falls_back_to_adc(): + """key 宣言でも鍵が無ければ adc (entrypoint と同じフォールバック)。""" + env = {keys.GCP_AUTH_MODE: "key"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_ADC + + +def test_declared_key_with_another_profiles_key_falls_back_to_adc(): + env = {keys.GCP_AUTH_MODE: "key", + keys.GCP_ACTIVE_PROFILE: "dev", + "GCP_CREDENTIALS_BASE64__prod": "eyJ9"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_ADC + + +@pytest.mark.parametrize("profile", ["", " "]) +def test_blank_active_profile_means_default(profile): + """entrypoint の ``${GCP_ACTIVE_PROFILE:-default}`` と揃える。""" + env = {keys.GCP_ACTIVE_PROFILE: profile, + "GCP_CREDENTIALS_BASE64__default": "eyJ9"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_KEY + + +def test_unset_with_legacy_key_resolves_to_key(): + """後方互換の GOOGLE_APPLICATION_CREDENTIALS_BASE64 も鍵として数える。""" + env = {"GOOGLE_APPLICATION_CREDENTIALS_BASE64": "eyJ9"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_KEY + + +def test_legacy_key_covers_a_named_active_profile(): + """プロファイル別の鍵が無ければ後方互換の変数を見る (entrypoint と同じ)。""" + env = {keys.GCP_ACTIVE_PROFILE: "prod", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64": "eyJ9"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_KEY + + +def test_empty_key_value_is_not_a_key(): + """env に空で書かれているだけの変数は鍵として数えない。""" + env = {"GCP_CREDENTIALS_BASE64__default": ""} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_ADC + + +@pytest.mark.parametrize("declared,expected", [ + ("adc", gcp_auth.AUTH_MODE_ADC), + ("key", gcp_auth.AUTH_MODE_KEY), + ("ADC", gcp_auth.AUTH_MODE_ADC), + (" key ", gcp_auth.AUTH_MODE_KEY), +]) +def test_explicit_mode_wins(declared, expected): + env = {keys.GCP_AUTH_MODE: declared, + "GCP_CREDENTIALS_BASE64__default": "eyJ9"} + assert gcp_auth.resolve_auth_mode(env) == expected + + +def test_explicit_adc_wins_over_present_key(): + """鍵があっても adc を宣言すれば鍵を使わない (AC12 (3) の戻り方向)。""" + env = {keys.GCP_AUTH_MODE: "adc", + "GCP_CREDENTIALS_BASE64__default": "eyJ9"} + assert gcp_auth.resolve_auth_mode(env) == gcp_auth.AUTH_MODE_ADC + + +@pytest.mark.parametrize("declared", ["", " ", "yes", "adc2", "keys"]) +def test_unknown_mode_falls_back_to_auto(declared): + """タイプミスで既存プロジェクトが起動できなくなるのを避ける。""" + with_key = {keys.GCP_AUTH_MODE: declared, + "GCP_CREDENTIALS_BASE64__default": "eyJ9"} + without_key = {keys.GCP_AUTH_MODE: declared} + + assert gcp_auth.resolve_auth_mode(with_key) == gcp_auth.AUTH_MODE_KEY + assert gcp_auth.resolve_auth_mode(without_key) == gcp_auth.AUTH_MODE_ADC + + +# --------------------------------------------------------------------------- +# 鍵モード専用変数の除外 (AC12 / AC13) +# --------------------------------------------------------------------------- + +def test_adc_excludes_the_key_only_names(): + assert gcp_auth.key_only_env_names(gcp_auth.AUTH_MODE_ADC) == ( + "GOOGLE_APPLICATION_CREDENTIALS", "BIGQUERY_KEY_FILE") + + +def test_key_mode_excludes_nothing(): + assert gcp_auth.key_only_env_names(gcp_auth.AUTH_MODE_KEY) == () + + +# --------------------------------------------------------------------------- +# コンテナへ渡す環境変数 +# --------------------------------------------------------------------------- + +def test_container_env_points_config_dirs_at_the_group_volume(): + env = gcp_auth.container_env({}) + + assert env["CLOUDSDK_CONFIG"] == "/persistent/group/gcloud" + assert env["GOOGLE_WORKSPACE_CLI_CONFIG_DIR"] == "/persistent/group/gws" + + +def test_container_env_carries_the_resolved_mode(): + """コンテナ側で解決し直させない (ホストと判定がずれないようにする)。""" + assert gcp_auth.container_env({})[keys.GCP_AUTH_MODE] == "adc" + assert gcp_auth.container_env( + {"GCP_CREDENTIALS_BASE64__default": "eyJ9"})[keys.GCP_AUTH_MODE] == "key" diff --git a/tests/snapshot/test_manager_volumes.py b/tests/snapshot/test_manager_volumes.py new file mode 100644 index 00000000..f5699a6a --- /dev/null +++ b/tests/snapshot/test_manager_volumes.py @@ -0,0 +1,399 @@ +"""スナップショットの対象ボリューム (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 + + +# --------------------------------------------------------------------------- +# メタデータの検証 (改変・持ち込みスナップショット対策) +# --------------------------------------------------------------------------- + +def _write_meta(root: Path, name: str, meta: dict) -> Path: + snap_dir = root / "backups" / name + snap_dir.mkdir(parents=True, exist_ok=True) + (snap_dir / "meta.yml").write_text(yaml.safe_dump(meta)) + (snap_dir / "full.tar.zst").write_text("archive") + return snap_dir + + +def test_absolute_path_as_volume_is_rejected(root): + """絶対パスを許すと任意のホストディレクトリを bind mount して消せてしまう。""" + from devbase.errors import SnapshotError + + snap_dir = _write_meta(root, "tampered", { + "name": "tampered", "type": "full", + "volumes": {"ai": "/Users/someone", "group": "devbase_home_default"}, + }) + mgr = SnapshotManager(root) + + with pytest.raises(SnapshotError) as excinfo: + mgr.snapshot_volumes(snap_dir) + assert "/Users/someone" in str(excinfo.value) + + +def test_unknown_mount_name_is_rejected(root): + """マウント名は消去コマンドのシェル文字列に入るため、既知の名前だけ許す。""" + from devbase.errors import SnapshotError + + snap_dir = _write_meta(root, "tampered2", { + "name": "tampered2", "type": "full", + "volumes": {"; rm -rf /": "devbase_home_default"}, + }) + mgr = SnapshotManager(root) + + with pytest.raises(SnapshotError): + mgr.snapshot_volumes(snap_dir) + + +def test_legacy_volume_value_is_validated_too(root): + """旧形式の `volume:` も同じ検証を通す。""" + from devbase.errors import SnapshotError + + snap_dir = _write_meta(root, "tampered3", { + "name": "tampered3", "type": "full", "volume": "/etc", + }) + mgr = SnapshotManager(root) + + with pytest.raises(SnapshotError): + mgr.snapshot_volumes(snap_dir) + + +def test_restore_refuses_before_touching_the_volumes(root): + """検証は消去コマンドを流す**前**に効く。""" + from devbase.errors import SnapshotError + + _write_meta(root, "tampered4", { + "name": "tampered4", "type": "full", + "volumes": {"ai": "../../etc", "group": "devbase_home_default"}, + }) + mgr = RecordingManager(root) + + with pytest.raises(SnapshotError): + mgr.restore("tampered4") + assert [c for c in mgr.calls if c["mode"] == "restore"] == [] + + +@pytest.mark.parametrize("group_volume", [ + "devbase_home_default", "devbase_home_kkg", "devbase_home_with", + "devbase_home_a-b_c.d", +]) +def test_devbase_owned_volume_names_pass(root, group_volume): + snap_dir = _write_meta(root, f"ok-{group_volume}", { + "name": "ok", "type": "full", + "volumes": {"ai": "devbase_home_ubuntu", "group": group_volume}, + }) + mgr = SnapshotManager(root) + + assert mgr.snapshot_volumes(snap_dir) == { + "ai": "devbase_home_ubuntu", "group": group_volume} + + +@pytest.mark.parametrize("name", [ + "mysql_data", # 同じ Docker 上の無関係なボリューム + "devbase_work_1", # devbase の作業ボリューム (スナップショット対象外) + "devbase_home_ubuntu", # group 側に共通ボリュームを書く + "devbase_home_1", # 数字のみのグループ名 (index と衝突) + "home_kkg", # プレフィックスが違う +]) +def test_unrelated_volume_names_are_rejected_for_the_group_mount(root, name): + """named volume の形をしているだけでは通さない。 + + 無関係なボリューム名を書けると、復元前の消去でその中身を失わせられる。 + """ + from devbase.errors import SnapshotError + + snap_dir = _write_meta(root, f"bad-{name}", { + "name": "bad", "type": "full", + "volumes": {"ai": "devbase_home_ubuntu", "group": name}, + }) + mgr = SnapshotManager(root) + + with pytest.raises(SnapshotError): + mgr.snapshot_volumes(snap_dir) + + +@pytest.mark.parametrize("name", ["mysql_data", "devbase_home_kkg", "devbase_work_1"]) +def test_shared_mount_only_accepts_the_shared_volume(root, name): + from devbase.errors import SnapshotError + + snap_dir = _write_meta(root, f"bad-shared-{name}", { + "name": "bad", "type": "full", + "volumes": {"ai": name, "group": "devbase_home_default"}, + }) + mgr = SnapshotManager(root) + + with pytest.raises(SnapshotError): + mgr.snapshot_volumes(snap_dir) + + +@pytest.mark.parametrize("name", [ + "devbase_home_", # 空のグループ名 (resolve は default に正規化してしまう) + "devbase_home_ kkg ", # 前後空白 (resolve は空白を落としてしまう) + "devbase_home_ KKG", +]) +def test_unnormalised_group_volume_names_are_rejected(root, name): + """検証を通るかどうかだけでは足りない。 + + ``resolve_account_group`` は空文字を ``default`` に、前後空白を落とした名前に + **正規化する**ため、通ること自体は不正な名前を許してしまう。実際にマウント + されるのは正規化前の生の名前なので、一致まで確認する。 + """ + from devbase.errors import SnapshotError + + snap_dir = _write_meta(root, f"unnorm-{abs(hash(name))}", { + "name": "unnorm", "type": "full", + "volumes": {"ai": "devbase_home_ubuntu", "group": name}, + }) + mgr = SnapshotManager(root) + + with pytest.raises(SnapshotError): + mgr.snapshot_volumes(snap_dir) diff --git a/tests/volume/test_compose_dev_environment.py b/tests/volume/test_compose_dev_environment.py index e220718d..cef01a07 100644 --- a/tests/volume/test_compose_dev_environment.py +++ b/tests/volume/test_compose_dev_environment.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os + import pytest import yaml @@ -43,10 +45,28 @@ REPO_ENV = {"DEVBASE_REPOS": "cGxhbg==", "DEVBASE_PRIMARY_DIR": "carmo"} +# devbase 自身が dev サービスへ常に載せる環境変数 (PLAN39)。 +# 個々のテストの期待値からは除いて比較し、内容そのものは +# test_devbase_managed_environment_is_always_present で固定する。 +DEVBASE_MANAGED = { + "DEVBASE_ACCOUNT_GROUP": "default", + "CLOUDSDK_CONFIG": "/persistent/group/gcloud", + "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "/persistent/group/gws", + "GCP_AUTH_MODE": "adc", +} + @pytest.fixture def project(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) + # devbase 由来の変数は常に dev へ載る (PLAN39)。外部環境で値が変わらないよう + # 解決の入力になるキーを落としておく。 + for name in ("DEVBASE_ACCOUNT_GROUP", "GCP_AUTH_MODE", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64"): + monkeypatch.delenv(name, raising=False) + for name in list(os.environ): + if name.startswith("GCP_CREDENTIALS_BASE64__"): + monkeypatch.delenv(name, raising=False) return tmp_path @@ -61,6 +81,11 @@ def env_of(service) -> dict: return dict(entry.split("=", 1) for entry in environment) +def user_env(service) -> dict: + """devbase 自身が載せる変数を除いた environment""" + return {k: v for k, v in env_of(service).items() if k not in DEVBASE_MANAGED} + + def test_dev_instances_receive_the_extra_environment(project): (project / "compose.yml").write_text(COMPOSE_DICT_ENV) @@ -90,7 +115,7 @@ def test_list_form_environment_is_supported(project): generate_scaled_compose(1, dev_environment=REPO_ENV) dev = generated(project)["services"]["dev-1"] - assert env_of(dev) == { + assert user_env(dev) == { "FEATURE_FLAG": "enabled", "DEVBASE_REPOS": "cGxhbg==", "DEVBASE_PRIMARY_DIR": "carmo", @@ -102,12 +127,30 @@ def test_environment_section_is_created_when_absent(project): generate_scaled_compose(1, dev_environment=REPO_ENV) - assert env_of(generated(project)["services"]["dev-1"]) == REPO_ENV + assert user_env(generated(project)["services"]["dev-1"]) == REPO_ENV + + +def test_without_extra_environment_only_devbase_values_are_added(project): + """呼び出し側が何も渡さなくても devbase 由来の変数は載る (PLAN39)。""" + (project / "compose.yml").write_text(COMPOSE_NO_ENV) + + generate_scaled_compose(1) + + assert user_env(generated(project)["services"]["dev-1"]) == {} + +def test_devbase_managed_environment_is_always_present(project): + """マウント先とコンテナ側の解決結果を必ず一致させるため、ホストが明示的に渡す。 -def test_without_extra_environment_nothing_is_added(project): + - ``DEVBASE_ACCOUNT_GROUP``: マウントされたグループボリュームと同じ解決結果 + - ``CLOUDSDK_CONFIG`` / ``GOOGLE_WORKSPACE_CLI_CONFIG_DIR``: gcloud / gws の + 設定ディレクトリ。entrypoint の export は docker exec のシェルに届かないため + compose で渡す必要がある + - ``GCP_AUTH_MODE``: ホストで解決した認証モード + """ (project / "compose.yml").write_text(COMPOSE_NO_ENV) generate_scaled_compose(1) - assert "environment" not in generated(project)["services"]["dev-1"] + env = env_of(generated(project)["services"]["dev-1"]) + assert {k: env[k] for k in DEVBASE_MANAGED} == DEVBASE_MANAGED diff --git a/tests/volume/test_compose_gcp_auth.py b/tests/volume/test_compose_gcp_auth.py new file mode 100644 index 00000000..1568b6cd --- /dev/null +++ b/tests/volume/test_compose_gcp_auth.py @@ -0,0 +1,338 @@ +"""生成 compose への GCP 認証モードの反映 (PLAN39 Task 5) + +`adc` では鍵モード専用の 2 変数を ``environment:`` の列挙から**外す**。名前が +載らなければ Compose はその変数をコンテナへ渡さないため、``docker exec`` の +シェルから見ても未設定になる。entrypoint の ``unset`` は PID 1 の子孫にしか +効かないので、ここで外すことが AC12 の要になる。 +""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.volume.compose import generate_scaled_compose + + +COMPOSE = """services: + dev: + image: alpine + volumes: + - x:/work +volumes: + x: {} +""" + +# 機密として列挙される名前 (実際の devbase env と同じ並び) +SECRET_NAMES = [ + "ANTHROPIC_API_KEY", + "GCP_CREDENTIALS_BASE64__default", + "GOOGLE_APPLICATION_CREDENTIALS", + "BIGQUERY_KEY_FILE", +] + + +@pytest.fixture +def project(tmp_path, monkeypatch): + (tmp_path / "compose.yml").write_text(COMPOSE) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + monkeypatch.delenv("GCP_AUTH_MODE", raising=False) + monkeypatch.delenv("GCP_ACTIVE_PROFILE", raising=False) + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS_BASE64", raising=False) + for name in list(dict.fromkeys(SECRET_NAMES)): + monkeypatch.delenv(name, raising=False) + return tmp_path + + +def env_names(project) -> set[str]: + config = yaml.safe_load((project / ".docker-compose.scale.yml").read_text()) + environment = config["services"]["dev-1"].get("environment") + if isinstance(environment, dict): + return set(environment) + return {item.split("=", 1)[0] for item in (environment or [])} + + +def env_map(project) -> dict: + config = yaml.safe_load((project / ".docker-compose.scale.yml").read_text()) + environment = config["services"]["dev-1"].get("environment") + if isinstance(environment, dict): + return environment + return dict( + item.split("=", 1) if "=" in item else (item, None) + for item in (environment or []) + ) + + +# --------------------------------------------------------------------------- +# 設定ディレクトリ (AC1 / AC2) +# --------------------------------------------------------------------------- + +def test_config_dirs_are_passed_to_the_container(project): + generate_scaled_compose(1) + + env = env_map(project) + assert env["CLOUDSDK_CONFIG"] == "/persistent/group/gcloud" + assert env["GOOGLE_WORKSPACE_CLI_CONFIG_DIR"] == "/persistent/group/gws" + + +def test_config_dirs_are_passed_to_every_instance(project): + generate_scaled_compose(3) + + config = yaml.safe_load((project / ".docker-compose.scale.yml").read_text()) + for index in (1, 2, 3): + environment = config["services"][f"dev-{index}"]["environment"] + assert environment["CLOUDSDK_CONFIG"] == "/persistent/group/gcloud" + + +# --------------------------------------------------------------------------- +# 認証モード (AC12) +# --------------------------------------------------------------------------- + +def test_adc_is_the_default_without_a_key(project): + generate_scaled_compose(1, secret_env_names=["ANTHROPIC_API_KEY"]) + + assert env_map(project)["GCP_AUTH_MODE"] == "adc" + + +def test_key_mode_is_auto_detected(project, monkeypatch): + """既存プロジェクト (鍵あり) は現行どおり鍵モードで動く。""" + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + assert env_map(project)["GCP_AUTH_MODE"] == "key" + + +def test_adc_drops_the_key_only_variables(project, monkeypatch): + """AC12 (1): 2 変数がコンテナへ渡らない。""" + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + names = env_names(project) + assert "GOOGLE_APPLICATION_CREDENTIALS" not in names + assert "BIGQUERY_KEY_FILE" not in names + # 鍵そのものと他の機密は従来どおり渡す + assert "GCP_CREDENTIALS_BASE64__default" in names + assert "ANTHROPIC_API_KEY" in names + + +def test_key_mode_keeps_the_key_only_variables(project, monkeypatch): + """AC12 (2): 鍵モードでは従来どおり 2 変数を渡す。""" + monkeypatch.setenv("GCP_AUTH_MODE", "key") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + names = env_names(project) + assert "GOOGLE_APPLICATION_CREDENTIALS" in names + assert "BIGQUERY_KEY_FILE" in names + + +def test_switching_back_to_adc_removes_them_again(project, monkeypatch): + """AC12 (3): key → adc へ戻すと 2 変数が消える。最も壊れやすい方向。""" + monkeypatch.setenv("GCP_AUTH_MODE", "key") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + assert "GOOGLE_APPLICATION_CREDENTIALS" in env_names(project) + + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + names = env_names(project) + assert "GOOGLE_APPLICATION_CREDENTIALS" not in names + assert "BIGQUERY_KEY_FILE" not in names + + +def test_adc_also_drops_them_from_the_origin_split(project, monkeypatch): + """由来別の列挙 (共通 / プロジェクト) からも外す。""" + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + + generate_scaled_compose( + 1, + secret_env_names=SECRET_NAMES, + global_env_names=["ANTHROPIC_API_KEY"], + project_env_names=["GOOGLE_APPLICATION_CREDENTIALS", "BIGQUERY_KEY_FILE"], + ) + + names = env_names(project) + assert "GOOGLE_APPLICATION_CREDENTIALS" not in names + assert "BIGQUERY_KEY_FILE" not in names + + +# --------------------------------------------------------------------------- +# 元の compose.yml が environment へ直書きしている場合 (AC12 (1)) +# --------------------------------------------------------------------------- + +INLINE_MAP_COMPOSE = """services: + dev: + image: alpine + environment: + GOOGLE_APPLICATION_CREDENTIALS: /home/ubuntu/.config/gcloud/credentials.json + BIGQUERY_KEY_FILE: /home/ubuntu/.config/gcloud/credentials.json + TZ: Asia/Tokyo + volumes: + - x:/work + batch: + image: alpine + environment: + - GOOGLE_APPLICATION_CREDENTIALS=/keys/sa.json + - TZ=Asia/Tokyo +volumes: + x: {} +""" + +ONLY_KEYS_COMPOSE = """services: + dev: + image: alpine + environment: + GOOGLE_APPLICATION_CREDENTIALS: /home/ubuntu/.config/gcloud/credentials.json + BIGQUERY_KEY_FILE: /home/ubuntu/.config/gcloud/credentials.json + volumes: + - x:/work +volumes: + x: {} +""" + + +# 非 dev サービスが共通機密を env_file で参照していた構成 +ENV_FILE_COMPOSE = """services: + dev: + image: alpine + volumes: + - x:/work + batch: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env +volumes: + x: {} +""" + + +def services(project) -> dict: + return yaml.safe_load( + (project / ".docker-compose.scale.yml").read_text())["services"] + + +def test_adc_drops_inline_key_paths_from_the_original_compose(project, monkeypatch): + """列挙を絞るだけでは残ってしまう直書きの値も消す。 + + 実体の無いパスが残ると ADC はユーザー認証へフォールバックせず + DefaultCredentialsError で落ちるため、値ごと取り除く必要がある。 + """ + (project / "compose.yml").write_text(INLINE_MAP_COMPOSE) + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + env = env_map(project) + assert "GOOGLE_APPLICATION_CREDENTIALS" not in env + assert "BIGQUERY_KEY_FILE" not in env + # 鍵と無関係な直書きの値は残す + assert env["TZ"] == "Asia/Tokyo" + + +def test_adc_keeps_inline_key_paths_of_non_dev_services(project, monkeypatch): + """非 dev サービスの明示設定は残す。 + + ``GCP_AUTH_MODE`` は **dev コンテナの認証方式**の宣言である。独自に鍵を + マウントしている batch のようなサービスから元の ``compose.yml`` の設定まで + 消すと、そのサービスを壊してしまう。 + """ + (project / "compose.yml").write_text(INLINE_MAP_COMPOSE) + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + batch = services(project)["batch"]["environment"] + assert batch == ["GOOGLE_APPLICATION_CREDENTIALS=/keys/sa.json", "TZ=Asia/Tokyo"] + + +def test_adc_keeps_the_key_env_names_of_non_dev_secret_receivers(project, monkeypatch): + """機密として 2 変数を受け取っていた非 dev サービスの列挙も絞らない。 + + 絞るのは dev へ渡す列挙だけ。共通機密から鍵パスを受け取っていたサービスが + adc への切り替えで値を失うと、そのサービスだけが起動できなくなる。 + """ + (project / "compose.yml").write_text(ENV_FILE_COMPOSE) + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + + generate_scaled_compose( + 1, secret_env_names=SECRET_NAMES, global_env_names=SECRET_NAMES, + project_env_names=[]) + + assert "GOOGLE_APPLICATION_CREDENTIALS" in services(project)["batch"]["environment"] + assert "GOOGLE_APPLICATION_CREDENTIALS" not in env_map(project) + + +def test_key_mode_keeps_inline_key_paths(project, monkeypatch): + """鍵モードでは直書きのパスを尊重する (前提 11)。""" + (project / "compose.yml").write_text(INLINE_MAP_COMPOSE) + monkeypatch.setenv("GCP_AUTH_MODE", "key") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + env = env_map(project) + assert env["GOOGLE_APPLICATION_CREDENTIALS"] is None # 機密として伏せ字化 + assert services(project)["batch"]["environment"] == [ + "GOOGLE_APPLICATION_CREDENTIALS=/keys/sa.json", "TZ=Asia/Tokyo"] + + +def test_inline_key_paths_are_dropped_from_the_dev_instance(project, monkeypatch): + """dev の environment に直書きされた 2 変数は消す。 + + 列挙を絞るだけでは元の ``compose.yml`` の直書きが生成物に残り、実在しない + パスが ADC を ``DefaultCredentialsError`` で落とす。 + """ + (project / "compose.yml").write_text(ONLY_KEYS_COMPOSE) + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + + generate_scaled_compose(1) + + names = env_names(project) + assert "GOOGLE_APPLICATION_CREDENTIALS" not in names + assert "BIGQUERY_KEY_FILE" not in names + + +def test_environment_is_removed_when_it_becomes_empty(): + """全部消えたら environment ごと落とす (空の map を残さない)。""" + from devbase.volume.compose import _drop_env_names + from devbase.env import gcp_auth + + service = {"image": "alpine", "environment": { + "GOOGLE_APPLICATION_CREDENTIALS": "/keys/sa.json", + "BIGQUERY_KEY_FILE": "/keys/sa.json", + }} + + _drop_env_names(service, gcp_auth.KEY_ONLY_ENV_KEYS) + + assert "environment" not in service + + +def test_declared_key_without_a_key_drops_them_too(project, monkeypatch): + """GCP_AUTH_MODE=key でも鍵が無ければ adc 相当 (entrypoint と同じ)。""" + (project / "compose.yml").write_text(INLINE_MAP_COMPOSE) + monkeypatch.setenv("GCP_AUTH_MODE", "key") + + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + env = env_map(project) + assert env["GCP_AUTH_MODE"] == "adc" + assert "GOOGLE_APPLICATION_CREDENTIALS" not in env + assert "BIGQUERY_KEY_FILE" not in env + + +def test_other_profiles_key_does_not_enable_key_mode(project, monkeypatch): + """アクティブでないプロファイルの鍵では key モードにしない。""" + monkeypatch.setenv("GCP_ACTIVE_PROFILE", "dev") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__prod", "eyJ9") + + generate_scaled_compose(1, secret_env_names=SECRET_NAMES) + + env = env_map(project) + assert env["GCP_AUTH_MODE"] == "adc" + assert "GOOGLE_APPLICATION_CREDENTIALS" not in env diff --git a/tests/volume/test_compose_group.py b/tests/volume/test_compose_group.py new file mode 100644 index 00000000..b29f2923 --- /dev/null +++ b/tests/volume/test_compose_group.py @@ -0,0 +1,201 @@ +"""グループボリュームのマウント・宣言・環境変数 (PLAN39 Task 2) + +生成 compose に対して次の 3 点を固定する。 + +1. dev インスタンスへ ``/persistent/group`` が `devbase_home_` としてマウントされる +2. そのボリュームが ``volumes:`` セクションへ ``external: true`` で宣言される +3. dev サービスの ``environment`` へ ``DEVBASE_ACCOUNT_GROUP`` が載る + (entrypoint が初回シードの判定に使う) +""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.errors import DevbaseError +from devbase.volume import compose + + +@pytest.fixture +def in_tmp_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + return tmp_path + + +def _write_compose(tmp_path, services: dict, volumes: dict | None = None) -> None: + document = {"services": services} + if volumes is not None: + document["volumes"] = volumes + (tmp_path / "compose.yml").write_text( + yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + + +def _load_scaled(tmp_path) -> dict: + return yaml.safe_load((tmp_path / ".docker-compose.scale.yml").read_text()) + + +def _mount_source(service: dict, target: str) -> str | None: + for vol in service.get("volumes", []): + if isinstance(vol, str): + parts = vol.split(":") + if len(parts) >= 2 and parts[1] == target: + return parts[0] + elif isinstance(vol, dict) and vol.get("target") == target: + return vol.get("source") + return None + + +def _env_value(service: dict, name: str): + env = service.get("environment") + if isinstance(env, dict): + return env.get(name) + if isinstance(env, list): + for entry in env: + if isinstance(entry, str) and entry.split("=", 1)[0] == name: + return entry.split("=", 1)[1] if "=" in entry else None + return None + + +# --------------------------------------------------------------------------- +# マウント (AC3 / AC5) +# --------------------------------------------------------------------------- + +def test_group_mount_is_added_when_absent(in_tmp_cwd): + """プロジェクト compose が宣言していなくても自動で足される (前提 4)。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1) + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + + assert _mount_source(dev, "/persistent/group") == "devbase_home_default" + + +def test_group_mount_follows_account_group(in_tmp_cwd, monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1) + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + + assert _mount_source(dev, "/persistent/group") == "devbase_home_kkg" + + +def test_declared_group_mount_is_rewritten(in_tmp_cwd, monkeypatch): + """プロジェクトが別のソースで宣言していても devbase 側の名前へ差し替える。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + _write_compose( + in_tmp_cwd, + {"dev": {"image": "dev:latest", + "volumes": ["someone_elses_volume:/persistent/group:rw"]}}, + volumes={"someone_elses_volume": {}}, + ) + + compose.generate_scaled_compose(scale=1) + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + + assert "devbase_home_kkg:/persistent/group:rw" in dev["volumes"] + + +def test_every_instance_gets_the_same_group_volume(in_tmp_cwd, monkeypatch): + """グループはインスタンス番号に依存しない (同グループ内で共有する)。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=3) + services = _load_scaled(in_tmp_cwd)["services"] + + for i in (1, 2, 3): + assert _mount_source(services[f"dev-{i}"], "/persistent/group") == "devbase_home_kkg" + + +def test_shared_ai_mount_is_unchanged(in_tmp_cwd, monkeypatch): + """共通ボリュームは分離の影響を受けない (分類 A は全グループ同一実体 / AC4)。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1) + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + + assert _mount_source(dev, "/persistent/ai") == "devbase_home_ubuntu" + + +# --------------------------------------------------------------------------- +# ボリューム宣言 +# --------------------------------------------------------------------------- + +def test_group_volume_is_declared_external(in_tmp_cwd, monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1) + volumes = _load_scaled(in_tmp_cwd)["volumes"] + + assert volumes["devbase_home_kkg"] == {"external": True} + assert volumes["devbase_home_ubuntu"] == {"external": True} + + +# --------------------------------------------------------------------------- +# 環境変数 +# --------------------------------------------------------------------------- + +def test_account_group_is_exposed_to_dev_service(in_tmp_cwd, monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1) + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + + assert _env_value(dev, "DEVBASE_ACCOUNT_GROUP") == "kkg" + + +def test_default_group_is_exposed_when_unset(in_tmp_cwd): + """未設定でも解決結果を明示的に渡す (コンテナ側で再解決させない)。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1) + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + + assert _env_value(dev, "DEVBASE_ACCOUNT_GROUP") == "default" + + +def test_account_group_is_added_to_list_form_environment(in_tmp_cwd, monkeypatch): + """既存の list 形式 environment を壊さない。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + _write_compose(in_tmp_cwd, { + "dev": {"image": "dev:latest", "environment": ["FEATURE_FLAG=enabled"]}, + }) + + compose.generate_scaled_compose(scale=1) + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + + assert _env_value(dev, "FEATURE_FLAG") == "enabled" + assert _env_value(dev, "DEVBASE_ACCOUNT_GROUP") == "kkg" + + +def test_caller_supplied_dev_environment_is_preserved(in_tmp_cwd, monkeypatch): + """clone プラン等 (PLAN32) と共存する。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose( + scale=1, dev_environment={"DEVBASE_PRIMARY_DIR": "app"}) + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + + assert _env_value(dev, "DEVBASE_PRIMARY_DIR") == "app" + assert _env_value(dev, "DEVBASE_ACCOUNT_GROUP") == "kkg" + + +# --------------------------------------------------------------------------- +# 検証 (AC7) +# --------------------------------------------------------------------------- + +def test_invalid_group_stops_compose_generation(in_tmp_cwd, monkeypatch): + """不正なグループ名は構成生成の時点で弾く (コンテナを起動させない)。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "ubuntu") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + with pytest.raises(DevbaseError): + compose.generate_scaled_compose(scale=1) diff --git a/tests/volume/test_compose_secret_env.py b/tests/volume/test_compose_secret_env.py index 723dd75a..60396aa2 100644 --- a/tests/volume/test_compose_secret_env.py +++ b/tests/volume/test_compose_secret_env.py @@ -2,12 +2,37 @@ from __future__ import annotations +import os + import pytest import yaml from devbase.volume.compose import generate_scaled_compose +# devbase 自身が dev サービスへ常に載せる変数 (PLAN39)。機密ではないので、 +# 「機密の渡し方」を見るこのファイルの期待値からは除いて比較する。 +DEVBASE_MANAGED = { + "DEVBASE_ACCOUNT_GROUP": "default", + "CLOUDSDK_CONFIG": "/persistent/group/gcloud", + "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "/persistent/group/gws", + "GCP_AUTH_MODE": "adc", +} + + +def _without_managed(environment): + """environment から devbase 由来の変数を除く (map / list の記法は保つ)""" + if isinstance(environment, dict): + return {k: v for k, v in environment.items() if k not in DEVBASE_MANAGED} + return [item for item in (environment or []) + if item.split('=', 1)[0] not in DEVBASE_MANAGED] + + +def secret_env(path, service='dev-1'): + return _without_managed( + generated(path)['services'][service].get('environment')) + + COMPOSE = """services: dev: image: alpine @@ -48,12 +73,23 @@ """ +def _clear_devbase_env(monkeypatch): + """devbase 由来の変数の解決が外部環境に左右されないようにする""" + for name in ('DEVBASE_ACCOUNT_GROUP', 'GCP_AUTH_MODE', + 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'): + monkeypatch.delenv(name, raising=False) + for name in list(os.environ): + if name.startswith('GCP_CREDENTIALS_BASE64__'): + monkeypatch.delenv(name, raising=False) + + @pytest.fixture def project(tmp_path, monkeypatch): (tmp_path / 'compose.yml').write_text(COMPOSE) (tmp_path / 'env').write_text('APP_NAME=web\n') monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) (tmp_path / 'root').mkdir() + _clear_devbase_env(monkeypatch) monkeypatch.chdir(tmp_path) return tmp_path @@ -65,6 +101,7 @@ def build(compose_text): (tmp_path / 'compose.yml').write_text(compose_text) monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) (tmp_path / 'root').mkdir(exist_ok=True) + _clear_devbase_env(monkeypatch) monkeypatch.chdir(tmp_path) return tmp_path return build @@ -78,7 +115,7 @@ def test_secret_names_are_listed_without_values(project): generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'DB_PASSWORD']) # 元が map 形式なら map のまま。機密キーだけ値なし参照 (None) になる - assert generated(project)['services']['dev-1']['environment'] == { + assert secret_env(project) == { 'FEATURE_FLAG': 'enabled', 'DB_PASSWORD': None, 'ANTHROPIC_API_KEY': None, @@ -91,7 +128,7 @@ def test_non_secret_environment_is_preserved_in_list_form(project_factory): generate_scaled_compose(1, secret_env_names=['DB_PASSWORD', 'ANTHROPIC_API_KEY']) # 元が list 形式なら list のまま。機密キーは裸のキー名へ落とす - assert generated(path)['services']['dev-1']['environment'] == [ + assert secret_env(path) == [ 'FEATURE_FLAG=enabled', 'DB_PASSWORD', 'PASSTHROUGH', @@ -118,12 +155,13 @@ def test_every_instance_gets_the_names(project): assert config['services'][f'dev-{index}']['environment']['TOKEN'] is None -def test_no_environment_section_without_secrets(project_factory): +def test_no_secret_names_are_listed_without_secrets(project_factory): + """機密が無ければ機密の列挙もしない (載るのは devbase 由来の値だけ)""" path = project_factory(COMPOSE_NO_ENV) generate_scaled_compose(1, secret_env_names=[]) - assert 'environment' not in generated(path)['services']['dev-1'] + assert secret_env(path) == {} def test_names_are_listed_when_original_has_no_environment(project_factory): @@ -131,8 +169,7 @@ def test_names_are_listed_when_original_has_no_environment(project_factory): generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'TOKEN']) - assert generated(path)['services']['dev-1']['environment'] == [ - 'ANTHROPIC_API_KEY', 'TOKEN'] + assert secret_env(path) == ['ANTHROPIC_API_KEY', 'TOKEN'] def test_missing_env_file_entries_are_dropped(project): @@ -306,8 +343,12 @@ def test_commented_out_references_still_receive_the_secrets(project_factory): def _env_names(service_config): - """map / list どちらの記法でも、列挙された変数名を集合で返す""" - environment = service_config.get('environment') + """map / list どちらの記法でも、列挙された機密の変数名を集合で返す + + devbase 由来の変数 (アカウントグループ / gcloud 設定ディレクトリ / 認証モード) + は機密ではないので数えない。 + """ + environment = _without_managed(service_config.get('environment')) if isinstance(environment, dict): return set(environment) return {item.split('=', 1)[0] for item in (environment or [])} diff --git a/tests/volume/test_manager_group.py b/tests/volume/test_manager_group.py new file mode 100644 index 00000000..b4e9d7a3 --- /dev/null +++ b/tests/volume/test_manager_group.py @@ -0,0 +1,155 @@ +"""アカウントグループの解決と検証 (PLAN39 Task 1) + +`DEVBASE_ACCOUNT_GROUP` は「使用する Google / AWS アカウントの単位」を宣言する +公開設定キー。解決結果はグループボリューム名 (`devbase_home_`) になるため、 +Docker のボリューム名として使えない文字列と、既存のボリューム名前空間 +(`devbase_home_ubuntu` / `devbase_home_`) と衝突する名前を起動前に弾く。 +""" + +from __future__ import annotations + +import pytest + +from devbase.errors import DevbaseError +from devbase.volume import manager + + +@pytest.fixture(autouse=True) +def _clean_group_env(monkeypatch): + """外部環境の DEVBASE_ACCOUNT_GROUP に左右されないよう既定で未設定にする。""" + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + + +# --------------------------------------------------------------------------- +# フォールバック (AC5) +# --------------------------------------------------------------------------- + +def test_unset_falls_back_to_default(): + """未設定なら default。既存プロジェクトは何も書かずに起動できる。""" + assert manager.resolve_account_group() == "default" + + +def test_empty_and_whitespace_fall_back_to_default(monkeypatch): + """空文字・空白のみも「未設定」として扱う (env に `KEY=` と書いた場合)。""" + for value in ("", " ", "\t"): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", value) + assert manager.resolve_account_group() == "default" + + +def test_explicit_none_reads_environment(monkeypatch): + """引数省略時は環境変数を読む (前提 3: 3 レベルの解決結果が入っている)。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + assert manager.resolve_account_group() == "kkg" + + +def test_argument_wins_over_environment(monkeypatch): + """引数が環境変数より優先される。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") + assert manager.resolve_account_group("with") == "with" + + +def test_surrounding_whitespace_is_stripped(monkeypatch): + """env ファイル由来の前後空白は落とす。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", " kkg ") + assert manager.resolve_account_group() == "kkg" + + +# --------------------------------------------------------------------------- +# 正常系 +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("group", ["default", "kkg", "with", "a", "a-b_c.d", "g1", "1g"]) +def test_valid_group_names_are_accepted(group): + assert manager.resolve_account_group(group) == group + + +def test_group_volume_name(): + assert manager.get_group_volume("kkg") == "devbase_home_kkg" + + +def test_group_volume_falls_back_to_default(): + assert manager.get_group_volume() == "devbase_home_default" + + +def test_group_volume_validates_its_argument(): + """ボリューム名の生成でも検証を通す (検証を迂回する経路を作らない)。""" + with pytest.raises(DevbaseError): + manager.get_group_volume("ubuntu") + + +# --------------------------------------------------------------------------- +# 拒否ケース (AC7) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("group", [ + "-leading-hyphen", # 先頭が英数字でない + ".leading-dot", + "_leading-underscore", + "with space", + "with/slash", + "with:colon", + "日本語", + "bad!name", # 記号を含む +]) +def test_invalid_characters_are_rejected(group): + with pytest.raises(DevbaseError) as excinfo: + manager.resolve_account_group(group) + # 何が悪いのか分かるメッセージにする + assert group in str(excinfo.value) + + +def test_reserved_ubuntu_is_rejected(): + """`ubuntu` は共通ボリューム devbase_home_ubuntu と衝突する。""" + with pytest.raises(DevbaseError) as excinfo: + manager.resolve_account_group("ubuntu") + message = str(excinfo.value) + assert "ubuntu" in message + assert manager.HOME_UBUNTU_VOLUME in message + + +@pytest.mark.parametrize("group", ["1", "2", "042"]) +def test_numeric_only_is_rejected(group): + """数字のみは devbase_home_ と衝突する (前提 6)。""" + with pytest.raises(DevbaseError) as excinfo: + manager.resolve_account_group(group) + assert "devbase_home_" in str(excinfo.value) + + +def test_invalid_environment_value_is_rejected(monkeypatch): + """環境変数経由でも同じ検証が効く (起動前に弾く)。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "ubuntu") + with pytest.raises(DevbaseError): + manager.resolve_account_group() + + +# --------------------------------------------------------------------------- +# 死んだ API の削除 (前提 6) +# --------------------------------------------------------------------------- + +def test_ai_volume_prefix_is_gone(): + """未使用の AI_VOLUME_PREFIX は削除済み。命名系統を 2 つ並べない。""" + assert not hasattr(manager, "AI_VOLUME_PREFIX") + + +# --------------------------------------------------------------------------- +# Docker を触る前に弾く (AC7) +# --------------------------------------------------------------------------- + +def test_ensure_volumes_rejects_bad_group_before_touching_docker(monkeypatch): + """グループ名が不正なら Docker の状態を一切変えずに失敗する。 + + 検証が共有ボリュームの作成より後ろにあると、入力ミスだけで + devbase_home_ubuntu が作られてしまう。 + """ + created: list[str] = [] + monkeypatch.setattr( + manager.VolumeManager, "_volume_exists", + lambda self, name: False) + monkeypatch.setattr( + manager.VolumeManager, "_create_volume", + lambda self, name: created.append(name) or True) + + with pytest.raises(DevbaseError): + manager.VolumeManager().ensure_volumes(1, group="ubuntu") + + assert created == []