From 038d0d8ff9d50a3b35de2851e1952ff82f6aa019 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 18:48:29 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20devbase=20up=20=E3=81=AE=E6=A9=9F?= =?UTF-8?q?=E5=AF=86=E3=81=AE=E6=B3=A8=E5=85=A5=E3=81=A7=20SecretStore=20?= =?UTF-8?q?=E3=82=92=E6=8C=81=E3=81=A1=E5=9B=9E=E3=82=8A=E3=80=81=E3=82=B5?= =?UTF-8?q?=E3=83=BC=E3=83=90=20backend=20=E3=81=AE=E5=BE=80=E5=BE=A9?= =?UTF-8?q?=E3=82=92=E8=AA=8D=E8=A8=BC=201=20=E5=9B=9E=20+=20=E5=8F=82?= =?UTF-8?q?=E7=85=A7=E3=81=94=E3=81=A8=E3=81=AB=201=20=E5=9B=9E=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(PLAN55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runtime.store_for / release_store を足し、resolve / inject / child_env と _ensure_env_files が同じ SecretStore を使う。捨てる契機は _dispatch_lifecycle の finally、TUI の委譲の入口、env init の子プロセスから戻った直後の 3 つ。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S9okWVz1S7VGUsCQWVMhb3 --- issues/PLAN55_up-single-injection.md | 70 ++++++++++ lib/devbase/commands/container.py | 19 ++- lib/devbase/env/runtime.py | 38 +++++- lib/devbase/tui/dispatch.py | 6 + tests/cli/test_up_roundtrips.py | 190 +++++++++++++++++++++++++++ tests/cli/tui/test_dispatch.py | 91 +++++++++++++ tests/conftest.py | 10 ++ tests/env/test_runtime_store.py | 102 ++++++++++++++ 8 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 tests/cli/test_up_roundtrips.py create mode 100644 tests/env/test_runtime_store.py 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..2b3abb3f 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -563,6 +563,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: @@ -1302,10 +1307,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 +1352,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...") 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_up_roundtrips.py b/tests/cli/test_up_roundtrips.py new file mode 100644 index 00000000..b3eaa31c --- /dev/null +++ b/tests/cli/test_up_roundtrips.py @@ -0,0 +1,190 @@ +"""`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')) + + +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_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 From dd86e7688d33dba7960605b462428911482ae3d1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:02:22 +0900 Subject: [PATCH 02/14] Test: characterize secret runtime error and early-return paths Add characterization coverage for stale secret caches after project dispatch errors, invalid UTF-8 project env files, missing DEVBASE_ROOT, and store release after project name resolution failure. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- tests/cli/test_project_dispatch.py | 14 ++++++++++++++ tests/cli/test_secret_injection.py | 16 ++++++++++++++++ tests/cli/test_up_roundtrips.py | 29 +++++++++++++++++++++++++++++ tests/env/test_runtime.py | 19 +++++++++++++++++++ 4 files changed, 78 insertions(+) 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..8ef87988 100644 --- a/tests/cli/test_secret_injection.py +++ b/tests/cli/test_secret_injection.py @@ -58,3 +58,19 @@ 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 == [] diff --git a/tests/cli/test_up_roundtrips.py b/tests/cli/test_up_roundtrips.py index b3eaa31c..f1ef7bd6 100644 --- a/tests/cli/test_up_roundtrips.py +++ b/tests/cli/test_up_roundtrips.py @@ -107,6 +107,35 @@ 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') 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'}) From 54a77e64a463eda7c64db81b57d7bf66e0e1b920 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:10:12 +0900 Subject: [PATCH 03/14] Test: characterize _inject_secrets error handling branch Add characterization test verifying that DevbaseError is swallowed and returns an empty SecretEnv when required=False, while DevbaseError is re-raised when required=True. Item-Id: R1-004 Round: 1 Impl-Runtime: agy Impl-Model: default --- tests/cli/test_secret_injection.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/cli/test_secret_injection.py b/tests/cli/test_secret_injection.py index 8ef87988..5564b1d7 100644 --- a/tests/cli/test_secret_injection.py +++ b/tests/cli/test_secret_injection.py @@ -74,3 +74,30 @@ def test_container_injection_without_root_returns_empty_secrets(monkeypatch, req 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) + From fe573e2a59cd4bce1aa04a6984242c7928ec604a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:21:53 +0900 Subject: [PATCH 04/14] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/commands/container.py#=5Fresolve=5Fproject=5Fnam?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project 切替時に呼び出し元固有の env キーを unset する段階を _unset_caller_only_env_keys(caller_keys, target_dir) として抽出した。 _resolve_project_name は 解決 → chdir/PWD 差し替え → 抽出関数呼び出し → env 反映 の順に整理され、クリーンアップ処理が独立した名前を持つ。 振る舞いは不変 (tests/cli/test_project_name_resolution.py を含む全テストが通過)。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default --- lib/devbase/commands/container.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 2b3abb3f..50e1a7fc 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -431,6 +431,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 +486,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 +499,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 経由なら既に同じ値が載っているため冪等。 From 7add6cffd701abda36c58fb0d78905dca8839469 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:27:27 +0900 Subject: [PATCH 05/14] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/commands/container.py#=5Fensure=5Fimage?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ensure_images 内で docker compose config を実行し JSON を解釈して dev サービス定義を取り出す処理を、同ファイルの _resolve_dev_service() の 呼び出しに置き換える。取得できない (None) ときは従来どおり _run_build() へフォールバックする。 現状固定テストとして _ensure_images の入口を subprocess.run の応答だけで 固定する 10 件を tests/cli/test_base_image_staleness.py に追加した。 Item-Id: R2-002 Round: 2 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 17 +--- tests/cli/test_base_image_staleness.py | 136 +++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 14 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 50e1a7fc..7a905ebe 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1436,29 +1436,18 @@ def _ensure_images() -> bool: logger.warning("compose.yml not found, skipping image check") return True - 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: + dev_service = _resolve_dev_service() + if dev_service is None: 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')) if not image_name: - logger.warning("No image specified for %s service", dev_service_name) + logger.warning("No image specified for %s service", get_dev_service_name()) return True inspect = subprocess.run( diff --git a/tests/cli/test_base_image_staleness.py b/tests/cli/test_base_image_staleness.py index c99a941d..4f940ef8 100644 --- a/tests/cli/test_base_image_staleness.py +++ b/tests/cli/test_base_image_staleness.py @@ -199,3 +199,139 @@ class _R: ) assert container._build_resolved(expires=7, no_cache=False) == 0 assert seen == {"expires": 7, "image": "dev:latest"} + + +# --------------------------------------------------------------------------- +# _ensure_images: devbase up の入口 (現状固定) +# --------------------------------------------------------------------------- + +def _stub_docker(monkeypatch, *, config_rc=0, config_out="", inspect_rc=0, inspect_out=""): + """docker compose config / docker image inspect の応答を固定する。""" + + class _R: + def __init__(self, rc, out): + self.returncode = rc + self.stdout = out + + def _run(cmd, **_k): + if cmd[:3] == ["docker", "compose", "config"]: + return _R(config_rc, config_out) + if cmd[:3] == ["docker", "image", "inspect"]: + return _R(inspect_rc, inspect_out) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr(container.subprocess, "run", _run) + + +def _compose_json(dev_service: dict) -> str: + return json.dumps({"services": {"dev": dev_service}}) + + +def test_ensure_images_missing_compose_is_noop(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(container, "_run_build", lambda **k: (_ for _ in ()).throw(AssertionError)) + assert container._ensure_images() is True + + +def test_ensure_images_config_failure_falls_back_to_build(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + _stub_docker(monkeypatch, config_rc=1) + captured = {} + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + assert container._ensure_images() is True + assert captured == {} + + +def test_ensure_images_config_invalid_json_falls_back_to_build(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + _stub_docker(monkeypatch, config_out="not json") + captured = {} + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + assert container._ensure_images() is True + assert captured == {} + + +def test_ensure_images_no_image_returns_true_without_build(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + _stub_docker(monkeypatch, config_out=_compose_json({"build": "."})) + monkeypatch.setattr(container, "_run_build", lambda **k: (_ for _ in ()).throw(AssertionError)) + assert container._ensure_images() is True + + +def test_ensure_images_missing_service_returns_true_without_build(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + _stub_docker(monkeypatch, config_out=json.dumps({"services": {}})) + monkeypatch.setattr(container, "_run_build", lambda **k: (_ for _ in ()).throw(AssertionError)) + assert container._ensure_images() is True + + +def test_ensure_images_missing_image_with_build_runs_build(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + _stub_docker( + monkeypatch, + config_out=_compose_json({"image": "dev:latest", "build": "."}), + inspect_rc=1, + ) + captured = {} + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + assert container._ensure_images() is True + assert captured == {} + + +def test_ensure_images_missing_image_without_build_pulls(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + _stub_docker( + monkeypatch, + config_out=_compose_json({"image": "dev:latest"}), + inspect_rc=1, + ) + pulled = [] + monkeypatch.setattr(container, "_pull_and_mark", lambda image: pulled.append(image) or True) + assert container._ensure_images() is True + assert pulled == ["dev:latest"] + + +def test_ensure_images_present_image_without_build_repulls_if_stale(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + _stub_docker( + monkeypatch, + config_out=_compose_json({"image": "dev:latest"}), + inspect_out=_inspect_json(1), + ) + seen = [] + monkeypatch.setattr(container, "_repull_if_stale", lambda image: seen.append(image) or True) + assert container._ensure_images() is True + assert seen == ["dev:latest"] + + +def test_ensure_images_present_image_with_build_delegates_to_expires(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + dev = {"image": "dev:latest", "build": "."} + _stub_docker( + monkeypatch, + config_out=_compose_json(dev), + inspect_out=_inspect_json(10), + ) + monkeypatch.setattr(container, "_image_max_age_days", lambda: 7) + seen = {} + monkeypatch.setattr( + container, "_build_with_expires", + lambda expires, image, inspect_json, svc: seen.update( + expires=expires, image=image, svc=svc + ) or True, + ) + assert container._ensure_images() is True + assert seen == {"expires": 7, "image": "dev:latest", "svc": dev} + + +def test_ensure_images_unexpected_error_falls_back_to_build(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + + def _run(cmd, **_k): + raise OSError("docker not found") + + monkeypatch.setattr(container.subprocess, "run", _run) + captured = {} + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + assert container._ensure_images() is True + assert captured == {} From 7352692c5cb88ccfcef322eb34a641f07e605d8f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:27:55 +0900 Subject: [PATCH 06/14] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20lib/devbase/commands/container.py#=5Fensur?= =?UTF-8?q?e=5Fimages"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7add6cffd701abda36c58fb0d78905dca8839469. --- lib/devbase/commands/container.py | 17 +++- tests/cli/test_base_image_staleness.py | 136 ------------------------- 2 files changed, 14 insertions(+), 139 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 7a905ebe..50e1a7fc 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1436,18 +1436,29 @@ def _ensure_images() -> bool: logger.warning("compose.yml not found, skipping image check") return True + dev_service_name = get_dev_service_name() + try: - dev_service = _resolve_dev_service() - if dev_service is None: + result = subprocess.run( + ['docker', 'compose', 'config', '--format', 'json'], + capture_output=True, + text=True, + check=False + ) + + if result.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')) if not image_name: - logger.warning("No image specified for %s service", get_dev_service_name()) + logger.warning("No image specified for %s service", dev_service_name) return True inspect = subprocess.run( diff --git a/tests/cli/test_base_image_staleness.py b/tests/cli/test_base_image_staleness.py index 4f940ef8..c99a941d 100644 --- a/tests/cli/test_base_image_staleness.py +++ b/tests/cli/test_base_image_staleness.py @@ -199,139 +199,3 @@ class _R: ) assert container._build_resolved(expires=7, no_cache=False) == 0 assert seen == {"expires": 7, "image": "dev:latest"} - - -# --------------------------------------------------------------------------- -# _ensure_images: devbase up の入口 (現状固定) -# --------------------------------------------------------------------------- - -def _stub_docker(monkeypatch, *, config_rc=0, config_out="", inspect_rc=0, inspect_out=""): - """docker compose config / docker image inspect の応答を固定する。""" - - class _R: - def __init__(self, rc, out): - self.returncode = rc - self.stdout = out - - def _run(cmd, **_k): - if cmd[:3] == ["docker", "compose", "config"]: - return _R(config_rc, config_out) - if cmd[:3] == ["docker", "image", "inspect"]: - return _R(inspect_rc, inspect_out) - raise AssertionError(f"unexpected command: {cmd}") - - monkeypatch.setattr(container.subprocess, "run", _run) - - -def _compose_json(dev_service: dict) -> str: - return json.dumps({"services": {"dev": dev_service}}) - - -def test_ensure_images_missing_compose_is_noop(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(container, "_run_build", lambda **k: (_ for _ in ()).throw(AssertionError)) - assert container._ensure_images() is True - - -def test_ensure_images_config_failure_falls_back_to_build(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - _stub_docker(monkeypatch, config_rc=1) - captured = {} - monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) - assert container._ensure_images() is True - assert captured == {} - - -def test_ensure_images_config_invalid_json_falls_back_to_build(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - _stub_docker(monkeypatch, config_out="not json") - captured = {} - monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) - assert container._ensure_images() is True - assert captured == {} - - -def test_ensure_images_no_image_returns_true_without_build(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - _stub_docker(monkeypatch, config_out=_compose_json({"build": "."})) - monkeypatch.setattr(container, "_run_build", lambda **k: (_ for _ in ()).throw(AssertionError)) - assert container._ensure_images() is True - - -def test_ensure_images_missing_service_returns_true_without_build(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - _stub_docker(monkeypatch, config_out=json.dumps({"services": {}})) - monkeypatch.setattr(container, "_run_build", lambda **k: (_ for _ in ()).throw(AssertionError)) - assert container._ensure_images() is True - - -def test_ensure_images_missing_image_with_build_runs_build(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - _stub_docker( - monkeypatch, - config_out=_compose_json({"image": "dev:latest", "build": "."}), - inspect_rc=1, - ) - captured = {} - monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) - assert container._ensure_images() is True - assert captured == {} - - -def test_ensure_images_missing_image_without_build_pulls(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - _stub_docker( - monkeypatch, - config_out=_compose_json({"image": "dev:latest"}), - inspect_rc=1, - ) - pulled = [] - monkeypatch.setattr(container, "_pull_and_mark", lambda image: pulled.append(image) or True) - assert container._ensure_images() is True - assert pulled == ["dev:latest"] - - -def test_ensure_images_present_image_without_build_repulls_if_stale(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - _stub_docker( - monkeypatch, - config_out=_compose_json({"image": "dev:latest"}), - inspect_out=_inspect_json(1), - ) - seen = [] - monkeypatch.setattr(container, "_repull_if_stale", lambda image: seen.append(image) or True) - assert container._ensure_images() is True - assert seen == ["dev:latest"] - - -def test_ensure_images_present_image_with_build_delegates_to_expires(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - dev = {"image": "dev:latest", "build": "."} - _stub_docker( - monkeypatch, - config_out=_compose_json(dev), - inspect_out=_inspect_json(10), - ) - monkeypatch.setattr(container, "_image_max_age_days", lambda: 7) - seen = {} - monkeypatch.setattr( - container, "_build_with_expires", - lambda expires, image, inspect_json, svc: seen.update( - expires=expires, image=image, svc=svc - ) or True, - ) - assert container._ensure_images() is True - assert seen == {"expires": 7, "image": "dev:latest", "svc": dev} - - -def test_ensure_images_unexpected_error_falls_back_to_build(tmp_path, monkeypatch): - _make_compose(tmp_path, monkeypatch) - - def _run(cmd, **_k): - raise OSError("docker not found") - - monkeypatch.setattr(container.subprocess, "run", _run) - captured = {} - monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) - assert container._ensure_images() is True - assert captured == {} From 66813cee67b7fb310c7fdbbb488349bc876b6818 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:30:53 +0900 Subject: [PATCH 07/14] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/commands/container.py#=5Fensure=5Fimages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract Compose services reading and dev image specification while preserving image checks and fallback behavior. Item-Id: R2-003 Round: 2 Impl-Runtime: codex Impl-Model: default --- lib/devbase/commands/container.py | 38 ++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 50e1a7fc..f0cebcf2 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1410,6 +1410,28 @@ def _image_max_age_days() -> int: return _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: """Check that required container images exist and are fresh. @@ -1439,23 +1461,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) From 30065e38272c349b4862fd9acc0df423ad51e131 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:37:55 +0900 Subject: [PATCH 08/14] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/commands/container.py#cmd=5Fscale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_scale 内のボリューム・ネットワーク確認、compose 生成、起動、待機処理を _run_scale_pipeline として抽出し、cmd_scale を引数検証・設定更新・パイプライン呼び出し・後続処理に整理。 Item-Id: R2-004 Round: 2 Impl-Runtime: agy Impl-Model: default --- lib/devbase/commands/container.py | 74 ++++++++------- tests/cli/test_container_scale.py | 148 ++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 31 deletions(-) create mode 100644 tests/cli/test_container_scale.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index f0cebcf2..174800a3 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1041,6 +1041,45 @@ def cmd_logs(follow: bool = False, tail: Optional[int] = None, # cmd_scale # --------------------------------------------------------------------------- +def _run_scale_pipeline(project_name: str, current_scale: int, new_scale: int, + config, target: docker_context.DockerTarget, + dev_service_name: str) -> Optional[Path]: + """[2/5]〜[5/5] のスケール本体 (volume/network/compose 生成・up・wait)。""" + logger.info("[2/5] Ensuring volumes exist for scale=%d...", new_scale) + ensure_volumes(new_scale, project_name) + + logger.info("[2.5/5] Ensuring network exists...") + 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)) + logger.info("Generated: %s", override_file) + + logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) + logger.info("Using --no-recreate to avoid restarting existing containers...") + + result = subprocess.run( + ['docker', 'compose', '-f', str(override_file), 'up', '-d', '--no-recreate'], + check=False + ) + + if result.returncode != 0: + logger.error("Failed to start new containers") + return None + + logger.info("[5/5] Waiting for new containers to be ready...") + wait_for_containers_ready( + container_prefix=dev_service_name, + scale=new_scale, + compose_file=override_file, + timeout=60 + ) + return override_file + + def cmd_scale(new_scale: int, project_name: str = None, context: Optional[str] = None) -> int: """Scale containers online without restarting existing ones""" @@ -1074,39 +1113,12 @@ def cmd_scale(new_scale: int, project_name: str = None, project_runtime.PROJECT_CONFIG_FILENAME, current_scale, new_scale) project_runtime.write_scale(Path.cwd(), new_scale) - logger.info("[2/5] Ensuring volumes exist for scale=%d...", new_scale) - ensure_volumes(new_scale, project_name) - - logger.info("[2.5/5] Ensuring network exists...") - 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)) - logger.info("Generated: %s", override_file) - - logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) - logger.info("Using --no-recreate to avoid restarting existing containers...") - - result = subprocess.run( - ['docker', 'compose', '-f', str(override_file), 'up', '-d', '--no-recreate'], - check=False - ) - - if result.returncode != 0: - logger.error("Failed to start new containers") + override_file = _run_scale_pipeline( + project_name, current_scale, new_scale, config, target, + dev_service_name) + if override_file is None: return 1 - logger.info("[5/5] Waiting for new containers to be ready...") - wait_for_containers_ready( - container_prefix=dev_service_name, - scale=new_scale, - compose_file=override_file, - timeout=60 - ) - # Run project-specific deploy script for newly added instances deploy_script = Path('./deploy') if deploy_script.exists() and deploy_script.is_file(): diff --git a/tests/cli/test_container_scale.py b/tests/cli/test_container_scale.py new file mode 100644 index 00000000..f9fdd93c --- /dev/null +++ b/tests/cli/test_container_scale.py @@ -0,0 +1,148 @@ +"""現状固定テスト: cmd_scale の振る舞い。 + +long_method リファクタリング (R2-004) において、 +引数検証、設定更新、パイプライン実行、後続処理が振る舞いを変えずに +動作することを固定する。 +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from devbase.commands import container +from devbase.errors import DevbaseError +from devbase.project import runtime as project_runtime +from devbase.utils import docker_context + + +PROJECT_YML = "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + + +def test_scale_rejects_non_positive_scale(monkeypatch, tmp_path): + """現状固定: 1 未満のスケールはエラーとなり 1 を返す。""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'project.yml').write_text(PROJECT_YML) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + + assert container.cmd_scale(0) == 1 + assert container.cmd_scale(-1) == 1 + + +def test_scale_rejects_scale_down_or_equal(monkeypatch, tmp_path): + """現状固定: 現在値以下のスケールは警告を出して 1 を返す。""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'project.yml').write_text( + "version: 1\nscale: 2\nrepos:\n - owner: volareinc\n repo: carmo\n") + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + + assert container.cmd_scale(2) == 1 + assert container.cmd_scale(1) == 1 + + +def test_scale_target_resolution_failure(monkeypatch, tmp_path): + """現状固定: docker context 解決に失敗したときはエラーとなり 1 を返す。""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'project.yml').write_text(PROJECT_YML) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + + def _fail_resolve(_): + raise DevbaseError("Target resolution failed") + + monkeypatch.setattr(container, '_resolve_docker_target', _fail_resolve) + assert container.cmd_scale(2) == 1 + + +def test_scale_happy_path(monkeypatch, tmp_path): + """現状固定: 正常系で project.yml の更新、パイプラインの各ステップ実行、成功ログまで完了する。""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'project.yml').write_text(PROJECT_YML) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + + calls = [] + monkeypatch.setattr(container, 'get_project_name', lambda: 'myproj') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'mydev') + monkeypatch.setattr(container, '_resolve_docker_target', + lambda ctx: docker_context.DockerTarget( + context=None, source='default', remote=False, home=None, gid=None)) + monkeypatch.setattr(container, 'ensure_volumes', lambda scale, proj: calls.append(('volumes', scale, proj))) + monkeypatch.setattr(container, 'ensure_network', lambda net: calls.append(('network', net))) + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) + + dummy_compose = tmp_path / 'dummy-compose.yml' + dummy_compose.write_text("services: {}") + monkeypatch.setattr(container, '_generate_compose_for', lambda *a, **k: dummy_compose) + monkeypatch.setattr(container.subprocess, 'run', + lambda cmd, **k: calls.append(('run', cmd)) or subprocess.CompletedProcess(cmd, 0)) + monkeypatch.setattr(container, 'wait_for_containers_ready', lambda **k: calls.append(('wait', k))) + + ret = container.cmd_scale(3) + assert ret == 0 + + # project.yml が更新されていること + cfg = project_runtime.current_project_config() + assert cfg.scale == 3 + + assert ('volumes', 3, 'myproj') in calls + assert ('network', 'devbase_net') in calls + assert any(c[0] == 'run' and 'up' in c[1] and '--no-recreate' in c[1] for c in calls) + assert any(c[0] == 'wait' and c[1]['scale'] == 3 for c in calls) + + +def test_scale_pipeline_docker_up_fails(monkeypatch, tmp_path): + """現状固定: docker compose up が失敗した場合は 1 を返す。""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'project.yml').write_text(PROJECT_YML) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + + monkeypatch.setattr(container, 'get_project_name', lambda: 'myproj') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'mydev') + monkeypatch.setattr(container, '_resolve_docker_target', + lambda ctx: docker_context.DockerTarget( + context=None, source='default', remote=False, home=None, gid=None)) + monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) + monkeypatch.setattr(container, 'ensure_network', lambda *a, **k: None) + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) + dummy_compose = tmp_path / 'dummy-compose.yml' + dummy_compose.write_text("services: {}") + monkeypatch.setattr(container, '_generate_compose_for', lambda *a, **k: dummy_compose) + monkeypatch.setattr(container.subprocess, 'run', + lambda cmd, **k: subprocess.CompletedProcess(cmd, 1)) + + ret = container.cmd_scale(2) + assert ret == 1 + + +def test_scale_runs_deploy_script_for_new_instances(monkeypatch, tmp_path): + """現状固定: ./deploy が存在する場合、追加されたインスタンスに対して実行される。""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'project.yml').write_text(PROJECT_YML) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + + deploy_file = tmp_path / 'deploy' + deploy_file.write_text("#!/bin/sh\n") + + deploy_calls = [] + monkeypatch.setattr(container, 'get_project_name', lambda: 'myproj') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'mydev') + monkeypatch.setattr(container, '_resolve_docker_target', + lambda ctx: docker_context.DockerTarget( + context=None, source='default', remote=False, home=None, gid=None)) + monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) + monkeypatch.setattr(container, 'ensure_network', lambda *a, **k: None) + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) + dummy_compose = tmp_path / 'dummy-compose.yml' + dummy_compose.write_text("services: {}") + monkeypatch.setattr(container, '_generate_compose_for', lambda *a, **k: dummy_compose) + monkeypatch.setattr(container.subprocess, 'run', + lambda cmd, **k: subprocess.CompletedProcess(cmd, 0)) + monkeypatch.setattr(container, 'wait_for_containers_ready', lambda **k: None) + monkeypatch.setattr(container, '_run_deploy_script_for_instances', + lambda script, instances, cfg: deploy_calls.append((script, list(instances)))) + + ret = container.cmd_scale(3) + assert ret == 0 + assert len(deploy_calls) == 1 + assert deploy_calls[0][1] == [2, 3] From 7961b33a2c5ec8f17e47e451c20a68443d7e6489 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:39:18 +0900 Subject: [PATCH 09/14] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/commands/container.py#cmd=5Fscale"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 30065e38272c349b4862fd9acc0df423ad51e131. --- lib/devbase/commands/container.py | 74 +++++++-------- tests/cli/test_container_scale.py | 148 ------------------------------ 2 files changed, 31 insertions(+), 191 deletions(-) delete mode 100644 tests/cli/test_container_scale.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 174800a3..f0cebcf2 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1041,45 +1041,6 @@ def cmd_logs(follow: bool = False, tail: Optional[int] = None, # cmd_scale # --------------------------------------------------------------------------- -def _run_scale_pipeline(project_name: str, current_scale: int, new_scale: int, - config, target: docker_context.DockerTarget, - dev_service_name: str) -> Optional[Path]: - """[2/5]〜[5/5] のスケール本体 (volume/network/compose 生成・up・wait)。""" - logger.info("[2/5] Ensuring volumes exist for scale=%d...", new_scale) - ensure_volumes(new_scale, project_name) - - logger.info("[2.5/5] Ensuring network exists...") - 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)) - logger.info("Generated: %s", override_file) - - logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) - logger.info("Using --no-recreate to avoid restarting existing containers...") - - result = subprocess.run( - ['docker', 'compose', '-f', str(override_file), 'up', '-d', '--no-recreate'], - check=False - ) - - if result.returncode != 0: - logger.error("Failed to start new containers") - return None - - logger.info("[5/5] Waiting for new containers to be ready...") - wait_for_containers_ready( - container_prefix=dev_service_name, - scale=new_scale, - compose_file=override_file, - timeout=60 - ) - return override_file - - def cmd_scale(new_scale: int, project_name: str = None, context: Optional[str] = None) -> int: """Scale containers online without restarting existing ones""" @@ -1113,12 +1074,39 @@ def cmd_scale(new_scale: int, project_name: str = None, project_runtime.PROJECT_CONFIG_FILENAME, current_scale, new_scale) project_runtime.write_scale(Path.cwd(), new_scale) - override_file = _run_scale_pipeline( - project_name, current_scale, new_scale, config, target, - dev_service_name) - if override_file is None: + logger.info("[2/5] Ensuring volumes exist for scale=%d...", new_scale) + ensure_volumes(new_scale, project_name) + + logger.info("[2.5/5] Ensuring network exists...") + 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)) + logger.info("Generated: %s", override_file) + + logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) + logger.info("Using --no-recreate to avoid restarting existing containers...") + + result = subprocess.run( + ['docker', 'compose', '-f', str(override_file), 'up', '-d', '--no-recreate'], + check=False + ) + + if result.returncode != 0: + logger.error("Failed to start new containers") return 1 + logger.info("[5/5] Waiting for new containers to be ready...") + wait_for_containers_ready( + container_prefix=dev_service_name, + scale=new_scale, + compose_file=override_file, + timeout=60 + ) + # Run project-specific deploy script for newly added instances deploy_script = Path('./deploy') if deploy_script.exists() and deploy_script.is_file(): diff --git a/tests/cli/test_container_scale.py b/tests/cli/test_container_scale.py deleted file mode 100644 index f9fdd93c..00000000 --- a/tests/cli/test_container_scale.py +++ /dev/null @@ -1,148 +0,0 @@ -"""現状固定テスト: cmd_scale の振る舞い。 - -long_method リファクタリング (R2-004) において、 -引数検証、設定更新、パイプライン実行、後続処理が振る舞いを変えずに -動作することを固定する。 -""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - -import pytest - -from devbase.commands import container -from devbase.errors import DevbaseError -from devbase.project import runtime as project_runtime -from devbase.utils import docker_context - - -PROJECT_YML = "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" - - -def test_scale_rejects_non_positive_scale(monkeypatch, tmp_path): - """現状固定: 1 未満のスケールはエラーとなり 1 を返す。""" - monkeypatch.chdir(tmp_path) - (tmp_path / 'project.yml').write_text(PROJECT_YML) - monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) - - assert container.cmd_scale(0) == 1 - assert container.cmd_scale(-1) == 1 - - -def test_scale_rejects_scale_down_or_equal(monkeypatch, tmp_path): - """現状固定: 現在値以下のスケールは警告を出して 1 を返す。""" - monkeypatch.chdir(tmp_path) - (tmp_path / 'project.yml').write_text( - "version: 1\nscale: 2\nrepos:\n - owner: volareinc\n repo: carmo\n") - monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) - - assert container.cmd_scale(2) == 1 - assert container.cmd_scale(1) == 1 - - -def test_scale_target_resolution_failure(monkeypatch, tmp_path): - """現状固定: docker context 解決に失敗したときはエラーとなり 1 を返す。""" - monkeypatch.chdir(tmp_path) - (tmp_path / 'project.yml').write_text(PROJECT_YML) - monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) - - def _fail_resolve(_): - raise DevbaseError("Target resolution failed") - - monkeypatch.setattr(container, '_resolve_docker_target', _fail_resolve) - assert container.cmd_scale(2) == 1 - - -def test_scale_happy_path(monkeypatch, tmp_path): - """現状固定: 正常系で project.yml の更新、パイプラインの各ステップ実行、成功ログまで完了する。""" - monkeypatch.chdir(tmp_path) - (tmp_path / 'project.yml').write_text(PROJECT_YML) - monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) - - calls = [] - monkeypatch.setattr(container, 'get_project_name', lambda: 'myproj') - monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'mydev') - monkeypatch.setattr(container, '_resolve_docker_target', - lambda ctx: docker_context.DockerTarget( - context=None, source='default', remote=False, home=None, gid=None)) - monkeypatch.setattr(container, 'ensure_volumes', lambda scale, proj: calls.append(('volumes', scale, proj))) - monkeypatch.setattr(container, 'ensure_network', lambda net: calls.append(('network', net))) - monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) - - dummy_compose = tmp_path / 'dummy-compose.yml' - dummy_compose.write_text("services: {}") - monkeypatch.setattr(container, '_generate_compose_for', lambda *a, **k: dummy_compose) - monkeypatch.setattr(container.subprocess, 'run', - lambda cmd, **k: calls.append(('run', cmd)) or subprocess.CompletedProcess(cmd, 0)) - monkeypatch.setattr(container, 'wait_for_containers_ready', lambda **k: calls.append(('wait', k))) - - ret = container.cmd_scale(3) - assert ret == 0 - - # project.yml が更新されていること - cfg = project_runtime.current_project_config() - assert cfg.scale == 3 - - assert ('volumes', 3, 'myproj') in calls - assert ('network', 'devbase_net') in calls - assert any(c[0] == 'run' and 'up' in c[1] and '--no-recreate' in c[1] for c in calls) - assert any(c[0] == 'wait' and c[1]['scale'] == 3 for c in calls) - - -def test_scale_pipeline_docker_up_fails(monkeypatch, tmp_path): - """現状固定: docker compose up が失敗した場合は 1 を返す。""" - monkeypatch.chdir(tmp_path) - (tmp_path / 'project.yml').write_text(PROJECT_YML) - monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) - - monkeypatch.setattr(container, 'get_project_name', lambda: 'myproj') - monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'mydev') - monkeypatch.setattr(container, '_resolve_docker_target', - lambda ctx: docker_context.DockerTarget( - context=None, source='default', remote=False, home=None, gid=None)) - monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) - monkeypatch.setattr(container, 'ensure_network', lambda *a, **k: None) - monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) - dummy_compose = tmp_path / 'dummy-compose.yml' - dummy_compose.write_text("services: {}") - monkeypatch.setattr(container, '_generate_compose_for', lambda *a, **k: dummy_compose) - monkeypatch.setattr(container.subprocess, 'run', - lambda cmd, **k: subprocess.CompletedProcess(cmd, 1)) - - ret = container.cmd_scale(2) - assert ret == 1 - - -def test_scale_runs_deploy_script_for_new_instances(monkeypatch, tmp_path): - """現状固定: ./deploy が存在する場合、追加されたインスタンスに対して実行される。""" - monkeypatch.chdir(tmp_path) - (tmp_path / 'project.yml').write_text(PROJECT_YML) - monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) - - deploy_file = tmp_path / 'deploy' - deploy_file.write_text("#!/bin/sh\n") - - deploy_calls = [] - monkeypatch.setattr(container, 'get_project_name', lambda: 'myproj') - monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'mydev') - monkeypatch.setattr(container, '_resolve_docker_target', - lambda ctx: docker_context.DockerTarget( - context=None, source='default', remote=False, home=None, gid=None)) - monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) - monkeypatch.setattr(container, 'ensure_network', lambda *a, **k: None) - monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) - dummy_compose = tmp_path / 'dummy-compose.yml' - dummy_compose.write_text("services: {}") - monkeypatch.setattr(container, '_generate_compose_for', lambda *a, **k: dummy_compose) - monkeypatch.setattr(container.subprocess, 'run', - lambda cmd, **k: subprocess.CompletedProcess(cmd, 0)) - monkeypatch.setattr(container, 'wait_for_containers_ready', lambda **k: None) - monkeypatch.setattr(container, '_run_deploy_script_for_instances', - lambda script, instances, cfg: deploy_calls.append((script, list(instances)))) - - ret = container.cmd_scale(3) - assert ret == 0 - assert len(deploy_calls) == 1 - assert deploy_calls[0][1] == [2, 3] From 61e68f8038abe2709af0b99381dc385f749b4a67 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:42:02 +0900 Subject: [PATCH 10/14] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/commands/container.py#=5Fbuild=5Fresolv?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_resolved 内で 5 回並んでいた `return 0 if else 1` の bool→終了コード 変換を、module ヘルパ `_exit_code(ok: bool) -> int` に集約。委譲行 (_build_with_expires) も同型のため同じヘルパへ統一した。振る舞いは不変。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: default --- lib/devbase/commands/container.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index f0cebcf2..f1eb1b65 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -40,6 +40,11 @@ # 共通ヘルパー # --------------------------------------------------------------------------- +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 @@ -1262,18 +1267,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 @@ -1281,8 +1286,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: From 3ad3fb94a7715b358d9d6f464eb00802c6ea0f33 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:53:26 +0900 Subject: [PATCH 11/14] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/commands/container.py#=5Fread=5Fcompose?= =?UTF-8?q?=5Fservices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resolve_dev_service が独自に `docker compose config --format json` を実行して JSON を読んでいたのを、_read_compose_services の呼び出しへ寄せる。compose config の呼び方は 1 箇所になり、_resolve_dev_service は returncode!=0 / JSONDecodeError で None、成功時に services.get(dev_service_name, {}) を返す薄い包みになる。 JSONDecodeError の握りは _resolve_dev_service 側に残す。共通関数側で握って (rc, {}) を返すと _ensure_images の不正 JSON 経路 (except → _run_build) が 「イメージ未指定 → True」へ変わるため。 両関数の本体はテストで通っていなかったので、書き換え前に現状固定テストを tests/cli/test_rebuild.py へ追加した (失敗時 None / (rc,{}) / 不正 JSON の伝播)。 Item-Id: R3-001 Round: 3 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 12 ++---- tests/cli/test_rebuild.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index f1eb1b65..ef1fdef1 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1239,17 +1239,13 @@ def cmd_build(image: Optional[str] = None, no_cache: bool = False, def _resolve_dev_service() -> Optional[dict]: """compose config から dev サービス定義を取得する。失敗時は None。""" - result = subprocess.run( - ['docker', 'compose', 'config', '--format', 'json'], - capture_output=True, text=True, check=False - ) - if result.returncode != 0: - return None try: - config = json.loads(result.stdout) + returncode, services = _read_compose_services() except json.JSONDecodeError: return None - return config.get('services', {}).get(get_dev_service_name(), {}) + if returncode != 0: + return None + return services.get(get_dev_service_name(), {}) def _build_resolved(expires: Optional[int], no_cache: bool) -> int: diff --git a/tests/cli/test_rebuild.py b/tests/cli/test_rebuild.py index f2c879cb..a5b9821e 100644 --- a/tests/cli/test_rebuild.py +++ b/tests/cli/test_rebuild.py @@ -119,6 +119,78 @@ def test_cmd_rebuild_propagates_returncode(monkeypatch): assert container.cmd_rebuild() == 2 +# --------------------------------------------------------------------------- +# compose config の読み取り (現状固定) +# --------------------------------------------------------------------------- + +def _fake_compose_config(monkeypatch, returncode: int, stdout: str): + """`docker compose config --format json` の結果を差し替える。""" + from devbase.commands import container + + class _R: + pass + + _R.returncode = returncode + _R.stdout = stdout + calls = [] + monkeypatch.setattr(container.subprocess, 'run', + lambda cmd, *a, **k: calls.append(cmd) or _R()) + return calls + + +def test_resolve_dev_service_none_when_compose_config_fails(monkeypatch): + from devbase.commands import container + _fake_compose_config(monkeypatch, returncode=1, stdout='') + assert container._resolve_dev_service() is None + + +def test_resolve_dev_service_none_on_invalid_json(monkeypatch): + from devbase.commands import container + _fake_compose_config(monkeypatch, returncode=0, stdout='not json') + assert container._resolve_dev_service() is None + + +def test_resolve_dev_service_returns_dev_service(monkeypatch): + from devbase.commands import container + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + calls = _fake_compose_config( + monkeypatch, returncode=0, + stdout='{"services": {"dev": {"image": "dev:latest"}}}') + assert container._resolve_dev_service() == {'image': 'dev:latest'} + assert calls == [['docker', 'compose', 'config', '--format', 'json']] + + +def test_resolve_dev_service_empty_when_service_missing(monkeypatch): + from devbase.commands import container + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + _fake_compose_config(monkeypatch, returncode=0, stdout='{"services": {}}') + assert container._resolve_dev_service() == {} + + +def test_read_compose_services_returns_rc_and_empty_on_failure(monkeypatch): + from devbase.commands import container + _fake_compose_config(monkeypatch, returncode=3, stdout='') + assert container._read_compose_services() == (3, {}) + + +def test_read_compose_services_returns_services(monkeypatch): + from devbase.commands import container + calls = _fake_compose_config( + monkeypatch, returncode=0, + stdout='{"services": {"dev": {"image": "dev:latest"}}}') + assert container._read_compose_services() == (0, {'dev': {'image': 'dev:latest'}}) + assert calls == [['docker', 'compose', 'config', '--format', 'json']] + + +def test_read_compose_services_raises_on_invalid_json(monkeypatch): + """不正な JSON は握らず伝播する (_ensure_images の except 経路が受ける)。""" + import json as _json + from devbase.commands import container + _fake_compose_config(monkeypatch, returncode=0, stdout='not json') + with pytest.raises(_json.JSONDecodeError): + container._read_compose_services() + + # --------------------------------------------------------------------------- # wrapper routing # --------------------------------------------------------------------------- From 9ceacdcbf1505508754b59b29962ee1a70d3ef56 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:53:58 +0900 Subject: [PATCH 12/14] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20lib/devbase/commands/container.py#=5Fread?= =?UTF-8?q?=5Fcompose=5Fservices"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3ad3fb94a7715b358d9d6f464eb00802c6ea0f33. --- lib/devbase/commands/container.py | 12 ++++-- tests/cli/test_rebuild.py | 72 ------------------------------- 2 files changed, 8 insertions(+), 76 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index ef1fdef1..f1eb1b65 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1239,13 +1239,17 @@ def cmd_build(image: Optional[str] = None, no_cache: bool = False, def _resolve_dev_service() -> Optional[dict]: """compose config から dev サービス定義を取得する。失敗時は None。""" + result = subprocess.run( + ['docker', 'compose', 'config', '--format', 'json'], + capture_output=True, text=True, check=False + ) + if result.returncode != 0: + return None try: - returncode, services = _read_compose_services() + config = json.loads(result.stdout) except json.JSONDecodeError: return None - if returncode != 0: - return None - return services.get(get_dev_service_name(), {}) + return config.get('services', {}).get(get_dev_service_name(), {}) def _build_resolved(expires: Optional[int], no_cache: bool) -> int: diff --git a/tests/cli/test_rebuild.py b/tests/cli/test_rebuild.py index a5b9821e..f2c879cb 100644 --- a/tests/cli/test_rebuild.py +++ b/tests/cli/test_rebuild.py @@ -119,78 +119,6 @@ def test_cmd_rebuild_propagates_returncode(monkeypatch): assert container.cmd_rebuild() == 2 -# --------------------------------------------------------------------------- -# compose config の読み取り (現状固定) -# --------------------------------------------------------------------------- - -def _fake_compose_config(monkeypatch, returncode: int, stdout: str): - """`docker compose config --format json` の結果を差し替える。""" - from devbase.commands import container - - class _R: - pass - - _R.returncode = returncode - _R.stdout = stdout - calls = [] - monkeypatch.setattr(container.subprocess, 'run', - lambda cmd, *a, **k: calls.append(cmd) or _R()) - return calls - - -def test_resolve_dev_service_none_when_compose_config_fails(monkeypatch): - from devbase.commands import container - _fake_compose_config(monkeypatch, returncode=1, stdout='') - assert container._resolve_dev_service() is None - - -def test_resolve_dev_service_none_on_invalid_json(monkeypatch): - from devbase.commands import container - _fake_compose_config(monkeypatch, returncode=0, stdout='not json') - assert container._resolve_dev_service() is None - - -def test_resolve_dev_service_returns_dev_service(monkeypatch): - from devbase.commands import container - monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') - calls = _fake_compose_config( - monkeypatch, returncode=0, - stdout='{"services": {"dev": {"image": "dev:latest"}}}') - assert container._resolve_dev_service() == {'image': 'dev:latest'} - assert calls == [['docker', 'compose', 'config', '--format', 'json']] - - -def test_resolve_dev_service_empty_when_service_missing(monkeypatch): - from devbase.commands import container - monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') - _fake_compose_config(monkeypatch, returncode=0, stdout='{"services": {}}') - assert container._resolve_dev_service() == {} - - -def test_read_compose_services_returns_rc_and_empty_on_failure(monkeypatch): - from devbase.commands import container - _fake_compose_config(monkeypatch, returncode=3, stdout='') - assert container._read_compose_services() == (3, {}) - - -def test_read_compose_services_returns_services(monkeypatch): - from devbase.commands import container - calls = _fake_compose_config( - monkeypatch, returncode=0, - stdout='{"services": {"dev": {"image": "dev:latest"}}}') - assert container._read_compose_services() == (0, {'dev': {'image': 'dev:latest'}}) - assert calls == [['docker', 'compose', 'config', '--format', 'json']] - - -def test_read_compose_services_raises_on_invalid_json(monkeypatch): - """不正な JSON は握らず伝播する (_ensure_images の except 経路が受ける)。""" - import json as _json - from devbase.commands import container - _fake_compose_config(monkeypatch, returncode=0, stdout='not json') - with pytest.raises(_json.JSONDecodeError): - container._read_compose_services() - - # --------------------------------------------------------------------------- # wrapper routing # --------------------------------------------------------------------------- From 82716868378b06f6a85f1995428eb6e4fd302a45 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 19:57:12 +0900 Subject: [PATCH 13/14] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/commands/container.py#cmd=5Flogin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share Compose context and secret preparation and override file argument construction. Preserve login command variants and execution order with CLI regression tests. Item-Id: R3-002 Round: 3 Impl-Runtime: codex Impl-Model: default --- lib/devbase/commands/container.py | 36 ++++++++++++++++++++----------- tests/cli/test_login_command.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 tests/cli/test_login_command.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index f1eb1b65..7cfa68b8 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -213,14 +213,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 @@ -1003,16 +1015,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 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), + ] From 15a6baaeed23d19296844a26fe7117d3faf418e4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 14 Sep 2026 20:07:18 +0900 Subject: [PATCH 14/14] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/commands/container.py#=5Fimage=5Fmax=5F?= =?UTF-8?q?age=5Fdays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate duplicate non-negative integer environment variable parsing in _image_max_age_days and _snapshot_min_interval_minutes into a shared helper _env_non_negative_int. Item-Id: R3-003 Round: 3 Impl-Runtime: agy Impl-Model: default --- lib/devbase/commands/container.py | 58 ++++++++++++----------- tests/cli/test_base_image_staleness.py | 65 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 28 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 7cfa68b8..35b88072 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -50,6 +50,28 @@ def _devbase_root() -> Optional[Path]: 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): """機密を復号して自プロセスの環境変数へ載せ、載せた内容を返す。 @@ -633,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: @@ -1409,20 +1421,10 @@ 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]: 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) + +