From d1c826a4d9f2d86d336a808279a1f3824b69ad15 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 7 Jun 2026 06:50:12 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(status):=20docker=20compose=20ps=20?= =?UTF-8?q?=E3=82=92=20--project-name=20=E3=81=A7=E6=98=8E=E7=A4=BA=20scop?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devbase list / status の各プロジェクト状態取得 (_container_status_for) が docker compose ps を親環境の COMPOSE_PROJECT_NAME を継承したまま実行しており、 全プロジェクトが同一 (カレント) プロジェクトの状態を返していた。 bin/devbase は常に COMPOSE_PROJECT_NAME を export するため、これを継承した devbase list の python プロセス → docker compose ps サブプロセスが env を ディレクトリ由来名より優先し、一覧が一律「running (1 containers)」になる。 PLAN06 で list が対話デフォルト化し全プロジェクトの状態を表示するように なったことで顕在化した既存バグ。 docker compose に --project-name を明示指定 (CLI フラグは 継承 env より優先) して各プロジェクト名で scope する。devbase up は COMPOSE_PROJECT_NAME = 各プロジェクト名でコンテナを起動するため、同名で scope すれば正確に取得できる。 回帰テスト (tests/plugin/test_container_status_for.py) を追加し、env 汚染下でも entry.name で scope することを検証。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/status.py | 10 ++- tests/plugin/test_container_status_for.py | 78 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tests/plugin/test_container_status_for.py diff --git a/lib/devbase/commands/status.py b/lib/devbase/commands/status.py index 3f3081c..cf22203 100644 --- a/lib/devbase/commands/status.py +++ b/lib/devbase/commands/status.py @@ -32,8 +32,16 @@ def _container_status_for(entry: Path) -> dict | None: return None try: + # `--project-name entry.name` で明示 scope する。bin/devbase が常に + # COMPOSE_PROJECT_NAME を export しており、これを継承したまま + # `docker compose ps` を叩くと docker compose は継承 env を + # ディレクトリ由来名より優先するため、全プロジェクトがカレント + # プロジェクトの状態を返してしまう (一覧が一律 running / 同一コンテナ数 + # になる回帰)。devbase up は COMPOSE_PROJECT_NAME = 各プロジェクト名 + # (= entry.name) でコンテナを起動するため、同じ名前で scope する。 proc = subprocess.run( - ["docker", "compose", "ps", "--format", "json"], + ["docker", "compose", "--project-name", entry.name, + "ps", "--format", "json"], cwd=str(entry), capture_output=True, text=True, diff --git a/tests/plugin/test_container_status_for.py b/tests/plugin/test_container_status_for.py new file mode 100644 index 0000000..65491ab --- /dev/null +++ b/tests/plugin/test_container_status_for.py @@ -0,0 +1,78 @@ +"""Regression tests for `_container_status_for` の compose project scope。 + +`bin/devbase` は常に `COMPOSE_PROJECT_NAME` を export する (cwd basename ないし +解決済みプロジェクト名)。`devbase list` の python プロセスはこれを継承するため、 +`docker compose ps` を明示的に `--project-name ` で scope しないと、 +docker compose が継承 env を優先して全プロジェクトで「同じ (カレント) プロジェクト」 +の状態を返してしまう (全項目が同一の running / コンテナ数になる回帰)。 +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from devbase.commands.status import _container_status_for + + +def _make_entry(tmp_path: Path, name: str) -> Path: + entry = tmp_path / "projects" / name + entry.mkdir(parents=True) + (entry / "compose.yml").write_text("services:\n dev:\n image: busybox\n") + return entry + + +def test_scopes_query_to_entry_name(tmp_path, monkeypatch): + """継承 COMPOSE_PROJECT_NAME に左右されず entry.name で scope する。""" + entry = _make_entry(tmp_path, "myproj") + + captured: dict = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + + class R: + returncode = 0 + stdout = '{"State":"running"}\n{"State":"running"}\n' + + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + # 別プロジェクトに居る状態 (wrapper が export 済み) を再現 + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "some-other-project") + + result = _container_status_for(entry) + + assert result == {"name": "myproj", "status": "running (2 containers)", "count": 2} + + cmd = captured["cmd"] + assert "--project-name" in cmd, f"--project-name で scope していない: {cmd}" + idx = cmd.index("--project-name") + assert cmd[idx + 1] == "myproj", f"entry.name で scope していない: {cmd}" + # global flag は subcommand より前に置くこと + assert cmd.index("--project-name") < cmd.index("ps") + + +def test_distinct_projects_get_distinct_scope(tmp_path, monkeypatch): + """異なる entry は異なる project 名でクエリされる (一律化しない)。""" + a = _make_entry(tmp_path, "proj-a") + b = _make_entry(tmp_path, "proj-b") + + seen: list[str] = [] + + def fake_run(cmd, **kwargs): + seen.append(cmd[cmd.index("--project-name") + 1]) + + class R: + returncode = 0 + stdout = "" + + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "current") + + _container_status_for(a) + _container_status_for(b) + + assert seen == ["proj-a", "proj-b"] From afda20ecc93579651ed335ca0157fcc35e92b1e8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 7 Jun 2026 12:36:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?perf(status):=20=E3=82=B3=E3=83=B3=E3=83=86?= =?UTF-8?q?=E3=83=8A=E7=8A=B6=E6=85=8B=E3=82=92=20docker=20ps=201=20?= =?UTF-8?q?=E5=9B=9E=E9=9B=86=E8=A8=88=E3=81=AB=E5=A4=89=E6=9B=B4=20(N?= =?UTF-8?q?=E2=86=921=20=E3=82=B5=E3=83=96=E3=83=97=E3=83=AD=E3=82=BB?= =?UTF-8?q?=E3=82=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devbase list / status の状態取得が「プロジェクト数ぶん docker compose ps を サブプロセス起動」していたのを、単一の docker ps で全 running コンテナを com.docker.compose.project ラベルごとに集計する方式へ変更する。 - _running_counts_by_project() を追加: docker ps を 1 回だけ実行し {project名: running数} を返す。 - _container_status_for(entry, counts) は counts マップを参照するだけに変更。 呼び出し側 (_get_container_status / list_projects) が 1 回集計して全 entry で 使い回す。ラベルで識別するため COMPOSE_PROJECT_NAME 継承の影響も構造的に 受けなくなり、前コミットの --project-name scope は不要になった。 - list_projects の ThreadPoolExecutor を廃止 (単一 docker ps なので並列化不要)。 - status.py の未使用 json import を削除。 プロジェクト数が増えてもサブプロセス起動は常に 1 回で済む。 テストも docker ps 集計方式の検証へ更新 (起動回数 1 回・ラベル集計・env 非依存)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/project.py | 25 ++-- lib/devbase/commands/status.py | 118 +++++++++--------- tests/cli/test_project_list.py | 26 ++-- tests/plugin/test_container_status_for.py | 138 +++++++++++++++++----- 4 files changed, 190 insertions(+), 117 deletions(-) diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index 8ce9242..771b85e 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -68,8 +68,6 @@ def list_projects(projects_dir: Path) -> list[dict]: """ # status ロジックは commands/status.py と共有する (PLAN06 リファクタで per-entry # 関数 _container_status_for を分離済み)。import は循環回避のため関数内で行う。 - from concurrent.futures import ThreadPoolExecutor - from devbase.commands import status as status_mod if not projects_dir.exists(): @@ -80,31 +78,28 @@ def list_projects(projects_dir: Path) -> list[dict]: entry for entry in sorted(projects_dir.iterdir()) if entry.is_symlink() or entry.is_dir() ] + if not entries: + return [] + + # コンテナ状態は docker ps 1 回で全プロジェクトぶん集計し (counts)、各 entry で + # 使い回す。プロジェクト数ぶん docker compose ps を起動していた旧実装の + # サブプロセスコストを N→1 に削減するため、並列化 (ThreadPoolExecutor) も不要。 + counts = status_mod._running_counts_by_project() def _status_for(entry: Path) -> str: # is_dir() は symlink 先まで辿る。broken symlink は False → unknown のまま。 - # _container_status_for は cwd= 引数で完結し global chdir を行わないため - # スレッド安全。各 `docker compose ps` は I/O バウンドで 10s timeout を - # 持つため、プロジェクト数が増えても並列化で総待ち時間を抑える。 if not entry.is_dir(): return "unknown" - st = status_mod._container_status_for(entry) + st = status_mod._container_status_for(entry, counts) return st["status"] if st is not None else "unknown" - # entries が空だと max_workers=0 で ValueError になるため早期 return。 - if not entries: - return [] - - with ThreadPoolExecutor(max_workers=min(8, len(entries))) as ex: - statuses = list(ex.map(_status_for, entries)) - return [ { "name": entry.name, "plugin": _resolve_plugin_name(entry) or "-", - "status": status, + "status": _status_for(entry), } - for entry, status in zip(entries, statuses) + for entry in entries ] diff --git a/lib/devbase/commands/status.py b/lib/devbase/commands/status.py index cf22203..55d8bb2 100644 --- a/lib/devbase/commands/status.py +++ b/lib/devbase/commands/status.py @@ -1,6 +1,5 @@ """devbase status - 環境ステータスの一覧表示""" -import json import subprocess from datetime import datetime from pathlib import Path @@ -16,79 +15,79 @@ logger = get_logger(__name__) -def _container_status_for(entry: Path) -> dict | None: - """単一プロジェクトディレクトリのコンテナ状態を取得する。 +_COMPOSE_PROJECT_LABEL = "com.docker.compose.project" - `projects/` (実ディレクトリ or plugin への symlink) を受け取り、 - ``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker - コマンドが利用できない / タイムアウト / 異常終了の場合は ``None`` を返す。 +# `counts` 引数の「未指定」を docker 不在 (None) と区別するための sentinel。 +_UNSET = object() - PLAN06 で ``project list`` (commands/project.py) が同じ per-entry ロジックを - 再利用するため、``_get_container_status`` のループ本体から分離した。挙動は - 分離前と同一 (None を返す条件 = 旧実装で ``continue`` していた条件)。 - """ - compose_file = entry / "compose.yml" - if not compose_file.exists(): - return None +def _running_counts_by_project() -> dict[str, int] | None: + """全 running コンテナを単一の ``docker ps`` で取得し、compose project 名 + ごとの running 数を返す。docker が使えない / 取得失敗時は ``None``。 + + プロジェクト数ぶん ``docker compose ps`` を起動する代わりに ``docker ps`` + 1 回で全コンテナのラベルを集計し、サブプロセス起動コストを N→1 に削減する。 + compose project は ``com.docker.compose.project`` ラベル (= devbase up 時の + COMPOSE_PROJECT_NAME = プロジェクト名) で識別するため、呼び出し側プロセスが + 継承する COMPOSE_PROJECT_NAME に一切影響されない (一覧が一律同一状態になる + 回帰を構造的に回避する)。``docker ps`` は既定で running のみを列挙する。 + """ try: - # `--project-name entry.name` で明示 scope する。bin/devbase が常に - # COMPOSE_PROJECT_NAME を export しており、これを継承したまま - # `docker compose ps` を叩くと docker compose は継承 env を - # ディレクトリ由来名より優先するため、全プロジェクトがカレント - # プロジェクトの状態を返してしまう (一覧が一律 running / 同一コンテナ数 - # になる回帰)。devbase up は COMPOSE_PROJECT_NAME = 各プロジェクト名 - # (= entry.name) でコンテナを起動するため、同じ名前で scope する。 proc = subprocess.run( - ["docker", "compose", "--project-name", entry.name, - "ps", "--format", "json"], - cwd=str(entry), + ["docker", "ps", + "--filter", f"label={_COMPOSE_PROJECT_LABEL}", + "--format", f'{{{{.Label "{_COMPOSE_PROJECT_LABEL}"}}}}'], capture_output=True, text=True, timeout=10, ) if proc.returncode != 0: return None + except (subprocess.TimeoutExpired, OSError): + # docker コマンドが利用できない、またはタイムアウト + return None - output = proc.stdout.strip() - if not output: - return {"name": entry.name, "status": "stopped", "count": 0} - - # docker compose ps --format json は1行1JSONまたはJSON配列 - containers = [] - for line in output.splitlines(): - line = line.strip() - if not line: - continue - try: - parsed = json.loads(line) - if isinstance(parsed, list): - containers.extend(parsed) - else: - containers.append(parsed) - except json.JSONDecodeError: - continue - - if not containers: - return {"name": entry.name, "status": "stopped", "count": 0} - - running = sum( - 1 for c in containers - if c.get("State", "").lower() == "running" - ) - total = len(containers) + counts: dict[str, int] = {} + for line in proc.stdout.splitlines(): + name = line.strip() + if name: + counts[name] = counts.get(name, 0) + 1 + return counts - if running > 0: - status = f"running ({total} containers)" - else: - status = "stopped" - return {"name": entry.name, "status": status, "count": total} +def _container_status_for(entry: Path, counts=_UNSET) -> dict | None: + """単一プロジェクトディレクトリのコンテナ状態を取得する。 - except (subprocess.TimeoutExpired, OSError): - # dockerコマンドが利用できない、またはタイムアウト + `projects/` (実ディレクトリ or plugin への symlink) を受け取り、 + ``{"name", "status", "count"}`` を返す。対象外 (compose.yml が無い) や docker + コマンドが利用できない場合は ``None`` を返す。 + + ``counts`` には ``_running_counts_by_project()`` の戻り値 (compose project 名 + → running 数) を渡す。一覧表示では呼び出し側が 1 回だけ集計して全 entry で + 使い回すことで docker サブプロセスの起動を 1 回に抑える。``counts`` を省略 + した単発呼び出しでは本関数内で都度集計する。``None`` (docker 不在) が明示的に + 渡された場合は再集計せず ``None`` を返す。 + """ + compose_file = entry / "compose.yml" + if not compose_file.exists(): + return None + + if counts is _UNSET: + counts = _running_counts_by_project() + if counts is None: + # docker が利用できない / 取得失敗 return None + # devbase up は COMPOSE_PROJECT_NAME = entry.name でコンテナを起動するため、 + # compose project ラベルが entry.name の running 数がこのプロジェクトの稼働数。 + running = counts.get(entry.name, 0) + if running > 0: + status = f"running ({running} containers)" + else: + status = "stopped" + + return {"name": entry.name, "status": status, "count": running} + def _get_container_status(projects_dir: Path) -> list[dict]: """projects/ 配下の各プロジェクトのコンテナ状態を取得する""" @@ -96,10 +95,13 @@ def _get_container_status(projects_dir: Path) -> list[dict]: if not projects_dir.exists(): return results + # docker ps は 1 回だけ実行し、全 entry で使い回す。 + counts = _running_counts_by_project() + for entry in sorted(projects_dir.iterdir()): if not entry.is_dir(): continue - status = _container_status_for(entry) + status = _container_status_for(entry, counts) if status is not None: results.append(status) diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py index 881eb6f..d549d0c 100644 --- a/tests/cli/test_project_list.py +++ b/tests/cli/test_project_list.py @@ -144,7 +144,7 @@ def test_list_projects_enumerates_name_plugin_status(tmp_path, monkeypatch): _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") # status は docker に依存させず固定値を返す - def fake_status(entry: Path): + def fake_status(entry, counts=None): return {"name": entry.name, "status": "running (2 containers)", "count": 2} monkeypatch.setattr(status_mod, "_container_status_for", fake_status) @@ -165,7 +165,7 @@ def test_list_projects_unknown_status_when_none(tmp_path, monkeypatch): _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) rows = project_mod.list_projects(tmp_path / "projects") assert rows[0]["status"] == "unknown" @@ -179,7 +179,7 @@ def test_list_projects_real_dir_plugin_dash(tmp_path, monkeypatch): projects_dir.mkdir() (projects_dir / "standalone").mkdir() - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) rows = project_mod.list_projects(projects_dir) assert rows[0]["name"] == "standalone" @@ -202,7 +202,7 @@ def test_cmd_project_list_prints_table(tmp_path, monkeypatch, capsys): _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") monkeypatch.setattr(status_mod, "_container_status_for", - lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0}) args = types.SimpleNamespace(interactive=False) rc = project_mod.cmd_project_list(tmp_path, args) @@ -231,7 +231,7 @@ def test_cmd_project_list_non_tty_falls_back_to_table(tmp_path, monkeypatch, cap _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") monkeypatch.setattr(status_mod, "_container_status_for", - lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0}) monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: False) called = [] @@ -256,7 +256,7 @@ def test_cmd_project_list_stdout_non_tty_falls_back_to_table(tmp_path, monkeypat _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") monkeypatch.setattr(status_mod, "_container_status_for", - lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0}) monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: False) @@ -285,7 +285,7 @@ def test_cmd_project_list_interactive_selects_and_ups(tmp_path, monkeypatch): _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") _make_plugin_project(tmp_path, "plugins/beta", "beta-proj") _link_project(tmp_path, "beta-proj", "plugins/beta", "beta-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) # 対話選択は TTY 環境でのみ起動するため isatty を True に固定する。 monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) @@ -313,7 +313,7 @@ def test_cmd_project_list_interactive_empty_input_aborts(tmp_path, monkeypatch): _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) monkeypatch.setattr("builtins.input", lambda *a, **k: "") @@ -335,7 +335,7 @@ def test_cmd_project_list_interactive_non_tty_eof(tmp_path, monkeypatch): _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) def raise_eof(*a, **k): raise EOFError @@ -360,7 +360,7 @@ def test_cmd_project_list_interactive_keyboard_interrupt_aborts(tmp_path, monkey _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) def raise_interrupt(*a, **k): raise KeyboardInterrupt @@ -385,7 +385,7 @@ def test_cmd_project_list_interactive_out_of_range_reprompts(tmp_path, monkeypat _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) @@ -410,7 +410,7 @@ def test_cmd_project_list_interactive_non_numeric_reprompts(tmp_path, monkeypatc _make_plugin_project(tmp_path, "repos/o--r/alpha", "alpha-proj") _link_project(tmp_path, "alpha-proj", "repos/o--r/alpha", "alpha-proj") - monkeypatch.setattr(status_mod, "_container_status_for", lambda entry: None) + monkeypatch.setattr(status_mod, "_container_status_for", lambda entry, counts=None: None) monkeypatch.setattr(project_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(project_mod.sys.stdout, "isatty", lambda: True) @@ -559,7 +559,7 @@ def test_get_container_status_uses_per_entry(tmp_path, monkeypatch): (projects_dir / "b").mkdir() monkeypatch.setattr(status_mod, "_container_status_for", - lambda entry: {"name": entry.name, "status": "stopped", "count": 0}) + lambda entry, counts=None: {"name": entry.name, "status": "stopped", "count": 0}) results = status_mod._get_container_status(projects_dir) names = sorted(r["name"] for r in results) assert names == ["a", "b"] diff --git a/tests/plugin/test_container_status_for.py b/tests/plugin/test_container_status_for.py index 65491ab..67efa72 100644 --- a/tests/plugin/test_container_status_for.py +++ b/tests/plugin/test_container_status_for.py @@ -1,10 +1,14 @@ -"""Regression tests for `_container_status_for` の compose project scope。 +"""Regression tests for コンテナ状態取得 (status.py)。 -`bin/devbase` は常に `COMPOSE_PROJECT_NAME` を export する (cwd basename ないし -解決済みプロジェクト名)。`devbase list` の python プロセスはこれを継承するため、 -`docker compose ps` を明示的に `--project-name ` で scope しないと、 -docker compose が継承 env を優先して全プロジェクトで「同じ (カレント) プロジェクト」 -の状態を返してしまう (全項目が同一の running / コンテナ数になる回帰)。 +`devbase list` / `status` の状態取得は当初プロジェクト数ぶん `docker compose ps` +をサブプロセス起動しており、(1) `bin/devbase` が常に export する +`COMPOSE_PROJECT_NAME` を継承して全プロジェクトが同一状態になる回帰、 +(2) N サブプロセス起動の重さ、の二点があった。 + +現在は `_running_counts_by_project()` が単一の `docker ps` で全 running コンテナ +を `com.docker.compose.project` ラベルごとに集計し、`_container_status_for()` は +その counts マップを参照するだけ。docker ps はラベルで識別するため +`COMPOSE_PROJECT_NAME` の継承に一切影響されない。 """ from __future__ import annotations @@ -12,7 +16,11 @@ import subprocess from pathlib import Path -from devbase.commands.status import _container_status_for +from devbase.commands import status as status_mod +from devbase.commands.status import ( + _container_status_for, + _running_counts_by_project, +) def _make_entry(tmp_path: Path, name: str) -> Path: @@ -22,57 +30,125 @@ def _make_entry(tmp_path: Path, name: str) -> Path: return entry -def test_scopes_query_to_entry_name(tmp_path, monkeypatch): - """継承 COMPOSE_PROJECT_NAME に左右されず entry.name で scope する。""" - entry = _make_entry(tmp_path, "myproj") +# --- _running_counts_by_project ------------------------------------------ + - captured: dict = {} +def test_counts_aggregates_single_docker_ps(monkeypatch): + """docker ps 1 回でラベルごとの running 数を集計する (N 回起動しない)。""" + calls: list[list[str]] = [] def fake_run(cmd, **kwargs): - captured["cmd"] = cmd + calls.append(cmd) class R: returncode = 0 - stdout = '{"State":"running"}\n{"State":"running"}\n' + # 9 個のうち carmo-system-console が複数 (複数コンテナ project) + stdout = "carmo-ai\ncarmo-system-console\ncarmo-system-console\n" return R() monkeypatch.setattr(subprocess, "run", fake_run) - # 別プロジェクトに居る状態 (wrapper が export 済み) を再現 + # 継承 COMPOSE_PROJECT_NAME があっても集計に影響しないこと + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "some-other-project") + + counts = _running_counts_by_project() + + assert counts == {"carmo-ai": 1, "carmo-system-console": 2} + # 1 回しか docker を起動していない + assert len(calls) == 1 + # docker compose ps ではなく docker ps をラベル filter で叩いている + assert calls[0][:2] == ["docker", "ps"] + assert any("label=com.docker.compose.project" in a for a in calls[0]) + + +def test_counts_none_when_docker_unavailable(monkeypatch): + """docker コマンドが無い (OSError) なら None。""" + + def fake_run(cmd, **kwargs): + raise FileNotFoundError("docker not found") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert _running_counts_by_project() is None + + +# --- _container_status_for ----------------------------------------------- + + +def test_status_uses_counts_and_ignores_env(tmp_path, monkeypatch): + """counts マップを参照し、継承 COMPOSE_PROJECT_NAME に影響されない。""" + entry = _make_entry(tmp_path, "myproj") monkeypatch.setenv("COMPOSE_PROJECT_NAME", "some-other-project") - result = _container_status_for(entry) + result = _container_status_for(entry, {"myproj": 3, "other": 9}) - assert result == {"name": "myproj", "status": "running (2 containers)", "count": 2} + assert result == {"name": "myproj", "status": "running (3 containers)", "count": 3} - cmd = captured["cmd"] - assert "--project-name" in cmd, f"--project-name で scope していない: {cmd}" - idx = cmd.index("--project-name") - assert cmd[idx + 1] == "myproj", f"entry.name で scope していない: {cmd}" - # global flag は subcommand より前に置くこと - assert cmd.index("--project-name") < cmd.index("ps") +def test_status_stopped_when_not_in_counts(tmp_path): + """counts に居なければ stopped (count=0)。""" + entry = _make_entry(tmp_path, "myproj") -def test_distinct_projects_get_distinct_scope(tmp_path, monkeypatch): - """異なる entry は異なる project 名でクエリされる (一律化しない)。""" + result = _container_status_for(entry, {"other": 1}) + + assert result == {"name": "myproj", "status": "stopped", "count": 0} + + +def test_status_none_without_compose(tmp_path): + """compose.yml が無ければ対象外 (None)。""" + entry = tmp_path / "projects" / "noncompose" + entry.mkdir(parents=True) + + assert _container_status_for(entry, {"noncompose": 1}) is None + + +def test_status_none_when_counts_none(tmp_path, monkeypatch): + """docker 不在 (counts=None) を明示的に渡したら None (再集計しない)。""" + entry = _make_entry(tmp_path, "myproj") + + def fake_run(cmd, **kwargs): # 呼ばれてはいけない + raise AssertionError("counts=None なら再集計してはならない") + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert _container_status_for(entry, None) is None + + +def test_distinct_projects_distinct_counts(tmp_path): + """同じ counts から各 entry が自分の名前ぶんだけ拾う。""" a = _make_entry(tmp_path, "proj-a") b = _make_entry(tmp_path, "proj-b") + counts = {"proj-a": 2, "proj-b": 5} + + assert _container_status_for(a, counts)["count"] == 2 + assert _container_status_for(b, counts)["count"] == 5 + + +def test_get_container_status_runs_docker_ps_once(tmp_path, monkeypatch): + """_get_container_status は docker ps を 1 回だけ実行し全 entry に使い回す。""" + projects_dir = tmp_path / "projects" + for name in ("a", "b", "c"): + d = projects_dir / name + d.mkdir(parents=True) + (d / "compose.yml").write_text("services:\n dev:\n image: busybox\n") - seen: list[str] = [] + calls = [] def fake_run(cmd, **kwargs): - seen.append(cmd[cmd.index("--project-name") + 1]) + calls.append(cmd) class R: returncode = 0 - stdout = "" + stdout = "a\nb\nb\n" return R() monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setenv("COMPOSE_PROJECT_NAME", "current") - _container_status_for(a) - _container_status_for(b) + results = status_mod._get_container_status(projects_dir) + by_name = {r["name"]: r for r in results} - assert seen == ["proj-a", "proj-b"] + assert by_name["a"]["status"] == "running (1 containers)" + assert by_name["b"]["status"] == "running (2 containers)" + assert by_name["c"]["status"] == "stopped" + # entry が 3 つでも docker 起動は 1 回 + assert len(calls) == 1