diff --git a/docs/specifications/compose-profiles.md b/docs/specifications/compose-profiles.md index ed146e3f..aa99a9bd 100644 --- a/docs/specifications/compose-profiles.md +++ b/docs/specifications/compose-profiles.md @@ -36,6 +36,8 @@ dev のほかに app / db などのサービスを持つプロジェクトで、 | プロファイルの操作 | `lib/devbase/commands/container.py` | 共通の前段 `_profile_targets`、`cmd_profile_up` / `cmd_profile_down` / `cmd_profile_list`、`_dev_instance_indices`、`_running_services` / `_running_label` | | 振り分け | `lib/devbase/commands/container.py` | `_dispatch_lifecycle` の handlers の `profile` と `_dispatch_profile`。名前を指定したときの切替 `_enter_project`。`cmd_container` の非推奨の警告 | | `devbase up` の起動 | `lib/devbase/commands/container.py` | `_run_deploy_pipeline` が停止より前に `default_services` を求め、`docker_compose_up` へ渡す | +| `devbase scale` の起動 | `lib/devbase/commands/container.py` | `cmd_scale` が生成物を作った直後に `default_services` を求め、`docker_compose` へ `up -d --no-recreate <既定のサービス...>` を `check=False` で渡す | +| Compose 設定の読み取り | `lib/devbase/commands/container.py` | `_compose_config_services` が `docker_compose` で `config --format json` を読む唯一の関数。`_resolve_dev_service`(`build --expires` / `rebuild`)と `_ensure_images`(起動前のイメージ確認)がその上に載る | | フックの環境変数 | `lib/devbase/project/runtime.py`、`commands/container.py` | `active_profiles_env` / `hook_env(config, active_profiles=())`。`_hook_vars`、`_run_deploy_script_for_instances(..., active_profiles=()) -> bool`、`_run_pre_up_hook` | | 引数の受け口 | `lib/devbase/cli.py` | `_add_profile_subparser(sub, with_name=...)`、`SUBCMD_MAP` と `SUBCMD_PREFIX_PREFERENCES` | | 一覧の操作メニュー | `lib/devbase/tui/actions_project.py` | `_PROFILE_OPS` / `_profile_names` / `_running_ops` / `_op_profile`、`_BACK_TO_TOP_OPS` | @@ -73,15 +75,16 @@ devbase 経由の Compose では、有効なプロファイルを devbase が経 | 経路 | 場所 | 用途 | | --- | --- | --- | -| `docker_compose` | `utils/docker.py` | `up` / `down` / `profile up` / `profile down` / `profile list` の `ps` | +| `docker_compose` | `utils/docker.py` | `up` / `down` / `scale` の `up -d --no-recreate` / `profile up` / `profile down` / `profile list` の `ps`、`_compose_config_services` の `config --format json`(`build --expires` / `rebuild` と起動前のイメージ確認) | | `_compose_lines` | `commands/container.py` | `config --profiles` / `config --services`(プロファイルの解決) | | `_compose_run` | `commands/container.py` | `devbase ps` / `devbase logs` | -| `_resolve_dev_service` | `commands/container.py` | `build --expires` / `rebuild` が読む `config --format json` | -| `_read_compose_services` | `commands/container.py` | 起動前のイメージ確認が読む `config --format json` | +| `cmd_login` | `commands/container.py` | `devbase login` の `exec <開発サービス名>- bash` | | `_query_container_name` | `editor/opener.py` | エディタを開くときの `ps --format json` | -`cmd_scale` が直接呼ぶ `docker compose -f <生成物> up -d --no-recreate` はこの対象に含めない。 -プロファイルの入口ではないためである。 +devbase が Compose を起動する経路はこの表の 5 つだけである。`docker_compose` 以外の 4 つは +`subprocess.run` を直接呼び、`env=compose_env()` を自分で渡す。打ち消しが要るのはプロファイルの +入口だからではなく、子プロセスへ利用者の値がそのまま渡るからである。読み取りだけの経路も +`exec` も同じ扱いにする。 値の決め方には次の理由がある。 @@ -107,6 +110,7 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に | 操作 | コマンド列(`docker compose -f <ファイル>` の後) | 子プロセスの `COMPOSE_PROFILES` | | --- | --- | --- | | `devbase up` の起動 | `up -d <既定のサービス...>`(`--profile` なし) | `__devbase_none__` | +| `devbase scale` の起動 | `up -d --no-recreate <既定のサービス...>`(`--profile` なし) | `__devbase_none__` | | `devbase down` / `devbase up` 冒頭の停止 | `--profile '*' down -t0` | `__devbase_none__` | | `profile up X` | `--profile X up -d --no-deps <サービス...>` | `__devbase_none__` | | `profile down X`(1 段目) | `--profile X stop <サービス...>` | `__devbase_none__` | @@ -115,6 +119,9 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に | 既定のサービスの解決 | `config --services` | `__devbase_none__` | | プロファイル名の解決 | `config --profiles` | `__devbase_none__` | | プロファイル X の解決 | `--profile X config --services` | `__devbase_none__` | +| Compose 設定の読み取り | `config --format json`(`-f` なし) | `__devbase_none__` | +| `devbase login`(生成物あり) | `exec <開発サービス名>- bash` | `__devbase_none__` | +| `devbase login`(生成物なし) | `exec --index= <開発サービス名> bash`(`-f` なし) | `__devbase_none__` | `docker_compose_up(compose_file, detach=True, services=())` は `services` が空なら従来どおり サービス名を付けない。`docker_compose_down` は引数を増やさず、常に `--profile '*'` を付ける。 @@ -137,6 +144,16 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に 指定のため、`.env` が別のプロファイルを有効にしても対象は狭まらない。`--profile '*'` を付けない と、プロファイルのサービスが動いたまま残り、network の削除にも失敗する。 +`devbase scale` は既存のコンテナを止めずにインスタンスを足す操作で、停止の段を持たない。 +`cmd_scale` は生成物を作った直後に `default_services(override_file)` を求め、 +`docker_compose(['up', '-d', '--no-recreate', *services], compose_file=override_file, check=False)` +で起動する。起動の対象を明示する理由は `up` と同じである。プロファイルのサービスは起動の対象に +入れず、既に動いているプロファイルのサービスは対象の外にあるため止めない。`check=False` で +終了コードを受け、0 以外なら `Failed to start new containers` を出して 1 を返す +(`docker_compose_up` は `check=True` 固定で、`subprocess.CalledProcessError` が `cmd_scale` の +`except DevbaseError` を素通りするため使わない)。`config --services` の失敗は `DevbaseError` として +`Scale failed: ...` で 1 になる。`project.yml` の `scale` はその時点で既に書き換わっている。 + プロファイルを持たないプロジェクトでは、`up` / `down` / `scale` が扱うコンテナの集合と順序は 変わらない。プロファイルのサービスは scale の対象にせず、複製されるのは開発サービスだけである。 @@ -384,7 +401,7 @@ TUI では、復元境界が機密の注入履歴も戻すため、別プロジ - `devbase up` はプロファイルのサービスも止め、既定のサービスだけを起動する。テスト用サーバを 使い続けるなら `up` の後に `profile up` をやり直す - `devbase down` はプロファイルのサービスも含めて削除する。`devbase scale` はプロファイルの - サービスを複製しない + サービスを複製せず、起動の対象にも入れない。既に動いているプロファイルのサービスは止めない - `COMPOSE_PROFILES` を端末や `.env` に置いても devbase 経由の操作には効かない。素の `docker compose` には従来どおり効く - プロファイル名に `__devbase_none__` を使わない @@ -398,13 +415,21 @@ TUI では、復元境界が機密の注入履歴も戻すため、別プロジ 検査する。実 docker と実 `DEVBASE_ROOT` には触れない。 - `compose_env` が `COMPOSE_PROFILES` を打ち消し用の名前にし(未設定でも入れる)、プロセスの環境を - 変えないこと。`docker_compose`・`ps` / `logs`・`config` を読む 2 経路・エディタの `ps` が - その環境を渡すこと。`down` が `--profile '*' down -t0` になり、`up` がサービス無しで従来の形、 + 変えないこと。`docker_compose`・`ps` / `logs`・`config --services`・`login` の `exec` + (生成物あり・なし)・エディタの `ps` がその環境を渡し、`config --format json` を読む 2 経路が + `docker_compose` を通ること。`_compose_config_services` が非 0 で空の `services`、読めない JSON で + `json.JSONDecodeError` を返し、`_resolve_dev_service` がどちらでも `None` を返すこと。`down` が `--profile '*' down -t0` になり、`up` がサービス無しで従来の形、 サービス有りで `--profile` 無しに名前を並べること(`tests/utils/test_docker_profiles.py`) - `devbase up` が生成物の既定のサービスを起動へ渡し、`default_services` が失敗したときは コンテナを止めないこと(`tests/commands/test_container_up_order.py`)。既存の `up` の harness は `default_services` を差し替える(`test_up_roundtrips.py` / `test_container_context.py` / `test_container_bao.py` / `tui/test_dispatch.py`) +- `devbase scale` が `up -d --no-recreate <既定のサービス...>` を打ち消し用の名前で起動し、呼び出し側の + `os.environ` を変えないこと。起動の非 0 で `Failed to start new containers` と 1 になり例外を + 出さないこと。プロファイルのサービスを起動の対象に入れないこと。正常系の順序 + (グループの検査 → `write_scale` → ボリューム → network → 生成 → `default_services` → 起動 → + ready 待ち → bao の token → `./deploy`)、bao と `./deploy` の範囲が `current + 1` から `new` まで + であること、停止を呼ばないこと(`tests/commands/test_container_scale_order.py`) - `default_services` が打ち消し用の名前で `config --services` を呼ぶこと、`profile_services` が 既定のサービスを差し引き、Compose が展開した名前を使い、プロファイルが無ければ空、解決の失敗で `DevbaseError` になること。生成物が無いときに Compose を呼ばずに 1、未知の名前で起動・停止を呼ばずに diff --git a/issues/PLAN65_scale-compose-path-impl1.md b/issues/PLAN65_scale-compose-path-impl1.md new file mode 100644 index 00000000..79f62917 --- /dev/null +++ b/issues/PLAN65_scale-compose-path-impl1.md @@ -0,0 +1,184 @@ +# PLAN65 実装 1 本目: Compose の呼び出しを共通経路へ寄せる + +## 関連リンク + +- 課題: devbasex/devbase#192 +- 要求と受け入れ条件: `issues/PLAN65_scale-compose-path.md` +- 設計: `issues/PLAN65_scale-compose-path-design.md`(設計 PR #225。案 A を承認済み) +- release PR: #212(base は `release/v3.7.0`) +- この計画が扱うのは設計の「実装の分け方」の **1 本目だけ**である。2 本目(`cmd_scale` の段階を分ける)は + この Pull Request のマージ後に別に出す + +## モード + +`standard`(`devbase scale` の子プロセスの環境と起動の対象が変わり、確定仕様も変える。要求の文書の判定のまま)。 + +## 目的と非目的 + +達成したい状態: + +- `lib/` の中で `compose_env()` を通さずに `docker compose` を起動する箇所が 0 件になる + (`cmd_scale` の `[4/5]` と `cmd_login` の 2 か所を塞ぐ) +- `devbase scale` の起動の対象が、`devbase up` と同じく `default_services(<生成物>)` で明示される +- `docker compose config --format json` を起動する関数が `_compose_config_services` の 1 つになる +- 確定仕様 `docs/specifications/compose-profiles.md` の経路の一覧が実装と一致し、 + 「devbase 経由の操作には効かない」が例外を持たない +- `devbase scale` の正常系の手順(順序と範囲)がテストで固定される + +やらないこと: + +- `cmd_scale` の段階の抽出(`_check_scale_request` / `_run_scale_pipeline` の新設)。2 本目の範囲で、 + 受け入れ条件 D-3・D-4 はこの Pull Request では満たさない +- `docker_compose_up()` / `docker_compose_down()` / `compose_env()` のシグネチャの変更(設計の決定 3) +- `cmd_scale` のログの文言・段階の番号・失敗の扱いの変更(決定 5・7) +- `_previous_scale_compose()` を `cmd_scale` へ入れること(決定 6) +- `docs/plugin-dev/compose-profiles.md` の変更(A-4) +- `cmd_scale` から `_report_missing_repos` / `_apply_window_titles` を呼ぶこと(#224 として起票済み) + +## 前提 + +- 前提 1: 実環境のプロジェクトで `devbase up` / `scale` / `login` を実行しない。確認はコマンド列を組み立てる + 水準と `subprocess.run` を差し替えた水準で行う +- 前提 2: `release/v3.7.0` を base にした Pull Request では CI が動かない(#216)。`uv run pytest` を手元で実行し、 + 結果と終了コードを Pull Request 本文の Test plan へ載せる +- 前提 3: `tests/conftest.py` の autouse fixture(#217)がテストごとに `DEVBASE_ROOT` を tmp へ向ける。 + 新しいテストもこれに乗る +- 前提 4: 変更前の全件の結果は `2863 passed`(`uv run pytest -q`、exit=0、2026-09-22、`b112584` + 空コミット) + +## 受け入れ条件(この Pull Request で満たすもの) + +設計文書の「受け入れ条件とどちらの Pull Request が対応するか」の表から、1 本目で満たすものを写す。 +番号は要求の文書のもの。 + +- [ ] A-1: `compose_env()` を渡さない起動が 0 件。`grep -rn "'docker', 'compose'\|\"docker\", \"compose\"" lib/` が 6 → 3 件 + (`utils/docker.py` の共通経路・`_compose_base_args`・`editor/opener.py`)。`cmd_login` は `_compose_base_args` 経由のため目視で辿る +- [ ] A-2: 確定仕様に `cmd_scale` を共通経路の対象から外す記述が無い。経路の表に `scale` の起動が載る +- [ ] A-3: 確定仕様の「組み立てるコマンド列」の表に `devbase scale` の行(`up -d --no-recreate <既定のサービス...>`、`__devbase_none__`)がある +- [ ] A-4: `docs/plugin-dev/compose-profiles.md` を変えない(`git diff --name-only` に出ない) +- [ ] A-5: `tests/utils/test_docker_profiles.py` の棚卸しのコメントと、そこに並ぶテストが変更後の経路 + (`_compose_run` / `_compose_lines` / `cmd_login` / `editor._query_container_name`)と一致する +- [ ] B-1: `COMPOSE_PROFILES=web` のうえで `cmd_scale(2)` の起動の子プロセスの `COMPOSE_PROFILES` が `__devbase_none__` +- [ ] B-2: `cmd_scale` の前後で `os.environ['COMPOSE_PROFILES']` が `web` のまま +- [ ] B-3: 起動のコマンド列が `['docker', 'compose', '-f', <生成物>, 'up', '-d', '--no-recreate', ]` +- [ ] B-4: 起動が非 0 なら `Failed to start new containers` を出して 1。例外は外へ出ない +- [ ] B-5: プロファイルを持たない生成物では、起動の対象が `config --services` の全件(サービス名を付けない `up` と同じ集合) +- [ ] C-1: 正常系の順序 `group → write_scale → volumes → network → generate → default_services → up → wait → bao → deploy` を固定するテストがある。`grep -rn "no-recreate" tests/` が 1 件以上 +- [ ] C-2: `_push_bao_token` の `start` が `current + 1`、`./deploy` の範囲が `range(current + 1, new + 1)` +- [ ] C-3: 既存の `cmd_scale` のテスト 4 か所を書き換えずに通す +- [ ] C-4: `uv run pytest` の全件が変更の前後で同じ結果(新設分だけ件数が増え、失敗 0) +- [ ] D-1: `grep -rn "'config', '--format', 'json'" lib/` が 1 件 +- [ ] D-2: `_compose_config_services` は非 0 で `(rc, {})`、不正 JSON で `json.JSONDecodeError`、正常で `(0, services)`。 + `_resolve_dev_service` は非 0 と不正 JSON で `None` +- [ ] E-1: `tests/commands/test_container_up_order.py` を書き換えずに通す +- [ ] E-2: `profiles:` を持つサービスが `scale` の起動のコマンド列に出ない +- [ ] E-3: `scale` の呼び出しに停止(`down` / `stop` / `rm`)が 1 件も無い + +## 代替案と採否 + +設計で決めたもの(決定 1〜4・9)はここで再検討しない。計画で決めたのはコミットの並べ方だけである。 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| A | 仕様 → 共通経路へ寄せる(新しい振る舞いのテストを先に書いて落とす)→ 現状固定テスト | **採用** | 設計文書の「#192 が指定した順序」の表のとおり。#192 の順序(仕様 → 共通経路 → 現状固定テスト)とも一致する | +| B | 現状固定テストを先に書いて緑を確かめ、そこから寄せる | 不採用 | 設計文書が「3 を 2 より先に置かない理由」で退けている。寄せる変更は `[4/5]` のコマンド列と `env` を**意図して**変えるため、現状(`env` 無し・サービス名無し)を先に固定すると 2 でそのテストを書き換えることになる。承認した設計から外れる | + +案 A の中での「先に失敗するテスト」の置き方: + +- 共通経路へ寄せるコミットでは、**新しい振る舞い**(B-1〜B-4、D-1/D-2、`cmd_login` の `env`)を表すテストを先に書き、 + 変更前の実装で落ちることを確かめてから寄せる(`tdd-cycle`) +- 現状固定テスト(C-1・C-2・E-3)は寄せた後の実装に対して書き、書いた時点で緑であることを確かめる。 + これが 2 本目(構造の変更)の前の安全網になる。2 本目の構造変更の前に緑で入っている、という設計の要件を満たす +- `[4/5]` 以外の手順(順序・範囲)は寄せるコミットで変わらないため、C-1 の順序のうち `default_services` の位置だけが + 寄せるコミットで新しく生まれる。これは B-3 のテストで先に固定する + +## 不変条件 + +- 呼び出し側の `os.environ` は書き換えない(`compose_env()` は複製を返す) +- `cmd_scale` のログの文字列(`[1/5]`〜`[5/5]`、`[2.5/5]`、`Using --no-recreate ...`、`Failed to start new containers`、 + `Scale failed: %s`、`=== Scale completed successfully ===`)は変えない +- `cmd_scale` は既存のコンテナを止めない(停止の段を持たない) + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| CLI(`devbase scale` / `devbase login`) | 引数は変えない | 変えない | +| `devbase scale` の起動 | 子プロセスの `COMPOSE_PROFILES` が常に `__devbase_none__`、対象が既定のサービス | **振る舞いが変わる**(承認済み)。端末や `.env` に `COMPOSE_PROFILES` を置いた人の `scale` はプロファイルのサービスを起動しなくなる。プロファイルを持たないプロジェクトでは対象の集合は変わらない | +| `devbase login` | `exec` の子プロセスの `COMPOSE_PROFILES` | 観測できる振る舞いは変わらない(開発サービスは `profiles:` を持たない) | +| モジュール関数 | `_read_compose_services` を削除し `_compose_config_services` を新設 | 呼び出し元は `_ensure_images` の 1 つ。`_resolve_dev_service` の名前と契約は保つ | + +## 修正対象 + +- `docs/specifications/compose-profiles.md` +- `lib/devbase/commands/container.py`(`cmd_scale` の `[4/5]` / `cmd_login` / `_resolve_dev_service` / `_read_compose_services` → `_compose_config_services` / `_ensure_images`) +- `tests/commands/test_container_scale_order.py`(新設) +- `tests/utils/test_docker_profiles.py` +- `issues/PLAN65_scale-compose-path-impl1.md`(この計画) + +## タスク分解 + +コミットは設計の順序表に合わせて 4 つにする(決定 2 のとおり `cmd_login` は独立したコミット)。 + +### Task 1: 確定仕様を書き換える(1 つ目のコミット) + +- **対象ファイル:** `docs/specifications/compose-profiles.md` +- **変更内容:** 設計の「確定仕様の書き換え」の表の 7 か所。構成要素の表に `scale` の起動、経路の表を 5 行 + (`docker_compose` / `_compose_lines` / `_compose_run` / `cmd_login` / `_query_container_name`)に畳み、 + `cmd_scale` の除外を「経路はこの表の 5 つだけ」に置き換え、コマンド列の表に `scale` と `login` の行、 + `up`/`down` の節に `scale` の段落、運用に「起動の対象にも入れない。動いているプロファイルのサービスは止めない」、 + テスト観点に `scale` の行 +- **満たす受け入れ条件:** A-2 / A-3 / A-4 +- **進め方:** ドキュメントのためテスト駆動は適用しない。`grep -n "cmd_scale"` と目視で確かめる + +### Task 2: `cmd_scale` の起動と config の読み取りを共通経路へ寄せる(2 つ目のコミット) + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/commands/test_container_scale_order.py`(新設)、`tests/utils/test_docker_profiles.py` +- **変更内容:** + - `[4/5]` を `docker_compose(['up', '-d', '--no-recreate', *services], compose_file=override_file, check=False)` にする。 + `services = default_services(override_file)` は `[3/5]` の生成の直後に求める(番号は付けない。決定 7) + - `_compose_config_services()` を新設し `docker_compose(['config', '--format', 'json'], check=False, capture_output=True)` を通す。 + `_read_compose_services` を削除し、`_ensure_images` と `_resolve_dev_service` をその上に載せる + - 棚卸しの節のうち config の 2 経路のテストを `docker_compose` を通る経路として書き直す +- **満たす受け入れ条件:** A-1(`cmd_scale` 分)/ B-1〜B-5 / D-1 / D-2 / E-2 +- **進め方:** 失敗するテスト(B-1〜B-5・E-2・D-2 の `_compose_config_services`)を先に書き、変更前の実装で落ちることを確かめる → + 寄せる → 全件 + +### Task 3: `cmd_login` に `compose_env()` を渡す(3 つ目のコミット) + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/utils/test_docker_profiles.py` +- **変更内容:** `subprocess.run(cmd, env=compose_env())` の 1 行。棚卸しのコメントを「`docker_compose` を通らずに直接呼ぶ経路 4 か所」 + (`_compose_run` / `_compose_lines` / `cmd_login` / `_query_container_name`)へ書き直し、`cmd_login`(生成物あり・なし)と `_compose_lines` のテストを並べる +- **満たす受け入れ条件:** A-1(`cmd_login` 分)/ A-5 +- **進め方:** `cmd_login` の `env` のテストを先に書いて落とす → 1 行 → 全件 + +### Task 4: `devbase scale` の正常系の手順を固定する(4 つ目のコミット) + +- **対象ファイル:** `tests/commands/test_container_scale_order.py` +- **変更内容:** `cmd_scale` の外部作用を差し替える harness(`test_container_up_order.py` の `up_harness` と同型)で、 + 順序(C-1)、bao と `./deploy` の範囲(C-2)、停止を呼ばないこと(E-3)を固定する +- **満たす受け入れ条件:** C-1 / C-2 / E-3 +- **進め方:** 現状固定テスト。書いた時点で緑であることを確かめる(落ちたら実装の振る舞いを読み直す。テストに合わせて実装を変えない) + +## 影響範囲 + +- `devbase scale`(起動の環境と対象)、`devbase login`(環境のみ)、`devbase build --expires` / `rebuild` / 起動前のイメージ確認(config の読み取りの経路。振る舞いは同じ) +- `tests/cli/test_base_image_staleness.py` は `_resolve_dev_service` の名前を差し替えるため、名前を保てば影響しない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `container.py` は 2000 行を超える 1 ファイルで、`cmd_scale` は 86 行の通しの関数 | 構造は 2 本目で分ける(設計の決定 10)。この Pull Request では `[4/5]` の数行と `default_services` の 1 行だけを触り、Task 4 の現状固定テストを 2 本目の前に入れる | +| `docker_compose_up()` を使うと `CalledProcessError` が `except DevbaseError` を素通りして traceback で落ちる | `docker_compose(..., check=False)` を直接呼ぶ(決定 3)。B-4 のテストで例外が外へ出ないことを固定する | +| `default_services` の解決の失敗という新しい失敗の経路 | `DevbaseError` として `Scale failed: ...` で 1 になる。`up` が同じ解決を既に行っているため、`up` が通るプロジェクトでは踏まない(設計の「処理の流れ」) | +| 既存の `test_container_context.py` の scale テストが `default_services` を差し替えていない harness で落ちる | harness は `default_services` を差し替え済み(`['dev-1']`)。C-3 のとおり書き換えずに通すことを確かめる | + +## 切り戻し手順 + +- この Pull Request の revert で戻る。データ(`project.yml` / 生成物)の形は変わらないため、移行は無い + +## 完了の定義 + +- [ ] 上の受け入れ条件(D-3・D-4 を除く)が、条件ごとに検証手段と結果で対応している +- [ ] `uv run pytest` の全件が exit=0 で、結果を Pull Request 本文の Test plan に載せた +- [ ] 4 つのコミットが設計の順序どおりに並び、Draft の Pull Request へ push した diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 43b2dc4e..2abd5046 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1410,7 +1410,8 @@ def cmd_login(index: str = '1', context: Optional[str] = None) -> int: else: cmd.extend(['exec', f'--index={index}', dev_service, 'bash']) - return subprocess.run(cmd).returncode + # 経路の表の 1 つとして子プロセスの COMPOSE_PROFILES を打ち消す (PLAN65 決定 2) + return subprocess.run(cmd, env=compose_env()).returncode # --------------------------------------------------------------------------- @@ -1640,14 +1641,17 @@ def cmd_scale(new_scale: int, project_name: str = None, logger.info("[3/5] Generating scaled compose file...") override_file = _build_scaled_override(new_scale, config, project_name, target) logger.info("Generated: %s", override_file) + # up と同じく起動の対象を生成物の既定のサービスで明示する (PLAN65 決定 4) + services = default_services(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 - ) + # 共通経路を通し、子プロセスの COMPOSE_PROFILES を打ち消す (PLAN65 決定 1)。 + # docker_compose_up は check=True 固定で CalledProcessError が except DevbaseError を + # 素通りするため、check=False で終了コードを見る (決定 3) + result = docker_compose(['up', '-d', '--no-recreate', *services], + compose_file=override_file, check=False) if result.returncode != 0: logger.error("Failed to start new containers") @@ -1788,17 +1792,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, env=compose_env(), - ) - if result.returncode != 0: - return None try: - config = json.loads(result.stdout) + returncode, services = _compose_config_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: @@ -1964,15 +1964,14 @@ def _image_max_age_days() -> int: ) -def _read_compose_services() -> tuple[int, dict]: - """Compose 設定の終了コードと services を取得する。""" - result = subprocess.run( - ['docker', 'compose', 'config', '--format', 'json'], - capture_output=True, - text=True, - check=False, - env=compose_env(), - ) +def _compose_config_services() -> tuple[int, dict]: + """``docker compose config --format json`` の (終了コード, services) を返す。 + + ``config --format json`` を起動する唯一の関数 (PLAN65 決定 9)。非 0 なら services は空。 + JSON として読めなければ :class:`json.JSONDecodeError` を伝播する。 + """ + result = docker_compose(['config', '--format', 'json'], + check=False, capture_output=True) if result.returncode != 0: return result.returncode, {} config = json.loads(result.stdout) @@ -2016,7 +2015,7 @@ def _ensure_images() -> bool: dev_service_name = get_dev_service_name() try: - returncode, services = _read_compose_services() + returncode, services = _compose_config_services() if returncode != 0: logger.info("Unable to check image status") logger.info("Running 'devbase container build' to ensure images exist...") diff --git a/tests/cli/test_login_command.py b/tests/cli/test_login_command.py index a965ef5c..a462c350 100644 --- a/tests/cli/test_login_command.py +++ b/tests/cli/test_login_command.py @@ -24,7 +24,8 @@ def test_login_command(tmp_path, monkeypatch, scaled, expected): monkeypatch.setattr(container, 'get_dev_service_name', lambda: events.append(('service',)) or 'dev') - def run(cmd): + def run(cmd, **kwargs): + # 子プロセスの env (compose_env) は tests/utils/test_docker_profiles.py が固定する (PLAN65) events.append(('run', cmd)) return subprocess.CompletedProcess(cmd, 7) @@ -34,3 +35,30 @@ def run(cmd): assert events == [ ('context', 'remote'), ('secrets', False), ('service',), ('run', expected), ] + + +@pytest.mark.parametrize(('scaled', 'expected'), [ + (True, ['docker', 'compose', '-f', '.docker-compose.scale.yml', + 'exec', 'dev-1', 'bash']), + (False, ['docker', 'compose', 'exec', '--index=1', 'dev', 'bash']), +]) +def test_login_command_defaults(tmp_path, monkeypatch, scaled, expected): + """現状固定: 引数省略時は context=None でインスタンス 1 に接続する。""" + monkeypatch.chdir(tmp_path) + if scaled: + (tmp_path / '.docker-compose.scale.yml').write_text('services: {}\n') + contexts = [] + commands = [] + monkeypatch.setattr(container, '_apply_context', contexts.append) + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + + def run(cmd, **kwargs): + commands.append(cmd) + return subprocess.CompletedProcess(cmd, 7) + + monkeypatch.setattr(container.subprocess, 'run', run) + + assert container.cmd_login() == 7 + assert contexts == [None] + assert commands == [expected] diff --git a/tests/commands/test_container_scale_order.py b/tests/commands/test_container_scale_order.py new file mode 100644 index 00000000..5db9b7c1 --- /dev/null +++ b/tests/commands/test_container_scale_order.py @@ -0,0 +1,349 @@ +"""``devbase scale`` の Compose の呼び出しと正常系の手順 (PLAN65)。 + +``devbase scale`` の起動は ``devbase up`` と同じ共通経路 (``docker_compose``) を通り、子プロセスの +``COMPOSE_PROFILES`` を打ち消し用の名前にする。起動の対象は生成物の既定のサービスを明示する。 + +実 docker と実 ``DEVBASE_ROOT`` には触れない。``subprocess.run`` を差し替えて、組み立てた +コマンド列と子プロセスの環境を拾う。 +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from devbase.commands import container +from devbase.errors import DevbaseError +from devbase.utils import docker +from devbase.utils import docker_context as dc + + +PROJECT_YML = "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + +# 生成物のサービスとそのプロファイル。``config --services`` の応答はここから作る +NO_PROFILE_SERVICES = {'dev-1': [], 'dev-2': [], 'redis': []} +WITH_PROFILE_SERVICES = {'dev-1': [], 'dev-2': [], 'redis': [], 'testdb': ['test']} + + +class FakeCompose: + """``subprocess.run`` の代わり。呼び出しを記録し、``config --services`` に答える。 + + ``config --services`` は Compose と同じく、有効なプロファイル (``--profile`` と子プロセスの + ``COMPOSE_PROFILES``) に属するサービスと、プロファイルを持たないサービスを返す。 + """ + + def __init__(self, services: dict, calls: list, up_returncode: int = 0): + self.services = services + self.calls = calls + self.up_returncode = up_returncode + + def __call__(self, cmd, **kwargs): + cmd = list(cmd) + env = kwargs.get('env') + self.calls.append(('run', {'cmd': cmd, 'env': env})) + if 'config' in cmd and '--services' in cmd: + active = set() + for i, arg in enumerate(cmd): + if arg == '--profile': + active.add(cmd[i + 1]) + raw = (env if env is not None else os.environ).get('COMPOSE_PROFILES', '') + active.update(p for p in raw.split(',') if p) + names = [name for name, profiles in self.services.items() + if not profiles or active.intersection(profiles)] + return subprocess.CompletedProcess(cmd, 0, '\n'.join(names) + '\n', '') + returncode = self.up_returncode if 'up' in cmd else 0 + return subprocess.CompletedProcess(cmd, returncode, '', '') + + +@pytest.fixture +def scale_harness(tmp_path, monkeypatch): + """``cmd_scale`` の外部作用を差し替え、呼び出しの順序を ``calls`` へ記録する。""" + monkeypatch.chdir(tmp_path) + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT'): + monkeypatch.delenv(name, raising=False) + dc.reset() + (tmp_path / 'project.yml').write_text(PROJECT_YML) + override = tmp_path / '.docker-compose.scale.yml' + calls: list = [] + + monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + monkeypatch.setattr(container, '_check_group_consistency', + lambda project=None: calls.append(('group', None)) or True) + monkeypatch.setattr(container, '_resolve_docker_target', lambda context=None: dc.DockerTarget( + context=None, source='none', remote=False, home=None, gid=None)) + + real_write_scale = container.project_runtime.write_scale + + def write_scale(project_dir, scale): + calls.append(('write_scale', scale)) + real_write_scale(project_dir, scale) + + monkeypatch.setattr(container.project_runtime, 'write_scale', write_scale) + monkeypatch.setattr(container, 'ensure_volumes', + lambda scale, project: calls.append(('volumes', scale))) + monkeypatch.setattr(container, 'ensure_network', + lambda name='devbase_net': calls.append(('network', name))) + + def build(scale, config, project_name, target): + calls.append(('generate', scale)) + override.write_text("services:\n dev-1: {}\n dev-2: {}\n") + return override + + monkeypatch.setattr(container, '_build_scaled_override', build) + + real_default_services = container.default_services + + def default_services(compose_file, environ=None): + calls.append(('default_services', Path(compose_file))) + return real_default_services(compose_file, environ) + + monkeypatch.setattr(container, 'default_services', default_services) + monkeypatch.setattr(container, 'wait_for_containers_ready', + lambda **k: calls.append(('wait', k))) + monkeypatch.setattr(container, '_push_bao_token', + lambda *a, **k: calls.append(('bao', {'args': a, **k}))) + real_deploy = container._run_deploy_script_for_instances + monkeypatch.setattr(container, '_run_deploy_script_for_instances', + lambda script, indices, config=None, active_profiles=(): + calls.append(('deploy', list(indices))) or True) + + fake = FakeCompose(NO_PROFILE_SERVICES, calls) + monkeypatch.setattr(subprocess, 'run', fake) + return {'calls': calls, 'override': override, 'fake': fake, 'root': tmp_path, + 'real_deploy': real_deploy} + + +def _runs(calls, *words): + return [c for name, c in calls if name == 'run' and all(w in c['cmd'] for w in words)] + + +def _up_calls(calls): + return [c for name, c in calls if name == 'run' and 'up' in c['cmd']] + + +# --------------------------------------------------------------------------- +# 起動の子プロセスの環境とコマンド列 (B-1〜B-5 / E-2) +# --------------------------------------------------------------------------- + +def test_scale_start_passes_reserved_profile_to_child(scale_harness, monkeypatch): + """B-1 / B-2: 利用者の COMPOSE_PROFILES は子プロセスで打ち消し、呼び出し側は変えない。""" + monkeypatch.setenv('COMPOSE_PROFILES', 'web') + + assert container.cmd_scale(2) == 0 + + up = _up_calls(scale_harness['calls']) + assert len(up) == 1 + assert up[0]['env'] is not None + assert up[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE + assert os.environ['COMPOSE_PROFILES'] == 'web' + + +def test_scale_start_names_default_services_of_generated_compose(scale_harness, monkeypatch): + """B-3: ``up -d --no-recreate`` に ``default_services(<生成物>)`` の全件を並びのまま渡す。""" + monkeypatch.setattr(container, 'default_services', + lambda compose_file, environ=None: ['dev-1', 'dev-2', 'redis']) + + assert container.cmd_scale(2) == 0 + + up = _up_calls(scale_harness['calls']) + assert [c['cmd'] for c in up] == [[ + 'docker', 'compose', '-f', str(scale_harness['override']), + 'up', '-d', '--no-recreate', 'dev-1', 'dev-2', 'redis']] + assert '--profile' not in up[0]['cmd'] + + +def test_scale_start_failure_returns_one_without_exception(scale_harness, caplog): + """B-4: 起動の非 0 は ``Failed to start new containers`` と 1。例外を外へ出さない。""" + scale_harness['fake'].up_returncode = 1 + + assert container.cmd_scale(2) == 1 + + assert 'Failed to start new containers' in caplog.text + names = [name for name, _ in scale_harness['calls']] + assert 'wait' not in names and 'bao' not in names and 'deploy' not in names + + +def test_scale_start_targets_every_service_without_profiles(scale_harness): + """B-5: プロファイルを持たない生成物では、対象は ``config --services`` の全件。""" + assert container.cmd_scale(2) == 0 + + up = _up_calls(scale_harness['calls']) + assert up[0]['cmd'][-3:] == ['dev-1', 'dev-2', 'redis'] + assert set(up[0]['cmd'][7:]) == set(NO_PROFILE_SERVICES) + + +def test_scale_start_leaves_out_profile_services(scale_harness, monkeypatch): + """E-2: 利用者が COMPOSE_PROFILES でプロファイルを有効にしても、起動の対象に入れない。""" + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + scale_harness['fake'].services = WITH_PROFILE_SERVICES + + assert container.cmd_scale(2) == 0 + + up = _up_calls(scale_harness['calls']) + assert 'testdb' not in up[0]['cmd'] + assert up[0]['cmd'][7:] == ['dev-1', 'dev-2', 'redis'] + config = _runs(scale_harness['calls'], 'config', '--services') + assert config and all(c['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE for c in config) + + +def test_scale_default_services_failure_is_a_scale_failure(scale_harness, monkeypatch, caplog): + """既定のサービスの解決の失敗は ``Scale failed`` で 1。起動しない。""" + def fail(compose_file, environ=None): + raise DevbaseError('config --services failed') + + monkeypatch.setattr(container, 'default_services', fail) + + assert container.cmd_scale(2) == 1 + + assert 'Scale failed: config --services failed' in caplog.text + assert _up_calls(scale_harness['calls']) == [] + + +# --------------------------------------------------------------------------- +# 正常系の手順の固定 (C-1 / C-2 / E-3)。cmd_scale の段階を分ける前の安全網 +# --------------------------------------------------------------------------- + +def _step(name, payload): + if name != 'run': + return name + cmd = payload['cmd'] + if 'config' in cmd: + return 'config' + if 'up' in cmd: + return 'up' + return 'run:' + ' '.join(cmd[2:]) + + +def test_scale_runs_the_steps_in_order(scale_harness): + """C-1: グループの検査 → write_scale → ボリューム → network → 生成 → 既定のサービス → 起動 + → ready 待ち → bao → ./deploy。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + + assert container.cmd_scale(3) == 0 + + steps = [_step(name, payload) for name, payload in scale_harness['calls']] + assert [s for s in steps if s != 'config'] == [ + 'group', 'write_scale', 'volumes', 'network', 'generate', 'default_services', + 'up', 'wait', 'bao', 'deploy'] + # 既定のサービスの解決 (config --services) は生成の後、起動の前に行う + assert steps.index('generate') < steps.index('config') < steps.index('up') + up = _up_calls(scale_harness['calls'])[0] + assert up['cmd'][4:7] == ['up', '-d', '--no-recreate'] + + +def test_scale_passes_the_generated_compose_and_new_scale(scale_harness): + """C-1: 各段に新しい scale と生成物が渡り、project.yml の scale が書き換わる。""" + assert container.cmd_scale(3) == 0 + + calls = dict((name, payload) for name, payload in scale_harness['calls'] if name != 'run') + override = scale_harness['override'] + assert calls['write_scale'] == 3 + assert calls['volumes'] == 3 + assert calls['network'] == 'devbase_net' + assert calls['generate'] == 3 + assert calls['default_services'] == override + assert calls['wait'] == {'container_prefix': 'dev', 'scale': 3, + 'compose_file': override, 'timeout': 60} + assert 'scale: 3' in (scale_harness['root'] / 'project.yml').read_text() + + +def test_scale_hooks_cover_only_the_new_instances(scale_harness): + """C-2: bao の token と ./deploy は current + 1 から new まで。既存のインスタンスを含めない。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + + assert container.cmd_scale(4) == 0 + + calls = dict((name, payload) for name, payload in scale_harness['calls'] if name != 'run') + assert calls['bao'] == {'args': ('proj', 4, 'dev'), + 'compose_file': scale_harness['override'], 'start': 2} + assert calls['deploy'] == [2, 3, 4] + + +def test_scale_skips_deploy_without_the_script(scale_harness): + """``./deploy`` が無ければ走らせない。bao の token は書く。""" + assert container.cmd_scale(2) == 0 + + names = [name for name, _ in scale_harness['calls']] + assert 'bao' in names and 'deploy' not in names + + +def test_scale_continues_after_deploy_failure_and_returns_zero(scale_harness, monkeypatch): + """現状固定: deploy の 2 が失敗しても 3 を実行し、scale 自体は 0 を返す。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + monkeypatch.setattr(container, '_run_deploy_script_for_instances', + scale_harness['real_deploy']) + deployed_indices = [] + + def run(cmd, **kwargs): + if cmd == ['bash', 'deploy']: + index = kwargs['env']['DEVBASE_INSTANCE_INDEX'] + deployed_indices.append(index) + if index == '2': + raise subprocess.CalledProcessError(1, cmd) + return subprocess.CompletedProcess(cmd, 0) + return scale_harness['fake'](cmd, **kwargs) + + monkeypatch.setattr(subprocess, 'run', run) + + assert container.cmd_scale(3) == 0 + assert deployed_indices == ['2', '3'] + + +def test_scale_never_stops_containers(scale_harness): + """E-3: scale は停止の段を持たない。down / stop / rm を呼ばない。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + + assert container.cmd_scale(3) == 0 + + runs = [payload['cmd'] for name, payload in scale_harness['calls'] if name == 'run'] + assert runs + for cmd in runs: + assert not {'down', 'stop', 'rm'} & set(cmd) + + +@pytest.mark.parametrize('new_scale', [0, 1]) +def test_scale_rejects_a_scale_not_above_current(scale_harness, new_scale): + """現状固定: 1 未満と現在以下は 1 を返し、project.yml を書き換えず、何も起動しない。""" + assert container.cmd_scale(new_scale) == 1 + + names = [name for name, _ in scale_harness['calls']] + assert names == ['group'] + assert (scale_harness['root'] / 'project.yml').read_text() == PROJECT_YML + + +def test_scale_passes_explicit_project_name(scale_harness, monkeypatch): + """現状固定: 明示的に指定された project_name が ensure_volumes, _build_scaled_override, _push_bao_token に渡る。""" + captured: dict = {} + + orig_volumes = container.ensure_volumes + + def fake_ensure_volumes(scale, project): + captured['volumes_project'] = project + return orig_volumes(scale, project) + + orig_build = container._build_scaled_override + + def fake_build(scale, config, project_name, target): + captured['build_project'] = project_name + return orig_build(scale, config, project_name, target) + + orig_push_bao = container._push_bao_token + + def fake_push_bao(project_name, *args, **kwargs): + captured['bao_project'] = project_name + return orig_push_bao(project_name, *args, **kwargs) + + monkeypatch.setattr(container, 'ensure_volumes', fake_ensure_volumes) + monkeypatch.setattr(container, '_build_scaled_override', fake_build) + monkeypatch.setattr(container, '_push_bao_token', fake_push_bao) + + assert container.cmd_scale(2, project_name='custom-proj') == 0 + + assert captured['volumes_project'] == 'custom-proj' + assert captured['build_project'] == 'custom-proj' + assert captured['bao_project'] == 'custom-proj' + diff --git a/tests/utils/test_docker_profiles.py b/tests/utils/test_docker_profiles.py index 02405093..4c1107fc 100644 --- a/tests/utils/test_docker_profiles.py +++ b/tests/utils/test_docker_profiles.py @@ -100,6 +100,12 @@ def test_up_names_the_given_services_without_profile(monkeypatch, fake_run): # --------------------------------------------------------------------------- # docker_compose を通らずに Compose を直接呼ぶ経路 (決定 7 の棚卸しの 4 か所) +# +# devbase が Compose を起動する経路は docker_compose とこの 4 つだけである (PLAN65 決定 1・2)。 +# _compose_run (ps / logs)・_compose_lines (config --services / --profiles)・ +# cmd_login (exec)・editor._query_container_name (ps --format json) は subprocess.run を +# 直接呼ぶため、env=compose_env() を自分で渡す。scale の起動と config --format json の +# 読み取りは docker_compose を通る (下の節と tests/commands/test_container_scale_order.py) # --------------------------------------------------------------------------- @pytest.fixture @@ -122,14 +128,34 @@ def test_ps_and_logs_pass_reserved_profile(container_run): assert [c['env']['COMPOSE_PROFILES'] for c in run.calls] == [docker.NO_PROFILE] * 2 -def test_compose_config_readers_pass_reserved_profile(container_run): + + +def test_compose_lines_pass_reserved_profile(container_run): container, run = container_run - container._resolve_dev_service() - container._read_compose_services() + container._compose_lines(Path('x.yml'), ['config', '--services']) + + assert run.calls[0]['cmd'] == ['docker', 'compose', '-f', 'x.yml', 'config', '--services'] + assert run.calls[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE - assert [c['cmd'][2] for c in run.calls] == ['config', 'config'] - assert [c['env']['COMPOSE_PROFILES'] for c in run.calls] == [docker.NO_PROFILE] * 2 + +@pytest.mark.parametrize('generated, expected_tail', [ + (True, ['exec', 'dev-2', 'bash']), + (False, ['exec', '--index=2', 'dev', 'bash']), +]) +def test_login_passes_reserved_profile(container_run, monkeypatch, generated, expected_tail): + container, run = container_run + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + if generated: + container._SCALE_COMPOSE_FILE.write_text('services: {}\n') + + container.cmd_login('2') + + assert run.calls[0]['cmd'][-len(expected_tail):] == expected_tail + assert run.calls[0]['env'] is not None + assert run.calls[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE + import os + assert os.environ['COMPOSE_PROFILES'] == 'test' def test_editor_container_name_query_passes_reserved_profile(monkeypatch): @@ -141,3 +167,50 @@ def test_editor_container_name_query_passes_reserved_profile(monkeypatch): assert run.calls[0]['cmd'][-4:] == ['ps', '--format', 'json', 'dev-1'] assert run.calls[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE + + +# --------------------------------------------------------------------------- +# config --format json の読み取り (PLAN65 決定 9)。docker_compose を通る唯一の関数に寄せる +# --------------------------------------------------------------------------- + +def test_compose_config_readers_pass_reserved_profile(container_run): + container, run = container_run + + container._resolve_dev_service() + container._compose_config_services() + + assert [c['cmd'] for c in run.calls] == [['docker', 'compose', 'config', '--format', 'json']] * 2 + assert [c['env']['COMPOSE_PROFILES'] for c in run.calls] == [docker.NO_PROFILE] * 2 + + +@pytest.mark.parametrize('returncode, stdout, expected', [ + (1, 'not json', (1, {})), + (0, '{"services": {"dev": {"image": "x"}}}', (0, {'dev': {'image': 'x'}})), +]) +def test_compose_config_services_contract(container_run, returncode, stdout, expected): + container, run = container_run + run.returncode, run.stdout = returncode, stdout + + assert container._compose_config_services() == expected + + +def test_compose_config_services_propagates_unreadable_json(container_run): + import json + container, run = container_run + run.stdout = 'not json' + + with pytest.raises(json.JSONDecodeError): + container._compose_config_services() + + +@pytest.mark.parametrize('returncode, stdout, expected', [ + (1, '{"services": {"dev": {"image": "x"}}}', None), + (0, 'not json', None), + (0, '{"services": {"dev": {"image": "x"}}}', {'image': 'x'}), +]) +def test_resolve_dev_service_contract(container_run, monkeypatch, returncode, stdout, expected): + container, run = container_run + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + run.returncode, run.stdout = returncode, stdout + + assert container._resolve_dev_service() == expected