diff --git a/issues/PLAN55_up-single-injection.md b/issues/PLAN55_up-single-injection.md index 5eeb5eab..d2e9ab5a 100644 --- a/issues/PLAN55_up-single-injection.md +++ b/issues/PLAN55_up-single-injection.md @@ -182,3 +182,73 @@ | 項目 | 誰が決めるか | 期限 | | --- | --- | --- | | ~~解決結果を引き継ぐ置き場~~ → 決まった: `runtime` モジュールが `SecretStore` を持ち回り、`_dispatch_lifecycle` の `finally` で捨てる(設計の決定 1・2) | 設計 Pull Request のマージで利用者が承認する | 設計 | + +## 実装計画 + +設計は `issues/PLAN55_up-single-injection-design.md`(マージ済み #176)。タスクは設計の +「構成要素」の行から導く。1 タスクが独立して検証できる単位にし、失敗するテスト → 最小実装 → +整理の順で進める。 + +### 修正対象 + +- `lib/devbase/env/runtime.py`、`lib/devbase/commands/container.py`、`lib/devbase/tui/dispatch.py` +- `tests/env/test_runtime_store.py`(新設)、`tests/cli/test_up_roundtrips.py`(新設)、 + `tests/cli/tui/test_dispatch.py`(足す)、`tests/conftest.py`(autouse で `release_store()`) + +### Task 1: `runtime.store_for` / `release_store` と、`resolve` / `inject` / `child_env` の切り替え + +- **対象ファイル:** `lib/devbase/env/runtime.py`、`tests/env/test_runtime_store.py` +- **変更内容:** モジュールの控え(`_store` / `_store_root`)と 2 関数を足す。`store` 引数が + `None` のとき `store_for(root)` を使う。明示的に渡された `store` は控えに入れない +- **満たす受け入れ条件:** `store_for` の規則の表(設計) +- **進め方:** 同一性・`root` 変更・解放後の作り直し・明示 `store` を控えない、の 4 テストを先に書く + +### Task 2: `_ensure_env_files` を持ち回った store に切り替え、`env init` の後に捨てる + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/cli/test_up_roundtrips.py` +- **変更内容:** `SecretStore(devbase_root)` → `runtime.store_for(devbase_root)`。`env init` の + 子プロセスから戻ったら終了コードによらず `runtime.release_store()`(決定 5) +- **満たす受け入れ条件:** 4(GET が増えない)、8(`env init` が書いた値で起動する) +- **進め方:** 偽サーバで注入 → `_ensure_env_files()` → GET 件数不変のテスト、`subprocess.run` を + 偽サーバへ書くスタブに差し替えて `_run_deploy_pipeline` へ渡る `SecretEnv` を見るテスト + +### Task 3: `_dispatch_lifecycle` の `finally` で捨てる(3 経路の往復を固定) + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/cli/test_up_roundtrips.py` +- **変更内容:** `finally` に `runtime.release_store()` を並べる +- **満たす受け入れ条件:** 1・2・3(認証 1 回 + GET 4 / ≤6 / 4)、5・6(既存テスト無変更) +- **進め方:** `cli._load_secret_env` → `container.cmd_project(ns)` を偽サーバ + docker 差し替えで + 走らせ、`openbao.logins` と GET の `kv_path` の集合を固定する + +### Task 4: TUI の委譲の入口で捨てる + +- **対象ファイル:** `lib/devbase/tui/dispatch.py`、`tests/cli/tui/test_dispatch.py` +- **変更内容:** `_preserve_cwd_env` の入口で `runtime.release_store()`(決定 3) +- **満たす受け入れ条件:** 9、決定 3 の規則 +- **進め方:** `store_for(root)` で控えを作ってから `dispatch_group` の handler 内で別インスタンスに + なるテスト、`env edit`(エディタのスタブ)→ `up` で新しい値が渡るテスト + +### Task 5: 既存テストの独立性 + +- **対象ファイル:** `tests/conftest.py` +- **変更内容:** autouse fixture で各テストの前後に `runtime.release_store()`。モジュールの控えが + テストをまたいで残らない +- **満たす受け入れ条件:** 5・7 +- **進め方:** `uv run pytest tests/` 全件 + +### リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `tests/cli/` の既存 harness が `SecretStore(root)` を直接作り `monkeypatch` している | Task 5 の autouse fixture。実装時に `grep -rn "SecretStore(" tests/` で数える | +| `container.py` は 1300 行超で、`_ensure_env_files` と `_dispatch_lifecycle` が離れている | 触るのは 2 関数の数行。タスクごとにテストを通す | + +### 切り戻し手順 + +- 差分を戻すだけ(永続データ・スキーマの変更なし)。`release_store()` を呼ばない古い経路が + 残っても、`SecretStore` を作り直す従来の動きに戻るだけで壊れない + +### 完了の定義 + +- [ ] 受け入れ条件 1〜9 をすべて満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run pytest tests/` / `ruff check lib` / `python -m compileall -q lib bin` が exit=0 diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 9adefb08..35b88072 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -40,11 +40,38 @@ # 共通ヘルパー # --------------------------------------------------------------------------- +def _exit_code(ok: bool) -> int: + """ビルド成否 (bool) をプロセス互換の終了コードへ写す (True=0 / False=1)。""" + return 0 if ok else 1 + + def _devbase_root() -> Optional[Path]: root = os.environ.get('DEVBASE_ROOT') return Path(root) if root else None +def _env_non_negative_int(env_name: str, default: int) -> int: + """環境変数から非負整数を読み出す。 + + 未設定・空文字なら default を返す。 + 負値または整数に変換できない場合は warning を出力して default にフォールバックする。 + """ + raw = os.environ.get(env_name) + if not raw: + return default + try: + value = int(raw) + if value < 0: + raise ValueError + return value + except ValueError: + logger.warning( + "Invalid %s=%r, using default %d", + env_name, raw, default + ) + return default + + def _inject_secrets(*, required: bool): """機密を復号して自プロセスの環境変数へ載せ、載せた内容を返す。 @@ -208,14 +235,26 @@ def _previous_scale_compose(): backup.unlink(missing_ok=True) -def _compose_run(subcommand: str, *extra_args: str, - context: Optional[str] = None) -> int: - """docker compose コマンドを実行する共通関数""" +def _prepare_compose(context: Optional[str]) -> None: + """Compose の接続先を反映してから機密を任意注入する。""" _apply_context(context) _inject_secrets(required=False) + + +def _compose_base_args(compose_file: Optional[Path]) -> list[str]: + """指定された override を付与した Compose のベース引数を返す。""" cmd = ['docker', 'compose'] - if _SCALE_COMPOSE_FILE.exists(): - cmd.extend(['-f', str(_SCALE_COMPOSE_FILE)]) + if compose_file is not None: + cmd.extend(['-f', str(compose_file)]) + return cmd + + +def _compose_run(subcommand: str, *extra_args: str, + context: Optional[str] = None) -> int: + """docker compose コマンドを実行する共通関数""" + _prepare_compose(context) + compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None + cmd = _compose_base_args(compose_file) cmd.append(subcommand) cmd.extend(extra_args) return subprocess.run(cmd).returncode @@ -431,6 +470,24 @@ def _load_project_env(env_file: Path) -> None: os.environ[key] = value +def _unset_caller_only_env_keys(caller_keys: set, target_dir: Path) -> None: + """呼び出し元 env にしか無いキーを os.environ から unset する。 + + 別プロジェクトから `project up other` を直接起動した場合、呼び出し元 env に + しか無いキー (例: DEV_SERVICE_NAME) が os.environ に残留し対象へ誤って + 引き継がれる。対象 (``target_dir`` = 現 CWD) の env を読み、呼び出し元にしか + 無いキーを unset してクリーンにする + (codex 指摘 / wrapper の _CALLER_ENV_KEYS と同等のフォールバック)。 + + Args: + caller_keys: chdir 前に記録した呼び出し元 env のキー集合。 + target_dir: 切替先プロジェクトのディレクトリ (既に chdir 済みの CWD)。 + """ + target_env_keys = _env_var_keys(target_dir / 'env') + for key in caller_keys - target_env_keys: + os.environ.pop(key, None) + + def _resolve_project_name(project_name: str) -> bool: """project name を $DEVBASE_ROOT/projects/ へ解決し chdir する。 @@ -468,12 +525,10 @@ def _resolve_project_name(project_name: str) -> bool: # chdir 前に呼び出し元 (現 CWD) の env が定義するキーを記録しておく。 # 別プロジェクトから `project up other` を直接起動した場合、呼び出し元 env に - # しか無いキー (例: DEV_SERVICE_NAME) が os.environ に残留し対象へ誤って - # 引き継がれるため、対象 env を読む前に unset してクリーンにする - # (codex 指摘 / wrapper の _CALLER_ENV_KEYS と同等のフォールバック)。 + # しか無いキーを chdir 後に unset するために使う (詳細は + # :func:`_unset_caller_only_env_keys`)。 # already_there (= 既に対象ディレクトリ。通常 wrapper 経由) の場合は呼び出し元 # =対象であり、wrapper 側で既にクリーン化済みのため何もしない。 - caller_env_keys: set = set() if not already_there: caller_env_keys = _env_var_keys(Path('env')) os.chdir(target) @@ -483,9 +538,7 @@ def _resolve_project_name(project_name: str) -> bool: # 切替先ではなく呼び出し元プロジェクトの機密を読んでしまう # (TUI の ``_run_in_project`` が PWD を差し替えているのと同じ理由)。 os.environ['PWD'] = str(target) - target_env_keys = _env_var_keys(Path('env')) - for key in caller_env_keys - target_env_keys: - os.environ.pop(key, None) + _unset_caller_only_env_keys(caller_env_keys, target) # wrapper の `source ./env` と同等に project env を os.environ へ反映する。 # wrapper 経由なら既に同じ値が載っているため冪等。 @@ -563,6 +616,11 @@ def _dispatch_lifecycle(args) -> int: return 1 finally: docker_context.reset() + # 持ち回った SecretStore の寿命はライフサイクル操作 1 回 (PLAN55 決定 2)。 + # 入口ではなく出口で捨てるのは、CLI では dispatch 前の注入 (cli._load_secret_env) + # が作ったものをこの操作の中で使い回すため。 + from devbase.env import runtime as _runtime + _runtime.release_store() def cmd_project(args) -> int: @@ -597,20 +655,10 @@ def _snapshot_min_interval_minutes() -> int: DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES で上書き可能 (0 で無効化=毎回取得)。 値が不正な場合は既定値にフォールバックする。 """ - raw = os.environ.get('DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES') - if not raw: - return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT - try: - value = int(raw) - if value < 0: - raise ValueError - return value - except ValueError: - logger.warning( - "Invalid DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=%r, using default %d", - raw, _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT - ) - return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT + return _env_non_negative_int( + 'DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES', + _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT, + ) def _auto_snapshot(remote: bool = False) -> None: @@ -979,16 +1027,14 @@ def cmd_down(context: Optional[str] = None) -> int: def cmd_login(index: str = '1', context: Optional[str] = None) -> int: """Login to container""" - _apply_context(context) - _inject_secrets(required=False) + _prepare_compose(context) dev_service = get_dev_service_name() - - if _SCALE_COMPOSE_FILE.exists(): - cmd = ['docker', 'compose', '-f', str(_SCALE_COMPOSE_FILE), - 'exec', f'{dev_service}-{index}', 'bash'] + compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None + cmd = _compose_base_args(compose_file) + if compose_file is not None: + cmd.extend(['exec', f'{dev_service}-{index}', 'bash']) else: - cmd = ['docker', 'compose', 'exec', f'--index={index}', - dev_service, 'bash'] + cmd.extend(['exec', f'--index={index}', dev_service, 'bash']) return subprocess.run(cmd).returncode @@ -1243,18 +1289,18 @@ def _build_resolved(expires: Optional[int], no_cache: bool) -> int: return 1 if no_cache: - return 0 if _run_build(no_cache=True) else 1 + return _exit_code(_run_build(no_cache=True)) if expires is None: - return 0 if _run_build() else 1 + return _exit_code(_run_build()) # expires 指定: project イメージの作成日と dev サービス定義 (base 判定用) が必要。 dev_service = _resolve_dev_service() if not dev_service: logger.info("Unable to read compose config; building with cache") - return 0 if _run_build() else 1 + return _exit_code(_run_build()) image_name = dev_service.get('image', '') if not image_name: - return 0 if _run_build() else 1 + return _exit_code(_run_build()) inspect = subprocess.run( ['docker', 'image', 'inspect', image_name], capture_output=True, text=True, check=False @@ -1262,8 +1308,8 @@ def _build_resolved(expires: Optional[int], no_cache: bool) -> int: if inspect.returncode != 0: # イメージ未存在 → キャッシュビルドで作成する。 logger.info("Container image '%s' not found; building...", image_name) - return 0 if _run_build() else 1 - return 0 if _build_with_expires(expires, image_name, inspect.stdout, dev_service) else 1 + return _exit_code(_run_build()) + return _exit_code(_build_with_expires(expires, image_name, inspect.stdout, dev_service)) def cmd_rebuild(expires: int = None, context: Optional[str] = None) -> int: @@ -1302,10 +1348,14 @@ def _ensure_env_files() -> bool: # 機密が暗号化されていれば平文の .env は存在しない。ファイルの有無ではなく # 秘密ストアに設定があるかで判定しないと、移行済みの環境で毎回 env init が # 走ってしまう。 + # + # SecretStore は注入と同じものを持ち回る (PLAN55)。作り直すとサーバ backend では + # 認証と参照ごとの取得がもう 1 巡走る。同じインスタンスなら注入で取得済みの控えから + # 返るので、ここはサーバへ行かない。 from devbase.env import runtime as _runtime - from devbase.env.secret_store import SecretRef, SecretStore + from devbase.env.secret_store import SecretRef - store = SecretStore(devbase_root) + store = _runtime.store_for(devbase_root) has_global = store.exists(SecretRef.for_global()) project_name = _runtime.current_project_name(devbase_root) @@ -1343,6 +1393,12 @@ def _ensure_env_files() -> bool: except Exception as e: logger.error("Running env init for devbase root: %s", e) success = False + finally: + # 書いたのは子プロセスで、持ち回っている SecretStore の控えは更新されない。 + # サーバ backend では最初の 404 が空として残り、そのまま起動すると env init が + # 書いた共通機密が渡らない。終了コードによらず捨て、以後は現物を読み直す + # (PLAN55 決定 5)。 + _runtime.release_store() if not has_project: logger.info("Creating project .env...") @@ -1365,20 +1421,32 @@ def _image_max_age_days() -> int: Override via the DEVBASE_IMAGE_MAX_AGE_DAYS environment variable. Falls back to the default on missing or malformed values. """ - raw = os.environ.get('DEVBASE_IMAGE_MAX_AGE_DAYS') - if not raw: - return _IMAGE_MAX_AGE_DAYS_DEFAULT - try: - value = int(raw) - if value < 0: - raise ValueError - return value - except ValueError: - logger.warning( - "Invalid DEVBASE_IMAGE_MAX_AGE_DAYS=%r, using default %d", - raw, _IMAGE_MAX_AGE_DAYS_DEFAULT - ) - return _IMAGE_MAX_AGE_DAYS_DEFAULT + return _env_non_negative_int( + 'DEVBASE_IMAGE_MAX_AGE_DAYS', + _IMAGE_MAX_AGE_DAYS_DEFAULT, + ) + + +def _read_compose_services() -> tuple[int, dict]: + """Compose 設定の終了コードと services を取得する。""" + result = subprocess.run( + ['docker', 'compose', 'config', '--format', 'json'], + capture_output=True, + text=True, + check=False + ) + if result.returncode != 0: + return result.returncode, {} + config = json.loads(result.stdout) + return result.returncode, config.get('services', {}) + + +def _dev_image_spec(services: dict, dev_service_name: str) -> tuple[dict, str, bool]: + """dev サービスと、そのイメージ名・ビルド定義の有無を取り出す。""" + dev_service = services.get(dev_service_name, {}) + image_name = dev_service.get('image', '') + has_build = bool(dev_service.get('build')) + return dev_service, image_name, has_build def _ensure_images() -> bool: @@ -1410,23 +1478,13 @@ def _ensure_images() -> bool: dev_service_name = get_dev_service_name() try: - result = subprocess.run( - ['docker', 'compose', 'config', '--format', 'json'], - capture_output=True, - text=True, - check=False - ) - - if result.returncode != 0: + returncode, services = _read_compose_services() + if returncode != 0: logger.info("Unable to check image status") logger.info("Running 'devbase container build' to ensure images exist...") return _run_build() - config = json.loads(result.stdout) - services = config.get('services', {}) - dev_service = services.get(dev_service_name, {}) - image_name = dev_service.get('image', '') - has_build = bool(dev_service.get('build')) + dev_service, image_name, has_build = _dev_image_spec(services, dev_service_name) if not image_name: logger.warning("No image specified for %s service", dev_service_name) diff --git a/lib/devbase/env/runtime.py b/lib/devbase/env/runtime.py index 9249baa7..2fc07259 100644 --- a/lib/devbase/env/runtime.py +++ b/lib/devbase/env/runtime.py @@ -25,6 +25,42 @@ logger = get_logger(__name__) +# --------------------------------------------------------------------------- +# SecretStore の持ち回り (PLAN55) +# --------------------------------------------------------------------------- + +#: 1 回のライフサイクル操作の間、持ち回る :class:`SecretStore` とその ``root``。 +#: +#: 注入は ``cli._load_secret_env`` (dispatch 前) / ``_dispatch_lifecycle`` (切替後) / +#: ``_run_deploy_pipeline`` (起動直前) の 3 か所で行われ、それぞれ別の理由で置かれている。 +#: 注入のたびに ``SecretStore`` を作り直すとサーバ backend では認証と取得が繰り返される +#: ため、インスタンスの寿命を操作 1 回に揃えて 2 度目以降の解決を控え (``_seen``) から +#: 返す。捨てる契機は呼び出し側 (``_dispatch_lifecycle`` の ``finally`` など) が持つ。 +_store: Optional[SecretStore] = None +_store_root: Optional[Path] = None + + +def store_for(devbase_root: Path) -> SecretStore: + """持ち回っている :class:`SecretStore` を返す。無ければ作り、``root`` が違えば作り直す。""" + global _store, _store_root + root = Path(devbase_root) + if _store is None or _store_root != root: + _store = SecretStore(root) + _store_root = root + return _store + + +def release_store() -> None: + """持ち回っている :class:`SecretStore` を捨てる。次の :func:`store_for` は作り直す。 + + 子プロセス (``env init``) がストアへ書いた後や、TUI の操作の入口で呼ぶ。控えを + 持ったまま続けると、現物と違う値で起動する。 + """ + global _store, _store_root + _store = None + _store_root = None + + # --------------------------------------------------------------------------- # プロジェクトの特定 # --------------------------------------------------------------------------- @@ -162,7 +198,7 @@ def resolve(devbase_root: Path, project: Optional[str] = None, 結果は従来と同じになる (前提 3)。 """ root = Path(devbase_root) - store = store if store is not None else SecretStore(root) + store = store if store is not None else store_for(root) team_global = store.load(SecretRef.for_global()) user_global = store.load(SecretRef.for_global(owner='user')) diff --git a/lib/devbase/tui/dispatch.py b/lib/devbase/tui/dispatch.py index 590e4b8a..4322f942 100644 --- a/lib/devbase/tui/dispatch.py +++ b/lib/devbase/tui/dispatch.py @@ -34,8 +34,14 @@ def _preserve_cwd_env(): しまう (PR #55 round1 codex/gemini major 指摘)。委譲チョークポイントである本層で 一括復元し、各 actions_* / 共有ハンドラへ復元処理を散らさない。 """ + from devbase.env import runtime as _runtime + old_cwd = os.getcwd() old_env = os.environ.copy() + # 持ち回った SecretStore は操作の入口で捨てる (PLAN55 決定 3)。TUI は 1 プロセスで + # 操作を続けるため、起動時や前の操作の控え (``_seen``) を持ち越すと、``env edit`` で + # 書いた直後の ``up`` が編集前の値で起動する。lifecycle / group のどちらもここを通る。 + _runtime.release_store() try: yield finally: diff --git a/tests/cli/test_base_image_staleness.py b/tests/cli/test_base_image_staleness.py index c99a941d..e5fb0658 100644 --- a/tests/cli/test_base_image_staleness.py +++ b/tests/cli/test_base_image_staleness.py @@ -199,3 +199,68 @@ class _R: ) assert container._build_resolved(expires=7, no_cache=False) == 0 assert seen == {"expires": 7, "image": "dev:latest"} + + +# --------------------------------------------------------------------------- +# _image_max_age_days (現状固定テスト) +# --------------------------------------------------------------------------- + +_MAX_AGE_ENV = "DEVBASE_IMAGE_MAX_AGE_DAYS" + + +def test_image_max_age_days_unset_returns_default(monkeypatch): + monkeypatch.delenv(_MAX_AGE_ENV, raising=False) + assert container._image_max_age_days() == container._IMAGE_MAX_AGE_DAYS_DEFAULT + assert container._IMAGE_MAX_AGE_DAYS_DEFAULT == 7 + + +def test_image_max_age_days_valid_value(monkeypatch): + monkeypatch.setenv(_MAX_AGE_ENV, "14") + assert container._image_max_age_days() == 14 + + +def test_image_max_age_days_zero(monkeypatch): + monkeypatch.setenv(_MAX_AGE_ENV, "0") + assert container._image_max_age_days() == 0 + + +def test_image_max_age_days_negative_falls_back_with_warning(monkeypatch, caplog): + monkeypatch.setenv(_MAX_AGE_ENV, "-5") + with caplog.at_level("WARNING"): + result = container._image_max_age_days() + assert result == container._IMAGE_MAX_AGE_DAYS_DEFAULT + assert any("Invalid DEVBASE_IMAGE_MAX_AGE_DAYS='-5'" in r.getMessage() for r in caplog.records) + + +def test_image_max_age_days_non_numeric_falls_back_with_warning(monkeypatch, caplog): + monkeypatch.setenv(_MAX_AGE_ENV, "abc") + with caplog.at_level("WARNING"): + result = container._image_max_age_days() + assert result == container._IMAGE_MAX_AGE_DAYS_DEFAULT + assert any("Invalid DEVBASE_IMAGE_MAX_AGE_DAYS='abc'" in r.getMessage() for r in caplog.records) + + +def test_env_non_negative_int(monkeypatch, caplog): + monkeypatch.delenv("TEST_ENV_VAR", raising=False) + assert container._env_non_negative_int("TEST_ENV_VAR", 60) == 60 + + monkeypatch.setenv("TEST_ENV_VAR", "") + assert container._env_non_negative_int("TEST_ENV_VAR", 60) == 60 + + monkeypatch.setenv("TEST_ENV_VAR", "30") + assert container._env_non_negative_int("TEST_ENV_VAR", 60) == 30 + + monkeypatch.setenv("TEST_ENV_VAR", "0") + assert container._env_non_negative_int("TEST_ENV_VAR", 60) == 0 + + monkeypatch.setenv("TEST_ENV_VAR", "-5") + with caplog.at_level("WARNING"): + assert container._env_non_negative_int("TEST_ENV_VAR", 60) == 60 + assert any("Invalid TEST_ENV_VAR='-5'" in r.getMessage() for r in caplog.records) + + monkeypatch.setenv("TEST_ENV_VAR", "invalid") + with caplog.at_level("WARNING"): + assert container._env_non_negative_int("TEST_ENV_VAR", 60) == 60 + assert any("Invalid TEST_ENV_VAR='invalid'" in r.getMessage() for r in caplog.records) + + diff --git a/tests/cli/test_login_command.py b/tests/cli/test_login_command.py new file mode 100644 index 00000000..a965ef5c --- /dev/null +++ b/tests/cli/test_login_command.py @@ -0,0 +1,36 @@ +"""login が生成する Compose コマンドの現状を固定する。""" + +import subprocess + +import pytest + +from devbase.commands import container + + +@pytest.mark.parametrize(('scaled', 'expected'), [ + (True, ['docker', 'compose', '-f', '.docker-compose.scale.yml', + 'exec', 'dev-2', 'bash']), + (False, ['docker', 'compose', 'exec', '--index=2', 'dev', 'bash']), +]) +def test_login_command(tmp_path, monkeypatch, scaled, expected): + monkeypatch.chdir(tmp_path) + if scaled: + (tmp_path / '.docker-compose.scale.yml').write_text('services: {}\n') + events = [] + monkeypatch.setattr(container, '_apply_context', + lambda context: events.append(('context', context))) + monkeypatch.setattr(container, '_inject_secrets', + lambda *, required: events.append(('secrets', required))) + monkeypatch.setattr(container, 'get_dev_service_name', + lambda: events.append(('service',)) or 'dev') + + def run(cmd): + events.append(('run', cmd)) + return subprocess.CompletedProcess(cmd, 7) + + monkeypatch.setattr(container.subprocess, 'run', run) + + assert container.cmd_login('2', context='remote') == 7 + assert events == [ + ('context', 'remote'), ('secrets', False), ('service',), ('run', expected), + ] diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 9160a578..eed97584 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -188,6 +188,20 @@ def test_lifecycle_no_resolution_without_name(monkeypatch): assert resolved == [] +def test_cmd_project_releases_store_when_project_is_missing(tmp_path, monkeypatch): + """現状固定: 名前解決で早期終了しても次の操作には新しいストアを渡す。""" + from devbase.commands import container + from devbase.env import runtime + + (tmp_path / 'projects').mkdir() + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + before = runtime.store_for(tmp_path) + + assert container.cmd_project(_args(subcommand='ps', name='missing-project')) == 1 + + assert runtime.store_for(tmp_path) is not before + + # --------------------------------------------------------------------------- # cli._dispatch: ルーティング # --------------------------------------------------------------------------- diff --git a/tests/cli/test_secret_injection.py b/tests/cli/test_secret_injection.py index 5da67b26..5564b1d7 100644 --- a/tests/cli/test_secret_injection.py +++ b/tests/cli/test_secret_injection.py @@ -58,3 +58,46 @@ def test_injection_is_skipped_before_devbase_root_is_read(monkeypatch): """DEVBASE_ROOT が無くても判定自体は成立する (例外を出さない)""" monkeypatch.delenv('DEVBASE_ROOT', raising=False) cli._load_secret_env('env', 'keygen') + + +@pytest.mark.parametrize('required', [True, False]) +def test_container_injection_without_root_returns_empty_secrets(monkeypatch, required): + """現状固定: root 未設定なら必須指定でも例外を出さず空を返す。""" + from devbase.commands import container + from devbase.env.runtime import SecretEnv + + monkeypatch.delenv('DEVBASE_ROOT', raising=False) + + secrets = container._inject_secrets(required=required) + + assert isinstance(secrets, SecretEnv) + assert not secrets + assert secrets.values == {} + assert secrets.names == [] + + +def test_container_injection_error_swallowed_when_not_required(tmp_path, monkeypatch): + """現状固定: required=False では DevbaseError を握り潰して空の SecretEnv を返し、 + required=True では再送出する。 + """ + from devbase.commands import container + from devbase.env import runtime + from devbase.env.runtime import SecretEnv + from devbase.errors import DevbaseError + + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + + def stub_inject(*_args, **_kwargs): + raise DevbaseError('failed to inject secrets') + + monkeypatch.setattr(runtime, 'inject', stub_inject) + + secrets = container._inject_secrets(required=False) + assert isinstance(secrets, SecretEnv) + assert not secrets + assert secrets.values == {} + assert secrets.names == [] + + with pytest.raises(DevbaseError, match='failed to inject secrets'): + container._inject_secrets(required=True) + diff --git a/tests/cli/test_up_roundtrips.py b/tests/cli/test_up_roundtrips.py new file mode 100644 index 00000000..f1ef7bd6 --- /dev/null +++ b/tests/cli/test_up_roundtrips.py @@ -0,0 +1,219 @@ +"""`devbase up` 1 回のサーバ backend への往復を固定する (PLAN55 / #168) + +仕様「OpenBao との契約」は `devbase up` 1 回あたり認証 1 回 + 参照ごとに取得 1 回を +想定している。CLI 全体では `cli._load_secret_env` (dispatch 前) / `_dispatch_lifecycle` +(切替後) / `_ensure_env_files` / `_run_deploy_pipeline` の 4 か所が `SecretStore` を +作り直していたため、認証 4 回・取得 12〜14 回になっていた。3 経路それぞれの往復を +偽サーバの記録で固定する。 +""" + +from __future__ import annotations + +import os +import subprocess +import types +from pathlib import Path + +import pytest + +from devbase import cli +from devbase.commands import container +from devbase.env import runtime +from devbase.env.secret_store import SecretRef, SecretStore +from devbase.utils import docker_context + +TEAM_GLOBAL = 'team/global' +USER_GLOBAL = 'users/member01/global' +TEAM_WEB = 'team/projects/web' +USER_WEB = 'users/member01/projects/web' +TEAM_API = 'team/projects/api' +USER_API = 'users/member01/projects/api' + +WEB_REFS = [TEAM_GLOBAL, USER_GLOBAL, TEAM_WEB, USER_WEB] +API_REFS = [TEAM_GLOBAL, USER_GLOBAL, TEAM_API, USER_API] + +LOCAL_TARGET = docker_context.DockerTarget(context=None, source='none', remote=False, + home=None, gid=None) + + +def _write_project(root: Path, name: str) -> Path: + project = root / 'projects' / name + project.mkdir(parents=True, exist_ok=True) + (project / 'project.yml').write_text( + "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") + (project / 'env').write_text(f"PROJECT_MARK={name}\n") + return project + + +@pytest.fixture +def up_root(openbao_root, openbao, monkeypatch): + """偽サーバに 2 プロジェクト分の機密を置き、docker の呼び出しを差し替えた DEVBASE_ROOT。 + + `_ensure_env_files` と `_inject_secrets` は本物を通す (往復を数える対象)。 + """ + root = openbao_root + _write_project(root, 'web') + _write_project(root, 'api') + openbao.put(TEAM_GLOBAL, {'SHARED': 'team'}) + openbao.put(USER_GLOBAL, {'MINE': 'me'}) + openbao.put(TEAM_WEB, {'WEB_ONLY': 'w'}) + openbao.put(TEAM_API, {'API_ONLY': 'a'}) + monkeypatch.setenv('DEVBASE_ROOT', str(root)) + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT', + 'COMPOSE_PROJECT_NAME', 'DEV_SERVICE_NAME', 'SHARED', 'MINE', + 'WEB_ONLY', 'API_ONLY', 'PROJECT_MARK', 'INIT_KEY'): + monkeypatch.delenv(name, raising=False) + docker_context.reset() + runtime.release_store() + runtime.clear_injected() + + seen: dict = {} + monkeypatch.setattr(container, '_resolve_docker_target', lambda cli_context=None: LOCAL_TARGET) + monkeypatch.setattr(container, '_run_pre_up_hook', lambda config=None: True) + monkeypatch.setattr(container, '_ensure_images', lambda: True) + monkeypatch.setattr(container, '_auto_snapshot', lambda *a, **k: None) + monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) + monkeypatch.setattr(container, 'ensure_network', lambda *a, **k: None) + monkeypatch.setattr(container, 'docker_compose_down', lambda **k: None) + monkeypatch.setattr(container, 'docker_compose_up', lambda **k: None) + monkeypatch.setattr(container, 'wait_for_containers_ready', lambda **k: None) + 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) + + def fake_generate(scale, secrets, dev_environment=None, **kw): + seen['secrets'] = secrets + seen['environ'] = dict(os.environ) + 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) + seen['root'] = root + yield seen + runtime.release_store() + runtime.clear_injected() + + +def _run_up(name=None) -> int: + """`devbase [project] up [name]` を CLI と同じ順で走らせる (dispatch 前の注入 → dispatch)""" + cli._load_secret_env('project', 'up') + ns = types.SimpleNamespace(subcommand='up', name=name, scale=None, + open_editor=False, open_index=None, context=None) + return container.cmd_project(ns) + + +def _gets(openbao): + return sorted(r.kv_path for r in openbao.requests_of('GET')) + + +@pytest.mark.parametrize(('subcommand', 'name'), [ + pytest.param('unknown', None, id='unknown-subcommand'), + pytest.param('ps', 'missing-project', id='missing-project'), + pytest.param('ps', None, id='handler-error'), +]) +def test_cmd_project_error_discards_cached_secrets( + openbao_root, openbao, monkeypatch, subcommand, name): + """現状固定: 異常終了後の解決では操作前の機密を持ち越さない。""" + root = openbao_root + monkeypatch.setenv('DEVBASE_ROOT', str(root)) + openbao.put(TEAM_GLOBAL, {'TOKEN': 'old'}) + assert runtime.resolve(root).values['TOKEN'] == 'old' + openbao.put(TEAM_GLOBAL, {'TOKEN': 'new'}) + + def fail_ps(**kwargs): + raise RuntimeError('ps failed') + + monkeypatch.setattr(container, 'cmd_ps', fail_ps) + args = types.SimpleNamespace(subcommand=subcommand, name=name) + if subcommand == 'ps' and name is None: + with pytest.raises(RuntimeError, match='ps failed'): + container.cmd_project(args) + else: + assert container.cmd_project(args) == 1 + + # fixture の解除処理が走る前に、公開入口から更新値を読み直す。 + assert runtime.resolve(root).values['TOKEN'] == 'new' + + +def test_up_in_project(up_root, openbao, monkeypatch): + """受け入れ条件 1: `web` の中で `up` → 認証 1 回、GET 4 回""" + monkeypatch.chdir(up_root['root'] / 'projects' / 'web') + monkeypatch.setenv('PWD', str(up_root['root'] / 'projects' / 'web')) + + assert _run_up() == 0 + + assert openbao.logins == 1 + assert _gets(openbao) == sorted(WEB_REFS) + assert up_root['secrets'].values['WEB_ONLY'] == 'w' + assert up_root['environ']['SHARED'] == 'team' + + +def test_up_other_project(up_root, openbao, monkeypatch): + """受け入れ条件 2: `api` の中で `up web` → 認証 1 回、GET 6 回以下、api 固有キーは残らない""" + monkeypatch.chdir(up_root['root'] / 'projects' / 'api') + monkeypatch.setenv('PWD', str(up_root['root'] / 'projects' / 'api')) + + assert _run_up('web') == 0 + + assert openbao.logins == 1 + gets = openbao.requests_of('GET') + assert len(gets) <= 6 + assert {r.kv_path for r in gets} == set(API_REFS) | set(WEB_REFS) + assert 'API_ONLY' not in up_root['environ'] + assert 'API_ONLY' not in up_root['secrets'].values + assert up_root['secrets'].values['WEB_ONLY'] == 'w' + + +def test_up_from_outside(up_root, openbao, monkeypatch): + """受け入れ条件 3: `projects/` の外で `up web` → 認証 1 回、GET 4 回""" + monkeypatch.chdir(up_root['root']) + monkeypatch.setenv('PWD', str(up_root['root'])) + + assert _run_up('web') == 0 + + assert openbao.logins == 1 + assert _gets(openbao) == sorted(WEB_REFS) + + +def test_ensure_env_files_reads_seen(up_root, openbao, monkeypatch): + """受け入れ条件 4: 注入の後の `_ensure_env_files` はサーバへ GET を出さない""" + monkeypatch.chdir(up_root['root'] / 'projects' / 'web') + monkeypatch.setenv('PWD', str(up_root['root'] / 'projects' / 'web')) + cli._load_secret_env('project', 'up') + before = len(openbao.requests_of('GET')) + + assert container._ensure_env_files() is True + + assert len(openbao.requests_of('GET')) == before + assert openbao.logins == 1 + + +def test_up_after_env_init_reads_written_values(up_root, openbao, monkeypatch): + """受け入れ条件 8: `team/global` 未作成で `up` → `env init` が書いた値で起動する (決定 5)""" + root = up_root['root'] + openbao.secrets.pop(TEAM_GLOBAL) + openbao.versions.pop(TEAM_GLOBAL) + monkeypatch.chdir(root / 'projects' / 'web') + monkeypatch.setenv('PWD', str(root / 'projects' / 'web')) + + child: dict = {} + + def fake_env_init(argv, **kwargs): + # 子プロセスの `env init` は別の SecretStore で書く。親の控えは更新されない。 + # 子の往復 (認証 1 + 書く前の GET) は親の数に入れないので、ここで分けて数える + logins, gets = openbao.logins, len(openbao.requests_of('GET')) + SecretStore(root).save(SecretRef.for_global(), {'INIT_KEY': 'value'}) + child['logins'] = openbao.logins - logins + child['gets'] = len(openbao.requests_of('GET')) - gets + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(container.subprocess, 'run', fake_env_init) + + assert _run_up() == 0 + + assert up_root['secrets'].values['INIT_KEY'] == 'value' + assert up_root['environ']['INIT_KEY'] == 'value' + # 親は、捨てて読み直した分 (認証 1 回 + 参照ごとに 1 回) だけ増える + assert openbao.logins - child['logins'] == 2 + assert len(openbao.requests_of('GET')) - child['gets'] <= 8 diff --git a/tests/cli/tui/test_dispatch.py b/tests/cli/tui/test_dispatch.py index 5d9d72b1..23290dd7 100644 --- a/tests/cli/tui/test_dispatch.py +++ b/tests/cli/tui/test_dispatch.py @@ -122,3 +122,94 @@ def handler(devbase_root, args): rc = dispatch.dispatch_group(handler, Path("/devbase"), "init", reset=True) assert rc == 7 assert captured == {"root": Path("/devbase"), "subcommand": "init", "reset": True} + + +# --------------------------------------------------------------------------- +# PLAN55: TUI は操作の入口で持ち回った SecretStore を捨てる (決定 3) +# --------------------------------------------------------------------------- + +def test_preserve_cwd_env_releases_store_on_entry(tmp_path): + """委譲の入口で控えを捨てるので、handler の中の store_for は別のインスタンスを返す。""" + from devbase.env import runtime + + runtime.release_store() + before = runtime.store_for(tmp_path) + seen = {} + + def handler(devbase_root, args): + seen["store"] = runtime.store_for(tmp_path) + return 0 + + try: + assert dispatch.dispatch_group(handler, tmp_path, "list") == 0 + assert seen["store"] is not before + finally: + runtime.release_store() + + +def test_lifecycle_after_env_edit_reads_written_values(openbao_root, openbao, monkeypatch): + """同じプロセスで `env edit` → `up` したとき、編集後の値で起動する (受け入れ条件 9)。""" + import subprocess + import types + + from devbase import cli + from devbase.commands import container + from devbase.commands import env as env_mod + from devbase.env import runtime + from devbase.utils import docker_context + + root = openbao_root + web = root / 'projects' / 'web' + (web / 'project.yml').write_text( + "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") + (web / 'env').write_text("") + openbao.put('team/global', {'REVIEW_KEY': 'old'}) + openbao.put('team/projects/web', {'WEB_ONLY': 'w'}) + monkeypatch.setenv('DEVBASE_ROOT', str(root)) + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT', + 'COMPOSE_PROJECT_NAME', 'REVIEW_KEY', 'WEB_ONLY'): + monkeypatch.delenv(name, raising=False) + docker_context.reset() + runtime.release_store() + runtime.clear_injected() + + seen = {} + target = docker_context.DockerTarget(context=None, source='none', remote=False, + home=None, gid=None) + monkeypatch.setattr(container, '_resolve_docker_target', lambda cli_context=None: target) + for name in ('_run_pre_up_hook', '_ensure_images'): + 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) + + def fake_generate(scale, secrets, dev_environment=None, **kw): + seen['secrets'] = secrets + seen['environ'] = dict(os.environ) + 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_editor(argv): + Path(argv[1]).write_text("REVIEW_KEY=new\n") + return 0 + + monkeypatch.setattr(env_mod.subprocess, 'call', fake_editor) + + try: + # TUI の起動: dispatch 前の注入が控えを作る + cli._load_secret_env('project', 'list') + assert dispatch.dispatch_group(env_mod.cmd_env, root, 'edit') == 0 + assert openbao.get('team/global') == {'REVIEW_KEY': 'new'} + + assert dispatch.dispatch_lifecycle('up', 'web', scale=None, open_editor=False, + open_index=None, context=None) == 0 + + assert seen['secrets'].values['REVIEW_KEY'] == 'new' + assert seen['environ']['REVIEW_KEY'] == 'new' + finally: + runtime.release_store() + runtime.clear_injected() diff --git a/tests/conftest.py b/tests/conftest.py index 7b3ee748..3e92cf3b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -319,6 +319,16 @@ def do_GET(self): truncate=state.truncate_get_body) +@pytest.fixture(autouse=True) +def _release_shared_secret_store(): + """持ち回りの SecretStore (PLAN55) をテストごとに捨て、控えが隣のテストへ漏れないようにする""" + from devbase.env import runtime + + runtime.release_store() + yield + runtime.release_store() + + @pytest.fixture def openbao(): """偽 OpenBao サーバ。テスト終了時に落とす。""" diff --git a/tests/env/test_runtime.py b/tests/env/test_runtime.py index 848941db..3cade63f 100644 --- a/tests/env/test_runtime.py +++ b/tests/env/test_runtime.py @@ -402,6 +402,25 @@ def test_names_are_listed_once_across_the_four_layers(root): assert resolved.names == ['A', 'K', 'B', 'C', 'D'] +def test_invalid_utf8_project_env_preserves_secret_values_and_origins(root, monkeypatch): + """現状固定: 不正 UTF-8 があれば有効な先頭行も含め上書きを無視する。""" + (root / 'projects' / 'web' / 'env').write_bytes(b'TOKEN=override\nINVALID=\xff\n') + monkeypatch.setenv('TOKEN', 'override') + store = _FourLayerStore(_layers( + team_global={'TOKEN': 'secret'}, user_global={'USER_GLOBAL': 'ug'}, + team_web={'PROJECT_ONLY': 'p'}, user_web={'USER_PROJECT': 'up'})) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values == { + 'TOKEN': 'secret', 'USER_GLOBAL': 'ug', + 'PROJECT_ONLY': 'p', 'USER_PROJECT': 'up', + } + assert set(resolved.global_names) == {'TOKEN', 'USER_GLOBAL'} + assert set(resolved.project_names) == {'PROJECT_ONLY', 'USER_PROJECT'} + assert set(resolved.names) == {'TOKEN', 'USER_GLOBAL', 'PROJECT_ONLY', 'USER_PROJECT'} + + def test_file_backends_resolve_exactly_as_before(root, store): """個人単位の参照を持たない backend では、結果が 2 層のときと同じ""" store.age.save(GLOBAL, {'TOKEN': 'global', 'ONLY_GLOBAL': 'g'}) diff --git a/tests/env/test_runtime_store.py b/tests/env/test_runtime_store.py new file mode 100644 index 00000000..93a7c3f2 --- /dev/null +++ b/tests/env/test_runtime_store.py @@ -0,0 +1,102 @@ +"""runtime.store_for / release_store: 1 回のライフサイクル操作の間 SecretStore を持ち回る (PLAN55) + +設計「`store_for` の規則」の表を固定する。控えを持ち回る目的はサーバ backend の往復を +減らすことで、同じインスタンスなら 2 度目の解決は `_seen` から返る。 +""" + +from __future__ import annotations + +import pytest + +from devbase.env import runtime +from devbase.env.secret_store import SecretRef, SecretStore + +TEAM_GLOBAL_PATH = 'team/global' +USER_GLOBAL_PATH = 'users/member01/global' + + +@pytest.fixture(autouse=True) +def _clean_store(): + runtime.release_store() + yield + runtime.release_store() + + +def test_store_for_returns_the_same_instance_for_the_same_root(tmp_path): + first = runtime.store_for(tmp_path) + assert isinstance(first, SecretStore) + assert runtime.store_for(tmp_path) is first + + +def test_store_for_rebuilds_when_the_root_changes(tmp_path): + first = runtime.store_for(tmp_path / 'a') + second = runtime.store_for(tmp_path / 'b') + assert second is not first + assert second.root == tmp_path / 'b' + + +def test_release_store_makes_the_next_call_rebuild(tmp_path): + first = runtime.store_for(tmp_path) + runtime.release_store() + assert runtime.store_for(tmp_path) is not first + + +def test_release_store_without_a_store_is_a_no_op(): + runtime.release_store() + runtime.release_store() + + +def test_explicit_store_is_not_retained(tmp_path): + """明示的に渡した SecretStore (移行など設定と違う backend) は控えに入れない""" + explicit = SecretStore(tmp_path) + runtime.resolve(tmp_path, None, store=explicit) + assert runtime.store_for(tmp_path) is not explicit + + +def test_resolve_twice_reuses_the_store_and_does_not_refetch(openbao_root, openbao): + """同じ操作の中で 2 度解決しても、サーバへは参照ごとに 1 回しか行かない""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + + runtime.resolve(openbao_root, None) + runtime.resolve(openbao_root, None) + + assert openbao.logins == 1 + assert sorted(r.kv_path for r in openbao.requests_of('GET')) == sorted([ + TEAM_GLOBAL_PATH, USER_GLOBAL_PATH]) + + +def test_release_then_resolve_reads_the_server_again(openbao_root, openbao): + """捨てた後の解決は現物を読む (決定 5 の前提)""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + runtime.resolve(openbao_root, None) + openbao.put(TEAM_GLOBAL_PATH, {'A': '2'}) + + assert runtime.resolve(openbao_root, None).values['A'] == '1' + runtime.release_store() + assert runtime.resolve(openbao_root, None).values['A'] == '2' + assert openbao.logins == 2 + + +def test_inject_and_child_env_share_the_store(openbao_root, openbao, monkeypatch): + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + environ: dict = {} + + runtime.inject(openbao_root, None, environ=environ) + env = runtime.child_env(openbao_root, None, base={}) + + assert environ['A'] == '1' and env['A'] == '1' + assert openbao.logins == 1 + assert len(openbao.requests_of('GET')) == 2 + + +def test_store_for_exists_is_served_from_seen(openbao_root, openbao): + """resolve の後の exists は _seen から返り、GET を足さない (受け入れ条件 4 の土台)""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + runtime.resolve(openbao_root, 'web') + before = len(openbao.requests_of('GET')) + + store = runtime.store_for(openbao_root) + assert store.exists(SecretRef.for_global()) is True + assert store.exists(SecretRef.for_project('web')) is False + + assert len(openbao.requests_of('GET')) == before