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 3f3081c..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,71 +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: proc = subprocess.run( - ["docker", "compose", "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/ 配下の各プロジェクトのコンテナ状態を取得する""" @@ -88,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 new file mode 100644 index 0000000..67efa72 --- /dev/null +++ b/tests/plugin/test_container_status_for.py @@ -0,0 +1,154 @@ +"""Regression tests for コンテナ状態取得 (status.py)。 + +`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 + +import subprocess +from pathlib import Path + +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: + entry = tmp_path / "projects" / name + entry.mkdir(parents=True) + (entry / "compose.yml").write_text("services:\n dev:\n image: busybox\n") + return entry + + +# --- _running_counts_by_project ------------------------------------------ + + +def test_counts_aggregates_single_docker_ps(monkeypatch): + """docker ps 1 回でラベルごとの running 数を集計する (N 回起動しない)。""" + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + + class R: + returncode = 0 + # 9 個のうち carmo-system-console が複数 (複数コンテナ project) + stdout = "carmo-ai\ncarmo-system-console\ncarmo-system-console\n" + + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + # 継承 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, {"myproj": 3, "other": 9}) + + assert result == {"name": "myproj", "status": "running (3 containers)", "count": 3} + + +def test_status_stopped_when_not_in_counts(tmp_path): + """counts に居なければ stopped (count=0)。""" + entry = _make_entry(tmp_path, "myproj") + + 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") + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + + class R: + returncode = 0 + stdout = "a\nb\nb\n" + + return R() + + monkeypatch.setattr(subprocess, "run", fake_run) + + results = status_mod._get_container_status(projects_dir) + by_name = {r["name"]: r for r in results} + + 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