Skip to content

Commit 9a1e504

Browse files
takemi-ohamaclaude
andauthored
feat: devbase up の機密の注入で SecretStore を持ち回り、サーバ backend の往復を設計の想定へ収める (PLAN55) (#177)
* feat: devbase up の機密の注入で SecretStore を持ち回り、サーバ backend の往復を認証 1 回 + 参照ごとに 1 回にする (PLAN55) runtime.store_for / release_store を足し、resolve / inject / child_env と _ensure_env_files が同じ SecretStore を使う。捨てる契機は _dispatch_lifecycle の finally、TUI の委譲の入口、env init の子プロセスから戻った直後の 3 つ。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9okWVz1S7VGUsCQWVMhb3 * Test: characterize secret runtime error and early-return paths Add characterization coverage for stale secret caches after project dispatch errors, invalid UTF-8 project env files, missing DEVBASE_ROOT, and store release after project name resolution failure. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default * Test: characterize _inject_secrets error handling branch Add characterization test verifying that DevbaseError is swallowed and returns an empty SecretEnv when required=False, while DevbaseError is re-raised when required=True. Item-Id: R1-004 Round: 1 Impl-Runtime: agy Impl-Model: default * Refactor: extract_method — lib/devbase/commands/container.py#_resolve_project_name project 切替時に呼び出し元固有の env キーを unset する段階を _unset_caller_only_env_keys(caller_keys, target_dir) として抽出した。 _resolve_project_name は 解決 → chdir/PWD 差し替え → 抽出関数呼び出し → env 反映 の順に整理され、クリーンアップ処理が独立した名前を持つ。 振る舞いは不変 (tests/cli/test_project_name_resolution.py を含む全テストが通過)。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default * Refactor: consolidate_duplication — lib/devbase/commands/container.py#_ensure_images _ensure_images 内で docker compose config を実行し JSON を解釈して dev サービス定義を取り出す処理を、同ファイルの _resolve_dev_service() の 呼び出しに置き換える。取得できない (None) ときは従来どおり _run_build() へフォールバックする。 現状固定テストとして _ensure_images の入口を subprocess.run の応答だけで 固定する 10 件を tests/cli/test_base_image_staleness.py に追加した。 Item-Id: R2-002 Round: 2 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "Refactor: consolidate_duplication — lib/devbase/commands/container.py#_ensure_images" This reverts commit 7add6cf. * Refactor: extract_method — lib/devbase/commands/container.py#_ensure_images Extract Compose services reading and dev image specification while preserving image checks and fallback behavior. Item-Id: R2-003 Round: 2 Impl-Runtime: codex Impl-Model: default * Refactor: extract_method — lib/devbase/commands/container.py#cmd_scale cmd_scale 内のボリューム・ネットワーク確認、compose 生成、起動、待機処理を _run_scale_pipeline として抽出し、cmd_scale を引数検証・設定更新・パイプライン呼び出し・後続処理に整理。 Item-Id: R2-004 Round: 2 Impl-Runtime: agy Impl-Model: default * Revert "Refactor: extract_method — lib/devbase/commands/container.py#cmd_scale" This reverts commit 30065e3. * Refactor: consolidate_duplication — lib/devbase/commands/container.py#_build_resolved _build_resolved 内で 5 回並んでいた `return 0 if <call> else 1` の bool→終了コード 変換を、module ヘルパ `_exit_code(ok: bool) -> int` に集約。委譲行 (_build_with_expires) も同型のため同じヘルパへ統一した。振る舞いは不変。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: default * Refactor: consolidate_duplication — lib/devbase/commands/container.py#_read_compose_services _resolve_dev_service が独自に `docker compose config --format json` を実行して JSON を読んでいたのを、_read_compose_services の呼び出しへ寄せる。compose config の呼び方は 1 箇所になり、_resolve_dev_service は returncode!=0 / JSONDecodeError で None、成功時に services.get(dev_service_name, {}) を返す薄い包みになる。 JSONDecodeError の握りは _resolve_dev_service 側に残す。共通関数側で握って (rc, {}) を返すと _ensure_images の不正 JSON 経路 (except → _run_build) が 「イメージ未指定 → True」へ変わるため。 両関数の本体はテストで通っていなかったので、書き換え前に現状固定テストを tests/cli/test_rebuild.py へ追加した (失敗時 None / (rc,{}) / 不正 JSON の伝播)。 Item-Id: R3-001 Round: 3 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "Refactor: consolidate_duplication — lib/devbase/commands/container.py#_read_compose_services" This reverts commit 3ad3fb9. * Refactor: consolidate_duplication — lib/devbase/commands/container.py#cmd_login Share Compose context and secret preparation and override file argument construction. Preserve login command variants and execution order with CLI regression tests. Item-Id: R3-002 Round: 3 Impl-Runtime: codex Impl-Model: default * Refactor: consolidate_duplication — lib/devbase/commands/container.py#_image_max_age_days Consolidate duplicate non-negative integer environment variable parsing in _image_max_age_days and _snapshot_min_interval_minutes into a shared helper _env_non_negative_int. Item-Id: R3-003 Round: 3 Impl-Runtime: agy Impl-Model: default --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 78f6bb0 commit 9a1e504

13 files changed

Lines changed: 839 additions & 70 deletions

‎issues/PLAN55_up-single-injection.md‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,73 @@
182182
| 項目 | 誰が決めるか | 期限 |
183183
| --- | --- | --- |
184184
| ~~解決結果を引き継ぐ置き場~~ → 決まった: `runtime` モジュールが `SecretStore` を持ち回り、`_dispatch_lifecycle` の `finally` で捨てる(設計の決定 1・2) | 設計 Pull Request のマージで利用者が承認する | 設計 |
185+
186+
## 実装計画
187+
188+
設計は `issues/PLAN55_up-single-injection-design.md`(マージ済み #176)。タスクは設計の
189+
「構成要素」の行から導く。1 タスクが独立して検証できる単位にし、失敗するテスト → 最小実装 →
190+
整理の順で進める。
191+
192+
### 修正対象
193+
194+
- `lib/devbase/env/runtime.py`、`lib/devbase/commands/container.py`、`lib/devbase/tui/dispatch.py`
195+
- `tests/env/test_runtime_store.py`(新設)、`tests/cli/test_up_roundtrips.py`(新設)、
196+
`tests/cli/tui/test_dispatch.py`(足す)、`tests/conftest.py`(autouse で `release_store()`)
197+
198+
### Task 1: `runtime.store_for` / `release_store` と、`resolve` / `inject` / `child_env` の切り替え
199+
200+
- **対象ファイル:** `lib/devbase/env/runtime.py`、`tests/env/test_runtime_store.py`
201+
- **変更内容:** モジュールの控え(`_store` / `_store_root`)と 2 関数を足す。`store` 引数が
202+
`None` のとき `store_for(root)` を使う。明示的に渡された `store` は控えに入れない
203+
- **満たす受け入れ条件:** `store_for` の規則の表(設計)
204+
- **進め方:** 同一性・`root` 変更・解放後の作り直し・明示 `store` を控えない、の 4 テストを先に書く
205+
206+
### Task 2: `_ensure_env_files` を持ち回った store に切り替え、`env init` の後に捨てる
207+
208+
- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/cli/test_up_roundtrips.py`
209+
- **変更内容:** `SecretStore(devbase_root)` → `runtime.store_for(devbase_root)`。`env init` の
210+
子プロセスから戻ったら終了コードによらず `runtime.release_store()`(決定 5)
211+
- **満たす受け入れ条件:** 4(GET が増えない)、8(`env init` が書いた値で起動する)
212+
- **進め方:** 偽サーバで注入 → `_ensure_env_files()` → GET 件数不変のテスト、`subprocess.run` を
213+
偽サーバへ書くスタブに差し替えて `_run_deploy_pipeline` へ渡る `SecretEnv` を見るテスト
214+
215+
### Task 3: `_dispatch_lifecycle` の `finally` で捨てる(3 経路の往復を固定)
216+
217+
- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/cli/test_up_roundtrips.py`
218+
- **変更内容:** `finally` に `runtime.release_store()` を並べる
219+
- **満たす受け入れ条件:** 1・2・3(認証 1 回 + GET 4 / ≤6 / 4)、5・6(既存テスト無変更)
220+
- **進め方:** `cli._load_secret_env` → `container.cmd_project(ns)` を偽サーバ + docker 差し替えで
221+
走らせ、`openbao.logins` と GET の `kv_path` の集合を固定する
222+
223+
### Task 4: TUI の委譲の入口で捨てる
224+
225+
- **対象ファイル:** `lib/devbase/tui/dispatch.py`、`tests/cli/tui/test_dispatch.py`
226+
- **変更内容:** `_preserve_cwd_env` の入口で `runtime.release_store()`(決定 3)
227+
- **満たす受け入れ条件:** 9、決定 3 の規則
228+
- **進め方:** `store_for(root)` で控えを作ってから `dispatch_group` の handler 内で別インスタンスに
229+
なるテスト、`env edit`(エディタのスタブ)→ `up` で新しい値が渡るテスト
230+
231+
### Task 5: 既存テストの独立性
232+
233+
- **対象ファイル:** `tests/conftest.py`
234+
- **変更内容:** autouse fixture で各テストの前後に `runtime.release_store()`。モジュールの控えが
235+
テストをまたいで残らない
236+
- **満たす受け入れ条件:** 5・7
237+
- **進め方:** `uv run pytest tests/` 全件
238+
239+
### リスクと対処
240+
241+
| リスク | 対処 |
242+
| --- | --- |
243+
| `tests/cli/` の既存 harness が `SecretStore(root)` を直接作り `monkeypatch` している | Task 5 の autouse fixture。実装時に `grep -rn "SecretStore(" tests/` で数える |
244+
| `container.py` は 1300 行超で、`_ensure_env_files` と `_dispatch_lifecycle` が離れている | 触るのは 2 関数の数行。タスクごとにテストを通す |
245+
246+
### 切り戻し手順
247+
248+
- 差分を戻すだけ(永続データ・スキーマの変更なし)。`release_store()` を呼ばない古い経路が
249+
残っても、`SecretStore` を作り直す従来の動きに戻るだけで壊れない
250+
251+
### 完了の定義
252+
253+
- [ ] 受け入れ条件 1〜9 をすべて満たし、条件ごとに検証手段と結果が対応している
254+
- [ ] `uv run pytest tests/` / `ruff check lib` / `python -m compileall -q lib bin` が exit=0

‎lib/devbase/commands/container.py‎

Lines changed: 127 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,38 @@
4040
# 共通ヘルパー
4141
# ---------------------------------------------------------------------------
4242

43+
def _exit_code(ok: bool) -> int:
44+
"""ビルド成否 (bool) をプロセス互換の終了コードへ写す (True=0 / False=1)。"""
45+
return 0 if ok else 1
46+
47+
4348
def _devbase_root() -> Optional[Path]:
4449
root = os.environ.get('DEVBASE_ROOT')
4550
return Path(root) if root else None
4651

4752

53+
def _env_non_negative_int(env_name: str, default: int) -> int:
54+
"""環境変数から非負整数を読み出す。
55+
56+
未設定・空文字なら default を返す。
57+
負値または整数に変換できない場合は warning を出力して default にフォールバックする。
58+
"""
59+
raw = os.environ.get(env_name)
60+
if not raw:
61+
return default
62+
try:
63+
value = int(raw)
64+
if value < 0:
65+
raise ValueError
66+
return value
67+
except ValueError:
68+
logger.warning(
69+
"Invalid %s=%r, using default %d",
70+
env_name, raw, default
71+
)
72+
return default
73+
74+
4875
def _inject_secrets(*, required: bool):
4976
"""機密を復号して自プロセスの環境変数へ載せ、載せた内容を返す。
5077
@@ -208,14 +235,26 @@ def _previous_scale_compose():
208235
backup.unlink(missing_ok=True)
209236

210237

211-
def _compose_run(subcommand: str, *extra_args: str,
212-
context: Optional[str] = None) -> int:
213-
"""docker compose コマンドを実行する共通関数"""
238+
def _prepare_compose(context: Optional[str]) -> None:
239+
"""Compose の接続先を反映してから機密を任意注入する。"""
214240
_apply_context(context)
215241
_inject_secrets(required=False)
242+
243+
244+
def _compose_base_args(compose_file: Optional[Path]) -> list[str]:
245+
"""指定された override を付与した Compose のベース引数を返す。"""
216246
cmd = ['docker', 'compose']
217-
if _SCALE_COMPOSE_FILE.exists():
218-
cmd.extend(['-f', str(_SCALE_COMPOSE_FILE)])
247+
if compose_file is not None:
248+
cmd.extend(['-f', str(compose_file)])
249+
return cmd
250+
251+
252+
def _compose_run(subcommand: str, *extra_args: str,
253+
context: Optional[str] = None) -> int:
254+
"""docker compose コマンドを実行する共通関数"""
255+
_prepare_compose(context)
256+
compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None
257+
cmd = _compose_base_args(compose_file)
219258
cmd.append(subcommand)
220259
cmd.extend(extra_args)
221260
return subprocess.run(cmd).returncode
@@ -431,6 +470,24 @@ def _load_project_env(env_file: Path) -> None:
431470
os.environ[key] = value
432471

433472

473+
def _unset_caller_only_env_keys(caller_keys: set, target_dir: Path) -> None:
474+
"""呼び出し元 env にしか無いキーを os.environ から unset する。
475+
476+
別プロジェクトから `project up other` を直接起動した場合、呼び出し元 env に
477+
しか無いキー (例: DEV_SERVICE_NAME) が os.environ に残留し対象へ誤って
478+
引き継がれる。対象 (``target_dir`` = 現 CWD) の env を読み、呼び出し元にしか
479+
無いキーを unset してクリーンにする
480+
(codex 指摘 / wrapper の _CALLER_ENV_KEYS と同等のフォールバック)。
481+
482+
Args:
483+
caller_keys: chdir 前に記録した呼び出し元 env のキー集合。
484+
target_dir: 切替先プロジェクトのディレクトリ (既に chdir 済みの CWD)。
485+
"""
486+
target_env_keys = _env_var_keys(target_dir / 'env')
487+
for key in caller_keys - target_env_keys:
488+
os.environ.pop(key, None)
489+
490+
434491
def _resolve_project_name(project_name: str) -> bool:
435492
"""project name を $DEVBASE_ROOT/projects/<name> へ解決し chdir する。
436493
@@ -468,12 +525,10 @@ def _resolve_project_name(project_name: str) -> bool:
468525

469526
# chdir 前に呼び出し元 (現 CWD) の env が定義するキーを記録しておく。
470527
# 別プロジェクトから `project up other` を直接起動した場合、呼び出し元 env に
471-
# しか無いキー (例: DEV_SERVICE_NAME) が os.environ に残留し対象へ誤って
472-
# 引き継がれるため、対象 env を読む前に unset してクリーンにする
473-
# (codex 指摘 / wrapper の _CALLER_ENV_KEYS と同等のフォールバック)。
528+
# しか無いキーを chdir 後に unset するために使う (詳細は
529+
# :func:`_unset_caller_only_env_keys`)。
474530
# already_there (= 既に対象ディレクトリ。通常 wrapper 経由) の場合は呼び出し元
475531
# =対象であり、wrapper 側で既にクリーン化済みのため何もしない。
476-
caller_env_keys: set = set()
477532
if not already_there:
478533
caller_env_keys = _env_var_keys(Path('env'))
479534
os.chdir(target)
@@ -483,9 +538,7 @@ def _resolve_project_name(project_name: str) -> bool:
483538
# 切替先ではなく呼び出し元プロジェクトの機密を読んでしまう
484539
# (TUI の ``_run_in_project`` が PWD を差し替えているのと同じ理由)。
485540
os.environ['PWD'] = str(target)
486-
target_env_keys = _env_var_keys(Path('env'))
487-
for key in caller_env_keys - target_env_keys:
488-
os.environ.pop(key, None)
541+
_unset_caller_only_env_keys(caller_env_keys, target)
489542

490543
# wrapper の `source ./env` と同等に project env を os.environ へ反映する。
491544
# wrapper 経由なら既に同じ値が載っているため冪等。
@@ -563,6 +616,11 @@ def _dispatch_lifecycle(args) -> int:
563616
return 1
564617
finally:
565618
docker_context.reset()
619+
# 持ち回った SecretStore の寿命はライフサイクル操作 1 回 (PLAN55 決定 2)。
620+
# 入口ではなく出口で捨てるのは、CLI では dispatch 前の注入 (cli._load_secret_env)
621+
# が作ったものをこの操作の中で使い回すため。
622+
from devbase.env import runtime as _runtime
623+
_runtime.release_store()
566624

567625

568626
def cmd_project(args) -> int:
@@ -597,20 +655,10 @@ def _snapshot_min_interval_minutes() -> int:
597655
DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES で上書き可能 (0 で無効化=毎回取得)。
598656
値が不正な場合は既定値にフォールバックする。
599657
"""
600-
raw = os.environ.get('DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES')
601-
if not raw:
602-
return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
603-
try:
604-
value = int(raw)
605-
if value < 0:
606-
raise ValueError
607-
return value
608-
except ValueError:
609-
logger.warning(
610-
"Invalid DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=%r, using default %d",
611-
raw, _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
612-
)
613-
return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
658+
return _env_non_negative_int(
659+
'DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES',
660+
_SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT,
661+
)
614662

615663

616664
def _auto_snapshot(remote: bool = False) -> None:
@@ -979,16 +1027,14 @@ def cmd_down(context: Optional[str] = None) -> int:
9791027

9801028
def cmd_login(index: str = '1', context: Optional[str] = None) -> int:
9811029
"""Login to container"""
982-
_apply_context(context)
983-
_inject_secrets(required=False)
1030+
_prepare_compose(context)
9841031
dev_service = get_dev_service_name()
985-
986-
if _SCALE_COMPOSE_FILE.exists():
987-
cmd = ['docker', 'compose', '-f', str(_SCALE_COMPOSE_FILE),
988-
'exec', f'{dev_service}-{index}', 'bash']
1032+
compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None
1033+
cmd = _compose_base_args(compose_file)
1034+
if compose_file is not None:
1035+
cmd.extend(['exec', f'{dev_service}-{index}', 'bash'])
9891036
else:
990-
cmd = ['docker', 'compose', 'exec', f'--index={index}',
991-
dev_service, 'bash']
1037+
cmd.extend(['exec', f'--index={index}', dev_service, 'bash'])
9921038

9931039
return subprocess.run(cmd).returncode
9941040

@@ -1243,27 +1289,27 @@ def _build_resolved(expires: Optional[int], no_cache: bool) -> int:
12431289
return 1
12441290

12451291
if no_cache:
1246-
return 0 if _run_build(no_cache=True) else 1
1292+
return _exit_code(_run_build(no_cache=True))
12471293
if expires is None:
1248-
return 0 if _run_build() else 1
1294+
return _exit_code(_run_build())
12491295

12501296
# expires 指定: project イメージの作成日と dev サービス定義 (base 判定用) が必要。
12511297
dev_service = _resolve_dev_service()
12521298
if not dev_service:
12531299
logger.info("Unable to read compose config; building with cache")
1254-
return 0 if _run_build() else 1
1300+
return _exit_code(_run_build())
12551301
image_name = dev_service.get('image', '')
12561302
if not image_name:
1257-
return 0 if _run_build() else 1
1303+
return _exit_code(_run_build())
12581304
inspect = subprocess.run(
12591305
['docker', 'image', 'inspect', image_name],
12601306
capture_output=True, text=True, check=False
12611307
)
12621308
if inspect.returncode != 0:
12631309
# イメージ未存在 → キャッシュビルドで作成する。
12641310
logger.info("Container image '%s' not found; building...", image_name)
1265-
return 0 if _run_build() else 1
1266-
return 0 if _build_with_expires(expires, image_name, inspect.stdout, dev_service) else 1
1311+
return _exit_code(_run_build())
1312+
return _exit_code(_build_with_expires(expires, image_name, inspect.stdout, dev_service))
12671313

12681314

12691315
def cmd_rebuild(expires: int = None, context: Optional[str] = None) -> int:
@@ -1302,10 +1348,14 @@ def _ensure_env_files() -> bool:
13021348
# 機密が暗号化されていれば平文の .env は存在しない。ファイルの有無ではなく
13031349
# 秘密ストアに設定があるかで判定しないと、移行済みの環境で毎回 env init が
13041350
# 走ってしまう。
1351+
#
1352+
# SecretStore は注入と同じものを持ち回る (PLAN55)。作り直すとサーバ backend では
1353+
# 認証と参照ごとの取得がもう 1 巡走る。同じインスタンスなら注入で取得済みの控えから
1354+
# 返るので、ここはサーバへ行かない。
13051355
from devbase.env import runtime as _runtime
1306-
from devbase.env.secret_store import SecretRef, SecretStore
1356+
from devbase.env.secret_store import SecretRef
13071357

1308-
store = SecretStore(devbase_root)
1358+
store = _runtime.store_for(devbase_root)
13091359
has_global = store.exists(SecretRef.for_global())
13101360

13111361
project_name = _runtime.current_project_name(devbase_root)
@@ -1343,6 +1393,12 @@ def _ensure_env_files() -> bool:
13431393
except Exception as e:
13441394
logger.error("Running env init for devbase root: %s", e)
13451395
success = False
1396+
finally:
1397+
# 書いたのは子プロセスで、持ち回っている SecretStore の控えは更新されない。
1398+
# サーバ backend では最初の 404 が空として残り、そのまま起動すると env init が
1399+
# 書いた共通機密が渡らない。終了コードによらず捨て、以後は現物を読み直す
1400+
# (PLAN55 決定 5)。
1401+
_runtime.release_store()
13461402

13471403
if not has_project:
13481404
logger.info("Creating project .env...")
@@ -1365,20 +1421,32 @@ def _image_max_age_days() -> int:
13651421
Override via the DEVBASE_IMAGE_MAX_AGE_DAYS environment variable.
13661422
Falls back to the default on missing or malformed values.
13671423
"""
1368-
raw = os.environ.get('DEVBASE_IMAGE_MAX_AGE_DAYS')
1369-
if not raw:
1370-
return _IMAGE_MAX_AGE_DAYS_DEFAULT
1371-
try:
1372-
value = int(raw)
1373-
if value < 0:
1374-
raise ValueError
1375-
return value
1376-
except ValueError:
1377-
logger.warning(
1378-
"Invalid DEVBASE_IMAGE_MAX_AGE_DAYS=%r, using default %d",
1379-
raw, _IMAGE_MAX_AGE_DAYS_DEFAULT
1380-
)
1381-
return _IMAGE_MAX_AGE_DAYS_DEFAULT
1424+
return _env_non_negative_int(
1425+
'DEVBASE_IMAGE_MAX_AGE_DAYS',
1426+
_IMAGE_MAX_AGE_DAYS_DEFAULT,
1427+
)
1428+
1429+
1430+
def _read_compose_services() -> tuple[int, dict]:
1431+
"""Compose 設定の終了コードと services を取得する。"""
1432+
result = subprocess.run(
1433+
['docker', 'compose', 'config', '--format', 'json'],
1434+
capture_output=True,
1435+
text=True,
1436+
check=False
1437+
)
1438+
if result.returncode != 0:
1439+
return result.returncode, {}
1440+
config = json.loads(result.stdout)
1441+
return result.returncode, config.get('services', {})
1442+
1443+
1444+
def _dev_image_spec(services: dict, dev_service_name: str) -> tuple[dict, str, bool]:
1445+
"""dev サービスと、そのイメージ名・ビルド定義の有無を取り出す。"""
1446+
dev_service = services.get(dev_service_name, {})
1447+
image_name = dev_service.get('image', '')
1448+
has_build = bool(dev_service.get('build'))
1449+
return dev_service, image_name, has_build
13821450

13831451

13841452
def _ensure_images() -> bool:
@@ -1410,23 +1478,13 @@ def _ensure_images() -> bool:
14101478
dev_service_name = get_dev_service_name()
14111479

14121480
try:
1413-
result = subprocess.run(
1414-
['docker', 'compose', 'config', '--format', 'json'],
1415-
capture_output=True,
1416-
text=True,
1417-
check=False
1418-
)
1419-
1420-
if result.returncode != 0:
1481+
returncode, services = _read_compose_services()
1482+
if returncode != 0:
14211483
logger.info("Unable to check image status")
14221484
logger.info("Running 'devbase container build' to ensure images exist...")
14231485
return _run_build()
14241486

1425-
config = json.loads(result.stdout)
1426-
services = config.get('services', {})
1427-
dev_service = services.get(dev_service_name, {})
1428-
image_name = dev_service.get('image', '')
1429-
has_build = bool(dev_service.get('build'))
1487+
dev_service, image_name, has_build = _dev_image_spec(services, dev_service_name)
14301488

14311489
if not image_name:
14321490
logger.warning("No image specified for %s service", dev_service_name)

0 commit comments

Comments
 (0)