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 4a3de97e..a1b0ef8c 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -411,6 +411,91 @@ devbase_setup_ai_settings() { 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" +} + # テストは関数定義だけを使う (source 時のみ有効な return で以降を読み飛ばす)。 if [ -n "${DEVBASE_ENTRYPOINT_LIB_ONLY:-}" ]; then return 0 2>/dev/null || exit 0 @@ -419,40 +504,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 diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 07ab8d29..fe27ee9e 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -188,6 +188,31 @@ AI CLI ツールの設定や認証情報は、コンテナを再生成しても > **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..76a81472 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -79,15 +79,64 @@ 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 +から取り除きます。 + +```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 認証 | キー | 説明 | 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..aac9e1b7 --- /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, Optional, 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 filter_key_env_names( + names: Optional[Sequence[str]], mode: str, +) -> Optional[Sequence[str]]: + """``adc`` モードでは鍵モード専用の変数名を列挙から外す。 + + 生成 compose の ``environment:`` に名前が載らなければ、Compose はその変数を + コンテナへ渡さない。値を空文字にするのではなく**渡さない**ことで、 + ``docker exec`` のシェルから見ても未設定になる。 + """ + if names is None or mode == AUTH_MODE_KEY: + return names + return [name for name in names if name not in KEY_ONLY_ENV_KEYS] diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index c18117d3..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: diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index afd3ce49..41df14ef 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -8,7 +8,7 @@ Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, ) -from devbase.env import compose_migrate, keys +from devbase.env import compose_migrate, gcp_auth, keys from devbase.errors import DockerError from devbase.log import get_logger @@ -220,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.filter_key_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: """機密の変数名を**由来別**に保持し、参照種別に応じた部分集合を切り出す。 @@ -528,8 +564,6 @@ 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) - secret_names = _SecretNames( - secret_env_names, global_env_names, project_env_names) # アカウントグループはここで 1 度だけ解決し、マウント・ボリューム宣言・ # 環境変数の 3 か所へ同じ値を配る。コンテナ側で解決し直させると、マウント @@ -540,15 +574,39 @@ def generate_scaled_compose( dev_environment = { **(dev_environment or {}), keys.DEVBASE_ACCOUNT_GROUP: account_group, + # gcloud / gws の設定ディレクトリと解決済みの認証モード (PLAN39) + **gcp_auth.container_env(os.environ), } + # ADC モードでは鍵モード専用の 2 変数を **列挙から外す**。名前が載らなければ + # Compose はその変数をコンテナへ渡さないので、docker exec のシェルから見ても + # 未設定になる。値を空にするだけでは entrypoint の外に効かない。 + auth_mode = dev_environment[keys.GCP_AUTH_MODE] + secret_env_names = gcp_auth.filter_key_env_names(secret_env_names, auth_mode) + global_env_names = gcp_auth.filter_key_env_names(global_env_names, auth_mode) + project_env_names = gcp_auth.filter_key_env_names(project_env_names, auth_mode) + + secret_names = _SecretNames( + secret_env_names, global_env_names, project_env_names) + + 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 では鍵をどのサービスにも書かないので、パスが + # 残っていること自体が DefaultCredentialsError の原因になる。全サービスから + # 名前ごと取り除く。 + if auth_mode != gcp_auth.AUTH_MODE_KEY: + for service in scaled_services.values(): + 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, group_volume, - secret_names=secret_names, - secret_services=secret_services, - dev_environment=dev_environment, - ), + 'services': scaled_services, 'volumes': _build_volumes_section(config, scale, group_volume), 'networks': _build_networks_section(config), } 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/env/test_gcp_auth.py b/tests/env/test_gcp_auth.py new file mode 100644 index 00000000..5aedad83 --- /dev/null +++ b/tests/env/test_gcp_auth.py @@ -0,0 +1,157 @@ +"""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) +# --------------------------------------------------------------------------- + +NAMES = ["ANTHROPIC_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", + "BIGQUERY_KEY_FILE", "GCP_CREDENTIALS_BASE64__default"] + + +def test_adc_drops_the_key_only_names(): + filtered = gcp_auth.filter_key_env_names(NAMES, gcp_auth.AUTH_MODE_ADC) + assert filtered == ["ANTHROPIC_API_KEY", "GCP_CREDENTIALS_BASE64__default"] + + +def test_key_mode_keeps_every_name(): + filtered = gcp_auth.filter_key_env_names(NAMES, gcp_auth.AUTH_MODE_KEY) + assert filtered == NAMES + + +def test_none_is_passed_through(): + """由来の内訳が渡されない場合の None をつぶさない。""" + assert gcp_auth.filter_key_env_names(None, gcp_auth.AUTH_MODE_ADC) is None + + +# --------------------------------------------------------------------------- +# コンテナへ渡す環境変数 +# --------------------------------------------------------------------------- + +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/volume/test_compose_dev_environment.py b/tests/volume/test_compose_dev_environment.py index ac99126f..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,13 +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) - # アカウントグループは常に dev へ載る (PLAN39)。外部環境で値が変わらないよう - # 未設定 = default に固定する。 - monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + # 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 @@ -64,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) @@ -93,11 +115,10 @@ 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", - "DEVBASE_ACCOUNT_GROUP": "default", } @@ -106,19 +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, "DEVBASE_ACCOUNT_GROUP": "default"} + assert user_env(generated(project)["services"]["dev-1"]) == REPO_ENV -def test_without_extra_environment_only_the_account_group_is_added(project): - """呼び出し側が何も渡さなくてもアカウントグループだけは載る (PLAN39)。 +def test_without_extra_environment_only_devbase_values_are_added(project): + """呼び出し側が何も渡さなくても devbase 由来の変数は載る (PLAN39)。""" + (project / "compose.yml").write_text(COMPOSE_NO_ENV) - マウントされるグループボリュームと entrypoint が見るグループ名を必ず一致 - させるため、解決結果はホスト側が明示的に渡す。 + generate_scaled_compose(1) + + assert user_env(generated(project)["services"]["dev-1"]) == {} + + +def test_devbase_managed_environment_is_always_present(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) - dev = generated(project)["services"]["dev-1"] - assert env_of(dev) == {"DEVBASE_ACCOUNT_GROUP": "default"} + 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..0c25e392 --- /dev/null +++ b/tests/volume/test_compose_gcp_auth.py @@ -0,0 +1,282 @@ +"""生成 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 + volumes: + - x:/work + batch: + image: alpine + environment: + GOOGLE_APPLICATION_CREDENTIALS: /keys/sa.json + BIGQUERY_KEY_FILE: /keys/sa.json +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_drops_inline_key_paths_from_non_dev_services(project, monkeypatch): + """list 記法の非 dev サービスからも消す (鍵はどこにも書かれない)。""" + (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 == ["TZ=Asia/Tokyo"] + + +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_environment_is_removed_when_it_becomes_empty(project, monkeypatch): + """全部消えたら environment ごと落とす (空の map を残さない)。""" + (project / "compose.yml").write_text(ONLY_KEYS_COMPOSE) + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + + generate_scaled_compose(1) + + assert "environment" not in services(project)["batch"] + + +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_secret_env.py b/tests/volume/test_compose_secret_env.py index 1e96a662..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,13 +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() - monkeypatch.delenv('DEVBASE_ACCOUNT_GROUP', raising=False) + _clear_devbase_env(monkeypatch) monkeypatch.chdir(tmp_path) return tmp_path @@ -66,7 +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) - monkeypatch.delenv('DEVBASE_ACCOUNT_GROUP', raising=False) + _clear_devbase_env(monkeypatch) monkeypatch.chdir(tmp_path) return tmp_path return build @@ -79,13 +114,11 @@ def generated(path): def test_secret_names_are_listed_without_values(project): generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'DB_PASSWORD']) - # 元が map 形式なら map のまま。機密キーだけ値なし参照 (None) になる。 - # DEVBASE_ACCOUNT_GROUP は機密ではない devbase 由来の値なので実値で載る。 - assert generated(project)['services']['dev-1']['environment'] == { + # 元が map 形式なら map のまま。機密キーだけ値なし参照 (None) になる + assert secret_env(project) == { 'FEATURE_FLAG': 'enabled', 'DB_PASSWORD': None, 'ANTHROPIC_API_KEY': None, - 'DEVBASE_ACCOUNT_GROUP': 'default', } @@ -95,12 +128,11 @@ 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', 'ANTHROPIC_API_KEY', - 'DEVBASE_ACCOUNT_GROUP=default', ] assert 'has-a-value' not in (path / '.docker-compose.scale.yml').read_text() @@ -129,8 +161,7 @@ def test_no_secret_names_are_listed_without_secrets(project_factory): generate_scaled_compose(1, secret_env_names=[]) - assert generated(path)['services']['dev-1']['environment'] == { - 'DEVBASE_ACCOUNT_GROUP': 'default'} + assert secret_env(path) == {} def test_names_are_listed_when_original_has_no_environment(project_factory): @@ -138,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', 'DEVBASE_ACCOUNT_GROUP=default'] + assert secret_env(path) == ['ANTHROPIC_API_KEY', 'TOKEN'] def test_missing_env_file_entries_are_dropped(project): @@ -313,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 [])} @@ -346,10 +380,9 @@ def test_service_referencing_both_gets_every_name(project_factory): config = generated(path) assert _env_names(config['services']['both']) == { 'SHARED_KEY', 'PROJECT_TOKEN'} - # dev は従来どおり全件 (env_file を書いていない構成でも両方が要る)。 - # DEVBASE_ACCOUNT_GROUP は機密ではなく devbase 由来の値として常に載る。 + # dev は従来どおり全件 (env_file を書いていない構成でも両方が要る) assert _env_names(config['services']['dev-1']) == { - 'SHARED_KEY', 'PROJECT_TOKEN', 'DEVBASE_ACCOUNT_GROUP'} + 'SHARED_KEY', 'PROJECT_TOKEN'} # 機密を参照していないサービスには何も注入しない assert 'environment' not in config['services']['none'] @@ -398,7 +431,7 @@ def fail_on_compose(self, *args, **kwargs): config = generated(path) assert _env_names(config['services']['dev-1']) == { - 'SHARED_KEY', 'PROJECT_TOKEN', 'DEVBASE_ACCOUNT_GROUP'} + 'SHARED_KEY', 'PROJECT_TOKEN'} for name in ('global_only', 'project_only', 'both', 'none'): assert 'environment' not in config['services'][name]