diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index 6f78c656..c874f796 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -155,6 +155,28 @@ RUN set -eux; \ ENV PATH="/root/.local/bin:/opt/google-cloud-sdk/bin:${PATH}" +# OpenBao CLI (bao)。devbase の機密のサーバ backend と同じ 2.6 系に固定する (PLAN54)。 +# 起動中のコンテナから自分の機密を読み書きし、管理者は管理スクリプトをここから実行する。 +# .deb は systemd ユニットやシステムユーザーを伴うため使わず、tar.gz から bao だけを置く。 +# 同じリリースの checksums.txt で検証し、一致しなければ展開せずにビルドを止める。 +ARG BAO_VERSION=2.6.2 +RUN set -eux; \ + case "$(dpkg --print-architecture)" in \ + amd64) bao_arch="amd64" ;; \ + arm64) bao_arch="arm64" ;; \ + *) echo "Unsupported architecture for bao: $(dpkg --print-architecture)" && exit 1 ;; \ + esac; \ + bao_tar="openbao_${BAO_VERSION}_linux_${bao_arch}.tar.gz"; \ + bao_base="https://github.com/openbao/openbao/releases/download/v${BAO_VERSION}"; \ + cd /tmp; \ + curl -fsSL "${bao_base}/${bao_tar}" -o "${bao_tar}"; \ + curl -fsSL "${bao_base}/checksums.txt" -o bao-checksums.txt; \ + bao_sum="$(grep " ${bao_tar}\$" bao-checksums.txt)"; \ + echo "${bao_sum}" | sha256sum -c -; \ + tar -xzf "${bao_tar}" -C /usr/local/bin bao; \ + rm -f "${bao_tar}" bao-checksums.txt; \ + bao version + # 確認 RUN gh --version && node --version && npm --version && aws --version && gcloud --version && session-manager-plugin --version diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index d166ba03..47432d80 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -233,6 +233,25 @@ devbase env exec -- docker compose config > `devbase env exec -- printenv` のように値を表示するコマンドは、画面共有や端末ログに認証情報がそのまま残ります。実行する場面に注意してください。 +## `devbase env token` + +起動中の dev コンテナの `~/.vault-token` を、OpenBao の新しい token で置き換えます(backend が +`openbao` のときだけ)。コンテナの中の `bao` の token が切れたときに使います。詳しくは +[機密の保存先を選ぶ](../env-backend.md) の「コンテナの中から `bao` を使う」を参照してください。 + +``` +devbase env token [--print] [--context NAME] +``` + +| オプション | 説明 | +|---|---| +| なし | 現在地のプロジェクトの起動中の dev コンテナ(サービス `-`)すべてへ書き、書いたコンテナ名を表示する | +| `--print` | コンテナへ書かず、token だけを標準出力へ出す | +| `--context NAME` | docker context を一時的に上書きする | + +プロジェクトの外で実行したとき、起動中の dev コンテナが無いときは、token を発行せずに +終了コード 1 で止まります。一部のコンテナへ書けなかったときも 1 です。 + ## `devbase env rekey` 誰が機密を復号できるかを変更し、暗号化済みの機密をまとめて暗号化し直します。 diff --git a/docs/user/cli-reference/README.md b/docs/user/cli-reference/README.md index 8ea5852e..f0f9c536 100644 --- a/docs/user/cli-reference/README.md +++ b/docs/user/cli-reference/README.md @@ -6,7 +6,7 @@ devbase の全コマンドの構文、オプション、使用例をまとめた |---------|------| | [トップレベルコマンド](01-toplevel.md) | `init` / `status` / `bin/rc` | | [project グループ](02-project.md) | コンテナのライフサイクル管理・一覧(`up` / `down` / `login` / `ps` / `logs` / `scale` / `build` / `rebuild` / `list`)と非推奨の `container` グループ | -| [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `encrypt` / `decrypt` / `exec` / `rekey` / `doctor` / `export` / `import`) | +| [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `encrypt` / `decrypt` / `exec` / `token` / `rekey` / `doctor` / `export` / `import`) | | [plugin グループ](04-plugin.md) | プラグインの管理(`list` / `install` / `uninstall` / `update` / `info` / `sync` / `migrate` / `repo *`) | | [snapshot グループ](05-snapshot.md) | スナップショットの管理(`create` / `list` / `restore` / `copy` / `delete` / `rotate`) | @@ -27,7 +27,7 @@ graph TD D --> D4["build [image] / rebuild [name]"] D --> D2["list [--no-interactive]"] E --> E1[init / sync / list / set / get / delete / edit / project] - E --> E2[keygen / encrypt / decrypt / exec / rekey / doctor] + E --> E2[keygen / encrypt / decrypt / exec / token / rekey / doctor] E --> E3[export / import] F --> F1[list / install / uninstall / update / info / sync / migrate] F --> F2[repo add / repo remove / repo list / repo refresh] diff --git a/docs/user/env-backend.md b/docs/user/env-backend.md index a2748f63..fcfdadc7 100644 --- a/docs/user/env-backend.md +++ b/docs/user/env-backend.md @@ -144,6 +144,65 @@ OpenBao の KV v2 にはコメント・空行の置き場がありません。`d 「他の誰かが先に書きました」と表示して止まります。黙って上書きすることはありません。 もう一度 `env edit`(または `set`)をやり直してください。 +## コンテナの中から `bao` を使う + +base イメージには OpenBao の CLI `bao`(サーバと同じ 2.6 系)が入っています。backend が +`openbao` の端末で `devbase up` すると、dev コンテナに次の 2 つが渡り、**再起動せずに** +自分の機密を読み書きできます。 + +| 渡るもの | 形 | +|---|---| +| 接続先 | 環境変数 `BAO_ADDR`(`backend.yml` の `openbao.url`) | +| token | ファイル `~/.vault-token`(`0600`)。`bao` が既定で読む | + +コンテナに置くのは 1 時間で切れる token だけで、`secret_id` はホストから出ません。token を +環境変数にしないのは、`docker inspect` や子プロセスの環境に残るためです。 + +### 読む・書く + +置き場のパスは `-mount=devbase` からの相対で、`` は `backend.yml` の `openbao.user` です。 + +| 置き場 | パス | +|---|---| +| 個人共通 | `users//global` | +| 個人のプロジェクト | `users//projects/` | +| チーム共通(読むだけ) | `team/global` | +| チームのプロジェクト(読むだけ) | `team/projects/` | + +```bash +bao kv get -mount=devbase users//global # 一覧 +bao kv get -mount=devbase -field=API_KEY users//global # 1 キー +bao kv patch -mount=devbase users//global NEW_KEY=value # 1 キーを足す・変える +``` + +**`kv put` はパスの中身を丸ごと置き換えます。** 指定しなかったキーは消えるので、1 キーだけを +足す・変えるときは `kv patch` を使ってください。キーを消すのは、ホストの +`devbase env delete --user KEY` が確実です(残すキーを読み直して丸ごと書き戻します)。 + +起動中のシェルの環境変数は、書き換えても変わりません。今のシェルで新しい値を使うときは +読み直します。次の `devbase up` からはコンテナの環境変数にも載ります。 + +```bash +export API_KEY="$(bao kv get -mount=devbase -field=API_KEY users//global)" +``` + +コンテナで書いた値は、ホストの手元キャッシュ(`secrets/cache/`)には反映されません。ホストの +`devbase up` / `env get` はサーバの現物を読むため、到達できる限り食い違いません。 + +### token が切れたら + +`bao` が `permission denied`(`Code: 403`)を返したら、token の期限(1 時間)が切れています。 +**ホストの**プロジェクトのディレクトリで次を実行すると、起動中の dev コンテナすべての +`~/.vault-token` を新しい token に置き換えます。 + +```bash +devbase env token # 起動中の dev コンテナへ書く +devbase env token --print # token を表示するだけ(手で渡すとき) +``` + +別ホストの Docker(`project.local.yml` の `docker.context`)で動かしているコンテナにも、同じ +接続先で届きます。`--context NAME` で一時的に上書きできます。 + ## サーバへ到達できないとき 取得できた機密は、参照ごとに age で暗号化して `secrets/cache/` に控えられます diff --git a/issues/PLAN54_bao-in-container.md b/issues/PLAN54_bao-in-container.md index e876322f..e9a1a9c0 100644 --- a/issues/PLAN54_bao-in-container.md +++ b/issues/PLAN54_bao-in-container.md @@ -168,3 +168,25 @@ | 項目 | 誰が決めるか | 期限 | | --- | --- | --- | | ~~token を取り直す手段~~ → 決まった: ホストの `devbase env token` が起動中のコンテナの `~/.vault-token` を書き換える(設計の決定 2。(b) `secret_id` をコンテナへ渡す案と (c) OIDC 案は採らない) | 設計 Pull Request のマージで利用者が承認する | 設計 | + +## 実装計画 + +設計は `issues/PLAN54_bao-in-container-design.md`(マージ済み #175)。PLAN55 (#177) が先に入ったため、 +`_push_bao_token` の token は `runtime.store_for(root)`(注入と同じ `SecretStore`)から取る +(設計「処理の流れ」の表「PLAN55 の後」)。 + +| Task | 対象ファイル | 変更内容 | 満たす受け入れ条件 | 進め方 | +| --- | --- | --- | --- | --- | +| 1 | `containers/base/Dockerfile`、`tests/containers/test_base_dockerfile_bao.py` | `ARG BAO_VERSION=2.6.2`、tar.gz + `checksums.txt` を取得し `sha256sum -c`、`bao` だけを `/usr/local/bin` へ | 1・2・10 | 文言を固定するテスト → Dockerfile。実ビルドは手で 1 度 | +| 2 | `lib/devbase/env/openbao.py`、`tests/env/test_openbao.py` | `issue_token()`(期限内なら再ログインしない) | 6 の土台 | 偽サーバで login 回数を固定 → 実装 | +| 3 | `lib/devbase/env/container_token.py`、`tests/env/test_container_token.py` | `push(names, token, runner=)`: `docker exec -i` + `mktemp` → `mv -f`、token は stdin のみ | 8 | runner のスタブで argv / input / 文言を固定 → 実装 | +| 4 | `lib/devbase/commands/container.py`、`tests/commands/test_container_bao.py` | openbao のとき `dev_environment` に `BAO_ADDR`、[5/6] の後に `_push_bao_token`(失敗は警告) | 7 | up の harness で compose 引数と docker exec の有無を固定 → 実装 | +| 5 | `lib/devbase/commands/env.py`、`lib/devbase/cli.py`、`tests/commands/test_env_token.py` | `devbase env token [--print] [--context NAME]`、`SUBCMD_MAP`、`_NO_SECRET_INJECTION` | 6 | 設計の状況表の行ごとにテスト → 実装 | +| 6 | `docs/user/env-backend.md` | 「コンテナの中から `bao` を使う」の節 | F4 | 文書 | + +設計からの追加(実装で決めたこと): `cmd_scale` も構成を作り直すため、`up` と同じく `BAO_ADDR` を足し、 +増やしたインスタンス(`current_scale + 1`〜)へ token を書く(2026-09-14。設計は `up` だけを挙げていたが、 +`scale` で増えたコンテナに `BAO_ADDR` と token が無い状態を作らないため)。 + +リスク: `container.py` は 1300 行超。触るのは `_run_deploy_pipeline` と `cmd_up` の後処理の数行に限る。 +切り戻し: 差分を戻すだけ(永続データなし)。イメージは再ビルドで元に戻る。 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 3b934679..44969b43 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -56,7 +56,7 @@ ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list'], ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'keygen', - 'exec', 'encrypt', 'decrypt', 'rekey', 'doctor', + 'exec', 'token', 'encrypt', 'decrypt', 'rekey', 'doctor', 'export', 'import', 'backend'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'], ('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'], @@ -368,6 +368,13 @@ def _add_env_parser(subparsers): metavar='-- CMD [ARGS...]', help='Command to run (prefix with -- to pass flags)') + env_token = env_sub.add_parser( + 'token', + help='Refresh the OpenBao token in running dev containers (~/.vault-token)') + env_token.add_argument('--print', dest='print_only', action='store_true', + help='Print a token to stdout instead of writing it into containers') + _add_context_arg(env_token) + for name, action in (('encrypt', 'Move plaintext settings into the encrypted store'), ('decrypt', 'Move encrypted settings back to plaintext')): sub = env_sub.add_parser(name, help=action) @@ -803,6 +810,9 @@ def main(): # backend の設定を触るコマンド。設定が壊れている・サーバに届かない状態でこそ # 実行されるため、注入で先に落ちないようにする。 ('env', 'backend'), + # コンテナの bao へ token を届けるだけで、機密の値は要らない。注入するとサーバへの + # 往復が増える (PLAN54)。 + ('env', 'token'), }) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 35b88072..4ad1edf3 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -202,6 +202,23 @@ def _generate_compose_for(scale: int, secrets, dev_environment=None, ) +def _build_scaled_override(scale: int, config, project_name: str, + target: docker_context.DockerTarget) -> Path: + """スケール構成 (override compose) を生成して返す。 + + デプロイ (``_run_deploy_pipeline``) とスケール (``cmd_scale``) が共有する + 「機密の復号 → dev 環境変数 (``BAO_ADDR`` 含む) の合成 → 構成生成」という + 同じ 3 手をまとめる。bao 環境やリモート引数の追加は片方だけを直すと食い違う + ため、1 箇所へ寄せる。 + """ + secrets = _inject_secrets(required=True) + dev_environment = {**project_runtime.container_env(config, project_name), + **_bao_environment()} + return _generate_compose_for( + scale, secrets, dev_environment=dev_environment, + **_remote_generate_kwargs(target)) + + @contextmanager def _previous_scale_compose(): """生成前の override compose を退避し、``down`` へ渡すパスとして貸し出す。 @@ -730,6 +747,70 @@ def _resolve_open_index(open_index: Optional[int], scale: int) -> int: return open_index +def _openbao_store(): + """backend が ``openbao`` なら注入と同じ ``SecretStore`` を、そうでなければ ``None`` を返す。 + + ``runtime.store_for`` は ``up`` の注入が作ったものを返す (PLAN55)。ここで読み直すと + サーバへの往復が増えるため、同じインスタンスを使う。設定を読めないときは ``None`` + (注入の側が既にその誤りで止めている)。 + """ + from devbase.env import runtime as _runtime + from devbase.env.secret_store import SecretStoreError + + root = _devbase_root() + if root is None: + return None + store = _runtime.store_for(root) + try: + return store if store.backend_name == 'openbao' else None + except SecretStoreError: + return None + + +def _bao_environment() -> dict: + """dev サービスへ足す ``BAO_ADDR`` (PLAN54)。backend が ``openbao`` でなければ空。 + + 接続先は機密ではないので compose の ``environment`` にリテラルで書く。token は + ここに載せない (``docker inspect`` に残る)。``~/.vault-token`` は起動後に + :func:`_push_bao_token` が書く。 + """ + store = _openbao_store() + if store is None: + return {} + return {'BAO_ADDR': store.config.openbao.url} + + +def _push_bao_token(project_name: str, scale: int, dev_service_name: str, + compose_file=None, start: int = 1) -> None: + """各 dev コンテナの ``~/.vault-token`` へ token を書く (PLAN54)。 + + backend が ``openbao`` でなければ何もしない。token が取れない・書けないときは + 警告にとどめる (起動は済んでおり、``devbase env token`` でやり直せる)。 + """ + from devbase.editor import opener + from devbase.env import container_token + from devbase.env.secret_store import SecretRef + + store = _openbao_store() + if store is None: + return + try: + token = store.backend_for(SecretRef.for_global()).issue_token() + names = [opener.resolve_container_name(dev_service_name, project_name, index, + compose_file=compose_file) + for index in range(start, scale + 1)] + written = container_token.push(names, token) + except Exception as e: # noqa: BLE001 - 付随処理で up を倒さない + logger.warning("コンテナへ bao の token を書けませんでした (devbase env token で" + "やり直せます): %s", e) + return + if len(written) < len(names): + logger.warning("bao の token を書けなかったコンテナがあります " + "(devbase env token でやり直せます)") + elif written: + logger.info("bao の token を書きました: %s", ', '.join(written)) + + def _apply_window_titles(project_name: str, scale: int, dev_service_name: str, compose_file=None) -> None: """各 dev コンテナの VS Code ウィンドウタイトルをコンテナ名始まりにする。 @@ -906,10 +987,7 @@ def _run_deploy_pipeline(project_name: str, scale: int, config, # にしないため。 with _previous_scale_compose() as down_compose_file: logger.info("[2/6] Generating scaled compose file...") - override_file = _generate_compose_for( - scale, _inject_secrets(required=True), - dev_environment=project_runtime.container_env(config, project_name), - **_remote_generate_kwargs(target)) + override_file = _build_scaled_override(scale, config, project_name, target) logger.info("Generated: %s", override_file) logger.info("[3/6] Stopping existing containers...") @@ -978,6 +1056,10 @@ def cmd_up(project_name: str = None, scale: int = None, _run_deploy_script_for_instances(deploy_script, range(1, scale + 1), config) + # 起動中のコンテナの bao が使う token を書く (PLAN54)。backend が openbao の + # ときだけ。書けなくても起動は済んでいるので up は倒さない。 + _push_bao_token(project_name, scale, dev_service_name, compose_file=override_file) + # VS Code のウィンドウタイトルをコンテナ名始まりに固定する # (自動オープンの有無に関わらず、手動アタッチにも効かせるため up 側で行う)。 _apply_window_titles(project_name, scale, dev_service_name, @@ -1108,10 +1190,7 @@ def cmd_scale(new_scale: int, project_name: str = None, ensure_network('devbase_net') logger.info("[3/5] Generating scaled compose file...") - override_file = _generate_compose_for( - new_scale, _inject_secrets(required=True), - dev_environment=project_runtime.container_env(config, project_name), - **_remote_generate_kwargs(target)) + override_file = _build_scaled_override(new_scale, config, project_name, target) logger.info("Generated: %s", override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) @@ -1134,6 +1213,10 @@ def cmd_scale(new_scale: int, project_name: str = None, timeout=60 ) + # 増やしたインスタンスにも bao の token を書く (PLAN54。既存のものは up で書いてある) + _push_bao_token(project_name, new_scale, dev_service_name, compose_file=override_file, + start=current_scale + 1) + # Run project-specific deploy script for newly added instances deploy_script = Path('./deploy') if deploy_script.exists() and deploy_script.is_file(): @@ -1598,15 +1681,7 @@ def _base_image_is_fresh(dev_service: dict, max_age: int) -> bool: base_ref = _get_base_image_ref(dev_service) if not base_ref: return False - inspect = subprocess.run( - ['docker', 'image', 'inspect', base_ref], - capture_output=True, - text=True, - check=False - ) - if inspect.returncode != 0: - return False - age_days = _get_image_age_days(inspect.stdout) + age_days = _inspect_image_age(base_ref) if age_days is None: return False if age_days < max_age: @@ -1664,6 +1739,19 @@ def _pull_and_mark(image_name: str) -> bool: return ok +def _inspect_image_age(ref: str) -> Optional[int]: + """Inspect an image and return its age in days, or None on failure.""" + inspect = subprocess.run( + ['docker', 'image', 'inspect', ref], + capture_output=True, + text=True, + check=False + ) + if inspect.returncode != 0: + return None + return _get_image_age_days(inspect.stdout) + + def _get_image_age_days(inspect_json: str) -> Optional[int]: """Return age of the inspected image in days, or None on failure.""" try: diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index d07ec7d0..30b90036 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -3,7 +3,7 @@ import os import subprocess from pathlib import Path -from typing import Optional +from typing import List, Optional import yaml @@ -132,6 +132,9 @@ def cmd_env(devbase_root: Path, args) -> int: 'project': lambda: cmd_env_project(devbase_root), 'export': lambda: cmd_env_export(devbase_root, args), 'import': lambda: cmd_env_import(devbase_root, args), + 'token': lambda: cmd_env_token(devbase_root, + print_only=getattr(args, 'print_only', False), + context=getattr(args, 'context', None)), 'exec': lambda: cmd_env_exec(devbase_root, list(getattr(args, 'argv', []) or []), context=getattr(args, 'context', None)), @@ -231,6 +234,138 @@ def cmd_env_exec(devbase_root: Path, argv, context: Optional[str] = None) -> int return 1 +def _running_dev_containers(project: str, dev_service_name: str, runner) -> Optional[List[str]]: + """起動中の dev コンテナの名前を ``-`` の番号順に返す。docker を呼べなければ ``None``。 + + ``up`` の構成は dev の各インスタンスをサービス ``-`` として定義する + (``volume/compose.py``)。プロジェクトのラベルだけで絞ると DB や snapshot にも届く。 + """ + import re + + try: + result = runner( + ['docker', 'ps', '--filter', f'label=com.docker.compose.project={project}', + '--format', '{{.Names}}\t{{.Label "com.docker.compose.service"}}'], + capture_output=True, text=True, check=False) + except (OSError, subprocess.SubprocessError) as e: + logger.error("docker ps を実行できませんでした: %s", e) + return None + if result.returncode != 0: + logger.error("docker ps が失敗しました (exit=%d): %s", result.returncode, + (result.stderr or '').strip()) + return None + pattern = re.compile(rf'^{re.escape(dev_service_name)}-([1-9][0-9]*)$') + found = [] + for line in (result.stdout or '').splitlines(): + name, _, service = line.partition('\t') + match = pattern.match(service.strip()) + if name and match: + found.append((int(match.group(1)), name.strip())) + return [name for _index, name in sorted(found)] + + +def _require_openbao_backend(store): + """backend が openbao であることを確かめ、対象の backend を返す。満たさなければ ``None``。""" + from devbase.env.secret_store import SecretRef, SecretStoreError + + try: + backend_name = store.backend_name + except SecretStoreError as e: + logger.error("%s", e) + return None + if backend_name != 'openbao': + logger.error("backend が openbao ではありません (現在: %s)。コンテナの bao へ渡す " + "token はサーバ backend でだけ発行できます", backend_name) + return None + return store.backend_for(SecretRef.for_global()) + + +def _issue_or_error(backend) -> Optional[str]: + """token を発行する。``DevbaseError`` はログへ落として ``None`` を返す。""" + from devbase.errors import DevbaseError + + try: + return backend.issue_token() + except DevbaseError as e: + logger.error("%s", e) + return None + + +def _push_token_to_running(devbase_root: Path, backend, context: Optional[str], + run, runner) -> int: + """起動中の dev コンテナへ token を配る。プロジェクト解決から書き込みまでを担う。 + + ``run`` は列挙・context 反映で使う (``runner or subprocess.run``)。``runner`` は + 書き込みへそのまま渡す元の値 (未指定なら ``None``)。 + """ + from devbase.commands.container import _load_project_env + from devbase.env import container_token + from devbase.project.local_config import load_project_local_config + from devbase.utils import docker_context + from devbase.volume.compose import get_dev_service_name + + project = _current_project_name(devbase_root) + if not project: + logger.error("プロジェクトのディレクトリ ($DEVBASE_ROOT/projects/) で実行してください") + return 1 + project_dir = Path(devbase_root) / 'projects' / project + + # 起動ラッパーが source するのは実行時のディレクトリの env だけ。下位ディレクトリから + # 打っても dev サービス名 (DEV_SERVICE_NAME) を取れるよう、プロジェクト直下の env を載せる + _load_project_env(project_dir / 'env') + dev_service_name = get_dev_service_name() + + settings = load_project_local_config(project_dir).docker + docker_context.apply(docker_context.choose_context(settings, cli_context=context), + track=False) + + names = _running_dev_containers(project, dev_service_name, run) + if names is None: + return 1 + if not names: + logger.error("起動中の dev コンテナがありません: %s", project) + return 1 + + token = _issue_or_error(backend) + if token is None: + return 1 + + written = container_token.push(names, token, runner=runner) + for name in written: + print(name) + failed = [name for name in names if name not in written] + for name in failed: + logger.error("token を書けませんでした: %s", name) + return 1 if failed else 0 + + +def cmd_env_token(devbase_root: Path, print_only: bool = False, + context: Optional[str] = None, runner=None) -> int: + """起動中の dev コンテナの ``~/.vault-token`` を新しい token で置き換える (PLAN54)。 + + コンテナの中の ``bao`` が使う token は 1 時間で切れる。コンテナに資格情報は置かず、 + ホストの資格情報でログインし直して届ける。処理の順は「backend の判定 → プロジェクト + → dev サービス名 → 接続先 → 対象のコンテナ → ログイン → 書き込み」で、ログインを + 対象が見つかった後に置く (届け先が無いのに token を発行させない)。``--print`` は + backend の判定の後すぐログインし、token だけを標準出力へ出す。 + """ + from devbase.env import runtime as _runtime + + run = runner or subprocess.run + backend = _require_openbao_backend(_runtime.store_for(devbase_root)) + if backend is None: + return 1 + + if print_only: + token = _issue_or_error(backend) + if token is None: + return 1 + print(token) + return 0 + + return _push_token_to_running(devbase_root, backend, context, run, runner) + + def cmd_env_init(devbase_root: Path, reset: bool = False) -> int: """全体環境の初期セットアップ(対話式)""" env_file = _global_env(devbase_root) @@ -638,6 +773,59 @@ def _edit_via_tempfile(env_file, editor: str) -> int: shutil.rmtree(workdir, ignore_errors=True) +def _collect_from_env_yml(env_file, variables: list) -> bool: + """env.yml の変数定義に従って設定値を収集する。必須値未入力なら False を返す。""" + for var in variables: + name = var.get('name', '') + prompt = var.get('prompt', name) + default = var.get('default', '') + required = var.get('required', False) + generate = var.get('generate', '') + + existing = env_file.get(name) + if existing: + print(f"{name}: 設定済み") + continue + + if generate: + import secrets + length = 64 + if ':' in generate: + _, length_str = generate.split(':', 1) + length = int(length_str) + value = secrets.token_hex(length // 2) + env_file.set(name, value) + print(f"{name}: (自動生成)") + else: + suffix = f" (デフォルト: {default})" if default else "" + suffix += " (必須)" if required else " (空でスキップ)" + value = safe_input(f"{prompt}{suffix}: ", default) + if value: + env_file.set(name, value) + elif required: + logger.error("必須変数 '%s' が設定されていません", name) + return False + return True + + +def _collect_interactively(env_file) -> None: + """env.yml 不在時の手入力ループ""" + print("env.yml が見つかりません。手動で変数を追加してください。") + print("(Ctrl+Dで終了)") + try: + while True: + line = safe_input("\nKEY=VALUE (空で終了): ") + if not line: + break + if '=' in line: + key, _, value = line.partition('=') + env_file.set(key.strip(), value.strip()) + else: + print("形式: KEY=VALUE") + except EOFError: + pass + + def cmd_env_project(devbase_root: Path) -> int: """プロジェクト固有変数の設定(対話式)""" env_file = _project_env(devbase_root) @@ -656,51 +844,10 @@ def cmd_env_project(devbase_root: Path) -> int: config = yaml.safe_load(f) or {} variables = config.get('variables', []) - for var in variables: - name = var.get('name', '') - prompt = var.get('prompt', name) - default = var.get('default', '') - required = var.get('required', False) - generate = var.get('generate', '') - - existing = env_file.get(name) - if existing: - print(f"{name}: 設定済み") - continue - - if generate: - import secrets - length = 64 - if ':' in generate: - _, length_str = generate.split(':', 1) - length = int(length_str) - value = secrets.token_hex(length // 2) - env_file.set(name, value) - print(f"{name}: (自動生成)") - else: - suffix = f" (デフォルト: {default})" if default else "" - suffix += " (必須)" if required else " (空でスキップ)" - value = safe_input(f"{prompt}{suffix}: ", default) - if value: - env_file.set(name, value) - elif required: - logger.error("必須変数 '%s' が設定されていません", name) - return 1 + if not _collect_from_env_yml(env_file, variables): + return 1 else: - print("env.yml が見つかりません。手動で変数を追加してください。") - print("(Ctrl+Dで終了)") - try: - while True: - line = safe_input("\nKEY=VALUE (空で終了): ") - if not line: - break - if '=' in line: - key, _, value = line.partition('=') - env_file.set(key.strip(), value.strip()) - else: - print("形式: KEY=VALUE") - except EOFError: - pass + _collect_interactively(env_file) env_file.save() logger.info("保存完了: %s (%d変数)", env_file.path, env_file.count()) diff --git a/lib/devbase/env/container_token.py b/lib/devbase/env/container_token.py new file mode 100644 index 00000000..3c8e1294 --- /dev/null +++ b/lib/devbase/env/container_token.py @@ -0,0 +1,62 @@ +"""起動中の dev コンテナへ OpenBao の token を届ける (PLAN54) + +コンテナの中の ``bao`` は、環境変数 ``BAO_TOKEN`` が無ければ ``~/.vault-token`` を読む。 +ここはその 1 ファイルを書くことだけを持つ。token の取得 (``OpenBaoBackend.issue_token``) +と backend の判定、届け先のコンテナの解決は呼び出し側 (``up`` / ``env token``) が持つ。 + +token を環境変数にしないのは、``docker inspect`` と子プロセスの環境に残るためである。 +``docker exec`` の argv にも載せず (``ps`` から読める)、stdin だけで渡す。接続先 +(``DOCKER_CONTEXT``) は呼び出し側が process の環境へ当てておく。 +""" + +from __future__ import annotations + +import subprocess +from typing import Callable, List, Optional, Sequence + +from devbase.log import get_logger + +logger = get_logger(__name__) + +#: コンテナの中で token を置くシェル。 +#: +#: 一時ファイルは ``mktemp`` で毎回新しく作る。固定名だと、その名前のファイルが既に +#: あるとき ``cat >`` が既存の inode へ書き、``umask 077`` が効かない (0644 のまま残る)。 +#: 同じディレクトリに書いてから ``mv -f`` で置き換えるので、途中で切れても空の +#: ``~/.vault-token`` は残らない。``chmod 0600`` は ``mktemp`` の実装差への保険。 +WRITE_COMMAND = ( + 'umask 077; ' + 'tmp=$(mktemp "$HOME/.vault-token.XXXXXX") || exit 1; ' + 'if cat > "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$HOME/.vault-token"; ' + 'then exit 0; else rm -f "$tmp"; exit 1; fi' +) + +_EXEC_TIMEOUT = 30 + + +def push(container_names: Sequence[str], token: str, + runner: Optional[Callable] = None) -> List[str]: + """各コンテナの ``~/.vault-token`` を ``token`` で置き換え、書けたコンテナ名を返す。 + + 書けなかったコンテナは警告を 1 行ずつ出して続ける。失敗を上へ伝えるかどうか + (``up`` は倒さない、``env token`` は非ゼロで終える) は呼び出し側が決める。 + """ + run = runner or subprocess.run + written: List[str] = [] + for name in container_names: + try: + result = run( + ['docker', 'exec', '-i', name, 'sh', '-c', WRITE_COMMAND], + input=token, capture_output=True, text=True, encoding='utf-8', + timeout=_EXEC_TIMEOUT, check=False, + ) + except (OSError, subprocess.SubprocessError) as e: + logger.warning("%s へ token を書けませんでした: %s", name, type(e).__name__) + continue + if result.returncode != 0: + detail = (result.stderr or '').strip().splitlines() + logger.warning("%s へ token を書けませんでした (exit=%d)%s", name, result.returncode, + f": {detail[-1]}" if detail else "") + continue + written.append(name) + return written diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index 23b3de62..b25079e6 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -78,6 +78,10 @@ class SecretConflictError(SecretRefusedError): """版の不一致。読んでから書くまでの間に他の誰かが書いた""" +def _dict_get(value: Any, key: str) -> Any: + return value.get(key) if isinstance(value, dict) else None + + class _HttpStatus(Exception): """HTTP の失敗応答 (内部用)""" @@ -92,7 +96,7 @@ def errors(self) -> List[str]: data = json.loads(self.body.decode('utf-8')) except (ValueError, UnicodeDecodeError): return [] - errors = data.get('errors') if isinstance(data, dict) else None + errors = _dict_get(data, 'errors') return [str(e) for e in errors] if isinstance(errors, list) else [] @@ -198,8 +202,8 @@ def login(self) -> None: "確認してください") from None raise SecretUnreachableError( f"OpenBao へ到達できません (HTTP {e.status})\n 接続先: {self.url}") from None - auth = data.get('auth') if isinstance(data, dict) else None - token = auth.get('client_token') if isinstance(auth, dict) else None + auth = _dict_get(data, 'auth') + token = _dict_get(auth, 'client_token') if not isinstance(token, str) or not token: raise SecretUnreachableError( f"OpenBao の認証応答を解釈できません\n 接続先: {self.url}") @@ -216,6 +220,15 @@ def _ensure_token(self) -> str: assert self._token is not None return self._token + def issue_token(self) -> str: + """起動中のコンテナの ``bao`` へ渡す token を返す (PLAN54)。 + + 読み書きに使っている token と同じもので、無いか期限が近ければログインし直す。 + 同じ ``SecretStore`` で注入した直後に呼べば、ログインは増えない。token は + 控えない (控えから起動したときに書き戻せないのと同じく、サーバの答えが要る)。 + """ + return self._ensure_token() + # -- HTTP ----------------------------------------------------------------- def _kv_path(self, kind: str, ref: SecretRef) -> str: @@ -272,19 +285,36 @@ def _unreadable(self, ref: SecretRef, why: str) -> SecretUnreachableError: return SecretUnreachableError( f"OpenBao の応答を解釈できません ({ref.label()}: {why})\n 接続先: {self.url}") + def _forbidden(self, ref: SecretRef, action: str, *, + hint: str = '', show_path: bool = True) -> SecretAuthError: + """HTTP 403 の共通の封筒 (label・HTTP 403・接続先) を組む。 + + ``action`` は「参照」に続く操作固有の語 (``を読む`` / ``への書き込み`` / + ``を削除する``)。``show_path`` が真ならパス行を足す。``hint`` があれば + 末尾へ足す (削除は付けない)。 + """ + message = ( + f"OpenBao でこの参照{action}権限がありません ({ref.label()}: HTTP 403)\n" + f" 接続先: {self.url}") + if show_path: + message += f"\n パス: {self.display_path(ref)}" + if hint: + message += f"\n{hint}" + return SecretAuthError(message) + # -- 取得 ----------------------------------------------------------------- @staticmethod def _version_of(data: Any) -> Optional[int]: """応答の ``data.metadata.version`` (無ければ ``None``)""" - inner = data.get('data') if isinstance(data, dict) else None - metadata = inner.get('metadata') if isinstance(inner, dict) else None - version = metadata.get('version') if isinstance(metadata, dict) else None + inner = _dict_get(data, 'data') + metadata = _dict_get(inner, 'metadata') + version = _dict_get(metadata, 'version') return version if isinstance(version, int) and not isinstance(version, bool) else None def _parse_secrets(self, ref: SecretRef, data: Any) -> Dict[str, str]: - inner = data.get('data') if isinstance(data, dict) else None - values = inner.get('data') if isinstance(inner, dict) else None + inner = _dict_get(data, 'data') + values = _dict_get(inner, 'data') if not isinstance(values, dict): raise self._unreadable(ref, 'data.data が辞書ではありません') result: Dict[str, str] = {} @@ -318,12 +348,10 @@ def fetch(self, ref: SecretRef) -> Dict[str, str]: self._remember(ref, {}, version if version is not None else 0) return {} if e.status == 403: - raise SecretAuthError( - f"OpenBao でこの参照を読む権限がありません ({ref.label()}: HTTP 403)\n" - f" 接続先: {self.url}\n" - f" パス: {self.display_path(ref)}\n" - f" backend.yml の openbao.user (現在: {self._settings.user}) が" - "本人の識別子と違う可能性があります") from None + raise self._forbidden( + ref, 'を読む', + hint=f" backend.yml の openbao.user (現在: {self._settings.user}) が" + "本人の識別子と違う可能性があります") from None raise self._unreachable(e.status, ref) from None secrets = self._parse_secrets(ref, data) version = self._version_of(data) @@ -402,12 +430,10 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: " もう一度読み直してから同じ操作をやり直してください") from None if e.status == 403: # ログインは通っている。資格の取り消しではなく、このパスへ書く権限が無い - raise SecretAuthError( - f"OpenBao でこの参照への書き込み権限がありません ({ref.label()}: HTTP 403)\n" - f" 接続先: {self.url}\n" - f" パス: {self.display_path(ref)}\n" - " チーム単位の置き場へ書けるのは、書き込み権限を付けられた" - "利用者だけです") from None + raise self._forbidden( + ref, 'への書き込み', + hint=" チーム単位の置き場へ書けるのは、書き込み権限を付けられた" + "利用者だけです") from None if 400 <= e.status < 500: # サーバが拒んだと確定した。サーバは変わっておらず、控えはそのまま正しい raise SecretRefusedError( @@ -421,8 +447,8 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: # 送った後の接続断・タイムアウト・途中切れ・解釈できない応答。結果が分からない self._forget(ref) raise - inner = response.get('data') if isinstance(response, dict) else None - new_version = inner.get('version') if isinstance(inner, dict) else None + inner = _dict_get(response, 'data') + new_version = _dict_get(inner, 'version') if not isinstance(new_version, int) or isinstance(new_version, bool): self._forget(ref) raise self._unreadable(ref, '保存の応答に data.version がありません') @@ -446,9 +472,7 @@ def remove(self, ref: SecretRef) -> bool: self._http('DELETE', self._kv_path('metadata', ref)) except _HttpStatus as e: if e.status == 403: - raise SecretAuthError( - f"OpenBao でこの参照を削除する権限がありません ({ref.label()}: HTTP 403)\n" - f" 接続先: {self.url}") from None + raise self._forbidden(ref, 'を削除する', show_path=False) from None self._forget(ref) raise self._unreachable(e.status, ref) from None except SecretUnreachableError: diff --git a/tests/cli/test_up_roundtrips.py b/tests/cli/test_up_roundtrips.py index f1ef7bd6..27e4d11d 100644 --- a/tests/cli/test_up_roundtrips.py +++ b/tests/cli/test_up_roundtrips.py @@ -80,6 +80,10 @@ def up_root(openbao_root, openbao, monkeypatch): monkeypatch.setattr(container, '_apply_window_titles', lambda *a, **k: None) monkeypatch.setattr(container, '_report_missing_repos', lambda *a, **k: None) monkeypatch.setattr(container, '_maybe_open_editor', lambda *a, **k: None) + # PLAN54: up の後処理が token を書く。docker は叩かず、token の取得 (往復) は数える + monkeypatch.setattr('devbase.editor.opener._query_container_name', lambda *a, **k: None) + monkeypatch.setattr('devbase.env.container_token.push', + lambda names, token, runner=None: list(names)) def fake_generate(scale, secrets, dev_environment=None, **kw): seen['secrets'] = secrets @@ -199,7 +203,11 @@ def test_up_after_env_init_reads_written_values(up_root, openbao, monkeypatch): child: dict = {} + real_run = subprocess.run + def fake_env_init(argv, **kwargs): + if list(argv[-2:]) != ['env', 'init']: + return real_run(argv, **kwargs) # 子プロセスの `env init` は別の SecretStore で書く。親の控えは更新されない。 # 子の往復 (認証 1 + 書く前の GET) は親の数に入れないので、ここで分けて数える logins, gets = openbao.logins, len(openbao.requests_of('GET')) diff --git a/tests/cli/tui/test_dispatch.py b/tests/cli/tui/test_dispatch.py index 23290dd7..a89f68d9 100644 --- a/tests/cli/tui/test_dispatch.py +++ b/tests/cli/tui/test_dispatch.py @@ -192,6 +192,10 @@ def fake_generate(scale, secrets, dev_environment=None, **kw): return compose monkeypatch.setattr(container, '_generate_compose_for', fake_generate) + # PLAN54: up の後処理が token を書く。docker は叩かない + monkeypatch.setattr('devbase.editor.opener._query_container_name', lambda *a, **k: None) + monkeypatch.setattr('devbase.env.container_token.push', + lambda names, token, runner=None: list(names)) def fake_editor(argv): Path(argv[1]).write_text("REVIEW_KEY=new\n") diff --git a/tests/commands/test_container_bao.py b/tests/commands/test_container_bao.py new file mode 100644 index 00000000..6a730b59 --- /dev/null +++ b/tests/commands/test_container_bao.py @@ -0,0 +1,142 @@ +"""`devbase up` が起動中のコンテナの `bao` へ接続先と token を渡す (PLAN54 / #169) + +- backend が ``openbao`` のとき、dev サービスの ``environment`` に ``BAO_ADDR`` を足し、 + [5/6] の後に各 dev コンテナの ``~/.vault-token`` へ token を書く +- それ以外の backend では何も足さず、``docker exec`` を呼ばない (生成する構成は変わらない) +- token を書けなくても ``up`` は倒さない +""" + +from __future__ import annotations + +import os +import types +from pathlib import Path + +import pytest + +from devbase import cli +from devbase.commands import container +from devbase.env import container_token, runtime +from devbase.utils import docker_context + +LOCAL = docker_context.DockerTarget(context=None, source='none', remote=False, home=None, gid=None) + + +def _project(root: Path, name: str = 'web') -> Path: + project = root / 'projects' / name + project.mkdir(parents=True, exist_ok=True) + (project / 'project.yml').write_text( + "version: 1\nscale: 2\nrepos:\n - owner: volareinc\n repo: carmo\n") + (project / 'env').write_text("") + return project + + +def _harness(monkeypatch, root: Path, seen: dict): + monkeypatch.setenv('DEVBASE_ROOT', str(root)) + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT', + 'COMPOSE_PROJECT_NAME', 'DEV_SERVICE_NAME', 'BAO_ADDR'): + monkeypatch.delenv(name, raising=False) + docker_context.reset() + runtime.clear_injected() + monkeypatch.setattr(container, '_resolve_docker_target', lambda cli_context=None: LOCAL) + for name in ('_run_pre_up_hook', '_ensure_images', '_ensure_env_files'): + monkeypatch.setattr(container, name, lambda *a, **k: True) + for name in ('_auto_snapshot', 'ensure_volumes', 'ensure_network', 'docker_compose_down', + 'docker_compose_up', 'wait_for_containers_ready', '_apply_window_titles', + '_report_missing_repos', '_maybe_open_editor'): + monkeypatch.setattr(container, name, lambda *a, **k: None) + # compose ps を叩かず、決定的な名前へ落とす + monkeypatch.setattr('devbase.editor.opener._query_container_name', lambda *a, **k: None) + + def fake_generate(scale, secrets, dev_environment=None, **kw): + seen['dev_environment'] = dict(dev_environment or {}) + compose = Path.cwd() / '.docker-compose.scale.yml' + compose.write_text("services:\n dev-1: {}\n") + return compose + + monkeypatch.setattr(container, '_generate_compose_for', fake_generate) + + def fake_push(names, token, runner=None): + seen.setdefault('pushes', []).append((list(names), token)) + return list(names) + + monkeypatch.setattr(container_token, 'push', fake_push) + + +def _up(project_dir: Path, monkeypatch) -> int: + monkeypatch.chdir(project_dir) + monkeypatch.setenv('PWD', str(project_dir)) + cli._load_secret_env('project', 'up') + ns = types.SimpleNamespace(subcommand='up', name=None, scale=None, + open_editor=False, open_index=None, context=None) + return container.cmd_project(ns) + + +def test_openbao_up_adds_bao_addr_and_pushes_the_token(openbao_root, openbao, monkeypatch): + seen: dict = {} + web = _project(openbao_root) + openbao.put('team/global', {'A': '1'}) + _harness(monkeypatch, openbao_root, seen) + + assert _up(web, monkeypatch) == 0 + + assert seen['dev_environment']['BAO_ADDR'] == openbao.url + (names, token), = seen['pushes'] + assert names == ['web-dev-1', 'web-dev-2'] + assert token == openbao.token + # 注入と同じ SecretStore の token なので、ログインは 1 回のまま + assert openbao.logins == 1 + + +def test_bao_addr_is_the_only_bao_value_in_the_environment(openbao_root, openbao, monkeypatch): + """token は compose の environment に載せない (docker inspect に残る)""" + seen: dict = {} + web = _project(openbao_root) + _harness(monkeypatch, openbao_root, seen) + + assert _up(web, monkeypatch) == 0 + + assert [k for k in seen['dev_environment'] if 'BAO' in k or 'TOKEN' in k] == ['BAO_ADDR'] + assert openbao.token not in seen['dev_environment'].values() + + +def test_age_up_adds_nothing_and_does_not_exec(tmp_path, monkeypatch): + from devbase.env import agekeys + + seen: dict = {} + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('HOME', str(tmp_path / 'home')) + agekeys.generate_key_file() + web = _project(tmp_path) + _harness(monkeypatch, tmp_path, seen) + + assert _up(web, monkeypatch) == 0 + + assert 'BAO_ADDR' not in seen['dev_environment'] + assert 'pushes' not in seen + + +def test_token_failure_does_not_fail_up(openbao_root, openbao, monkeypatch): + seen: dict = {} + web = _project(openbao_root) + _harness(monkeypatch, openbao_root, seen) + + def broken_push(names, token, runner=None): + raise RuntimeError('docker is gone') + + monkeypatch.setattr(container_token, 'push', broken_push) + + assert _up(web, monkeypatch) == 0 + + +def test_push_bao_token_starts_from_the_added_instance(openbao_root, openbao, monkeypatch): + """scale で増やしたインスタンスだけへ書く (既存のものは up で書いてある)""" + seen: dict = {} + _project(openbao_root) + _harness(monkeypatch, openbao_root, seen) + monkeypatch.chdir(openbao_root / 'projects' / 'web') + + container._push_bao_token('web', 3, 'dev', start=3) + + (names, _token), = seen['pushes'] + assert names == ['web-dev-3'] diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py index 8b325dbe..25b166af 100644 --- a/tests/commands/test_container_up_order.py +++ b/tests/commands/test_container_up_order.py @@ -69,6 +69,9 @@ def up_harness(tmp_path, monkeypatch): monkeypatch.setattr(container, 'docker_compose_up', lambda **k: calls.append(('up', k))) monkeypatch.setattr(container, 'wait_for_containers_ready', lambda **k: None) monkeypatch.setattr(container, '_maybe_open_editor', lambda *a, **k: None) + # PLAN54: 実行シェルの DEVBASE_ROOT (利用者の実環境) の backend を読まない + monkeypatch.setattr(container, '_bao_environment', lambda: {}) + monkeypatch.setattr(container, '_push_bao_token', lambda *a, **k: None) def fake_down(compose_file=None): # 停止時点で渡された compose の中身も記録する (旧構成であること) diff --git a/tests/commands/test_env_project.py b/tests/commands/test_env_project.py new file mode 100644 index 00000000..14abcb0d --- /dev/null +++ b/tests/commands/test_env_project.py @@ -0,0 +1,151 @@ +"""cmd_env_project: プロジェクト環境変数の設定(現状固定テスト)""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest +import yaml + +from devbase.commands import env as env_cmd + + +@pytest.fixture +def project_setup(tmp_path, monkeypatch): + """一時ディレクトリの実 SecretStore を使い、projects/web 配下に移動する""" + project_dir = tmp_path / 'projects' / 'web' + project_dir.mkdir(parents=True) + monkeypatch.setenv('PWD', str(project_dir)) + monkeypatch.chdir(project_dir) + return tmp_path, project_dir + + +def test_env_project_outside_project_directory(tmp_path, monkeypatch, caplog): + """projects/ 配下以外で実行された場合は 1 を返し、エラーログを出力する""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('PWD', str(tmp_path)) + + with caplog.at_level(logging.ERROR): + rc = env_cmd.cmd_env_project(tmp_path) + + assert rc == 1 + assert "projects/ 配下で実行してください" in caplog.text + + +def test_env_project_with_env_yml_variables(project_setup, monkeypatch, capsys, caplog): + """env.yml の variables 処理 (既存値スキップ・generate 既定長・generate 明示長・任意値の空入力)""" + devbase_root, project_dir = project_setup + + # 既存値 + env_path = project_dir / '.env' + env_path.write_text("EXISTING_KEY=old_value\n", encoding="utf-8") + + env_yml_content = { + 'variables': [ + {'name': 'EXISTING_KEY', 'prompt': 'Existing Key'}, + {'name': 'GEN_DEFAULT', 'generate': 'token'}, + {'name': 'GEN_CUSTOM', 'generate': 'token:32'}, + {'name': 'OPTIONAL_EMPTY', 'prompt': 'Optional', 'required': False}, + {'name': 'NORMAL_INPUT', 'prompt': 'Normal', 'default': 'my_default'}, + ] + } + (project_dir / 'env.yml').write_text(yaml.dump(env_yml_content), encoding='utf-8') + + # safe_input: OPTIONAL_EMPTY は空入力 (default も空 -> スキップ), NORMAL_INPUT は入力値 + inputs = iter(["", "custom_val"]) + monkeypatch.setattr('devbase.commands.env.safe_input', + lambda prompt, default="": (next(inputs) or default)) + + # secrets.token_hex: 境界で固定 (length // 2 のバイト数を 16進数文字列化) + monkeypatch.setattr('secrets.token_hex', lambda n: 'aa' * n) + + with caplog.at_level(logging.INFO): + rc = env_cmd.cmd_env_project(devbase_root) + + assert rc == 0 + captured = capsys.readouterr() + assert "=== web プロジェクト環境変数 ===" in captured.out + assert "EXISTING_KEY: 設定済み" in captured.out + assert "GEN_DEFAULT: (自動生成)" in captured.out + assert "GEN_CUSTOM: (自動生成)" in captured.out + + saved_text = env_path.read_text(encoding="utf-8") + assert "EXISTING_KEY=old_value" in saved_text + assert f"GEN_DEFAULT={'aa' * 32}" in saved_text # 64 hex chars + assert f"GEN_CUSTOM={'aa' * 16}" in saved_text # 32 hex chars + assert "NORMAL_INPUT=custom_val" in saved_text + assert "OPTIONAL_EMPTY" not in saved_text + + # 保存後の件数表示 + assert "保存完了:" in caplog.text + assert "(4変数)" in caplog.text + + +def test_env_project_with_env_yml_required_empty_aborts(project_setup, monkeypatch, caplog): + """必須変数で空入力の場合は中止して 1 を返し、保存しない""" + devbase_root, project_dir = project_setup + + env_yml_content = { + 'variables': [ + {'name': 'REQUIRED_KEY', 'prompt': 'Required Key', 'required': True}, + ] + } + (project_dir / 'env.yml').write_text(yaml.dump(env_yml_content), encoding='utf-8') + + monkeypatch.setattr('devbase.commands.env.safe_input', lambda prompt, default="": "") + + with caplog.at_level(logging.ERROR): + rc = env_cmd.cmd_env_project(devbase_root) + + assert rc == 1 + assert "必須変数 'REQUIRED_KEY' が設定されていません" in caplog.text + assert not (project_dir / '.env').exists() + + +def test_env_project_without_env_yml_interactive_loop(project_setup, monkeypatch, capsys, caplog): + """env.yml 不在時の手入力ループ (不正形式の再案内、KEY=VALUE の設定、空行での終了)""" + devbase_root, project_dir = project_setup + + inputs = iter([ + "INVALID_NO_EQUALS", + "FOO=bar", + "BAZ = qux ", + "", + ]) + monkeypatch.setattr('devbase.commands.env.safe_input', + lambda prompt, default="": next(inputs)) + + with caplog.at_level(logging.INFO): + rc = env_cmd.cmd_env_project(devbase_root) + + assert rc == 0 + captured = capsys.readouterr() + assert "env.yml が見つかりません。手動で変数を追加してください。" in captured.out + assert "(Ctrl+Dで終了)" in captured.out + assert "形式: KEY=VALUE" in captured.out + + saved_text = (project_dir / '.env').read_text(encoding="utf-8") + assert "FOO=bar" in saved_text + assert "BAZ=qux" in saved_text + + assert "保存完了:" in caplog.text + assert "(2変数)" in caplog.text + + +def test_env_project_without_env_yml_eof_termination(project_setup, monkeypatch, caplog): + """env.yml 不在時の手入力ループで EOFError が発生したときに正常終了して保存する""" + devbase_root, project_dir = project_setup + + def raise_eof(prompt, default=""): + raise EOFError + + monkeypatch.setattr('devbase.commands.env.safe_input', raise_eof) + + with caplog.at_level(logging.INFO): + rc = env_cmd.cmd_env_project(devbase_root) + + assert rc == 0 + assert (project_dir / '.env').exists() + assert "保存完了:" in caplog.text + assert "(0変数)" in caplog.text diff --git a/tests/commands/test_env_token.py b/tests/commands/test_env_token.py new file mode 100644 index 00000000..f5646b7b --- /dev/null +++ b/tests/commands/test_env_token.py @@ -0,0 +1,274 @@ +"""`devbase env token [--print] [--context NAME]` (PLAN54 / #169) + +起動中の dev コンテナの ``~/.vault-token`` を、再起動せずに新しい token で置き換える。 +docker は runner のスタブで置き換え、設計「`devbase env token`」の状況表を行ごとに固定する。 +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from devbase.commands import env as env_cmd +from devbase.env import container_token + +TEAM_GLOBAL = 'team/global' + + +class Docker: + """``docker ps`` と ``docker exec`` を記録するスタブ""" + + def __init__(self, ps_lines=(), fail_exec=()): + self.ps_lines = list(ps_lines) + self.fail_exec = set(fail_exec) + self.calls = [] # (argv, DOCKER_CONTEXT) + + def __call__(self, argv, **kwargs): + import os + + self.calls.append((list(argv), os.environ.get('DOCKER_CONTEXT'))) + if argv[:2] == ['docker', 'ps']: + return subprocess.CompletedProcess(argv, 0, stdout='\n'.join(self.ps_lines) + '\n', + stderr='') + if argv[:2] == ['docker', 'exec']: + rc = 1 if argv[3] in self.fail_exec else 0 + return subprocess.CompletedProcess(argv, rc, stdout='', stderr='') + raise AssertionError(f'unexpected: {argv}') + + def of(self, verb): + return [c for c in self.calls if c[0][1] == verb] + + +@pytest.fixture +def web(openbao_root, openbao, monkeypatch): + project = openbao_root / 'projects' / 'web' + (project / 'env').write_text('') + openbao.put(TEAM_GLOBAL, {'A': '1'}) + # cmd_env_token はプロジェクトの env と docker context を os.environ へ載せる。 + # 元が未設定の変数は delenv だけでは戻す控えが残らないため、先に setenv して控えを作る + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT', 'DEV_SERVICE_NAME', + 'COMPOSE_PROJECT_NAME'): + monkeypatch.setenv(name, '') + monkeypatch.delenv(name) + monkeypatch.chdir(project) + monkeypatch.setenv('PWD', str(project)) + return project + + +def _run(root, docker, **kw): + return env_cmd.cmd_env_token(root, runner=docker, **kw) + + +def test_writes_the_token_into_running_dev_containers(openbao_root, openbao, web, capsys): + docker = Docker(['web-dev-1\tdev-1', 'web-dev-2\tdev-2']) + + assert _run(openbao_root, docker) == 0 + + ps, = docker.of('ps') + assert '--filter' in ps[0] and 'label=com.docker.compose.project=web' in ps[0] + execs = docker.of('exec') + assert [c[0][3] for c in execs] == ['web-dev-1', 'web-dev-2'] + out = capsys.readouterr().out + assert out.split() == ['web-dev-1', 'web-dev-2'] + assert openbao.token not in out + assert openbao.logins == 1 + + +def test_non_dev_services_of_the_project_are_skipped(openbao_root, web): + docker = Docker(['web-db-1\tdb', 'web-snapshot-1\tsnapshot', 'web-dev-1\tdev-1', + 'web-devtools-1\tdevtools-1']) + + assert _run(openbao_root, docker) == 0 + + assert [c[0][3] for c in docker.of('exec')] == ['web-dev-1'] + + +def test_dev_service_name_comes_from_the_project_env_even_in_a_subdirectory( + openbao_root, web, monkeypatch): + (web / 'env').write_text('DEV_SERVICE_NAME=workspace\n') + sub = web / 'src' + sub.mkdir() + monkeypatch.chdir(sub) + monkeypatch.setenv('PWD', str(sub)) + docker = Docker(['web-workspace-1\tworkspace-1', 'web-dev-1\tdev-1']) + + assert _run(openbao_root, docker) == 0 + + assert [c[0][3] for c in docker.of('exec')] == ['web-workspace-1'] + + +def test_print_outputs_only_the_token_without_looking_at_containers( + openbao_root, openbao, monkeypatch, capsys): + monkeypatch.chdir(openbao_root) + monkeypatch.setenv('PWD', str(openbao_root)) + docker = Docker() + + assert _run(openbao_root, docker, print_only=True) == 0 + + assert capsys.readouterr().out == openbao.token + '\n' + assert docker.calls == [] + + +def test_outside_projects_fails_without_logging_in(openbao_root, openbao, monkeypatch): + monkeypatch.chdir(openbao_root) + monkeypatch.setenv('PWD', str(openbao_root)) + docker = Docker() + + assert _run(openbao_root, docker) == 1 + + assert docker.calls == [] + assert openbao.logins == 0 + + +def test_no_running_dev_container_fails_without_logging_in(openbao_root, openbao, web, caplog): + docker = Docker(['web-db-1\tdb']) + + assert _run(openbao_root, docker) == 1 + + assert docker.of('exec') == [] + assert openbao.logins == 0 + assert '起動中の dev コンテナがありません' in caplog.text + + +def test_partial_failure_is_non_zero(openbao_root, web): + docker = Docker(['web-dev-1\tdev-1', 'web-dev-2\tdev-2'], fail_exec={'web-dev-2'}) + + assert _run(openbao_root, docker) == 1 + + +def test_rejected_login_is_non_zero(openbao_root, openbao, web): + openbao.reject_login = True + docker = Docker(['web-dev-1\tdev-1']) + + assert _run(openbao_root, docker) == 1 + assert docker.of('exec') == [] + + +def test_local_yml_context_applies_to_both_ps_and_exec(openbao_root, web): + (web / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n") + docker = Docker(['web-dev-1\tdev-1']) + + assert _run(openbao_root, docker) == 0 + + assert [ctx for _argv, ctx in docker.calls] == ['gpu-wsl', 'gpu-wsl'] + + +def test_cli_context_wins(openbao_root, web): + (web / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n") + docker = Docker(['web-dev-1\tdev-1']) + + assert _run(openbao_root, docker, context='other') == 0 + + assert {ctx for _argv, ctx in docker.calls} == {'other'} + + +def test_non_openbao_backend_fails(tmp_path, monkeypatch, caplog): + monkeypatch.chdir(tmp_path) + docker = Docker() + + assert env_cmd.cmd_env_token(tmp_path, runner=docker) == 1 + + assert docker.calls == [] + assert 'openbao' in caplog.text + + +def test_cli_parses_env_token(): + from devbase import cli + + parser = cli._create_parser() + args = parser.parse_args(['env', 'token', '--print', '--context', 'x']) + assert (args.command, args.subcommand, args.print_only, args.context) == \ + ('env', 'token', True, 'x') + assert cli._skip_secret_injection('env', 'token') + + +def test_push_is_the_shared_writer(openbao_root, web, monkeypatch): + """書き込みは up と同じ container_token.push を通る""" + seen = {} + monkeypatch.setattr(container_token, 'push', + lambda names, token, runner=None: seen.setdefault('names', names) or names) + + assert _run(openbao_root, Docker(['web-dev-1\tdev-1'])) == 0 + assert seen['names'] == ['web-dev-1'] + + +@pytest.mark.parametrize('failure', [ + 'nonzero', FileNotFoundError('docker'), + subprocess.TimeoutExpired(['docker', 'ps'], 30), +], ids=['nonzero', 'missing-docker', 'timeout']) +def test_docker_ps_failure_does_not_issue_or_distribute_a_token( + tmp_path, monkeypatch, capsys, failure): + """現状固定: 列挙の失敗は終了値 1 となり、認証にも配布にも進まない。""" + from devbase.env import runtime + + class Backend: + def __init__(self): + self.issued_tokens = [] + + def issue_token(self): + token = 's.fake-issued-token' + self.issued_tokens.append(token) + return token + + backend = Backend() + + class Store: + backend_name = 'openbao' + + def backend_for(self, ref): + return backend + + monkeypatch.setattr(runtime, 'store_for', lambda root: Store()) + project = tmp_path / 'projects' / 'web' + project.mkdir(parents=True) + (project / 'env').write_text('') + monkeypatch.chdir(project) + monkeypatch.setenv('PWD', str(project)) + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT', + 'DEV_SERVICE_NAME', 'COMPOSE_PROJECT_NAME'): + monkeypatch.setenv(name, '') + monkeypatch.delenv(name) + calls = [] + + def runner(argv, **kwargs): + calls.append(list(argv)) + assert argv[:2] == ['docker', 'ps'] + if isinstance(failure, Exception): + raise failure + return subprocess.CompletedProcess(argv, 1, stdout='', stderr='docker unavailable') + + assert env_cmd.cmd_env_token(tmp_path, runner=runner) == 1 + + assert capsys.readouterr().out == '' + assert backend.issued_tokens == [] + assert any(argv[:2] == ['docker', 'ps'] for argv in calls) + assert not any(argv[:2] == ['docker', 'exec'] for argv in calls) + + +def test_dev_containers_are_ordered_numerically_and_malformed_lines_are_skipped( + openbao_root, web, capsys): + """現状固定: ps 出力の番号を数値順 (1, 2, 10) に並べ、0・先頭ゼロ・非数値・タブなし行を除外する。""" + ps_lines = [ + 'web-dev-10\tdev-10', + 'web-dev-0\tdev-0', + 'web-dev-2\tdev-2', + 'malformed-line-without-tab', + 'web-dev-01\tdev-01', + 'web-dev-1\tdev-1', + 'web-dev-x\tdev-x', + ] + docker = Docker(ps_lines) + + assert _run(openbao_root, docker) == 0 + + exec_targets = [c[0][3] for c in docker.of('exec')] + out_containers = capsys.readouterr().out.split() + + expected = ['web-dev-1', 'web-dev-2', 'web-dev-10'] + assert exec_targets == expected + assert out_containers == expected + invalid_names = {'web-dev-0', 'web-dev-01', 'web-dev-x', 'malformed-line-without-tab'} + assert not any(name in invalid_names for name in exec_targets) + assert not any(name in invalid_names for name in out_containers) + diff --git a/tests/containers/test_base_dockerfile_bao.py b/tests/containers/test_base_dockerfile_bao.py new file mode 100644 index 00000000..699d27c7 --- /dev/null +++ b/tests/containers/test_base_dockerfile_bao.py @@ -0,0 +1,77 @@ +"""base イメージの OpenBao CLI (`bao`) の導入 (PLAN54 / #169) + +Docker を起動せず、Dockerfile の文言で次の 3 点を固定する。実際のビルドは手で確かめる。 + +- 版は ``ARG BAO_VERSION`` の 1 か所で持ち、サーバと同じ 2.6 系に固定する +- amd64 / arm64 の両方の tar.gz を選べる +- 同じリリースの ``checksums.txt`` で SHA-256 を検証し、一致しなければビルドを止める +""" + +from __future__ import annotations + +import re +from pathlib import Path + +DOCKERFILE = Path(__file__).resolve().parents[2] / "containers" / "base" / "Dockerfile" + + +def _statements() -> str: + """コメント行を除いた Dockerfile の本文 (説明の注記に assertion が反応しないように)""" + return "\n".join( + line for line in DOCKERFILE.read_text().splitlines() + if not line.lstrip().startswith("#") + ) + + +def _bao_run_block() -> str: + """`bao` を入れる RUN の 1 命令分 (行継続を含む) を取り出す""" + lines = _statements().splitlines() + start = next((i for i, line in enumerate(lines) if line.startswith("ARG BAO_VERSION=")), None) + assert start is not None, "ARG BAO_VERSION が見つからない" + assert lines[start + 1].startswith("RUN "), "ARG BAO_VERSION の直後に RUN が無い" + block = [] + for line in lines[start + 1:]: + block.append(line) + if not line.rstrip().endswith("\\"): + break + return "\n".join(block) + + +def test_version_is_pinned_once_in_an_arg(): + text = _statements() + assert re.search(r"^ARG BAO_VERSION=2\.6\.\d+$", text, flags=re.MULTILINE) + # 版の数字は ARG の 1 か所だけに書く (URL やファイル名は変数を参照する) + assert len(re.findall(r"2\.6\.\d+", text)) == 1 + + +def test_both_architectures_are_selectable(): + block = _bao_run_block() + assert "dpkg --print-architecture" in block + assert "amd64" in block and "arm64" in block + assert "openbao_${BAO_VERSION}_linux_${bao_arch}.tar.gz" in block + + +def test_tarball_is_verified_against_the_release_checksums(): + block = _bao_run_block() + assert 'bao_base="https://github.com/openbao/openbao/releases/download/v${BAO_VERSION}"' in block + assert '"${bao_base}/checksums.txt"' in block + assert "sha256sum -c" in block + # 検証の後に展開する (検証の前に展開すると、改ざんされた物を置いてから止まる) + assert block.index("sha256sum -c") < block.index("tar -xzf") + + +def test_checksum_line_lookup_must_hit_before_verification(): + block = _bao_run_block() + # checksums.txt から対象行を取り出すことを独立の命令にし、0 件なら set -e でそこで止める。 + # grep をパイプの先頭に置くと終了値が捨てられ、sha256sum の空入力の挙動に検証が依存する + assert 'bao_sum="$(grep " ${bao_tar}\\$" bao-checksums.txt)"' in block + assert 'echo "${bao_sum}" | sha256sum -c -' in block + assert not re.search(r"grep [^;]*\| *sha256sum", block) + assert "set -eux" in block + assert block.index('bao_sum="$(grep') < block.index("sha256sum -c") + + +def test_only_the_bao_binary_is_installed_and_checked(): + block = _bao_run_block() + assert re.search(r"tar -xzf \S+ -C /usr/local/bin bao", block) + assert "bao version" in block diff --git a/tests/env/test_container_token.py b/tests/env/test_container_token.py new file mode 100644 index 00000000..1e1215e8 --- /dev/null +++ b/tests/env/test_container_token.py @@ -0,0 +1,109 @@ +"""container_token.push: token を起動中のコンテナの ~/.vault-token へ届ける (PLAN54) + +docker は呼ばず、runner のスタブで次を固定する。 + +- token は ``docker exec -i`` の stdin だけで渡し、argv にもログにも出さない +- コンテナの中では ``mktemp`` で毎回新しい一時ファイルへ書き、``mv -f`` で置き換える + (固定名だと既存の inode へ書いて ``umask 077`` が効かない) +- 書けたコンテナ名を返し、書けなかったものは警告にとどめる +""" + +from __future__ import annotations + +import logging +import subprocess + +import pytest + +from devbase.env import container_token + +TOKEN = 's.fake-token-1234567890' + + +class Recorder: + def __init__(self, fail=()): + self.calls = [] + self.fail = set(fail) + + def __call__(self, argv, **kwargs): + self.calls.append((argv, kwargs)) + container = argv[3] + rc = 1 if container in self.fail else 0 + return subprocess.CompletedProcess(argv, rc, stdout='', stderr='boom' if rc else '') + + +def test_push_writes_through_docker_exec_stdin(): + runner = Recorder() + + written = container_token.push(['web-dev-1'], TOKEN, runner=runner) + + assert written == ['web-dev-1'] + (argv, kwargs), = runner.calls + assert argv[:4] == ['docker', 'exec', '-i', 'web-dev-1'] + assert argv[4:6] == ['sh', '-c'] + assert kwargs['input'] == TOKEN + assert all(TOKEN not in part for part in argv) + + +def test_shell_uses_a_fresh_tempfile_then_moves_it_into_place(): + script = container_token.WRITE_COMMAND + + assert 'umask 077' in script + assert 'mktemp "$HOME/.vault-token.XXXXXX"' in script + assert 'chmod 0600' in script + assert 'mv -f "$tmp" "$HOME/.vault-token"' in script + assert 'rm -f "$tmp"' in script + assert '.vault-token.tmp' not in script + + +def test_push_repeats_for_each_container_and_reports_failures(caplog): + runner = Recorder(fail={'web-dev-2'}) + + with caplog.at_level(logging.DEBUG): + written = container_token.push(['web-dev-1', 'web-dev-2', 'web-dev-3'], TOKEN, + runner=runner) + + assert written == ['web-dev-1', 'web-dev-3'] + assert [c[0][3] for c in runner.calls] == ['web-dev-1', 'web-dev-2', 'web-dev-3'] + assert 'web-dev-2' in caplog.text + assert TOKEN not in caplog.text + + +def test_push_treats_a_missing_docker_as_a_failure(caplog): + def runner(argv, **kwargs): + raise FileNotFoundError('docker') + + with caplog.at_level(logging.DEBUG): + assert container_token.push(['web-dev-1'], TOKEN, runner=runner) == [] + assert TOKEN not in caplog.text + + +def test_push_with_no_containers_does_nothing(): + runner = Recorder() + assert container_token.push([], TOKEN, runner=runner) == [] + assert runner.calls == [] + + +@pytest.mark.parametrize('error_type', [subprocess.TimeoutExpired, subprocess.CalledProcessError]) +def test_push_continues_after_a_subprocess_exception_without_logging_token(caplog, error_type): + """現状固定: 途中の例外を警告にとどめ、後続にも stdin で token を届ける。""" + deliveries = [] + + def runner(argv, **kwargs): + name = argv[3] + deliveries.append((name, kwargs['input'])) + if name == 'web-dev-2': + if error_type is subprocess.TimeoutExpired: + raise error_type(argv, 30, output=TOKEN, stderr=TOKEN) + raise error_type(1, argv, output=TOKEN, stderr=TOKEN) + return subprocess.CompletedProcess(argv, 0, stdout='', stderr='') + + with caplog.at_level(logging.DEBUG): + written = container_token.push(['web-dev-1', 'web-dev-2', 'web-dev-3'], TOKEN, + runner=runner) + + assert written == ['web-dev-1', 'web-dev-3'] + assert deliveries == [('web-dev-1', TOKEN), ('web-dev-2', TOKEN), ('web-dev-3', TOKEN)] + assert any(record.levelno == logging.WARNING and 'web-dev-2' in record.getMessage() + for record in caplog.records) + assert TOKEN not in caplog.text diff --git a/tests/env/test_openbao.py b/tests/env/test_openbao.py index 5fb23774..9f63439d 100644 --- a/tests/env/test_openbao.py +++ b/tests/env/test_openbao.py @@ -2,7 +2,10 @@ from __future__ import annotations +import io +import json from pathlib import Path +from urllib import request import pytest @@ -28,6 +31,36 @@ USER_WEB_PATH = 'users/member01/projects/web' +@pytest.mark.parametrize('body', [ + [], {}, {'auth': None}, {'auth': {}}, + {'auth': {'client_token': 123}}, {'auth': {'client_token': ''}}, +], ids=['array', 'missing-auth', 'null-auth', 'missing-token', 'numeric-token', 'empty-token']) +def test_issue_token_recovers_after_an_unreadable_login_response(tmp_path, monkeypatch, body): + """現状固定: HTTP 成功でも不正な認証応答は拒み、次の発行で再試行する。""" + from devbase.env import backend_config, bootstrap + + backend_config.save(tmp_path, backend_config.BackendConfig( + backend='openbao', cache_enabled=False, + openbao=backend_config.OpenBaoSettings(url='https://openbao.invalid', user='member01'), + )) + credentials = bootstrap.Credentials('fake-role-id', 'fake-secret-id') + monkeypatch.setattr(bootstrap, 'load', lambda root: credentials) + + def urlopen(req, **kwargs): + response = io.BytesIO(json.dumps(body).encode('utf-8')) + response.status = 200 + return response + + monkeypatch.setattr(request, 'urlopen', urlopen) + backend = OpenBaoBackend(SecretStore(tmp_path)) + + with pytest.raises(SecretUnreachableError): + backend.issue_token() + + body = {'auth': {'client_token': 's.fake-recovered-token', 'lease_duration': 3600}} + assert backend.issue_token() == 's.fake-recovered-token' + + @pytest.fixture def store(openbao_root): return SecretStore(openbao_root) @@ -430,3 +463,42 @@ def test_backend_test_fails_without_a_server_backend(tmp_path, caplog): assert env_backend.cmd_env_backend_test(tmp_path) == 1 assert 'openbao' in caplog.text + + +# --------------------------------------------------------------------------- +# token の発行 (PLAN54: コンテナの ~/.vault-token へ届ける) +# --------------------------------------------------------------------------- + +def test_issue_token_returns_the_login_token(store, openbao): + backend = store.backend_for(GLOBAL) + + token = backend.issue_token() + + assert token == openbao.token + assert openbao.logins == 1 + + +def test_issue_token_reuses_the_token_obtained_for_reads(store, openbao): + """注入で読んだ同じ SecretStore からなら、ログインし直さない (up の往復を増やさない)""" + store.load(GLOBAL) + backend = store.backend_for(GLOBAL) + + assert backend.issue_token() == openbao.token + assert openbao.logins == 1 + + +def test_issue_token_logs_in_again_near_expiry(store, openbao, monkeypatch): + backend = store.backend_for(GLOBAL) + backend.issue_token() + monkeypatch.setattr(backend, '_token_expires_at', 0.0) + + backend.issue_token() + + assert openbao.logins == 2 + + +def test_issue_token_raises_auth_error_on_rejected_login(store, openbao): + openbao.reject_login = True + + with pytest.raises(SecretAuthError): + store.backend_for(GLOBAL).issue_token()