From 557f412f0d3c6a215ab40eff651476d5da12b91f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 05:36:56 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(PLAN58):=20devbase=20=E7=B5=8C?= =?UTF-8?q?=E7=94=B1=E3=81=AE=20Compose=20=E3=81=AE=E6=9C=89=E5=8A=B9?= =?UTF-8?q?=E3=81=AA=E3=83=97=E3=83=AD=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=82=92=20devbase=20=E3=81=8C=E6=B1=BA=E3=82=81=E3=82=8B=20(#?= =?UTF-8?q?189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 子プロセスの COMPOSE_PROFILES へ打ち消し用のプロファイル名を入れる (決定 7) - devbase up の起動は既定のサービスを明示し、停止は --profile '*' で全体を対象にする - プロファイル名とサービスの対応を docker compose config で解決する (決定 1) - 実装計画 issues/PLAN58_compose-profiles-impl.md を追加 Co-Authored-By: Claude Opus 5 (1M context) --- issues/PLAN58_compose-profiles-impl.md | 137 +++++++++++++++++++++ lib/devbase/commands/container.py | 58 ++++++++- lib/devbase/editor/opener.py | 4 + lib/devbase/utils/docker.py | 35 +++++- tests/cli/test_up_roundtrips.py | 1 + tests/cli/tui/test_dispatch.py | 1 + tests/commands/test_container_bao.py | 1 + tests/commands/test_container_context.py | 1 + tests/commands/test_container_profile.py | 143 ++++++++++++++++++++++ tests/commands/test_container_up_order.py | 28 +++++ tests/utils/test_docker_profiles.py | 143 ++++++++++++++++++++++ 11 files changed, 544 insertions(+), 8 deletions(-) create mode 100644 issues/PLAN58_compose-profiles-impl.md create mode 100644 tests/commands/test_container_profile.py create mode 100644 tests/utils/test_docker_profiles.py diff --git a/issues/PLAN58_compose-profiles-impl.md b/issues/PLAN58_compose-profiles-impl.md new file mode 100644 index 00000000..592741ba --- /dev/null +++ b/issues/PLAN58_compose-profiles-impl.md @@ -0,0 +1,137 @@ +# PLAN58: Compose の profiles で付随サービス群を後から起動・停止する — 実装計画 + +## 関連リンク + +- 課題: devbasex/devbase#189 +- 要求と受け入れ条件: [PLAN58_compose-profiles.md](PLAN58_compose-profiles.md) +- 設計: [PLAN58_compose-profiles-design.md](PLAN58_compose-profiles-design.md) +- 決定の記録: [PLAN58_compose-profiles-decisions.md](PLAN58_compose-profiles-decisions.md) +- 設計 Pull Request: devbasex/devbase#190(マージ済み・2026-09-17 承認) + +## モード + +`standard`。公開インタフェース(`devbase project profile` / `container profile`、TUI の操作メニュー、フックの環境変数)を追加し、`up` / `down` の本番の振る舞いを変える。 + +## 目的と非目的 + +達成したい状態は要求仕様の「目的」のとおりである。受け入れ条件・前提・対象範囲は要求仕様を唯一の置き場とし、ここへ写さない。 + +やらないこと(要求仕様の「含まない」に加えて、この計画で決めたもの): + +- `cmd_scale` が直接呼ぶ `compose up -d --no-recreate` の `env=`(要求仕様で範囲外) +- `docker_compose_down` の引数追加(決定 5) +- `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS` の変更(決定 6) + +## 修正対象 + +| ファイル | 変更 | +| --- | --- | +| `lib/devbase/utils/docker.py` | `compose_env()` を新設。`docker_compose` が `env=` を渡す。`docker_compose_up` が `services` を受ける。`docker_compose_down` が `--profile '*'` を足す | +| `lib/devbase/commands/container.py` | `default_services` / `profile_services` / `_dev_instance_indices` / `cmd_profile_up` / `cmd_profile_down` / `cmd_profile_list` を新設。`_compose_run` / `_resolve_dev_service` / `_read_compose_services` が `env=` を渡す。`_run_deploy_pipeline` が既定のサービスを渡す。`_run_deploy_script_for_instances` が成否と `active_profiles` を持つ。`_dispatch_lifecycle` に `profile` | +| `lib/devbase/editor/opener.py` | `ps --format json` の呼び出しへ `env=` を渡す | +| `lib/devbase/project/runtime.py` | `hook_env(config, active_profiles=())` | +| `lib/devbase/cli.py` | `project profile` / `container profile` の subparser(`dest='profile_subcommand'`)、`SUBCMD_MAP` | +| `lib/devbase/tui/actions_project.py` | `_running_ops(devbase_root, name)` で 2 項目を出し分け、プロファイル名の選択と委譲 | +| `docs/plugin-dev/compose-profiles.md` | 新設。プロジェクト作者向けの書き方(`profiles:`、`required: false`、予約名 `__devbase_none__`、最低対応版 2.20.0) | +| `docs/plugin-dev/quickstart.md` | フックへ渡る環境変数の表へ `DEVBASE_ACTIVE_PROFILES` | +| `tests/utils/test_docker_profiles.py` | 新設 | +| `tests/commands/test_container_profile.py` | 新設 | +| `tests/cli/tui/test_profile_menu.py` | 新設 | +| `tests/volume/test_compose_profiles.py` | 新設 | +| `tests/commands/test_hook_env.py` | `DEVBASE_ACTIVE_PROFILES` の検査を追加 | +| 既存の `up` のテストの harness(`test_container_up_order.py` / `test_up_roundtrips.py` / `test_container_context.py` / `test_container_bao.py` / `tui/test_dispatch.py`) | `default_services` を差し替える(実 docker へ問い合わせないため) | + +## タスク分解 + +機能単位で切る。各タスクは「失敗するテスト → 通す最小実装 → 整理」で進め、終わるたびに `uv run pytest tests/ -q` を通す。 + +### Task 1: devbase 経由の Compose へ打ち消し用のプロファイル名を渡す(決定 7 の 1) + +- **対象:** `utils/docker.py`、`commands/container.py`(`_compose_run` / `_resolve_dev_service` / `_read_compose_services`)、`editor/opener.py` +- **変更:** `compose_env(environ=None) -> dict` を `utils/docker.py` に置き、`os.environ` の複製の `COMPOSE_PROFILES` を `__devbase_none__` にして返す。定数 `NO_PROFILE = '__devbase_none__'`。5 経路が `env=compose_env()` を渡す +- **満たす受け入れ条件:** 停止の網羅「`COMPOSE_PROFILES` が端末の環境変数に設定…」「`.env` に書かれた…」の自動検査の部分 +- **テスト:** `COMPOSE_PROFILES=test` を `monkeypatch.setenv` で置き、各経路の `subprocess.run` に渡った `env['COMPOSE_PROFILES']` が打ち消し用の名前であること。他の環境変数が保たれること + +### Task 2: プロファイルの解決(決定 1) + +- **対象:** `commands/container.py` +- **変更:** `default_services(compose_file, environ=None) -> list[str]`(`config --services`)と `profile_services(compose_file, environ=None) -> dict[str, list[str]]`(`config --profiles` → 各 X で `--profile X config --services` から既定を差し引く)。どちらも `compose_env` を渡し、失敗は `DevbaseError` にする(呼び出し側で扱いを分ける)。並びは Compose の出力順を保つ +- **満たす受け入れ条件:** `profile list` の名前と対応、変数の式を含む `profiles` の名前一致(テスト設計の該当行) +- **テスト:** `subprocess.run` を偽物に差し替え、`config --profiles` / `config --services` の出力から対応が作られること。引数列に `-f <生成物>` と `--profile X` が subcommand より前に並ぶこと + +### Task 3: `up` の起動は既定のサービスを明示し、停止は全プロファイルを対象にする(F4、決定 4・7 の 2) + +- **対象:** `utils/docker.py`(`docker_compose_up` / `docker_compose_down`)、`commands/container.py`(`_run_deploy_pipeline`)、既存テストの harness +- **変更:** `docker_compose_up(compose_file, detach=True, services=())` が `['up', '-d', *services]` を組む。`docker_compose_down` は `['--profile', '*', 'down', '-t0']`。`_run_deploy_pipeline` は生成直後に `default_services(override_file)` を求めて渡す +- **満たす受け入れ条件:** 停止の網羅の 6 件(自動検査の部分)、退行しないこと +- **テスト:** `docker_compose_down` の引数列、`docker_compose_up` の引数列(空なら従来どおり `['up', '-d']`)、`_run_deploy_pipeline` が既定のサービスを `docker_compose_up` へ渡すこと、起動の引数列に `--profile` が入らないこと + +### Task 4: フックへ有効なプロファイルを伝える(F5、決定 3) + +- **対象:** `project/runtime.py`、`commands/container.py`(`_run_deploy_script_for_instances`) +- **変更:** `hook_env(config, active_profiles=())` が `DEVBASE_ACTIVE_PROFILES=','.join(active_profiles)` を足す。`_run_deploy_script_for_instances(..., active_profiles=()) -> bool`。`config` が無いときも `DEVBASE_ACTIVE_PROFILES` は渡す。`cmd_up` / `cmd_scale` は戻り値を使わない +- **満たす受け入れ条件:** フックの「`./pre-up` と `./deploy` は受け取る。`up` からは空」、「フックの失敗が終了コードへ出る」の関数側 +- **テスト:** `tests/commands/test_hook_env.py` の既存の dump スクリプトで値を確かめる。失敗する `./deploy` で `False` が返ること + +### Task 5: `cmd_profile_up` / `cmd_profile_down` / `cmd_profile_list`(F1〜F3、決定 2・5) + +- **対象:** `commands/container.py` +- **変更:** + - 共通の前段: `_prepare_compose(context)` → 生成物が無ければ `devbase up` を促して 1 → `profile_services` → 未知の名前なら既知の一覧を出して 1 + - `up`: `docker_compose(['--profile', X, 'up', '-d', '--no-deps', *services], check=False)`。0 以外ならその終了コードを返す。`./deploy` があれば `_dev_instance_indices(生成物)`(`get_dev_service_name()` の `<名前>-<数字>` を生成物の `services` から読む)へ `active_profiles=(X,)` で走らせ、1 つでも失敗すれば 1 + - `down`: `stop` → 失敗なら 1(`rm` を呼ばない)→ `rm -f`。フックは呼ばない + - `list`: `ps --format json` を `compose_env` で呼び、`State == running` のサービスを数えて `PROFILE / SERVICES / RUNNING` の表を出す。`ps` が失敗したら RUNNING を `不明` にして 0。生成物が無ければ 1 + - 起動・停止の対象を `logger.info` で 1 行ずつ残す +- **満たす受け入れ条件:** 起動と停止の自動検査の部分すべて、フックの `profile up` 側(`DEVBASE_ACTIVE_PROFILES=X`、生成物の番号で全インスタンス、`DEV_SERVICE_NAME=workspace`、`./pre-up` を呼ばない、失敗で 0 以外) +- **テスト:** `tests/commands/test_container_profile.py`。偽の `subprocess.run` で引数列・呼び出し回数・終了コード・出力を検査する(テスト設計の表の該当行を 1 つずつ) + +### Task 6: 引数の受け口(決定 6) + +- **対象:** `cli.py`、`commands/container.py`(`_dispatch_lifecycle`) +- **変更:** `project profile {up,down} [name] ` と `project profile list [name]`、`container profile {up,down} ` / `list`。入れ子は `dest='profile_subcommand'`。`--context` も付ける。`_dispatch_lifecycle` の handlers へ `'profile'` を足し、`profile_subcommand` で振り分ける。`SUBCMD_MAP` の `project` / `container` へ `profile` を足す +- **満たす受け入れ条件:** `project profile up <プロジェクト> X` が現在地と同じ結果、`container` / `ct` が同じ結果で非推奨の警告 1 行、`project profile list` が `project list` へ流れない +- **テスト:** 既存の `tests/cli/test_project_dispatch.py` の書き方に合わせ、解析結果と呼ばれたハンドラの引数を検査する + +### Task 7: 一覧の操作メニュー(F6、決定 8) + +- **対象:** `tui/actions_project.py` +- **変更:** `_running_ops(devbase_root, name) -> list` を新設し、`projects//.docker-compose.scale.yml` があり `profile_services` が 1 件以上返すときだけ「テスト用サーバ起動 (profile up)」「テスト用サーバ停止 (profile down)」を足す。解決はそのプロジェクトのディレクトリを作業ディレクトリにして行い、失敗は「持たない」として扱う。選択後は `menu.select` でプロファイル名を選ばせ(1 件でも選択を出す)、`dispatch_lifecycle('profile', name, profile_subcommand=..., profile=...)` へ委譲する。2 項目は `_BACK_TO_TOP_OPS` に入れる +- **満たす受け入れ条件:** TUI の 6 件の自動検査の部分 +- **テスト:** `tests/cli/tui/test_profile_menu.py`。`profile_services` を差し替えて項目の出し分け、委譲の属性、`back_after` を検査する。フォールバック(番号入力)は既存テストが通ること + +### Task 8: 生成物が `profiles` と `depends_on.required` を保つことの固定 + +- **対象:** `tests/volume/test_compose_profiles.py`(実装の変更は想定しない) +- **満たす受け入れ条件:** 退行しないこと「生成物は `profiles:` を保つ」、テスト設計「生成物が `depends_on` の `required` を保つ」 +- **進め方:** 現状固定テスト。失敗したときだけ `volume/compose.py` を直す + +### Task 9: プロジェクト作者向けの文書 + +- **対象:** `docs/plugin-dev/compose-profiles.md`(新設)、`docs/plugin-dev/quickstart.md`、`docs/README.md` の索引 +- **満たす受け入れ条件:** 対象範囲「プロファイルを使うプロジェクト作者向けの文書」 +- **進め方:** テスト駆動は当たらない(文書)。コマンド例は Task 6 の実装と突き合わせる + +### Task 10: 手動確認 + +- **対象:** 要求仕様「検証手段」の手動確認 7 行 +- **進め方:** `alpine:3` の最小構成を scratchpad に作り、この作業ツリーの `bin/devbase` で通す。結果(Container ID / `StartedAt` の前後)は Pull Request 本文へ貼る。実 docker を使うため、自分のプロジェクトとは別の `COMPOSE_PROJECT_NAME` で行う + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `commands/container.py` は 1962 行あり、Task 1〜6 の多くが触る | 先に整える対象にはしない。追加は既存の関数の並び(ヘルパー → dispatch → `cmd_*`)へ足し、構造の見直しは構造改善(`cross-refactoring`)へ回す。タスクごとに全テストを通す | +| `_run_deploy_pipeline` が `config` を呼ぶため、既存テストの harness が実 docker へ問い合わせる | Task 3 で各 harness へ `default_services` の差し替えを足す。足し漏れは `docker` の無い環境で失敗として現れるため、差し替え前にテストを流して対象を洗い出す | +| TUI で行を選ぶたびに `docker compose config` が 2 回以上走る | 起動中の行を選んだときだけ呼ぶ。遅さが目立てば構造改善で対応を検討する | +| 生成物が `${VAR:?}` を含み、機密を注入しない TUI の解決で `config` が失敗する | 失敗は「プロファイルを持たない」として 2 項目を出さない。CLI の `profile list` では注入済みで呼ぶ | +| `--profile '*'` を古い Compose が解釈しない | 設計の「未確認のまま残ること」のまま。手動確認は v5.1.4 で行う | + +## 切り戻し手順 + +データ移行は無い。Pull Request を revert すれば戻る。プロジェクト側の `compose.yml` に書いた `profiles:` は、revert 後の devbase では従来の Compose の挙動(`up` で起動しない・`down` で残る)に戻る。 + +## 完了の定義 + +- [ ] 要求仕様の受け入れ条件がすべて満たされ、条件ごとに自動テストか手動確認の結果が対応している +- [ ] `uv run pytest tests/ -q` と `uv run ruff check lib/ tests/` が通る +- [ ] 手動確認 7 行の結果を Pull Request 本文へ記録した diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index c7bb62d0..789fac4c 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -20,6 +20,7 @@ get_dev_service_name, ) from devbase.utils.docker import ( + compose_env, docker_compose, docker_compose_down, docker_compose_up, @@ -274,7 +275,52 @@ def _compose_run(subcommand: str, *extra_args: str, cmd = _compose_base_args(compose_file) cmd.append(subcommand) cmd.extend(extra_args) - return subprocess.run(cmd).returncode + # 表示するサービスの集合を利用者の COMPOSE_PROFILES に左右させない (PLAN58 決定 7) + return subprocess.run(cmd, env=compose_env()).returncode + + +def _compose_lines(compose_file: Path, args: list[str], environ=None) -> list[str]: + """``docker compose -f `` の標準出力を行の一覧で返す。 + + 有効なプロファイルは ``args`` の ``--profile`` だけで決まる (PLAN58 決定 7)。 + 失敗は :class:`DevbaseError` にする。扱い (止める / 項目を出さない) は呼び出し側が決める。 + """ + cmd = [*_compose_base_args(compose_file), *args] + result = subprocess.run(cmd, capture_output=True, text=True, check=False, + env=compose_env(environ)) + if result.returncode != 0: + raise DevbaseError( + f"docker compose {' '.join(args)} failed: {(result.stderr or '').strip()}") + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def default_services(compose_file: Path, environ=None) -> list[str]: + """``profiles`` を持たない既定のサービス名を返す (PLAN58 決定 1)。 + + ``devbase up`` の起動はこの一覧を明示して渡す。プロファイルが何らかの形で有効に + なっても、一覧に無いサービスは起動しない (決定 7)。 + """ + return _compose_lines(compose_file, ['config', '--services'], environ) + + +def profile_services(compose_file: Path, environ=None) -> dict[str, list[str]]: + """プロファイル名 → そのプロファイルに属するサービス名 の対応を返す (PLAN58 決定 1)。 + + 生成物を自分で読まず Compose に解決させる。``profiles: ["${X:-test}"]`` のような + 変数の式は Compose が展開する。``config --format json`` は有効でないプロファイルの + サービスを含まないため、プロファイルごとに ``--services`` を問い合わせて既定の + サービスを差し引く。デーモンへの接続は要らない。 + """ + names = _compose_lines(compose_file, ['config', '--profiles'], environ) + if not names: + return {} + defaults = set(default_services(compose_file, environ)) + return { + name: [service for service in _compose_lines( + compose_file, ['--profile', name, 'config', '--services'], environ) + if service not in defaults] + for name in names + } def _run_deploy_script_for_instances(deploy_script: Path, indices, @@ -1071,7 +1117,10 @@ def _run_deploy_pipeline(project_name: str, scale: int, config, docker_compose_down(compose_file=down_compose_file) logger.info("[4/6] Starting containers...") - docker_compose_up(compose_file=override_file, detach=True) + # 起動の対象は既定のサービスに限る。端末や .env の COMPOSE_PROFILES でプロファイルが + # 有効になっても、プロファイルのサービスは起動しない (PLAN58 決定 7) + docker_compose_up(compose_file=override_file, detach=True, + services=default_services(override_file)) logger.info("[5/6] Waiting for containers to be ready...") wait_for_containers_ready( @@ -1428,7 +1477,7 @@ 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 + capture_output=True, text=True, check=False, env=compose_env(), ) if result.returncode != 0: return None @@ -1608,7 +1657,8 @@ def _read_compose_services() -> tuple[int, dict]: ['docker', 'compose', 'config', '--format', 'json'], capture_output=True, text=True, - check=False + check=False, + env=compose_env(), ) if result.returncode != 0: return result.returncode, {} diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 78f5af63..ef65fe98 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -50,6 +50,7 @@ from typing import Callable, Optional from devbase.log import get_logger +from devbase.utils.docker import compose_env logger = get_logger(__name__) @@ -400,6 +401,9 @@ def _query_container_name(dev_service_name: str, index: int, proc = run( cmd, capture_output=True, text=True, timeout=10, + # ps が解釈するサービスの集合を利用者の COMPOSE_PROFILES に左右させない + # (PLAN58 決定 7) + env=compose_env(), ) except Exception: # noqa: BLE001 - docker 不在等は保険なので握り潰す return None diff --git a/lib/devbase/utils/docker.py b/lib/devbase/utils/docker.py index 754ccc7c..5fc0e60d 100644 --- a/lib/devbase/utils/docker.py +++ b/lib/devbase/utils/docker.py @@ -1,15 +1,34 @@ """Docker command utilities for devbase""" +import os import subprocess import time from pathlib import Path -from typing import List, Optional, Tuple +from typing import Dict, List, Mapping, Optional, Sequence, Tuple from devbase.errors import DockerError from devbase.log import get_logger logger = get_logger("devbase.utils.docker") +#: devbase 経由の Compose へ ``COMPOSE_PROFILES`` として渡す、打ち消し用のプロファイル名 +#: (PLAN58 決定 7)。どのプロジェクトも定義しない名前で、利用者の端末の値とプロジェクトの +#: ``.env`` の値をどちらも無効にする。有効なプロファイルは経路ごとに ``--profile`` で決める。 +NO_PROFILE = '__devbase_none__' + + +def compose_env(environ: Optional[Mapping[str, str]] = None) -> Dict[str, str]: + """devbase が ``docker compose`` を呼ぶときの子プロセスの環境を返す。 + + ``environ`` (既定は ``os.environ``) の複製の ``COMPOSE_PROFILES`` を + :data:`NO_PROFILE` にする。キーを外すだけでは足りない。Compose は環境変数が + 無ければプロジェクトの ``.env`` の値を採るため、値を入れて上書きする。空文字列に + しないのは、空の解釈 (空の一覧 / 未設定) が版に依るかを調べずに済ませるため。 + """ + env = dict(os.environ if environ is None else environ) + env['COMPOSE_PROFILES'] = NO_PROFILE + return env + def docker_compose( command: List[str], @@ -46,7 +65,8 @@ def docker_compose( cmd, capture_output=capture_output, text=True, - check=check + check=check, + env=compose_env(), ) return result except subprocess.CalledProcessError as e: @@ -202,11 +222,14 @@ def docker_compose_down(compose_file: Optional[Path] = None) -> None: """ Stop and remove containers using docker compose down + プロファイルのサービスも対象にするため ``--profile '*'`` を付ける (PLAN58 決定 4)。 + 付けないと、プロファイル付きのサービスが動いたまま残り、network の削除にも失敗する。 + Args: compose_file: Compose file path (optional) """ try: - docker_compose(['down', '-t0'], compose_file=compose_file, check=True) + docker_compose(['--profile', '*', 'down', '-t0'], compose_file=compose_file, check=True) except subprocess.CalledProcessError as e: # Don't raise exception if down fails (containers might not exist) if e.returncode != 0: @@ -215,7 +238,8 @@ def docker_compose_down(compose_file: Optional[Path] = None) -> None: def docker_compose_up( compose_file: Optional[Path] = None, - detach: bool = True + detach: bool = True, + services: Sequence[str] = (), ) -> None: """ Start containers using docker compose up @@ -223,10 +247,13 @@ def docker_compose_up( Args: compose_file: Compose file path (optional) detach: Run in detached mode + services: 起動の対象。``devbase up`` は既定のサービスをすべて渡す (PLAN58 決定 7)。 + 空なら従来どおりサービス名を付けない """ cmd = ['up'] if detach: cmd.append('-d') + cmd.extend(services) docker_compose(cmd, compose_file=compose_file, check=True) diff --git a/tests/cli/test_up_roundtrips.py b/tests/cli/test_up_roundtrips.py index 7c69e5ec..8f293553 100644 --- a/tests/cli/test_up_roundtrips.py +++ b/tests/cli/test_up_roundtrips.py @@ -76,6 +76,7 @@ def up_root(openbao_root, openbao, monkeypatch): 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, 'default_services', lambda *a, **k: ['dev-1']) 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) diff --git a/tests/cli/tui/test_dispatch.py b/tests/cli/tui/test_dispatch.py index a89f68d9..f4a64ce9 100644 --- a/tests/cli/tui/test_dispatch.py +++ b/tests/cli/tui/test_dispatch.py @@ -183,6 +183,7 @@ def test_lifecycle_after_env_edit_reads_written_values(openbao_root, openbao, mo 'docker_compose_up', 'wait_for_containers_ready', '_apply_window_titles', '_report_missing_repos', '_maybe_open_editor'): monkeypatch.setattr(container, name, lambda *a, **k: None) + monkeypatch.setattr(container, 'default_services', lambda *a, **k: ['dev-1']) def fake_generate(scale, secrets, dev_environment=None, **kw): seen['secrets'] = secrets diff --git a/tests/commands/test_container_bao.py b/tests/commands/test_container_bao.py index 6a730b59..1ef5b61e 100644 --- a/tests/commands/test_container_bao.py +++ b/tests/commands/test_container_bao.py @@ -45,6 +45,7 @@ def _harness(monkeypatch, root: Path, seen: dict): 'docker_compose_up', 'wait_for_containers_ready', '_apply_window_titles', '_report_missing_repos', '_maybe_open_editor'): monkeypatch.setattr(container, name, lambda *a, **k: None) + monkeypatch.setattr(container, 'default_services', lambda *a, **k: ['dev-1']) # compose ps を叩かず、決定的な名前へ落とす monkeypatch.setattr('devbase.editor.opener._query_container_name', lambda *a, **k: None) diff --git a/tests/commands/test_container_context.py b/tests/commands/test_container_context.py index 45c380e9..994466fe 100644 --- a/tests/commands/test_container_context.py +++ b/tests/commands/test_container_context.py @@ -91,6 +91,7 @@ def _f(*a, **k): monkeypatch.setattr(container, 'ensure_network', record('network')) monkeypatch.setattr(container, 'docker_compose_down', record('down')) monkeypatch.setattr(container, 'docker_compose_up', record('up')) + monkeypatch.setattr(container, 'default_services', lambda *a, **k: ['dev-1']) monkeypatch.setattr(container, 'wait_for_containers_ready', record('wait')) monkeypatch.setattr(container, '_apply_window_titles', record('titles')) monkeypatch.setattr(container, '_report_missing_repos', lambda *a, **k: None) diff --git a/tests/commands/test_container_profile.py b/tests/commands/test_container_profile.py new file mode 100644 index 00000000..986c271a --- /dev/null +++ b/tests/commands/test_container_profile.py @@ -0,0 +1,143 @@ +"""プロファイルの解決と操作 (PLAN58) + +実 docker には触れない。``subprocess.run`` を偽の Compose に差し替え、組み立てた +コマンド列・終了コード・出力を検査する。dev の Container ID が変わらないことは実 +コンテナが要るため手動確認で見る (要求仕様「検証手段」)。 +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from devbase.commands import container +from devbase.errors import DevbaseError +from devbase.utils.docker import NO_PROFILE + +COMPOSE = Path('.docker-compose.scale.yml') + + +class FakeCompose: + """``docker compose`` の問い合わせに答え、呼び出しを記録する偽物。 + + ``profiles`` はプロファイル名 → サービス名 (宣言順)。``defaults`` は既定の + サービス。``running`` は ``ps`` が ``running`` と答えるサービス名。 + ``fail`` は subcommand 名 → 終了コード (``ps`` / ``up`` / ``stop`` / ``rm``)。 + """ + + def __init__(self, profiles=None, defaults=('dev-1',), running=(), fail=None): + self.profiles = dict(profiles or {}) + self.defaults = list(defaults) + self.running = set(running) + self.fail = dict(fail or {}) + self.calls: list[dict] = [] + + @property + def compose_calls(self): + return [c for c in self.calls if c['cmd'][:2] == ['docker', 'compose']] + + def _args(self, cmd): + """``-f `` と ``--profile X`` を取り除いた引数と、指定されたプロファイル""" + rest, profiles, i = [], [], 2 + while i < len(cmd): + if cmd[i] in ('-f', '--profile'): + if cmd[i] == '--profile': + profiles.append(cmd[i + 1]) + i += 2 + continue + rest.append(cmd[i]) + i += 1 + return rest, profiles + + def __call__(self, cmd, **kwargs): + cmd = list(cmd) + self.calls.append({'cmd': cmd, **kwargs}) + if cmd[:2] != ['docker', 'compose']: + return subprocess.CompletedProcess(cmd, 0, '', '') + args, profiles = self._args(cmd) + sub = args[0] + if sub in self.fail: + return subprocess.CompletedProcess(cmd, self.fail[sub], '', 'boom') + out = '' + if args[:2] == ['config', '--profiles']: + out = ''.join(f'{name}\n' for name in sorted(self.profiles)) + elif args[:2] == ['config', '--services']: + names = list(self.defaults) + for p in profiles: + names += self.profiles.get(p, []) + out = ''.join(f'{name}\n' for name in sorted(set(names))) + elif sub == 'ps': + out = ''.join(json.dumps({'Service': s, 'State': 'running'}) + '\n' + for s in sorted(self.running)) + return subprocess.CompletedProcess(cmd, 0, out, '') + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(container, '_prepare_compose', lambda context=None: None) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + return tmp_path + + +def use(monkeypatch, fake: FakeCompose) -> FakeCompose: + monkeypatch.setattr(container.subprocess, 'run', fake) + return fake + + +# --------------------------------------------------------------------------- +# Task 2: プロファイルの解決 (決定 1) +# --------------------------------------------------------------------------- + +def test_default_services_come_from_config_with_reserved_profile(project, monkeypatch): + fake = use(monkeypatch, FakeCompose(profiles={'test': ['app']}, + defaults=['dev-1', 'dev-2', 'redis'])) + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + + assert container.default_services(COMPOSE) == ['dev-1', 'dev-2', 'redis'] + + call = fake.calls[0] + assert call['cmd'] == ['docker', 'compose', '-f', str(COMPOSE), 'config', '--services'] + assert call['env']['COMPOSE_PROFILES'] == NO_PROFILE + + +def test_profile_services_subtract_default_services(project, monkeypatch): + fake = use(monkeypatch, FakeCompose( + profiles={'test': ['app', 'mysql'], 'cache': ['valkey']}, + defaults=['dev-1', 'redis'])) + + assert container.profile_services(COMPOSE) == { + 'cache': ['valkey'], + 'test': ['app', 'mysql'], + } + # --profile は subcommand より前に置く (docker_compose は -f の後にそのまま並べる) + profiled = [c['cmd'] for c in fake.calls if '--profile' in c['cmd']] + assert ['docker', 'compose', '-f', str(COMPOSE), '--profile', 'test', + 'config', '--services'] in profiled + assert all(c['env']['COMPOSE_PROFILES'] == NO_PROFILE for c in fake.calls) + + +def test_profile_services_use_names_expanded_by_compose(project, monkeypatch): + # profiles: ["${TEST_PROFILE:-test}"] の展開は Compose が行う。devbase は + # config --profiles が返した名前をそのまま使う + use(monkeypatch, FakeCompose(profiles={'demo': ['app']})) + + assert list(container.profile_services(COMPOSE)) == ['demo'] + + +def test_profile_services_empty_without_profiles(project, monkeypatch): + use(monkeypatch, FakeCompose()) + + assert container.profile_services(COMPOSE) == {} + + +def test_resolution_failure_raises(project, monkeypatch): + use(monkeypatch, FakeCompose(fail={'config': 15})) + + with pytest.raises(DevbaseError): + container.profile_services(COMPOSE) + with pytest.raises(DevbaseError): + container.default_services(COMPOSE) diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py index e9835233..8c58f36d 100644 --- a/tests/commands/test_container_up_order.py +++ b/tests/commands/test_container_up_order.py @@ -67,6 +67,9 @@ def up_harness(tmp_path, monkeypatch): monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) monkeypatch.setattr(container, 'ensure_network', lambda *a, **k: None) monkeypatch.setattr(container, 'docker_compose_up', lambda **k: calls.append(('up', k))) + # PLAN58: 起動の対象は生成物を docker compose config で読んで決める。実 docker に触れない + monkeypatch.setattr(container, 'default_services', + lambda compose_file, environ=None: ['dev-1']) monkeypatch.setattr(container, 'wait_for_containers_ready', lambda **k: None) monkeypatch.setattr(container, '_maybe_open_editor', lambda *a, **k: None) # PLAN54: 実行シェルの DEVBASE_ROOT (利用者の実環境) の backend を読まない @@ -287,3 +290,28 @@ def stop_here(*args, **kwargs): assert 'scale: 2' in (mismatch['project'] / 'project.yml').read_text() assert ('volumes',) in mismatch['calls'] + + +def test_up_names_default_services_from_generated_compose(up_harness, monkeypatch): + """PLAN58 決定 7: 起動は生成物の既定のサービスをすべて明示して渡す。""" + calls = up_harness + asked = [] + + def fake_generate(scale, secrets, dev_environment=None): + container._SCALE_COMPOSE_FILE.write_text(NEW_COMPOSE) + return container._SCALE_COMPOSE_FILE + + def fake_defaults(compose_file, environ=None): + asked.append(Path(compose_file).read_text()) + return ['dev-1', 'redis'] + + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: object()) + monkeypatch.setattr(container, '_generate_compose_for', fake_generate) + monkeypatch.setattr(container, 'default_services', fake_defaults) + + assert container.cmd_up() == 0 + + assert asked == [NEW_COMPOSE] # 生成した新構成から求める + up = [k for name, k in calls if name == 'up'] + assert up == [{'compose_file': container._SCALE_COMPOSE_FILE, 'detach': True, + 'services': ['dev-1', 'redis']}] diff --git a/tests/utils/test_docker_profiles.py b/tests/utils/test_docker_profiles.py new file mode 100644 index 00000000..02405093 --- /dev/null +++ b/tests/utils/test_docker_profiles.py @@ -0,0 +1,143 @@ +"""devbase 経由の Compose の有効なプロファイルを devbase が決める (PLAN58 決定 7) + +利用者の端末の ``COMPOSE_PROFILES`` やプロジェクトの ``.env`` に書かれた値で、 +devbase が起動・停止・読み取りするサービスの集合が変わらないようにする。子プロセスの +``COMPOSE_PROFILES`` へ、どのプロジェクトも定義しない打ち消し用のプロファイル名を入れる。 +キーを外すだけでは Compose が ``.env`` の値を採るため足りない。 +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from devbase.utils import docker + + +class FakeRun: + """``subprocess.run`` の代わりに呼び出しを記録する。""" + + def __init__(self, returncode: int = 0, stdout: str = ''): + self.calls: list[dict] = [] + self.returncode = returncode + self.stdout = stdout + + def __call__(self, cmd, **kwargs): + self.calls.append({'cmd': list(cmd), **kwargs}) + return subprocess.CompletedProcess(cmd, self.returncode, self.stdout, '') + + +@pytest.fixture +def fake_run(monkeypatch): + run = FakeRun() + monkeypatch.setattr(docker.subprocess, 'run', run) + return run + + +def test_compose_env_overrides_profiles_with_reserved_name(monkeypatch): + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + monkeypatch.setenv('DEVBASE_SAMPLE', 'kept') + + env = docker.compose_env() + + assert env['COMPOSE_PROFILES'] == '__devbase_none__' + assert env['DEVBASE_SAMPLE'] == 'kept' + + +def test_compose_env_sets_reserved_name_even_when_unset(monkeypatch): + # キーが無いと Compose はプロジェクトの .env の COMPOSE_PROFILES を採る + monkeypatch.delenv('COMPOSE_PROFILES', raising=False) + + assert docker.compose_env()['COMPOSE_PROFILES'] == docker.NO_PROFILE + + +def test_compose_env_does_not_modify_process_environment(monkeypatch): + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + + docker.compose_env() + + import os + assert os.environ['COMPOSE_PROFILES'] == 'test' + + +def test_docker_compose_passes_reserved_profile_to_child(monkeypatch, fake_run): + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + + docker.docker_compose(['ps'], compose_file=Path('x.yml')) + + assert fake_run.calls[0]['env']['COMPOSE_PROFILES'] == '__devbase_none__' + + +def test_down_targets_every_profile(monkeypatch, fake_run): + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + + docker.docker_compose_down(compose_file=Path('x.yml')) + + call = fake_run.calls[0] + assert call['cmd'] == ['docker', 'compose', '-f', 'x.yml', + '--profile', '*', 'down', '-t0'] + assert call['env']['COMPOSE_PROFILES'] == '__devbase_none__' + + +def test_up_without_services_keeps_the_current_form(fake_run): + docker.docker_compose_up(compose_file=Path('x.yml')) + + assert fake_run.calls[0]['cmd'] == ['docker', 'compose', '-f', 'x.yml', 'up', '-d'] + + +def test_up_names_the_given_services_without_profile(monkeypatch, fake_run): + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + + docker.docker_compose_up(compose_file=Path('x.yml'), services=['dev-1', 'dev-2']) + + call = fake_run.calls[0] + assert call['cmd'] == ['docker', 'compose', '-f', 'x.yml', 'up', '-d', 'dev-1', 'dev-2'] + assert '--profile' not in call['cmd'] + assert call['env']['COMPOSE_PROFILES'] == '__devbase_none__' + + +# --------------------------------------------------------------------------- +# docker_compose を通らずに Compose を直接呼ぶ経路 (決定 7 の棚卸しの 4 か所) +# --------------------------------------------------------------------------- + +@pytest.fixture +def container_run(monkeypatch, tmp_path): + from devbase.commands import container + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + run = FakeRun(stdout='{"services": {}}') + monkeypatch.setattr(container.subprocess, 'run', run) + monkeypatch.setattr(container, '_prepare_compose', lambda context: None) + return container, run + + +def test_ps_and_logs_pass_reserved_profile(container_run): + container, run = container_run + + container._compose_run('ps') + container._compose_run('logs', '--tail', '5') + + assert [c['env']['COMPOSE_PROFILES'] for c in run.calls] == [docker.NO_PROFILE] * 2 + + +def test_compose_config_readers_pass_reserved_profile(container_run): + container, run = container_run + + container._resolve_dev_service() + container._read_compose_services() + + 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 + + +def test_editor_container_name_query_passes_reserved_profile(monkeypatch): + from devbase.editor import opener + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + run = FakeRun(stdout='') + + opener._query_container_name('dev', 1, compose_file='x.yml', runner=run) + + assert run.calls[0]['cmd'][-4:] == ['ps', '--format', 'json', 'dev-1'] + assert run.calls[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE From 4775a237cab0779731732e0523a607416c22cf93 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 05:37:32 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(PLAN58):=20=E3=83=95=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=81=B8=E6=9C=89=E5=8A=B9=E3=81=AA=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92=20DEVBASE=5FACTIV?= =?UTF-8?q?E=5FPROFILES=20=E3=81=A7=E4=BC=9D=E3=81=88=E3=82=8B=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 23 +++++++++-- lib/devbase/project/runtime.py | 17 +++++++- tests/commands/test_hook_env.py | 69 +++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 789fac4c..c7eaa472 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -323,14 +323,27 @@ def profile_services(compose_file: Path, environ=None) -> dict[str, list[str]]: } +def _hook_vars(config=None, active_profiles=()) -> dict: + """フックへ渡す環境変数。``config`` が無くても有効なプロファイルは伝える。""" + if config is None: + return project_runtime.active_profiles_env(active_profiles) + return project_runtime.hook_env(config, active_profiles=active_profiles) + + def _run_deploy_script_for_instances(deploy_script: Path, indices, - config=None) -> None: + config=None, active_profiles=()) -> bool: """デプロイスクリプトをスケールされた各インスタンスに対して実行する。 ``config`` (``project.yml``) を渡すと、clone 先やリポジトリ URL をフックへ - 環境変数で伝える (:func:`devbase.project.runtime.hook_env`)。 + 環境変数で伝える (:func:`devbase.project.runtime.hook_env`)。``active_profiles`` + は加工せずに渡す (PLAN58 決定 3)。 + + 失敗したインスタンスがあっても残りは実行し、全インスタンスで成功したかを返す。 + ``cmd_up`` / ``cmd_scale`` は戻り値を使わず警告だけにとどめ、``profile up`` は + 終了コードへ反映する。 """ - hook_vars = project_runtime.hook_env(config) if config is not None else {} + hook_vars = _hook_vars(config, active_profiles) + ok = True for i in indices: logger.info("[Bonus] Running deploy script for instance %d...", i) env = {**os.environ, **hook_vars, 'DEVBASE_INSTANCE_INDEX': str(i)} @@ -339,6 +352,8 @@ def _run_deploy_script_for_instances(deploy_script: Path, indices, logger.info("Deploy script completed for instance %d", i) except subprocess.CalledProcessError as e: logger.warning("Deploy script failed for instance %d (exit code %d)", i, e.returncode) + ok = False + return ok def _run_pre_up_hook(config=None) -> bool: @@ -361,7 +376,7 @@ def _run_pre_up_hook(config=None) -> bool: return True logger.info("Running pre-up hook: %s", pre_up_script) - hook_vars = project_runtime.hook_env(config) if config is not None else {} + hook_vars = _hook_vars(config) try: subprocess.run(['bash', str(pre_up_script)], check=True, env={**os.environ, **hook_vars}) diff --git a/lib/devbase/project/runtime.py b/lib/devbase/project/runtime.py index 21968ecd..fe75b0a8 100644 --- a/lib/devbase/project/runtime.py +++ b/lib/devbase/project/runtime.py @@ -11,7 +11,7 @@ import json import re from pathlib import Path -from typing import Any, Dict, Mapping +from typing import Any, Dict, Mapping, Sequence from devbase.errors import ConfigError from devbase.project.config import ( @@ -95,7 +95,18 @@ def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]: return env -def hook_env(config: ProjectConfig) -> Dict[str, str]: +def active_profiles_env(active_profiles: Sequence[str] = ()) -> Dict[str, str]: + """フックへ有効なプロファイルを伝える ``DEVBASE_ACTIVE_PROFILES`` を組み立てる (PLAN58 決定 3)。 + + 名前と区切り (カンマ) を決めるのはここだけである。``devbase up`` からは空、 + ``devbase project profile up X`` からは ``X`` 1 つが渡る。カンマ区切りは同時に + 複数を起動する操作を足すときのための予約で、現時点でその形になる経路は無い。 + 呼び出し元の環境に同名の値が残っていても上書きするため、空でもキーは必ず持つ。 + """ + return {"DEVBASE_ACTIVE_PROFILES": ",".join(active_profiles)} + + +def hook_env(config: ProjectConfig, active_profiles: Sequence[str] = ()) -> Dict[str, str]: """``pre-up`` / ``deploy`` フックへ渡す環境変数を組み立てる。 フックはホスト側で動き、clone 先のパスやリポジトリ URL を必要とすることが @@ -107,12 +118,14 @@ def hook_env(config: ProjectConfig) -> Dict[str, str]: - ``DEVBASE_PRIMARY_URL`` : primary repo の clone URL - ``DEVBASE_WORK_DIR`` : コンテナ内の既定の作業ディレクトリ - ``DEVBASE_REPO_DIRS`` : 全 repo のディレクトリ名 (空白区切り、宣言順) + - ``DEVBASE_ACTIVE_PROFILES`` : 有効なプロファイル (:func:`active_profiles_env`) """ return { "DEVBASE_PRIMARY_DIR": config.primary.dir, "DEVBASE_PRIMARY_URL": config.primary.url, "DEVBASE_WORK_DIR": config.resolved_work_dir(), "DEVBASE_REPO_DIRS": " ".join(repo.dir for repo in config.repos), + **active_profiles_env(active_profiles), } diff --git a/tests/commands/test_hook_env.py b/tests/commands/test_hook_env.py index 120e5196..36641f3d 100644 --- a/tests/commands/test_hook_env.py +++ b/tests/commands/test_hook_env.py @@ -85,3 +85,72 @@ def test_hook_env_does_not_leak_into_the_parent_process(tmp_path, monkeypatch, c container._run_pre_up_hook(config) assert "DEVBASE_WORK_DIR" not in os.environ + + +# --------------------------------------------------------------------------- +# PLAN58 決定 3: 有効なプロファイルをフックへ伝える +# --------------------------------------------------------------------------- + +PROFILE_DUMP = '''#!/bin/bash +echo "${DEVBASE_ACTIVE_PROFILES-unset}" >> profiles.txt +''' + + +def dumped_profiles(tmp_path: Path) -> list: + return (tmp_path / "profiles.txt").read_text().splitlines() + + +def test_hook_env_carries_active_profiles(config): + assert project_hook_env(config)["DEVBASE_ACTIVE_PROFILES"] == "" + assert project_hook_env(config, active_profiles=("test",))["DEVBASE_ACTIVE_PROFILES"] == "test" + + +def project_hook_env(config, **kwargs): + from devbase.project import runtime + return runtime.hook_env(config, **kwargs) + + +def test_hooks_from_up_receive_empty_active_profiles(tmp_path, monkeypatch, config): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("DEVBASE_ACTIVE_PROFILES", "stale") + (tmp_path / "pre-up").write_text(PROFILE_DUMP) + (tmp_path / "deploy").write_text(PROFILE_DUMP) + + container._run_pre_up_hook(config) + container._run_deploy_script_for_instances(tmp_path / "deploy", [1], config) + + assert dumped_profiles(tmp_path) == ["", ""] + + +def test_deploy_receives_the_started_profile_for_every_instance(tmp_path, monkeypatch, config): + monkeypatch.chdir(tmp_path) + (tmp_path / "deploy").write_text(PROFILE_DUMP) + + ok = container._run_deploy_script_for_instances( + tmp_path / "deploy", [1, 2], config, active_profiles=("test",)) + + assert ok is True + assert dumped_profiles(tmp_path) == ["test", "test"] + + +def test_deploy_without_config_still_receives_active_profiles(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "deploy").write_text(PROFILE_DUMP) + + container._run_deploy_script_for_instances(tmp_path / "deploy", [1], + active_profiles=("test",)) + + assert dumped_profiles(tmp_path) == ["test"] + + +def test_deploy_reports_failure_of_any_instance(tmp_path, monkeypatch, config): + monkeypatch.chdir(tmp_path) + (tmp_path / "deploy").write_text( + '#!/bin/bash\necho "$DEVBASE_INSTANCE_INDEX" >> ran.txt\n' + '[ "$DEVBASE_INSTANCE_INDEX" != 1 ]\n') + + ok = container._run_deploy_script_for_instances(tmp_path / "deploy", [1, 2], config) + + assert ok is False + # 失敗しても残りのインスタンスは従来どおり実行する + assert (tmp_path / "ran.txt").read_text().split() == ["1", "2"] From 51ed8827df36a6fd000d44caff5177b80a2bf789 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 05:38:57 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat(PLAN58):=20=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E3=83=BB=E5=81=9C=E6=AD=A2=E3=83=BB=E4=B8=80=E8=A6=A7=E3=82=92?= =?UTF-8?q?=20cmd=5Fprofile=5Fup=20/=20down=20/=20list=20=E3=81=AB?= =?UTF-8?q?=E7=BD=AE=E3=81=8F=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 150 ++++++++++++++++ tests/commands/test_container_profile.py | 209 ++++++++++++++++++++++- 2 files changed, 358 insertions(+), 1 deletion(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index c7eaa472..d77d3b22 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1287,6 +1287,156 @@ def cmd_logs(follow: bool = False, tail: Optional[int] = None, return _compose_run('logs', *extra, context=context) +# --------------------------------------------------------------------------- +# cmd_profile_up / cmd_profile_down / cmd_profile_list (PLAN58) +# --------------------------------------------------------------------------- + +def _profile_targets(profile: Optional[str], context: Optional[str]): + """プロファイルの操作の共通の前段。 + + 接続先の反映と機密の注入を済ませ、生成物からプロファイルとサービスの対応を得る。 + ``profile`` を渡すとその名前を検査する。止まるべきときは ``None`` を返す + (理由はログへ出す)。 + """ + _prepare_compose(context) + if not _SCALE_COMPOSE_FILE.exists(): + logger.error("%s がありません。先に `devbase up` を実行してください。", + _SCALE_COMPOSE_FILE) + return None + try: + profiles = profile_services(_SCALE_COMPOSE_FILE) + except DevbaseError as e: + logger.error("プロファイルを解決できません: %s", e) + return None + if profile is not None and profile not in profiles: + logger.error("プロファイル '%s' はありません。使えるプロファイル: %s", + profile, ', '.join(profiles) or '(なし)') + return None + return profiles + + +def _dev_instance_indices(compose_file: Path) -> list[int]: + """生成物が持つ開発コンテナ ``<開発サービス名>-`` の番号を昇順で返す。 + + ``project.yml`` の ``scale`` は使わない。``up`` の後に書き換えられると稼働中の + インスタンスと食い違うため、``up`` が作った生成物から数える (PLAN58 設計「構造」)。 + """ + import yaml + with open(compose_file, encoding='utf-8') as f: + services = (yaml.safe_load(f) or {}).get('services') or {} + pattern = re.compile(rf'{re.escape(get_dev_service_name())}-(\d+)') + return sorted(int(m.group(1)) for name in services + if (m := pattern.fullmatch(name))) + + +def cmd_profile_up(profile: str, context: Optional[str] = None) -> int: + """プロファイルのサービスを起動し、``./deploy`` を呼び直す (PLAN58 F1)。 + + サービス名をすべて明示し ``--no-deps`` を付ける。依存先の dev-1..N を操作の対象に + 入れず、再作成も再起動もしないため (決定 2)。``./pre-up`` は呼ばない (決定 3)。 + """ + profiles = _profile_targets(profile, context) + if profiles is None: + return 1 + services = profiles[profile] + deploy_script = Path('./deploy') + config = None + if deploy_script.is_file(): + try: + config = project_runtime.current_project_config() + except DevbaseError as e: + logger.error("Profile up failed: %s", e) + return 1 + + logger.info("Starting profile '%s': %s", profile, ', '.join(services)) + result = docker_compose(['--profile', profile, 'up', '-d', '--no-deps', *services], + compose_file=_SCALE_COMPOSE_FILE, check=False) + if result.returncode != 0: + logger.error("Failed to start profile '%s' (exit code %d)", profile, result.returncode) + return result.returncode + + if deploy_script.is_file(): + ok = _run_deploy_script_for_instances( + deploy_script, _dev_instance_indices(_SCALE_COMPOSE_FILE), config, + active_profiles=(profile,)) + if not ok: + return 1 + return 0 + + +def cmd_profile_down(profile: str, context: Optional[str] = None) -> int: + """プロファイルのサービスを停止してコンテナを削除する (PLAN58 F2)。 + + ``down <サービス>`` は依存元 (dev) も対象に含めるため使わず、``stop`` と ``rm -f`` + の 2 段で行う (決定 5)。猶予は既定、ボリュームは残す。フックは呼ばない。 + """ + profiles = _profile_targets(profile, context) + if profiles is None: + return 1 + services = profiles[profile] + + logger.info("Stopping profile '%s': %s", profile, ', '.join(services)) + for step in (['stop'], ['rm', '-f']): + result = docker_compose(['--profile', profile, *step, *services], + compose_file=_SCALE_COMPOSE_FILE, check=False) + if result.returncode != 0: + logger.error("Failed to %s profile '%s' (exit code %d)", + step[0], profile, result.returncode) + return result.returncode + return 0 + + +def _running_services(compose_file: Path) -> Optional[set]: + """``ps --format json`` で ``State`` が ``running`` のサービス名を返す。失敗なら ``None``。 + + 出力は版により 1 行 1 JSON (新しめ) か JSON 配列 (古め) になる。 + """ + try: + result = docker_compose(['ps', '--format', 'json'], compose_file=compose_file, + check=False, capture_output=True, silent_error=True) + except OSError: + return None + if result.returncode != 0: + return None + text = (result.stdout or '').strip() + try: + parsed = json.loads(text) if text.startswith('[') else [ + json.loads(line) for line in text.splitlines() if line.strip()] + except ValueError: + return None + return {item.get('Service') for item in parsed + if isinstance(item, dict) and item.get('State') == 'running'} + + +def _running_label(services: list[str], running: Optional[set]) -> str: + if running is None: + return '不明' + count = sum(1 for service in services if service in running) + state = ('running' if count == len(services) + else 'partial' if count else 'stopped') + return f'{count}/{len(services)} {state}' + + +def cmd_profile_list(context: Optional[str] = None) -> int: + """プロファイルの名前・サービス・稼働状況を表で出す (PLAN58 F3)。 + + 名前と対応の解決にデーモンは要らない。接続できないときは稼働状況を ``不明`` にして + 0 で終わる (決定 1)。 + """ + profiles = _profile_targets(None, context) + if profiles is None: + return 1 + running = _running_services(_SCALE_COMPOSE_FILE) if profiles else set() + rows = [('PROFILE', 'SERVICES', 'RUNNING')] + [ + (name, ','.join(services), _running_label(services, running)) + for name, services in profiles.items() + ] + widths = [max(len(row[i]) for row in rows) for i in range(2)] + for name, services, label in rows: + print(f'{name:<{widths[0]}} {services:<{widths[1]}} {label}') + return 0 + + # --------------------------------------------------------------------------- # cmd_scale # --------------------------------------------------------------------------- diff --git a/tests/commands/test_container_profile.py b/tests/commands/test_container_profile.py index 986c271a..2a263e30 100644 --- a/tests/commands/test_container_profile.py +++ b/tests/commands/test_container_profile.py @@ -18,6 +18,7 @@ from devbase.utils.docker import NO_PROFILE COMPOSE = Path('.docker-compose.scale.yml') +_REAL_RUN = subprocess.run class FakeCompose: @@ -56,7 +57,7 @@ def __call__(self, cmd, **kwargs): cmd = list(cmd) self.calls.append({'cmd': cmd, **kwargs}) if cmd[:2] != ['docker', 'compose']: - return subprocess.CompletedProcess(cmd, 0, '', '') + return _REAL_RUN(cmd, **kwargs) # フック (bash ./deploy) は実際に走らせる args, profiles = self._args(cmd) sub = args[0] if sub in self.fail: @@ -141,3 +142,209 @@ def test_resolution_failure_raises(project, monkeypatch): container.profile_services(COMPOSE) with pytest.raises(DevbaseError): container.default_services(COMPOSE) + + +# --------------------------------------------------------------------------- +# Task 5: 起動・停止・一覧 (F1〜F3、決定 2・3・5) +# --------------------------------------------------------------------------- + +def write_generated(root: Path, dev: str = 'dev', scale: int = 1, extra=('app', 'mysql')): + lines = ['services:'] + lines += [f' {name}:\n image: alpine:3\n profiles: [test]' for name in extra] + lines += [f' {dev}-{i}:\n image: alpine:3' for i in range(1, scale + 1)] + (root / COMPOSE).write_text('\n'.join(lines) + '\n') + + +TEST_PROFILES = {'test': ['app', 'mysql'], 'cache': ['valkey']} + + +def creating_calls(fake: FakeCompose): + return [c['cmd'] for c in fake.compose_calls + if {'up', 'stop', 'rm', 'down', 'create', 'start'} & set(c['cmd'])] + + +@pytest.mark.parametrize('op', ['up', 'down', 'list']) +def test_missing_generated_compose_stops_before_compose(project, monkeypatch, caplog, op): + fake = use(monkeypatch, FakeCompose(profiles=TEST_PROFILES)) + + if op == 'list': + rc = container.cmd_profile_list() + else: + rc = getattr(container, f'cmd_profile_{op}')('test') + + assert rc == 1 + assert fake.calls == [] + assert 'devbase up' in caplog.text + + +@pytest.mark.parametrize('op', ['up', 'down']) +def test_unknown_profile_lists_known_names(project, monkeypatch, caplog, op): + write_generated(project) + fake = use(monkeypatch, FakeCompose(profiles=TEST_PROFILES)) + + rc = getattr(container, f'cmd_profile_{op}')('nope') + + assert rc == 1 + assert creating_calls(fake) == [] + assert 'nope' in caplog.text + assert 'cache' in caplog.text and 'test' in caplog.text + + +def test_profile_up_names_every_service_with_no_deps(project, monkeypatch, caplog): + write_generated(project) + fake = use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, defaults=['dev-1'])) + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + caplog.set_level('INFO') + + assert container.cmd_profile_up('test') == 0 + + ups = creating_calls(fake) + assert ups == [['docker', 'compose', '-f', str(COMPOSE), '--profile', 'test', + 'up', '-d', '--no-deps', 'app', 'mysql']] + assert 'dev-1' not in ups[0] + assert all(c['env']['COMPOSE_PROFILES'] == NO_PROFILE for c in fake.compose_calls) + assert "app, mysql" in caplog.text + + +def test_profile_up_returns_compose_exit_code(project, monkeypatch): + # デーモンへ接続できないとき Compose は 1 で落ちる。その値をそのまま返す + write_generated(project) + use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, fail={'up': 1})) + (project / 'deploy').write_text('#!/bin/bash\ntouch ran\n') + + assert container.cmd_profile_up('test') == 1 + assert not (project / 'ran').exists() + + +def write_project_yml(root: Path, scale: int = 1): + (root / 'project.yml').write_text( + f"version: 1\nscale: {scale}\nrepos:\n - owner: volareinc\n repo: carmo\n") + + +DEPLOY_DUMP = ('#!/bin/bash\n' + 'echo "$DEVBASE_INSTANCE_INDEX $DEVBASE_ACTIVE_PROFILES" >> deploy.txt\n') + + +def test_profile_up_runs_deploy_for_running_instances_of_generated_compose(project, monkeypatch): + # up を scale 2 で通した後に project.yml を 1 へ書き換えても、稼働中の 2 台へ走る + write_generated(project, scale=2) + write_project_yml(project, scale=1) + (project / 'deploy').write_text(DEPLOY_DUMP) + (project / 'pre-up').write_text('#!/bin/bash\ntouch pre-up-ran\n') + use(monkeypatch, FakeCompose(profiles=TEST_PROFILES)) + + assert container.cmd_profile_up('test') == 0 + + assert (project / 'deploy.txt').read_text().splitlines() == ['1 test', '2 test'] + assert not (project / 'pre-up-ran').exists() + + +def test_profile_up_follows_dev_service_name(project, monkeypatch): + write_generated(project, dev='workspace', scale=2) + write_project_yml(project, scale=2) + (project / 'deploy').write_text(DEPLOY_DUMP) + use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, defaults=['workspace-1', 'workspace-2'])) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'workspace') + + assert container.cmd_profile_up('test') == 0 + + assert (project / 'deploy.txt').read_text().splitlines() == ['1 test', '2 test'] + + +def test_profile_up_fails_when_deploy_fails(project, monkeypatch): + write_generated(project) + write_project_yml(project) + (project / 'deploy').write_text('#!/bin/bash\nexit 3\n') + use(monkeypatch, FakeCompose(profiles=TEST_PROFILES)) + + assert container.cmd_profile_up('test') != 0 + + +def test_profile_down_stops_then_removes_without_volumes(project, monkeypatch, caplog): + write_generated(project) + fake = use(monkeypatch, FakeCompose(profiles=TEST_PROFILES)) + (project / 'deploy').write_text('#!/bin/bash\ntouch ran\n') + caplog.set_level('INFO') + + assert container.cmd_profile_down('test') == 0 + + base = ['docker', 'compose', '-f', str(COMPOSE), '--profile', 'test'] + assert creating_calls(fake) == [base + ['stop', 'app', 'mysql'], + base + ['rm', '-f', 'app', 'mysql']] + flat = [arg for cmd in creating_calls(fake) for arg in cmd] + assert 'down' not in flat and '-v' not in flat and '--volumes' not in flat + assert not (project / 'ran').exists() # 停止はフックを呼ばない + assert "app, mysql" in caplog.text + + +def test_profile_down_skips_rm_when_stop_fails(project, monkeypatch): + write_generated(project) + fake = use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, fail={'stop': 1})) + + assert container.cmd_profile_down('test') == 1 + + assert [cmd[6] for cmd in creating_calls(fake)] == ['stop'] + + +def test_profile_down_fails_when_rm_fails(project, monkeypatch): + write_generated(project) + use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, fail={'rm': 1})) + + assert container.cmd_profile_down('test') == 1 + + +@pytest.mark.parametrize('running, expected', [ + ({'app', 'mysql', 'dev-1'}, '2/2 running'), + ({'app', 'dev-1'}, '1/2 partial'), + ({'dev-1'}, '0/2 stopped'), +]) +def test_profile_list_shows_services_and_running_state(project, monkeypatch, capsys, + running, expected): + write_generated(project) + use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, running=running)) + + assert container.cmd_profile_list() == 0 + + out = capsys.readouterr().out.splitlines() + assert out[0].split() == ['PROFILE', 'SERVICES', 'RUNNING'] + rows = {line.split()[0]: line for line in out[1:]} + assert 'app,mysql' in rows['test'] and expected in rows['test'] + assert 'valkey' in rows['cache'] and '0/1 stopped' in rows['cache'] + + +def test_profile_list_marks_running_unknown_without_daemon(project, monkeypatch, capsys): + write_generated(project) + use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, fail={'ps': 1})) + + assert container.cmd_profile_list() == 0 + + out = capsys.readouterr().out.splitlines() + rows = {line.split()[0]: line for line in out[1:]} + assert 'app,mysql' in rows['test'] and rows['test'].split()[-1] == '不明' + + +def test_profile_list_accepts_json_array_from_older_compose(project, monkeypatch, capsys): + write_generated(project) + fake = FakeCompose(profiles={'test': ['app']}) + use(monkeypatch, fake) + original = fake.__call__ + + def array_ps(cmd, **kwargs): + result = original(cmd, **kwargs) + if 'ps' in cmd: + result.stdout = json.dumps([{'Service': 'app', 'State': 'running'}]) + return result + + monkeypatch.setattr(container.subprocess, 'run', array_ps) + + assert container.cmd_profile_list() == 0 + assert '1/1 running' in capsys.readouterr().out + + +def test_profile_list_without_profiles_prints_header_only(project, monkeypatch, capsys): + write_generated(project, extra=()) + use(monkeypatch, FakeCompose()) + + assert container.cmd_profile_list() == 0 + + assert capsys.readouterr().out.split() == ['PROFILE', 'SERVICES', 'RUNNING'] From f13f10096ac2df87361b3c3172b40a5f832cd6b5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 05:40:48 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat(PLAN58):=20project=20/=20container?= =?UTF-8?q?=20=E3=81=AB=20profile=20=E3=82=B5=E3=83=96=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=82=92=E8=B6=B3=E3=81=99=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- etc/_devbase | 8 ++ etc/devbase-completion.bash | 10 ++- lib/devbase/cli.py | 38 ++++++++- lib/devbase/commands/container.py | 14 ++++ tests/cli/test_completion.py | 13 ++++ tests/cli/test_profile_dispatch.py | 119 +++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 tests/cli/test_profile_dispatch.py diff --git a/etc/_devbase b/etc/_devbase index 84f1a8cf..c47caa67 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -81,6 +81,7 @@ _devbase() { 'build:Build container images' 'rebuild:Rebuild images without cache (docker compose build --no-cache)' 'list:List projects (NAME / PLUGIN / STATUS)' + 'profile:Start / stop compose profile services' ) container_subcommands=( @@ -92,6 +93,7 @@ _devbase() { 'scale:Scale containers online' 'build:Build container images' 'rebuild:Rebuild images without cache (docker compose build --no-cache)' + 'profile:Start / stop compose profile services' ) env_subcommands=( @@ -203,6 +205,9 @@ _devbase() { '--interactive[(compat) interactive selection, default]' \ '-i[(compat) interactive selection, default]' ;; + profile) + _values 'operation' up down list + ;; *) _describe -t project-commands 'project command' project_subcommands ;; @@ -228,6 +233,9 @@ _devbase() { build) _arguments '1:image:' ;; + profile) + _values 'operation' up down list + ;; *) _describe -t container-commands 'container command' container_subcommands ;; diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index 3b7a192c..06418b05 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -33,8 +33,8 @@ _devbase_completions() { local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build rebuild ps scale list help" # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 - local project_subcommands="up down ps login logs scale build rebuild list" - local container_subcommands="up down ps login logs scale build rebuild" + local project_subcommands="up down ps login logs scale build rebuild list profile" + local container_subcommands="up down ps login logs scale build rebuild profile" local env_subcommands="init sync list set get delete edit project export import keygen exec encrypt decrypt rekey doctor" local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" @@ -126,6 +126,9 @@ _devbase_completions() { list) COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur")) ;; + profile) + COMPREPLY=($(compgen -W "up down list" -- "$cur")) + ;; esac fi # container subcommand arguments (非推奨: project へ移行してください) @@ -137,6 +140,9 @@ _devbase_completions() { scale) COMPREPLY=($(compgen -W "1 2 3 4 5" -- "$cur")) ;; + profile) + COMPREPLY=($(compgen -W "up down list" -- "$cur")) + ;; ps) if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "--all -a" -- "$cur")) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 26644dbb..f42dd155 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -53,8 +53,10 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { - ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list'], - ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild'], + ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list', + 'profile'], + ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', + 'profile'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'keygen', 'exec', 'token', 'encrypt', 'decrypt', 'rekey', 'doctor', 'export', 'import', 'backend'], @@ -66,6 +68,9 @@ # 優先的に解決させる。例えば `devbase env e` は従来 `edit` のみに解決されていたが、 # `export` 追加後は ambiguous になるため、既存ショートカットを維持するために維持先を明示する。 SUBCMD_PREFIX_PREFERENCES = { + # `profile` 追加 (PLAN58) の前は `p` が `ps` に一意に解決されていた。 + ('project',): {'p': 'ps'}, + ('container', 'ct'): {'p': 'ps'}, ('env',): { 'e': 'edit', # `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に @@ -186,6 +191,28 @@ def _add_build_subparser(sub): 'DAYS days (default 7). Base image is judged independently.') +def _add_profile_subparser(sub, *, with_name: bool): + """`profile {up,down,list}` を登録する (PLAN58 決定 6)。 + + 入れ子は `dest='profile_subcommand'` を使う。親の `subcommand` を再利用すると + `_dispatch` が `project profile list` を `project list` へ流すため。`project` だけが + `[name]` を受け、並びは `scale` と同じ `[name] ` にする。 + """ + p = sub.add_parser('profile', help='Start / stop compose profile services') + profile_sub = p.add_subparsers(dest='profile_subcommand') + for op, help_text in (('up', 'Start the services of a profile'), + ('down', 'Stop and remove the services of a profile')): + op_parser = profile_sub.add_parser(op, help=help_text) + if with_name: + _add_name_arg(op_parser) + op_parser.add_argument('profile', help='Profile name in compose.yml') + _add_context_arg(op_parser) + list_parser = profile_sub.add_parser('list', help='List profiles and their state') + if with_name: + _add_name_arg(list_parser) + _add_context_arg(list_parser) + + def _add_container_parser(subparsers): """Container group parser""" ct_parser = subparsers.add_parser('container', aliases=['ct'], @@ -215,6 +242,8 @@ def _add_container_parser(subparsers): _add_context_arg(ct_sub.add_parser( 'rebuild', help='Rebuild stale images (= build --expires=7)')) + _add_profile_subparser(ct_sub, with_name=False) + def _add_project_parser(subparsers): """Project group parser (CWD 非依存のプロジェクト操作)。 @@ -269,6 +298,11 @@ def _add_project_parser(subparsers): _add_context_arg(_add_name_arg(pj_sub.add_parser( 'rebuild', help='Rebuild stale images (= build --expires=7)'))) + # `profile` の `[name]` は Python 側 (_dispatch_lifecycle) で解決する。3 番目の引数は + # up / down / list になるため、wrapper の _PROJECT_NAME_SUBCOMMANDS には含めない + # (PLAN58 決定 6)。 + _add_profile_subparser(pj_sub, with_name=True) + # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 _add_list_subparser(pj_sub) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index d77d3b22..9c90934c 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -684,6 +684,7 @@ def _dispatch_lifecycle(args) -> int: no_cache=getattr(args, 'no_cache', False), expires=getattr(args, 'expires', None), **ctx), 'rebuild': lambda: cmd_rebuild(**ctx), + 'profile': lambda: _dispatch_profile(args, ctx), } handler = handlers.get(subcmd) @@ -701,6 +702,19 @@ def _dispatch_lifecycle(args) -> int: _runtime.release_store() +def _dispatch_profile(args, ctx: dict) -> int: + """`profile {up,down,list}` を振り分ける (PLAN58 決定 6)。""" + operation = getattr(args, 'profile_subcommand', None) + if operation == 'up': + return cmd_profile_up(args.profile, **ctx) + if operation == 'down': + return cmd_profile_down(args.profile, **ctx) + if operation == 'list': + return cmd_profile_list(**ctx) + logger.error("profile の操作を指定してください: up, down, list") + return 1 + + def cmd_project(args) -> int: """`devbase project [name]` ディスパッチャ (推奨エントリ)。""" return _dispatch_lifecycle(args) diff --git a/tests/cli/test_completion.py b/tests/cli/test_completion.py index 8a42f2f5..0bae82c5 100644 --- a/tests/cli/test_completion.py +++ b/tests/cli/test_completion.py @@ -175,3 +175,16 @@ def test_zsh_completion_syntax_ok(): proc = subprocess.run(["zsh", "-n", str(ZSH_COMPLETION)], capture_output=True, text=True) assert proc.returncode == 0, proc.stderr + + +# PLAN58: `profile` サブコマンド +@pytest.mark.parametrize("group", ["project", "container", "ct"]) +def test_bash_profile_subcommand_and_operations(fake_root, group): + assert "profile" in _bash_complete(f"devbase {group} ''", 2, fake_root) + assert set(_bash_complete(f"devbase {group} profile ''", 3, fake_root)) == {"up", "down", "list"} + + +def test_zsh_completion_mentions_profile(): + text = ZSH_COMPLETION.read_text() + assert "'profile:" in text + assert "_values 'operation' up down list" in text diff --git a/tests/cli/test_profile_dispatch.py b/tests/cli/test_profile_dispatch.py new file mode 100644 index 00000000..57f131fa --- /dev/null +++ b/tests/cli/test_profile_dispatch.py @@ -0,0 +1,119 @@ +"""PLAN58 決定 6: `project profile` / `container profile` の引数の受け口 + +`project` は既存の `scale` と同じ `[name] <値>` の並びでプロジェクト名を受ける。 +`container` / `ct` は現在地で動く既存の規約に従い、プロジェクト名を受けない。 +入れ子の subparser は `dest='profile_subcommand'` を使う。`subcommand` を再利用すると +`devbase project profile list` が `project list` (プロジェクト一覧) へ流れる。 +""" + +from __future__ import annotations + +import logging +import sys + +import pytest + +from devbase import cli +from devbase.commands import container + + +def parse(*argv): + return cli._create_parser().parse_args(list(argv)) + + +@pytest.fixture +def recorded(monkeypatch): + """profile の 3 つの入口と名前解決を記録に差し替える。""" + calls = [] + monkeypatch.setattr(container, '_resolve_project_name', + lambda name: calls.append(('resolve', name)) or True) + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) + for op in ('up', 'down'): + monkeypatch.setattr(container, f'cmd_profile_{op}', + lambda profile, op=op, **kw: calls.append((op, profile, kw)) or 0) + monkeypatch.setattr(container, 'cmd_profile_list', + lambda **kw: calls.append(('list', kw)) or 0) + return calls + + +@pytest.mark.parametrize('op', ['up', 'down']) +def test_project_profile_takes_profile_only(op): + args = parse('project', 'profile', op, 'test') + assert (args.subcommand, args.profile_subcommand) == ('profile', op) + assert args.name is None + assert args.profile == 'test' + + +@pytest.mark.parametrize('op', ['up', 'down']) +def test_project_profile_takes_name_then_profile(op): + args = parse('project', 'profile', op, 'carmo', 'test') + assert args.name == 'carmo' + assert args.profile == 'test' + + +def test_project_profile_list_takes_optional_name(): + assert parse('project', 'profile', 'list').name is None + assert parse('project', 'profile', 'list', 'carmo').name == 'carmo' + + +@pytest.mark.parametrize('group', ['container', 'ct']) +def test_container_profile_rejects_project_name(group, capsys): + args = parse(group, 'profile', 'up', 'test') + assert args.profile == 'test' + assert not hasattr(args, 'name') + with pytest.raises(SystemExit): + parse(group, 'profile', 'up', 'carmo', 'test') + + +def test_project_profile_list_is_not_project_list(monkeypatch, recorded): + listed = [] + import devbase.commands.project as project_commands + monkeypatch.setattr(project_commands, 'cmd_project_list', + lambda root, args: listed.append(args) or 0) + + assert cli._dispatch('project', parse('project', 'profile', 'list')) == 0 + + assert listed == [] + assert recorded == [('list', {})] + + +def test_named_profile_up_resolves_project_first(recorded): + args = parse('project', 'profile', 'up', 'carmo', 'test', '--context', 'remote') + + assert cli._dispatch('project', args) == 0 + + assert recorded == [('resolve', 'carmo'), ('up', 'test', {'context': 'remote'})] + + +def test_profile_without_name_runs_in_current_project(recorded): + assert cli._dispatch('project', parse('project', 'profile', 'down', 'test')) == 0 + + assert recorded == [('down', 'test', {})] + + +@pytest.mark.parametrize('group', ['container', 'ct']) +def test_container_profile_matches_project_and_warns_once(group, recorded, caplog): + with caplog.at_level(logging.WARNING, logger='devbase.commands.container'): + assert cli._dispatch(group, parse(group, 'profile', 'up', 'test')) == 0 + + assert recorded == [('up', 'test', {})] + assert len([r for r in caplog.records if '非推奨' in r.message]) == 1 + + +def test_profile_without_operation_fails(recorded): + assert cli._dispatch('project', parse('project', 'profile')) == 1 + assert recorded == [] + + +@pytest.mark.parametrize('group', ['project', 'container']) +def test_prefix_p_still_means_ps(monkeypatch, group): + """`profile` を足しても、従来一意だった `p` は `ps` のまま解決する。""" + monkeypatch.setattr(sys, 'argv', ['devbase', group, 'p']) + cli._expand_argv() + assert sys.argv[2] == 'ps' + + +def test_prefix_pr_resolves_profile(monkeypatch): + monkeypatch.setattr(sys, 'argv', ['devbase', 'project', 'pr', 'list']) + cli._expand_argv() + assert sys.argv[2] == 'profile' From ebf1caf7753f1ebe3418141eda918c8ddf42b7a6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 05:44:16 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat(PLAN58):=20devbase=20list=20?= =?UTF-8?q?=E3=81=AE=E6=93=8D=E4=BD=9C=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=8B=E3=82=89=E3=83=97=E3=83=AD=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=82=92=E8=B5=B7=E5=8B=95=E3=83=BB=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/tui/actions_project.py | 63 +++++++++++-- tests/cli/tui/test_actions_project.py | 16 ++-- tests/cli/tui/test_profile_menu.py | 125 ++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 tests/cli/tui/test_profile_menu.py diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 96ffe802..73613849 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -40,23 +40,57 @@ ("再ビルド (rebuild --no-cache)", "rebuild"), ] -# 実行後にサブメニューへ留まらずトップ一覧へ戻る操作。up/down はコンテナの -# 起動状態が大きく変わるため、最新状態の一覧を見せる方が自然 (それ以外の +# プロファイルを持つプロジェクトにだけ足す操作 (PLAN58 決定 8)。持たないプロジェクトで +# 選べると、選んだ後に「プロファイルがありません」と戻ることになるため出し分ける。 +_PROFILE_OPS: list[tuple[str, str]] = [ + ("テスト用サーバ起動 (profile up)", "profile-up"), + ("テスト用サーバ停止 (profile down)", "profile-down"), +] + +# 実行後にサブメニューへ留まらずトップ一覧へ戻る操作。up/down とプロファイルの +# 起動・停止はコンテナの数が変わるため、最新状態の一覧を見せる方が自然 (それ以外の # login/ps/logs/scale/build/rebuild は連続操作できるようサブメニューに留まる)。 -_BACK_TO_TOP_OPS = frozenset({"up", "down"}) +_BACK_TO_TOP_OPS = frozenset({"up", "down", "profile-up", "profile-down"}) # 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。 _ARG_CANCEL = flow.ARG_CANCEL _ABORT = flow.ABORT -def _select_action(name: str): +def _profile_names(devbase_root: Path, name: str) -> list[str]: + """プロジェクトのプロファイル名。生成物が無い・解決に失敗したときは空。 + + 解決は ``docker compose config`` で行い、デーモンへの接続は要らない (PLAN58 決定 1)。 + 失敗を「持たない」として扱うのは、一覧の操作メニューを出すこと自体を止めないため。 + """ + from devbase.commands import container + from devbase.errors import DevbaseError + + compose_file = Path(devbase_root) / "projects" / name / container._SCALE_COMPOSE_FILE + if not compose_file.is_file(): + return [] + try: + return list(container.profile_services(compose_file)) + except (DevbaseError, OSError) as e: + logger.debug("プロファイルを解決できません (%s): %s", name, e) + return [] + + +def _running_ops(devbase_root: Path, name: str) -> list[tuple[str, str]]: + """running 行で選べる操作。プロファイルを持つときだけ起動・停止の 2 項目を足す。""" + ops = list(_RUNNING_OPS) + if _profile_names(devbase_root, name): + ops += _PROFILE_OPS + return ops + + +def _select_action(name: str, ops=None): """running 中プロジェクトの操作を選ぶサブメニュー。 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → 一覧へ戻る) / ``None`` (Ctrl-C 中止)。 """ return menu.select(f"'{name}' は起動中です。操作を選択 {menu.HINT_BACK}:", - list(_RUNNING_OPS), back=True, search=False) + list(_RUNNING_OPS if ops is None else ops), back=True, search=False) def _optional_int(message: str, *, min_value: int = 0): @@ -123,6 +157,18 @@ def _op_build(devbase_root: Path, name: str): return dispatch_lifecycle("build", name, image=image or None) +def _op_profile(operation: str): + """プロファイルの起動・停止。名前が 1 つだけでも選択として出す (PLAN58)。""" + def run(devbase_root: Path, name: str): + names = _profile_names(devbase_root, name) + profile = flow.need(menu.select( + f"'{name}' のプロファイルを選択 {menu.HINT_BACK}:", + [(n, n) for n in names], back=True, search=False)) + return dispatch_lifecycle("profile", name, profile_subcommand=operation, + profile=profile) + return run + + _OP_HANDLERS = { # up/down/ps は引数なしで即実行。up は scale 属性を参照する (常に None。他 # コマンドは無視する)。down はデータを失わない (volume 保持・up で復旧可能) @@ -140,6 +186,8 @@ def _op_build(devbase_root: Path, name: str): "logs": _op_logs, "scale": _op_scale, "build": _op_build, + "profile-up": _op_profile("up"), + "profile-down": _op_profile("down"), } @@ -152,7 +200,7 @@ def _run_operation(devbase_root: Path, name: str, op: str): """ handler = _OP_HANDLERS.get(op) if handler is None: - # 到達しない (メニュー値は _RUNNING_OPS に限定される)。保守的に no-op。 + # 到達しない (メニュー値は _running_ops の戻り値に限定される)。保守的に no-op。 logger.error("未知の操作です: %s", op) raise flow.BackOut return handler(devbase_root, name) @@ -168,8 +216,9 @@ def _operation_menu(devbase_root: Path, name: str): 一覧へ戻り最新状態を再表示する。引数収集を中止 (``_ARG_CANCEL``) した場合も 同じサブメニューを再表示する。 """ + ops = _running_ops(devbase_root, name) return flow.menu_loop( - lambda: _select_action(name), + lambda: _select_action(name, ops), lambda op: _run_operation(devbase_root, name, op), back_after=lambda op: op in _BACK_TO_TOP_OPS) diff --git a/tests/cli/tui/test_actions_project.py b/tests/cli/tui/test_actions_project.py index e7057a5d..ae731cbb 100644 --- a/tests/cli/tui/test_actions_project.py +++ b/tests/cli/tui/test_actions_project.py @@ -54,7 +54,7 @@ def test_handle_row_running_shows_action_menu(monkeypatch, tmp_path, action, seen = {} _sel = _seq(action, menu.MENU_BACK) monkeypatch.setattr(actions_project, "_select_action", - lambda name: seen.update(name=name) or _sel()) + lambda name, ops=None: seen.update(name=name) or _sel()) captured = {} monkeypatch.setattr(container_mod, "cmd_project", lambda args: captured.update( @@ -73,7 +73,7 @@ def test_handle_row_stays_in_submenu_for_non_updown_op(monkeypatch, tmp_path): select_calls = [] sel = _seq("build", menu.MENU_BACK) monkeypatch.setattr(actions_project, "_select_action", - lambda name: select_calls.append(1) or sel()) + lambda name, ops=None: select_calls.append(1) or sel()) calls = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: calls.append(1) or 1) @@ -92,7 +92,7 @@ def test_handle_row_up_down_return_to_top(monkeypatch, tmp_path, op): # MENU_BACK を後ろに置くが、up/down は 1 回の実行でトップへ戻るため使われない。 sel = _seq(op, menu.MENU_BACK) monkeypatch.setattr(actions_project, "_select_action", - lambda name: select_calls.append(1) or sel()) + lambda name, ops=None: select_calls.append(1) or sel()) calls = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: calls.append(1) or 0) @@ -109,7 +109,7 @@ def test_handle_row_non_running_direct_up(monkeypatch, tmp_path, status): action_calls = [] monkeypatch.setattr(actions_project, "_select_action", - lambda name: action_calls.append(name) or "down") + lambda name, ops=None: action_calls.append(name) or "down") captured = {} monkeypatch.setattr(container_mod, "cmd_project", lambda args: captured.update( @@ -125,7 +125,7 @@ def test_handle_row_action_menu_back_returns_menu_back(monkeypatch, tmp_path): """running 行のサブメニューで Esc/← (MENU_BACK) → 一覧へ戻る (何も起動しない)。""" from devbase.commands import container as container_mod - monkeypatch.setattr(actions_project, "_select_action", lambda name: menu.MENU_BACK) + monkeypatch.setattr(actions_project, "_select_action", lambda name, ops=None: menu.MENU_BACK) called = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) @@ -138,7 +138,7 @@ def test_handle_row_action_menu_ctrl_c_aborts(monkeypatch, tmp_path): """running 行のサブメニューで Ctrl-C (None) → 全体中止 (None を返す)。""" from devbase.commands import container as container_mod - monkeypatch.setattr(actions_project, "_select_action", lambda name: None) + monkeypatch.setattr(actions_project, "_select_action", lambda name, ops=None: None) called = [] monkeypatch.setattr(container_mod, "cmd_project", lambda args: called.append(1) or 0) @@ -425,7 +425,7 @@ def test_operation_menu_arg_cancel_reshows_submenu(monkeypatch, tmp_path): select = _seq("scale", "build", menu.MENU_BACK) select_calls = [] monkeypatch.setattr(actions_project, "_select_action", - lambda name: select_calls.append(1) or select()) + lambda name, ops=None: select_calls.append(1) or select()) run_calls = [] @@ -446,7 +446,7 @@ def test_operation_menu_clears_screen_after_execution(monkeypatch, tmp_path): 引数収集中止 (_ARG_CANCEL) では出力が無いためクリアしない。 """ select = _seq("scale", "build", menu.MENU_BACK) - monkeypatch.setattr(actions_project, "_select_action", lambda name: select()) + monkeypatch.setattr(actions_project, "_select_action", lambda name, ops=None: select()) monkeypatch.setattr(actions_project, "_run_operation", lambda root, name, op: actions_project._ARG_CANCEL if op == "scale" else 0) diff --git a/tests/cli/tui/test_profile_menu.py b/tests/cli/tui/test_profile_menu.py new file mode 100644 index 00000000..425081b2 --- /dev/null +++ b/tests/cli/tui/test_profile_menu.py @@ -0,0 +1,125 @@ +"""PLAN58 決定 8: `devbase list` の起動中の行から、プロファイルを起動・停止する + +2 項目はプロファイルを持つプロジェクトにだけ出す。選んだ後はプロファイル名を選ばせ +(1 件でも選択を出す)、共有のハンドラ ``cmd_project`` へ委譲する。TUI はコマンドの +中身を持たない。 +""" + +from __future__ import annotations + +import pytest + +from devbase.commands import container +from devbase.errors import DevbaseError +from devbase.tui import actions_project, flow, menu + +PROFILE_ITEMS = [("テスト用サーバ起動 (profile up)", "profile-up"), + ("テスト用サーバ停止 (profile down)", "profile-down")] + + +@pytest.fixture(autouse=True) +def _no_pause(monkeypatch): + monkeypatch.setattr(flow, "pause_for_review", lambda: True) + + +@pytest.fixture +def root(tmp_path): + (tmp_path / "projects" / "carmo").mkdir(parents=True) + return tmp_path + + +def generated(root): + path = root / "projects" / "carmo" / ".docker-compose.scale.yml" + path.write_text("services: {}\n") + return path + + +def resolve_to(monkeypatch, result): + asked = [] + + def fake(compose_file, environ=None): + asked.append(compose_file) + if isinstance(result, Exception): + raise result + return result + + monkeypatch.setattr(container, "profile_services", fake) + return asked + + +def test_profile_items_follow_running_ops_when_profiles_exist(root, monkeypatch): + path = generated(root) + asked = resolve_to(monkeypatch, {"test": ["app"]}) + + ops = actions_project._running_ops(root, "carmo") + + assert ops == list(actions_project._RUNNING_OPS) + PROFILE_ITEMS + assert asked == [path] + + +@pytest.mark.parametrize("result", [{}, DevbaseError("config failed")]) +def test_no_profile_items_without_profiles(root, monkeypatch, result): + generated(root) + resolve_to(monkeypatch, result) + + assert actions_project._running_ops(root, "carmo") == list(actions_project._RUNNING_OPS) + + +def test_no_profile_items_before_first_up(root, monkeypatch): + asked = resolve_to(monkeypatch, {"test": ["app"]}) + + assert actions_project._running_ops(root, "carmo") == list(actions_project._RUNNING_OPS) + assert asked == [] + + +@pytest.mark.parametrize("op, sub", [("profile-up", "up"), ("profile-down", "down")]) +def test_profile_item_asks_profile_name_even_for_one_and_delegates(root, monkeypatch, op, sub): + generated(root) + resolve_to(monkeypatch, {"test": ["app", "mysql"]}) + prompts = [] + + def fake_select(message, choices, **kwargs): + prompts.append(list(choices)) + return "test" + + monkeypatch.setattr(menu, "select", fake_select) + delegated = [] + monkeypatch.setattr(container, "cmd_project", + lambda args: delegated.append(vars(args).copy()) or 0) + + assert actions_project._run_operation(root, "carmo", op) == 0 + + assert prompts == [[("test", "test")]] + assert delegated == [{"subcommand": "profile", "name": "carmo", + "profile_subcommand": sub, "profile": "test"}] + + +def test_profile_selection_back_returns_to_submenu(root, monkeypatch): + generated(root) + resolve_to(monkeypatch, {"test": ["app"]}) + monkeypatch.setattr(menu, "select", lambda *a, **k: menu.MENU_BACK) + delegated = [] + monkeypatch.setattr(container, "cmd_project", lambda args: delegated.append(args) or 0) + + assert actions_project._run_operation(root, "carmo", "profile-up") is flow.ARG_CANCEL + assert delegated == [] + + +@pytest.mark.parametrize("op", ["profile-up", "profile-down"]) +def test_profile_ops_return_to_project_list(root, monkeypatch, op): + generated(root) + resolve_to(monkeypatch, {"test": ["app"]}) + shown = [] + + def fake_action(name, ops=None): + shown.append(ops) + return op if len(shown) == 1 else menu.MENU_BACK + + monkeypatch.setattr(actions_project, "_select_action", fake_action) + monkeypatch.setattr(menu, "select", lambda *a, **k: "test") + monkeypatch.setattr(container, "cmd_project", lambda args: 0) + + result = actions_project.handle_row(root, {"name": "carmo", "status": "running (2 containers)"}) + + assert result is menu.MENU_BACK # 1 回の実行で一覧へ戻る (2 回目の選択が無い) + assert len(shown) == 1 and PROFILE_ITEMS[0] in shown[0] From 0480af1ca570cb9b73762946fe062fe2bee2408d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 05:44:30 +0900 Subject: [PATCH 06/15] =?UTF-8?q?test(PLAN58):=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=89=A9=E3=81=8C=20profiles=20=E3=81=A8=20depends=5Fon.requir?= =?UTF-8?q?ed=20=E3=82=92=E4=BF=9D=E3=81=A4=E3=81=93=E3=81=A8=E3=82=92?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E3=81=99=E3=82=8B=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- tests/volume/test_compose_profiles.py | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/volume/test_compose_profiles.py diff --git a/tests/volume/test_compose_profiles.py b/tests/volume/test_compose_profiles.py new file mode 100644 index 00000000..d405854d --- /dev/null +++ b/tests/volume/test_compose_profiles.py @@ -0,0 +1,71 @@ +"""PLAN58: 生成物 (.docker-compose.scale.yml) が profiles と depends_on.required を保つ + +プロファイルの判定は Compose に任せる (決定 1)。そのため生成物が元の compose.yml の +``profiles:`` を落とすと、``devbase up`` がプロファイルのサービスまで起動してしまう。 +``depends_on`` の ``required: false`` は、既定の ``up`` でプロファイルのサービスが +未定義になっても構成の検証を通すために要る。 +""" + +from __future__ import annotations + +import yaml +import pytest + +from devbase.volume import compose + + +@pytest.fixture +def in_tmp_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + return tmp_path + + +def generate(tmp_path, services: dict, scale: int = 1) -> dict: + (tmp_path / "compose.yml").write_text( + yaml.safe_dump({"services": services}, sort_keys=False), encoding="utf-8") + compose.generate_scaled_compose(scale=scale) + return yaml.safe_load((tmp_path / ".docker-compose.scale.yml").read_text())["services"] + + +def test_non_dev_services_keep_profiles(in_tmp_cwd): + scaled = generate(in_tmp_cwd, { + "dev": {"image": "dev:latest"}, + "app": {"image": "app:latest", "profiles": ["test"]}, + "mysql": {"image": "mysql:8", "profiles": ["${TEST_PROFILE:-test}", "db"]}, + "redis": {"image": "redis:7"}, + }, scale=2) + + assert scaled["app"]["profiles"] == ["test"] + assert scaled["mysql"]["profiles"] == ["${TEST_PROFILE:-test}", "db"] + assert "profiles" not in scaled["redis"] + assert "profiles" not in scaled["dev-1"] and "profiles" not in scaled["dev-2"] + + +def test_depends_on_dev_keeps_condition_and_required_per_instance(in_tmp_cwd): + scaled = generate(in_tmp_cwd, { + "dev": {"image": "dev:latest"}, + "app": { + "image": "app:latest", + "profiles": ["test"], + "depends_on": {"dev": {"condition": "service_started", "required": False}}, + }, + }, scale=2) + + assert scaled["app"]["depends_on"] == { + "dev-1": {"condition": "service_started", "required": False}, + "dev-2": {"condition": "service_started", "required": False}, + } + + +def test_dev_depends_on_profile_service_keeps_required(in_tmp_cwd): + scaled = generate(in_tmp_cwd, { + "dev": { + "image": "dev:latest", + "depends_on": {"db": {"condition": "service_started", "required": False}}, + }, + "db": {"image": "mysql:8", "profiles": ["test"]}, + }) + + assert scaled["dev-1"]["depends_on"] == { + "db": {"condition": "service_started", "required": False}} From 4768f275241f55b14bc08cdc1c9b7fc46f220396 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 05:45:05 +0900 Subject: [PATCH 07/15] =?UTF-8?q?docs(PLAN58):=20profiles=20=E3=81=A7?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E7=94=A8=E3=82=B5=E3=83=BC=E3=83=90?= =?UTF-8?q?=E3=82=92=E5=BE=8C=E3=81=8B=E3=82=89=E8=B5=B7=E5=8B=95=E3=83=BB?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E3=81=99=E3=82=8B=E6=9B=B8=E3=81=8D=E6=96=B9?= =?UTF-8?q?=E3=81=A8=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=82=92=E6=A1=88?= =?UTF-8?q?=E5=86=85=E3=81=99=E3=82=8B=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- docs/README.md | 4 +- docs/plugin-dev/compose-profiles.md | 111 ++++++++++++++++++++++++++ docs/plugin-dev/quickstart.md | 1 + docs/user/cli-reference/02-project.md | 24 +++++- 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 docs/plugin-dev/compose-profiles.md diff --git a/docs/README.md b/docs/README.md index b47db2f4..71c157f1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,6 +77,7 @@ graph LR | [plugin.yml リファレンス](plugin-dev/plugin-yml-reference.md) | プラグイン定義ファイルの全フィールド | | [compose.yml ガイドライン](plugin-dev/compose-yml-guidelines.md) | Docker Compose 設定のベストプラクティス | | [repo 連携プロジェクトと pre-up populate](plugin-dev/repo-backed-projects.md) | 外部リポジトリを共有 work ボリュームへ populate する `pre-up` パターンと冪等スキップ | +| [テスト用サーバを後から起動・停止する](plugin-dev/compose-profiles.md) | Compose の `profiles` で付随サービス群を `devbase up` から外し、`devbase project profile` で起動・停止する | ### devbase 開発者(devbase 本体を改善したい方) @@ -114,7 +115,8 @@ docs/ │ ├── quickstart.md ← クイックスタート │ ├── plugin-yml-reference.md ← plugin.yml リファレンス │ ├── compose-yml-guidelines.md ← compose.yml ガイドライン -│ └── repo-backed-projects.md ← repo 連携 / pre-up populate パターン +│ ├── repo-backed-projects.md ← repo 連携 / pre-up populate パターン +│ └── compose-profiles.md ← profiles でテスト用サーバを後から起動・停止 └── developer/ ← devbase 開発者向け ├── architecture.md ← アーキテクチャ ├── contributing.md ← コントリビューション diff --git a/docs/plugin-dev/compose-profiles.md b/docs/plugin-dev/compose-profiles.md new file mode 100644 index 00000000..84b147ca --- /dev/null +++ b/docs/plugin-dev/compose-profiles.md @@ -0,0 +1,111 @@ +# テスト用サーバを後から起動・停止する(Compose の profiles) + +dev のほかに app / db などのサービスを持つプロジェクトで、`devbase up` の既定では dev だけを起動し、テスト用のサーバ群は必要なときだけ後から起動・停止するための書き方です。起動・停止のどちらでも、dev コンテナは再作成も再起動もされません。 + +## 使える環境 + +| 項目 | 条件 | +| --- | --- | +| Docker Compose | **2.20.0 以上**。`depends_on` の `required` を使うため。動作を確かめたのは v5.1.4 | +| devbase | `devbase project profile` があるバージョン | + +## 1. `compose.yml` の書き方 + +テスト用のサービスへ `profiles:` を書きます。プロファイル名はプロジェクトが自由に決めてかまいません(devbase は既定の名前を持ちません)。 + +```yaml +services: + dev: + image: ... + # dev から app への依存を残すなら required: false を付ける + depends_on: + app: + condition: service_started + required: false + + app: + image: ... + profiles: [test] + depends_on: + mysql: + condition: service_healthy + + mysql: + image: mysql:8 + profiles: [test] +``` + +| 書き方 | 理由 | +| --- | --- | +| `profiles: [test]` | `devbase up` の既定の起動対象から外れる | +| 既定のサービスからプロファイルのサービスへの `depends_on` には `required: false` | 付けないと、既定の `up` が `service "dev" depends on undefined service "app": invalid compose project` で止まる | +| プロファイルのサービスから dev への `depends_on` | どちらの形(`[dev]` / `required: false` 付き)でも書けます。devbase は `--no-deps` で起動するため dev は対象に入りません | + +プロファイル名に `__devbase_none__` は使わないでください。devbase が「どのプロファイルも有効にしない」ために予約している名前です。 + +## 2. コマンド + +```bash +devbase project profile list # プロファイルと稼働状況を見る +devbase project profile up test # test のサービスを起動する +devbase project profile down test # test のサービスを停止して削除する(ボリュームは残る) + +devbase project profile up carmo test # 任意のディレクトリから carmo の test を起動する +``` + +`devbase list` で起動中のプロジェクトを選ぶと、操作のメニューに「テスト用サーバ起動 (profile up)」「テスト用サーバ停止 (profile down)」が出ます(プロファイルを持つプロジェクトだけ)。 + +`profile list` の表は次のとおりです。 + +```text +PROFILE SERVICES RUNNING +test app,mysql 2/2 running +``` + +| RUNNING | 意味 | +| --- | --- | +| `2/2 running` | すべて稼働中 | +| `1/2 partial` | 一部だけ稼働中 | +| `0/2 stopped` | 停止中 | +| `不明` | Docker のデーモンへ接続できず、稼働状況を得られなかった | + +どのコマンドも、先に `devbase up` を済ませて `.docker-compose.scale.yml` がある状態で使います。 + +## 3. `devbase up` / `devbase down` との関係 + +| 操作 | プロファイルのサービス | +| --- | --- | +| `devbase up` | 起動しない。起動していた場合も、冒頭の停止で止まる。**テストを続けるなら `up` の後にもう一度 `profile up` する** | +| `devbase down` | dev と一緒に削除する | +| `devbase scale` | 複製しない(dev だけが増える) | + +端末の環境変数やプロジェクトの `.env` に `COMPOSE_PROFILES` を書いても、devbase 経由の操作には効きません。有効なプロファイルは devbase のコマンドで決めます。素の `docker compose` を叩いたときは従来どおり効きます。 + +## 4. `deploy` フックでの分岐 + +`devbase project profile up <名前>` は、サービスの起動が終わった後に `deploy` フックを稼働中の全インスタンスについて呼び直します。`pre-up` は呼びません。 + +フックは `DEVBASE_ACTIVE_PROFILES` で、どの経路から呼ばれたかを見分けられます。 + +| 呼ばれ方 | `DEVBASE_ACTIVE_PROFILES` | +| --- | --- | +| `devbase up` の `pre-up` / `deploy` | 空 | +| `devbase project profile up test` の `deploy` | `test` | + +```bash +#!/bin/bash +# projects//deploy +if [ "$DEVBASE_ACTIVE_PROFILES" = "test" ]; then + echo "テスト用サーバの初期データを投入する" + exit 0 +fi +# 以下は devbase up のときの処理 +``` + +`deploy` が失敗すると `profile up` も 0 以外で終わります。値は常にプロファイル名 1 つです(カンマ区切りは将来の拡張のための予約です)。 + +## 関連 + +- [compose.yml ガイドライン](compose-yml-guidelines.md) +- [フックへ渡る環境変数](quickstart.md#フックへ渡る環境変数) +- [`devbase project profile`](../user/cli-reference/02-project.md#devbase-project-profile) diff --git a/docs/plugin-dev/quickstart.md b/docs/plugin-dev/quickstart.md index 175c4021..052953d0 100644 --- a/docs/plugin-dev/quickstart.md +++ b/docs/plugin-dev/quickstart.md @@ -181,6 +181,7 @@ MY_SECRET_API_KEY=sk-xxxxxxxxxxxx | `DEVBASE_WORK_DIR` | コンテナ内の既定の作業ディレクトリ(`work_dir`。未指定なら `/work/$DEVBASE_PRIMARY_DIR`) | ✓ | ✓ | | `DEVBASE_REPO_DIRS` | 全リポジトリのディレクトリ名を `project.yml` の宣言順に空白区切りで並べたもの | ✓ | ✓ | | `DEVBASE_INSTANCE_INDEX` | 実行対象のインスタンス番号(1 始まり)。`pre-up` はインスタンスごとに実行されないため渡りません | -- | ✓ | +| `DEVBASE_ACTIVE_PROFILES` | 有効な Compose のプロファイル名。`devbase up` からは空、`devbase project profile up <名前>` の後の `deploy` にはその名前([テスト用サーバを後から起動・停止する](compose-profiles.md)) | ✓ | ✓ | primary は `repos` の先頭(または `primary: true` を付けた 1 件)で、常にちょうど 1 件です。primary 以外も含めて全リポジトリを回したい場合は `DEVBASE_REPO_DIRS` を使います。 diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 7022aaef..f8c4bc3d 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -42,7 +42,7 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up ## `--context NAME`(共通オプション) -`up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild`(`project` / +`up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild` / `profile`(`project` / `container` 配下と、トップレベルのショートカット)は `--context NAME` を受け付けます。 そのコマンドの `docker` / `docker compose` を、指定した docker context の daemon へ向けます。 @@ -183,6 +183,28 @@ devbase project scale adminer 3 新しい値は `project.yml` の `scale` に書き戻されるため、次回の `devbase up` にも引き継がれます。 +## `devbase project profile` + +`compose.yml` で `profiles:` を付けたサービス群を、dev コンテナに触れずに後から起動・停止します。 +書き方は [テスト用サーバを後から起動・停止する](../../plugin-dev/compose-profiles.md) を参照してください。 + +``` +devbase project profile up [name] [--context NAME] +devbase project profile down [name] [--context NAME] +devbase project profile list [name] [--context NAME] +``` + +| パラメータ | 必須 | 説明 | +|-----------|------|------| +| `name` | いいえ | 対象プロジェクト名(省略時はカレント)。`container profile` / `ct profile` では受け付けません | +| `` | はい(`up` / `down`) | `compose.yml` に書いたプロファイル名 | + +- `up`: そのプロファイルのサービスをすべて `--no-deps` 付きで起動し、`deploy` フックを `DEVBASE_ACTIVE_PROFILES=` で稼働中の全インスタンスについて呼び直す +- `down`: そのプロファイルのサービスを `stop` → `rm -f` で停止・削除する(ボリュームは残る。フックは呼ばない) +- `list`: `PROFILE` / `SERVICES` / `RUNNING` の表を出す。Docker のデーモンへ接続できないときは `RUNNING` を `不明` にする +- どれも `devbase up` の後(`.docker-compose.scale.yml` がある状態)で使う。無ければ終了コード 1 +- `devbase up` の冒頭の停止と `devbase down` は、プロファイルのサービスも止める + ## `devbase project migrate-config` 旧 `env` 形式(`GIT_USER` / `GIT_REPO` / `GIT_HOST` / `WORK_DIR` / `CONTAINER_SCALE` / From 928990da479464359274dff1fc29f616c062620d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 06:01:13 +0900 Subject: [PATCH 08/15] Test: characterize profile status errors and hook environment Add characterization tests for malformed ps JSON, ps execution errors, and comma-separated active profiles. Production code is unchanged. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- tests/commands/test_container_profile.py | 23 +++++++++++++++++++++++ tests/commands/test_hook_env.py | 6 ++++++ 2 files changed, 29 insertions(+) diff --git a/tests/commands/test_container_profile.py b/tests/commands/test_container_profile.py index 2a263e30..c0774069 100644 --- a/tests/commands/test_container_profile.py +++ b/tests/commands/test_container_profile.py @@ -323,6 +323,29 @@ def test_profile_list_marks_running_unknown_without_daemon(project, monkeypatch, assert 'app,mysql' in rows['test'] and rows['test'].split()[-1] == '不明' +@pytest.mark.parametrize('ps_failure', ['invalid_json', 'os_error']) +def test_profile_list_marks_running_unknown_when_ps_fails(project, monkeypatch, capsys, + ps_failure): + write_generated(project, extra=('app',)) + fake = FakeCompose(profiles={'test': ['app']}) + + def failing_ps(cmd, **kwargs): + if 'ps' in cmd: + if ps_failure == 'os_error': + raise OSError('cannot execute docker compose ps') + return subprocess.CompletedProcess(cmd, 0, '{broken json', '') + return fake(cmd, **kwargs) + + monkeypatch.setattr(container.subprocess, 'run', failing_ps) + + assert container.cmd_profile_list() == 0 + + rows = {line.split()[0]: line.split() + for line in capsys.readouterr().out.splitlines()[1:]} + assert rows['test'][1] == 'app' + assert rows['test'][-1] == '不明' + + def test_profile_list_accepts_json_array_from_older_compose(project, monkeypatch, capsys): write_generated(project) fake = FakeCompose(profiles={'test': ['app']}) diff --git a/tests/commands/test_hook_env.py b/tests/commands/test_hook_env.py index 36641f3d..13c61297 100644 --- a/tests/commands/test_hook_env.py +++ b/tests/commands/test_hook_env.py @@ -105,6 +105,12 @@ def test_hook_env_carries_active_profiles(config): assert project_hook_env(config, active_profiles=("test",))["DEVBASE_ACTIVE_PROFILES"] == "test" +def test_hook_env_joins_multiple_active_profiles_in_order(config): + env = project_hook_env(config, active_profiles=("test", "cache", "debug")) + + assert env["DEVBASE_ACTIVE_PROFILES"] == "test,cache,debug" + + def project_hook_env(config, **kwargs): from devbase.project import runtime return runtime.hook_env(config, **kwargs) From 3de1d4b625dda92416b274ec87af3ac082069098 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 06:06:01 +0900 Subject: [PATCH 09/15] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20te?= =?UTF-8?q?sts/commands/test=5Fcontainer=5Fprofile.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_profile_up で deploy 存在時に current_project_config が DevbaseError を送出した際の終了コードと未実行の振る舞いを固定する。 Item-Id: R1-002 Round: 1 Impl-Runtime: agy Impl-Model: default --- tests/commands/test_container_profile.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/commands/test_container_profile.py b/tests/commands/test_container_profile.py index c0774069..9dd96d73 100644 --- a/tests/commands/test_container_profile.py +++ b/tests/commands/test_container_profile.py @@ -260,6 +260,21 @@ def test_profile_up_fails_when_deploy_fails(project, monkeypatch): assert container.cmd_profile_up('test') != 0 +def test_profile_up_fails_when_project_config_raises_devbase_error(project, monkeypatch): + write_generated(project) + (project / 'deploy').write_text('#!/bin/bash\ntouch ran\n') + fake = use(monkeypatch, FakeCompose(profiles=TEST_PROFILES)) + + def failing_config(): + raise DevbaseError("broken config") + + monkeypatch.setattr(container.project_runtime, 'current_project_config', failing_config) + + assert container.cmd_profile_up('test') == 1 + assert creating_calls(fake) == [] + assert not (project / 'ran').exists() + + def test_profile_down_stops_then_removes_without_volumes(project, monkeypatch, caplog): write_generated(project) fake = use(monkeypatch, FakeCompose(profiles=TEST_PROFILES)) From baec17f81977594eb74fdba8a04c1614e003b90b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 06:43:19 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=E3=83=97=E3=83=AD=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E4=B8=80=E8=A6=A7=E3=81=AE=E7=A8=BC?= =?UTF-8?q?=E5=83=8D=E7=8A=B6=E6=B3=81=E3=82=92=E5=85=A8=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=20ps=20=E3=81=8B?= =?UTF-8?q?=E3=82=89=E6=95=B0=E3=81=88=E3=82=8B=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非アクティブなプロファイルのサービスを ps に出さない版でも RUNNING が stopped に張り付かないよう、_running_services の ps に --profile '*' を付ける。 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 3 ++- tests/commands/test_container_profile.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 9c90934c..09c69c91 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1404,9 +1404,10 @@ def _running_services(compose_file: Path) -> Optional[set]: """``ps --format json`` で ``State`` が ``running`` のサービス名を返す。失敗なら ``None``。 出力は版により 1 行 1 JSON (新しめ) か JSON 配列 (古め) になる。 + ``--profile '*'`` は、非アクティブなプロファイルのサービスを ps に出さない版への備え。 """ try: - result = docker_compose(['ps', '--format', 'json'], compose_file=compose_file, + result = docker_compose(['--profile', '*', 'ps', '--format', 'json'], compose_file=compose_file, check=False, capture_output=True, silent_error=True) except OSError: return None diff --git a/tests/commands/test_container_profile.py b/tests/commands/test_container_profile.py index 9dd96d73..69733289 100644 --- a/tests/commands/test_container_profile.py +++ b/tests/commands/test_container_profile.py @@ -327,6 +327,19 @@ def test_profile_list_shows_services_and_running_state(project, monkeypatch, cap assert 'valkey' in rows['cache'] and '0/1 stopped' in rows['cache'] +def test_profile_list_asks_ps_for_all_profiles(project, monkeypatch): + """非アクティブなプロファイルのサービスを ps に出さない版があるため ``--profile '*'`` を付ける""" + write_generated(project) + fake = use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, running={'app'})) + + assert container.cmd_profile_list() == 0 + + ps_calls = [c['cmd'] for c in fake.compose_calls if 'ps' in c['cmd']] + assert len(ps_calls) == 1 + cmd = ps_calls[0] + assert cmd[cmd.index('ps') - 2:cmd.index('ps')] == ['--profile', '*'] + + def test_profile_list_marks_running_unknown_without_daemon(project, monkeypatch, capsys): write_generated(project) use(monkeypatch, FakeCompose(profiles=TEST_PROFILES, fail={'ps': 1})) From f5fd93a88483a8bd095ae4d1348d117637495658 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 06:54:58 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=E8=B5=B7=E5=8B=95=E5=AF=BE?= =?UTF-8?q?=E8=B1=A1=E3=81=AE=E8=A7=A3=E6=B1=BA=E3=82=92=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E5=89=8D=E3=81=AB=E7=A7=BB=E3=81=97=E3=80=81TUI=20=E3=81=AE?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E8=A7=A3?= =?UTF-8?q?=E6=B1=BA=E3=82=92=E5=AF=BE=E8=B1=A1=E3=83=97=E3=83=AD=E3=82=B8?= =?UTF-8?q?=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AE=E7=92=B0=E5=A2=83=E3=81=A7?= =?UTF-8?q?=E8=A1=8C=E3=81=86=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _run_deploy_pipeline: default_services を docker_compose_down より前に求める。 config --services が失敗しても稼働中の環境を落とさず旧構成を書き戻す - TUI の _profile_names: container.project_profile_names を _preserve_cwd_env の 中で呼び、対象プロジェクトの env と機密を載せてから Compose に解決させる。 切替手順は _enter_project として _dispatch_lifecycle と共有する Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 66 +++++++++++++++++------ lib/devbase/tui/actions_project.py | 23 +++----- tests/cli/tui/test_profile_menu.py | 56 +++++++++++++++++-- tests/commands/test_container_up_order.py | 27 ++++++++++ 4 files changed, 139 insertions(+), 33 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 09c69c91..a543b813 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -628,6 +628,49 @@ def _resolve_project_name(project_name: str) -> bool: return True +def _enter_project(project_name: str) -> bool: + """対象プロジェクトへ切り替え、その env と機密を載せる。解決できなければ False。 + + CWD と ``os.environ`` を書き換える。元へ戻すのは呼び出し側の責務 (CLI はプロセスの + 終了で、TUI は ``tui.dispatch._preserve_cwd_env`` で戻す)。 + """ + # cli.main() は dispatch の前に**現在地**の機密を注入している。切替先の + # env を読む**前**に切替元の機密を落とす (PLAN52)。後に落とすと、 + # clear_injected が「注入前の値」へ戻す動きで、切替先の env が載せた + # 同名キー (DEVBASE_DOCKER_CONTEXT など) まで消してしまう。 + from devbase.env import runtime as _runtime + _runtime.clear_injected() + if not _resolve_project_name(project_name): + return False + # 切替先の機密で作り直してから context を解決する。 + _inject_secrets(required=False) + return True + + +def project_profile_names(project_name: str) -> list[str]: + """プロジェクトのプロファイル名。生成物が無い・解決に失敗したときは空 (PLAN58 決定 8)。 + + 別ディレクトリから開いた一覧 (TUI) から呼ばれるため、CLI と同じく対象プロジェクトへ + 切り替えて env と機密を載せてから Compose に解決させる。生成物が対象の env にだけある + 変数を参照していても解決できるようにするため。CWD と ``os.environ`` は戻さないので、 + 呼び出し側が復元の範囲を張る。失敗を「持たない」とするのは、操作メニューを出すこと + 自体を止めないため。 + """ + from devbase.env import runtime as _runtime + + docker_context.reset() + try: + if not _enter_project(project_name) or not _SCALE_COMPOSE_FILE.is_file(): + return [] + return list(profile_services(_SCALE_COMPOSE_FILE)) + except (DevbaseError, OSError) as e: + logger.debug("プロファイルを解決できません (%s): %s", project_name, e) + return [] + finally: + docker_context.reset() + _runtime.release_store() + + def _dispatch_lifecycle(args) -> int: """`project` / `container` 共有のサブコマンドディスパッチャ。 @@ -652,17 +695,8 @@ def _dispatch_lifecycle(args) -> int: try: # name 指定時はディレクトリを解決して chdir する。解決失敗 (DEVBASE_ROOT 未設定 # / 存在しない name) は候補提示の上でエラー終了する。 - if project_name: - # cli.main() は dispatch の前に**現在地**の機密を注入している。切替先の - # env を読む**前**に切替元の機密を落とす (PLAN52)。後に落とすと、 - # clear_injected が「注入前の値」へ戻す動きで、切替先の env が載せた - # 同名キー (DEVBASE_DOCKER_CONTEXT など) まで消してしまう。 - from devbase.env import runtime as _runtime - _runtime.clear_injected() - if not _resolve_project_name(project_name): - return 1 - # 切替先の機密で作り直してから context を解決する。 - _inject_secrets(required=False) + if project_name and not _enter_project(project_name): + return 1 # `--context` は指定されたときだけ渡す。各 handler の既定は None なので結果は # 同じで、指定が無い経路は従来と同じ呼び出しの形を保つ。 @@ -1141,15 +1175,17 @@ def _run_deploy_pipeline(project_name: str, scale: int, config, logger.info("[2/6] Generating scaled compose file...") override_file = _build_scaled_override(scale, config, project_name, target) logger.info("Generated: %s", override_file) + # 起動の対象は既定のサービスに限る。端末や .env の COMPOSE_PROFILES でプロファイルが + # 有効になっても、プロファイルのサービスは起動しない (PLAN58 決定 7)。 + # 構成の解決 (docker compose config) も停止より前に済ませる。補間エラーなどで + # 失敗しても、稼働中の環境を落としたままにせず旧構成を書き戻して止まる + services = default_services(override_file) logger.info("[3/6] Stopping existing containers...") docker_compose_down(compose_file=down_compose_file) logger.info("[4/6] Starting containers...") - # 起動の対象は既定のサービスに限る。端末や .env の COMPOSE_PROFILES でプロファイルが - # 有効になっても、プロファイルのサービスは起動しない (PLAN58 決定 7) - docker_compose_up(compose_file=override_file, detach=True, - services=default_services(override_file)) + docker_compose_up(compose_file=override_file, detach=True, services=services) logger.info("[5/6] Waiting for containers to be ready...") wait_for_containers_ready( diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 73613849..186e78b6 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -22,7 +22,7 @@ from devbase.log import get_logger from devbase.tui import flow, menu -from devbase.tui.dispatch import dispatch_lifecycle +from devbase.tui.dispatch import _preserve_cwd_env, dispatch_lifecycle logger = get_logger(__name__) @@ -57,29 +57,22 @@ _ABORT = flow.ABORT -def _profile_names(devbase_root: Path, name: str) -> list[str]: +def _profile_names(name: str) -> list[str]: """プロジェクトのプロファイル名。生成物が無い・解決に失敗したときは空。 - 解決は ``docker compose config`` で行い、デーモンへの接続は要らない (PLAN58 決定 1)。 - 失敗を「持たない」として扱うのは、一覧の操作メニューを出すこと自体を止めないため。 + 解決は対象プロジェクトの env と機密を載せて ``docker compose config`` で行う + (PLAN58 決定 1)。切替による CWD / 環境変数の変更は TUI セッションへ残さない。 """ from devbase.commands import container - from devbase.errors import DevbaseError - compose_file = Path(devbase_root) / "projects" / name / container._SCALE_COMPOSE_FILE - if not compose_file.is_file(): - return [] - try: - return list(container.profile_services(compose_file)) - except (DevbaseError, OSError) as e: - logger.debug("プロファイルを解決できません (%s): %s", name, e) - return [] + with _preserve_cwd_env(): + return container.project_profile_names(name) def _running_ops(devbase_root: Path, name: str) -> list[tuple[str, str]]: """running 行で選べる操作。プロファイルを持つときだけ起動・停止の 2 項目を足す。""" ops = list(_RUNNING_OPS) - if _profile_names(devbase_root, name): + if _profile_names(name): ops += _PROFILE_OPS return ops @@ -160,7 +153,7 @@ def _op_build(devbase_root: Path, name: str): def _op_profile(operation: str): """プロファイルの起動・停止。名前が 1 つだけでも選択として出す (PLAN58)。""" def run(devbase_root: Path, name: str): - names = _profile_names(devbase_root, name) + names = _profile_names(name) profile = flow.need(menu.select( f"'{name}' のプロファイルを選択 {menu.HINT_BACK}:", [(n, n) for n in names], back=True, search=False)) diff --git a/tests/cli/tui/test_profile_menu.py b/tests/cli/tui/test_profile_menu.py index 425081b2..2e207c65 100644 --- a/tests/cli/tui/test_profile_menu.py +++ b/tests/cli/tui/test_profile_menu.py @@ -7,6 +7,9 @@ from __future__ import annotations +import os +from pathlib import Path + import pytest from devbase.commands import container @@ -23,8 +26,19 @@ def _no_pause(monkeypatch): @pytest.fixture -def root(tmp_path): +def root(tmp_path, monkeypatch): + """``$DEVBASE_ROOT`` を tmp へ向け、機密の注入は実 backend に触れないよう差し替える。 + + 解決は対象プロジェクトへ chdir してから行う。テストの CWD は ``elsewhere`` に置き、 + 別ディレクトリから一覧を開いた状況にする。 + """ (tmp_path / "projects" / "carmo").mkdir(parents=True) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + monkeypatch.setenv("PWD", str(elsewhere)) + monkeypatch.chdir(elsewhere) + monkeypatch.setattr(container, "_inject_secrets", lambda *, required: None) return tmp_path @@ -38,7 +52,7 @@ def resolve_to(monkeypatch, result): asked = [] def fake(compose_file, environ=None): - asked.append(compose_file) + asked.append(Path(compose_file).resolve()) if isinstance(result, Exception): raise result return result @@ -54,7 +68,7 @@ def test_profile_items_follow_running_ops_when_profiles_exist(root, monkeypatch) ops = actions_project._running_ops(root, "carmo") assert ops == list(actions_project._RUNNING_OPS) + PROFILE_ITEMS - assert asked == [path] + assert asked == [path.resolve()] @pytest.mark.parametrize("result", [{}, DevbaseError("config failed")]) @@ -123,3 +137,39 @@ def fake_action(name, ops=None): assert result is menu.MENU_BACK # 1 回の実行で一覧へ戻る (2 回目の選択が無い) assert len(shown) == 1 and PROFILE_ITEMS[0] in shown[0] + + +def test_profiles_resolve_inside_target_project_and_restore_session(root, monkeypatch): + """別ディレクトリから開いても、対象プロジェクトの env と機密を載せた上で解決する。 + + 生成物が ``${REVIEW_APP_IMAGE:?required}`` のように対象の env にだけある変数を + 参照しても解決できるようにするため。終わった後は CWD と ``os.environ`` を戻し、 + TUI セッションへ残さない。 + """ + project = root / "projects" / "carmo" + (project / "env").write_text("REVIEW_APP_IMAGE=review:latest\n") + generated(root) + injected = [] + monkeypatch.setattr(container, "_inject_secrets", + lambda *, required: injected.append( + (required, Path.cwd().resolve()))) + seen = {} + + def fake(compose_file, environ=None): + seen["cwd"] = Path.cwd().resolve() + seen["image"] = os.environ.get("REVIEW_APP_IMAGE") + seen["project"] = os.environ.get("COMPOSE_PROJECT_NAME") + return {"test": ["app"]} + + monkeypatch.setattr(container, "profile_services", fake) + monkeypatch.delenv("REVIEW_APP_IMAGE", raising=False) + before_cwd = Path.cwd() + before_env = os.environ.copy() + + ops = actions_project._running_ops(root, "carmo") + + assert ops == list(actions_project._RUNNING_OPS) + PROFILE_ITEMS + assert seen == {"cwd": project.resolve(), "image": "review:latest", "project": "carmo"} + assert injected == [(False, project.resolve())] + assert Path.cwd() == before_cwd + assert os.environ.copy() == before_env diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py index 8c58f36d..b616a595 100644 --- a/tests/commands/test_container_up_order.py +++ b/tests/commands/test_container_up_order.py @@ -315,3 +315,30 @@ def fake_defaults(compose_file, environ=None): up = [k for name, k in calls if name == 'up'] assert up == [{'compose_file': container._SCALE_COMPOSE_FILE, 'detach': True, 'services': ['dev-1', 'redis']}] + + +def test_default_services_failure_keeps_containers_running(up_harness, monkeypatch): + """起動対象の解決に失敗したら停止も起動もせず、旧構成を書き戻して失敗する。 + + ``config --services`` の失敗 (補間エラー・CLI の不整合) を停止の後に知ると、 + 稼働中の開発環境が止まったまま残る。構成の解決は停止より前に済ませる。 + """ + calls = up_harness + container._SCALE_COMPOSE_FILE.write_text(OLD_COMPOSE) + + def fake_generate(scale, secrets, dev_environment=None): + container._SCALE_COMPOSE_FILE.write_text(NEW_COMPOSE) + return container._SCALE_COMPOSE_FILE + + def broken_defaults(compose_file, environ=None): + raise DevbaseError('docker compose config --services failed') + + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: object()) + monkeypatch.setattr(container, '_generate_compose_for', fake_generate) + monkeypatch.setattr(container, 'default_services', broken_defaults) + + assert container.cmd_up() == 1 + + assert calls == [] # down も up も呼ばれていない + assert container._SCALE_COMPOSE_FILE.read_text() == OLD_COMPOSE + assert not Path(f'{container._SCALE_COMPOSE_FILE}.prev').exists() From fe0b1e5c3c96c3eb5f102a68033b99038dbef664 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 07:12:58 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20TUI=20=E3=81=AE=E5=BE=A9=E5=85=83?= =?UTF-8?q?=E5=A2=83=E7=95=8C=E3=81=A7=E6=A9=9F=E5=AF=86=E3=81=AE=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E5=B1=A5=E6=AD=B4=E3=82=82=E5=80=A4=E3=81=A8=E5=90=8C?= =?UTF-8?q?=E6=99=82=E3=81=AB=E6=88=BB=E3=81=99=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _preserve_cwd_env は os.environ の値だけを戻し、runtime の注入履歴を戻して いなかった。ハンドラの中で別プロジェクトへ切り替えると、値は切替元へ戻るのに 履歴は切替先のものになり、次の clear_injected が切替元固有の機密を落とせず 次に操作するプロジェクトの Compose 子プロセスへ渡っていた。メニュー表示時の プロファイル照会と、既存の dispatch_lifecycle の経路の両方が該当する。 runtime に snapshot_injected / restore_injected を足し、_preserve_cwd_env が 入口で履歴を控え、finally で os.environ と同時に書き戻す。 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/env/runtime.py | 21 +++++++++++++ lib/devbase/tui/dispatch.py | 7 +++++ tests/cli/tui/test_dispatch.py | 54 ++++++++++++++++++++++++++++++++++ tests/env/test_runtime.py | 53 +++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+) diff --git a/lib/devbase/env/runtime.py b/lib/devbase/env/runtime.py index b08d4e9b..000f1781 100644 --- a/lib/devbase/env/runtime.py +++ b/lib/devbase/env/runtime.py @@ -260,6 +260,27 @@ def _history_for(target) -> Dict[str, Optional[str]]: return originals +def snapshot_injected(environ=None) -> Optional[Dict[str, Optional[str]]]: + """対象マッピングの注入履歴の複製を返す。履歴が無ければ None。 + + 環境変数の**値**を控えて後で戻す呼び出し側 (TUI の + ``tui.dispatch._preserve_cwd_env``) が、値と一緒に履歴も戻すために使う。値だけを + 戻すと、戻った機密を次の :func:`clear_injected` が知らずに残してしまう。 + """ + target = environ if environ is not None else os.environ + entry = _injected_originals.get(id(target)) + return None if entry is None else dict(entry[1]) + + +def restore_injected(snapshot: Optional[Dict[str, Optional[str]]], environ=None) -> None: + """:func:`snapshot_injected` で控えた履歴を書き戻す。None なら履歴を消す。""" + target = environ if environ is not None else os.environ + if snapshot is None: + _injected_originals.pop(id(target), None) + else: + _injected_originals[id(target)] = (target, dict(snapshot)) + + def clear_injected(environ=None) -> List[str]: """この実行で載せた機密を取り除き、注入前の状態へ戻す。 diff --git a/lib/devbase/tui/dispatch.py b/lib/devbase/tui/dispatch.py index 4322f942..7d50f0b2 100644 --- a/lib/devbase/tui/dispatch.py +++ b/lib/devbase/tui/dispatch.py @@ -33,11 +33,17 @@ def _preserve_cwd_env(): 直前プロジェクトの CWD / 環境変数 (PWD 含む) を後続操作 (env get 等) が参照して しまう (PR #55 round1 codex/gemini major 指摘)。委譲チョークポイントである本層で 一括復元し、各 actions_* / 共有ハンドラへ復元処理を散らさない。 + + 機密の注入履歴 (``runtime.snapshot_injected``) も値と同時に戻す。ハンドラの中で + 別プロジェクトへ切り替えると履歴は切替先のものになる。値だけを切替元へ戻すと、 + 次の ``clear_injected`` が切替元固有の機密を知らずに残し、次に操作するプロジェクトの + Compose 子プロセスへ渡ってしまう (PR #191 round 3 codex major 指摘)。 """ from devbase.env import runtime as _runtime old_cwd = os.getcwd() old_env = os.environ.copy() + old_injected = _runtime.snapshot_injected() # 持ち回った SecretStore は操作の入口で捨てる (PLAN55 決定 3)。TUI は 1 プロセスで # 操作を続けるため、起動時や前の操作の控え (``_seen``) を持ち越すと、``env edit`` で # 書いた直後の ``up`` が編集前の値で起動する。lifecycle / group のどちらもここを通る。 @@ -49,6 +55,7 @@ def _preserve_cwd_env(): os.chdir(old_cwd) os.environ.clear() os.environ.update(old_env) + _runtime.restore_injected(old_injected) def dispatch_lifecycle(subcommand: str, name: str | None = None, **attrs) -> int: diff --git a/tests/cli/tui/test_dispatch.py b/tests/cli/tui/test_dispatch.py index f4a64ce9..e4f88e03 100644 --- a/tests/cli/tui/test_dispatch.py +++ b/tests/cli/tui/test_dispatch.py @@ -218,3 +218,57 @@ def fake_editor(argv): finally: runtime.release_store() runtime.clear_injected() + + +# --------------------------------------------------------------------------- +# PR #191 round 3: 機密の注入履歴も CWD・環境変数と揃えて戻す +# --------------------------------------------------------------------------- + +def test_preserve_cwd_env_restores_the_injection_history(tmp_path, monkeypatch): + """切替先で注入し直しても、抜けた後の解除は切替元固有の機密を落とす。 + + 値だけを戻して履歴を戻さないと、戻った切替元の機密を次の解除が知らず、 + 次に操作するプロジェクトの Compose 子プロセスへ渡ってしまう。 + """ + import pyrage + + from devbase.env import runtime + from devbase.env.secret_store import SecretRef, SecretStore + + (tmp_path / 'projects' / 'web').mkdir(parents=True) + (tmp_path / 'projects' / 'api').mkdir(parents=True) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(runtime, '_injected_originals', {}) + for name in ('WEB_ONLY', 'API_ONLY'): + # 一度設定してから消し、失敗時にも monkeypatch が未設定へ戻すようにする + monkeypatch.setenv(name, '') + monkeypatch.delenv(name) + + identity = pyrage.x25519.Identity.generate() + key = tmp_path / 'id.key' + key.write_text(str(identity)) + store = SecretStore(tmp_path, recipients=[str(identity.to_public())], + identities=[str(key)]) + store.age.save(SecretRef.for_project('web'), {'WEB_ONLY': 'w'}) + store.age.save(SecretRef.for_project('api'), {'API_ONLY': 'a'}) + + def enter_api(devbase_root, args): + runtime.clear_injected() + runtime.inject(devbase_root, 'api', store=store) + assert 'WEB_ONLY' not in os.environ + return 0 + + try: + # 現在地 (web) の機密を載せた状態から、別プロジェクト (api) を照会する + runtime.inject(tmp_path, 'web', store=store) + assert dispatch.dispatch_group(enter_api, tmp_path, 'list') == 0 + assert os.environ['WEB_ONLY'] == 'w' + + # 次の操作の切替で、切替元固有の機密が落ちる + runtime.clear_injected() + assert 'WEB_ONLY' not in os.environ + assert 'API_ONLY' not in os.environ + finally: + runtime.clear_injected() + runtime.release_store() diff --git a/tests/env/test_runtime.py b/tests/env/test_runtime.py index d9819b47..5657b449 100644 --- a/tests/env/test_runtime.py +++ b/tests/env/test_runtime.py @@ -304,6 +304,59 @@ def test_child_env_keeps_the_existing_environment(root, store): assert env['TOKEN'] == 'sk-1' +def test_restore_injected_brings_back_the_history_of_the_snapshot(root, store): + """控えた履歴を書き戻すと、その後の解除は控えた時点で載っていた機密を落とす""" + (root / 'projects' / 'api').mkdir() + store.age.save(WEB, {'WEB_ONLY': 'w'}) + store.age.save(API, {'API_ONLY': 'a'}) + environ = {} + + runtime.inject(root, 'web', environ=environ, store=store) + snapshot = runtime.snapshot_injected(environ) + saved_values = dict(environ) + + # 控えた後の切替で履歴が入れ替わる + runtime.clear_injected(environ) + runtime.inject(root, 'api', environ=environ, store=store) + + # 値と履歴を揃えて戻す + environ.clear() + environ.update(saved_values) + runtime.restore_injected(snapshot, environ) + + assert runtime.clear_injected(environ) == ['WEB_ONLY'] + assert environ == {} + + +def test_snapshot_is_not_changed_by_later_injection(root, store): + """控えは複製なので、後の注入で書き足された履歴が混ざらない""" + store.age.save(WEB, {'WEB_ONLY': 'w'}) + environ = {} + + runtime.inject(root, 'web', environ=environ, store=store) + snapshot = runtime.snapshot_injected(environ) + store.age.save(GLOBAL, {'LATER': 'l'}) + runtime.inject(root, 'web', environ=environ, store=store) + + runtime.restore_injected(snapshot, environ) + + assert runtime.clear_injected(environ) == ['WEB_ONLY'] + + +def test_restoring_an_empty_snapshot_drops_the_history(root, store): + """履歴が無い時点の控えを書き戻すと、その後に作られた履歴は消える""" + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + environ = {} + + snapshot = runtime.snapshot_injected(environ) + runtime.inject(root, None, environ=environ, store=store) + + runtime.restore_injected(snapshot, environ) + + assert runtime.clear_injected(environ) == [] + assert id(environ) not in runtime._injected_originals + + # --------------------------------------------------------------------------- # プロジェクトの特定 # --------------------------------------------------------------------------- From 8775d17e9268ac15847b8aeabd7cf45024ca9c8b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 07:13:52 +0900 Subject: [PATCH 13/15] =?UTF-8?q?docs(PLAN58):=20=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E4=B8=AD=E3=81=AB=E7=AF=84=E5=9B=B2=E3=81=B8=E5=85=A5=E3=82=8C?= =?UTF-8?q?=E3=81=9F=E6=B3=A8=E5=85=A5=E5=B1=A5=E6=AD=B4=E3=81=AE=E5=BE=A9?= =?UTF-8?q?=E5=85=83=E3=82=92=E8=A8=88=E7=94=BB=E3=81=B8=E8=A8=98=E9=8C=B2?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- issues/PLAN58_compose-profiles-impl.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/issues/PLAN58_compose-profiles-impl.md b/issues/PLAN58_compose-profiles-impl.md index 592741ba..cb6d55de 100644 --- a/issues/PLAN58_compose-profiles-impl.md +++ b/issues/PLAN58_compose-profiles-impl.md @@ -116,6 +116,10 @@ - **対象:** 要求仕様「検証手段」の手動確認 7 行 - **進め方:** `alpine:3` の最小構成を scratchpad に作り、この作業ツリーの `bin/devbase` で通す。結果(Container ID / `StartedAt` の前後)は Pull Request 本文へ貼る。実 docker を使うため、自分のプロジェクトとは別の `COMPOSE_PROJECT_NAME` で行う +## 実装中に範囲へ入れたもの + +- `tui/dispatch.py` の `_preserve_cwd_env()` が機密の注入履歴を戻さず、TUI で別プロジェクトを 2 回続けて操作すると最初のプロジェクト固有の機密が次の Compose へ渡る欠陥(PR 前からの `dispatch_lifecycle` 経路にもある)。Task 7 のメニュー表示時の照会が同じ欠陥を操作前に踏ませるため、原因と形が同じとして範囲に入れ、`runtime.snapshot_injected` / `restore_injected` で両経路を 1 か所で直した(PR #191 レビュー round 3) + ## リスクと対処 | リスク | 対処 | From bf2cf5258806fe9c7145552e6e825b7a7c68b35c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 07:26:14 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix(PLAN58):=20=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E8=A7=A3=E6=B1=BA=E3=81=8C?= =?UTF-8?q?=E7=A9=BA=E3=81=AE=E3=81=A8=E3=81=8D=E7=A9=BA=E3=81=AE=E9=81=B8?= =?UTF-8?q?=E6=8A=9E=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC=E3=82=92=E5=87=BA?= =?UTF-8?q?=E3=81=95=E3=81=9A=E3=82=B5=E3=83=96=E3=83=A1=E3=83=8B=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=81=B8=E6=88=BB=E3=82=8B=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/tui/actions_project.py | 5 +++++ tests/cli/tui/test_profile_menu.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/devbase/tui/actions_project.py b/lib/devbase/tui/actions_project.py index 186e78b6..9ad83d3f 100644 --- a/lib/devbase/tui/actions_project.py +++ b/lib/devbase/tui/actions_project.py @@ -154,6 +154,11 @@ def _op_profile(operation: str): """プロファイルの起動・停止。名前が 1 つだけでも選択として出す (PLAN58)。""" def run(devbase_root: Path, name: str): names = _profile_names(name) + if not names: + # 項目を出した後に解決が空になった (状態変化・解決失敗)。空の選択肢は + # questionary が ValueError で落ちるため、選択を出さずサブメニューへ戻る。 + logger.warning("'%s' のプロファイルを解決できませんでした。", name) + raise flow.BackOut profile = flow.need(menu.select( f"'{name}' のプロファイルを選択 {menu.HINT_BACK}:", [(n, n) for n in names], back=True, search=False)) diff --git a/tests/cli/tui/test_profile_menu.py b/tests/cli/tui/test_profile_menu.py index 2e207c65..e0f2349e 100644 --- a/tests/cli/tui/test_profile_menu.py +++ b/tests/cli/tui/test_profile_menu.py @@ -119,6 +119,21 @@ def test_profile_selection_back_returns_to_submenu(root, monkeypatch): assert delegated == [] +@pytest.mark.parametrize("result", [{}, DevbaseError("config failed")]) +def test_profile_item_without_resolved_names_returns_to_submenu(root, monkeypatch, result): + """項目を出した後に解決が空になっても、空の選択メニューを出さずにサブメニューへ戻る。""" + generated(root) + resolve_to(monkeypatch, result) + prompts = [] + monkeypatch.setattr(menu, "select", lambda *a, **k: prompts.append(a) or "test") + delegated = [] + monkeypatch.setattr(container, "cmd_project", lambda args: delegated.append(args) or 0) + + assert actions_project._run_operation(root, "carmo", "profile-up") is flow.ARG_CANCEL + assert prompts == [] + assert delegated == [] + + @pytest.mark.parametrize("op", ["profile-up", "profile-down"]) def test_profile_ops_return_to_project_list(root, monkeypatch, op): generated(root) From d08af7676c25a139074135b19c40917784b346b6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 17 Sep 2026 07:32:07 +0900 Subject: [PATCH 15/15] =?UTF-8?q?docs(PLAN58):=20CHANGELOG=20=E3=81=AE=20U?= =?UTF-8?q?nreleased=20=E3=81=AB=20profiles=20=E3=81=AE=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5395dc..e7f50ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ ## [Unreleased] +### Added + +- **Compose の `profiles` を付けた付随サービス群を、dev コンテナに触れずに後から起動・停止できるように + しました(PLAN58 / #189, #191)。** `devbase up` の既定では dev だけが起動します。 + - `devbase project profile up|down [name] ` / `devbase project profile list [name]` を追加しました + (`container` / `ct` にもあります。`[name]` は受け付けません)。起動は `--no-deps` で対象サービスだけを、 + 停止は `stop` → `rm -f` で行い、名前付きボリュームは残します + - `devbase list` の起動中の行の操作メニューに「テスト用サーバ起動 / 停止」を足しました(プロファイルを + 持つプロジェクトだけ) + - `profile up` の後に `deploy` フックを呼び直します。フックは `DEVBASE_ACTIVE_PROFILES` で起動した + プロファイル名を受け取ります(`devbase up` からは空) + - 利用には Docker Compose 2.20.0 以上が要ります(`depends_on.required` のため)。書き方は + `docs/plugin-dev/compose-profiles.md` + +### Changed + +- `devbase down` と `devbase up` の冒頭の停止は、プロファイルのサービスも止めるようになりました + (`--profile '*'`)。`devbase up` の起動は `profiles` を持たないサービスを名前で明示して起動します +- devbase 経由の `docker compose` には、端末の環境変数やプロジェクトの `.env` の `COMPOSE_PROFILES` が + 効かなくなりました(devbase が `__devbase_none__` で上書きします)。素の `docker compose` には影響しません + +### Fixed + +- `devbase list` から別のプロジェクトを続けて操作すると、最初のプロジェクトにだけある機密が次の + プロジェクトの `docker compose` へ渡ることがあった問題を直しました(#191) + ## [3.4.0] - 2026-09-15 機密の保存先に OpenBao を選べるようになりました。起動中の dev コンテナの中からも `bao` で