Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
038d0d8
feat: devbase up の機密の注入で SecretStore を持ち回り、サーバ backend の往復を認証 1 回 + 参…
takemi-ohama Sep 14, 2026
dd86e76
Test: characterize secret runtime error and early-return paths
takemi-ohama Sep 14, 2026
54a77e6
Test: characterize _inject_secrets error handling branch
takemi-ohama Sep 14, 2026
fe573e2
Refactor: extract_method — lib/devbase/commands/container.py#_resolve…
takemi-ohama Sep 14, 2026
7add6cf
Refactor: consolidate_duplication — lib/devbase/commands/container.py…
takemi-ohama Sep 14, 2026
7352692
Revert "Refactor: consolidate_duplication — lib/devbase/commands/cont…
takemi-ohama Sep 14, 2026
66813ce
Refactor: extract_method — lib/devbase/commands/container.py#_ensure_…
takemi-ohama Sep 14, 2026
30065e3
Refactor: extract_method — lib/devbase/commands/container.py#cmd_scale
takemi-ohama Sep 14, 2026
7961b33
Revert "Refactor: extract_method — lib/devbase/commands/container.py#…
takemi-ohama Sep 14, 2026
61e68f8
Refactor: consolidate_duplication — lib/devbase/commands/container.py…
takemi-ohama Sep 14, 2026
3ad3fb9
Refactor: consolidate_duplication — lib/devbase/commands/container.py…
takemi-ohama Sep 14, 2026
9ceacdc
Revert "Refactor: consolidate_duplication — lib/devbase/commands/cont…
takemi-ohama Sep 14, 2026
8271686
Refactor: consolidate_duplication — lib/devbase/commands/container.py…
takemi-ohama Sep 14, 2026
15a6baa
Refactor: consolidate_duplication — lib/devbase/commands/container.py…
takemi-ohama Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions issues/PLAN55_up-single-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,73 @@
| 項目 | 誰が決めるか | 期限 |
| --- | --- | --- |
| ~~解決結果を引き継ぐ置き場~~ → 決まった: `runtime` モジュールが `SecretStore` を持ち回り、`_dispatch_lifecycle` の `finally` で捨てる(設計の決定 1・2) | 設計 Pull Request のマージで利用者が承認する | 設計 |

## 実装計画

設計は `issues/PLAN55_up-single-injection-design.md`(マージ済み #176)。タスクは設計の
「構成要素」の行から導く。1 タスクが独立して検証できる単位にし、失敗するテスト → 最小実装 →
整理の順で進める。

### 修正対象

- `lib/devbase/env/runtime.py`、`lib/devbase/commands/container.py`、`lib/devbase/tui/dispatch.py`
- `tests/env/test_runtime_store.py`(新設)、`tests/cli/test_up_roundtrips.py`(新設)、
`tests/cli/tui/test_dispatch.py`(足す)、`tests/conftest.py`(autouse で `release_store()`)

### Task 1: `runtime.store_for` / `release_store` と、`resolve` / `inject` / `child_env` の切り替え

- **対象ファイル:** `lib/devbase/env/runtime.py`、`tests/env/test_runtime_store.py`
- **変更内容:** モジュールの控え(`_store` / `_store_root`)と 2 関数を足す。`store` 引数が
`None` のとき `store_for(root)` を使う。明示的に渡された `store` は控えに入れない
- **満たす受け入れ条件:** `store_for` の規則の表(設計)
- **進め方:** 同一性・`root` 変更・解放後の作り直し・明示 `store` を控えない、の 4 テストを先に書く

### Task 2: `_ensure_env_files` を持ち回った store に切り替え、`env init` の後に捨てる

- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/cli/test_up_roundtrips.py`
- **変更内容:** `SecretStore(devbase_root)` → `runtime.store_for(devbase_root)`。`env init` の
子プロセスから戻ったら終了コードによらず `runtime.release_store()`(決定 5)
- **満たす受け入れ条件:** 4(GET が増えない)、8(`env init` が書いた値で起動する)
- **進め方:** 偽サーバで注入 → `_ensure_env_files()` → GET 件数不変のテスト、`subprocess.run` を
偽サーバへ書くスタブに差し替えて `_run_deploy_pipeline` へ渡る `SecretEnv` を見るテスト

### Task 3: `_dispatch_lifecycle` の `finally` で捨てる(3 経路の往復を固定)

- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/cli/test_up_roundtrips.py`
- **変更内容:** `finally` に `runtime.release_store()` を並べる
- **満たす受け入れ条件:** 1・2・3(認証 1 回 + GET 4 / ≤6 / 4)、5・6(既存テスト無変更)
- **進め方:** `cli._load_secret_env` → `container.cmd_project(ns)` を偽サーバ + docker 差し替えで
走らせ、`openbao.logins` と GET の `kv_path` の集合を固定する

### Task 4: TUI の委譲の入口で捨てる

- **対象ファイル:** `lib/devbase/tui/dispatch.py`、`tests/cli/tui/test_dispatch.py`
- **変更内容:** `_preserve_cwd_env` の入口で `runtime.release_store()`(決定 3)
- **満たす受け入れ条件:** 9、決定 3 の規則
- **進め方:** `store_for(root)` で控えを作ってから `dispatch_group` の handler 内で別インスタンスに
なるテスト、`env edit`(エディタのスタブ)→ `up` で新しい値が渡るテスト

### Task 5: 既存テストの独立性

- **対象ファイル:** `tests/conftest.py`
- **変更内容:** autouse fixture で各テストの前後に `runtime.release_store()`。モジュールの控えが
テストをまたいで残らない
- **満たす受け入れ条件:** 5・7
- **進め方:** `uv run pytest tests/` 全件

### リスクと対処

| リスク | 対処 |
| --- | --- |
| `tests/cli/` の既存 harness が `SecretStore(root)` を直接作り `monkeypatch` している | Task 5 の autouse fixture。実装時に `grep -rn "SecretStore(" tests/` で数える |
| `container.py` は 1300 行超で、`_ensure_env_files` と `_dispatch_lifecycle` が離れている | 触るのは 2 関数の数行。タスクごとにテストを通す |

### 切り戻し手順

- 差分を戻すだけ(永続データ・スキーマの変更なし)。`release_store()` を呼ばない古い経路が
残っても、`SecretStore` を作り直す従来の動きに戻るだけで壊れない

### 完了の定義

- [ ] 受け入れ条件 1〜9 をすべて満たし、条件ごとに検証手段と結果が対応している
- [ ] `uv run pytest tests/` / `ruff check lib` / `python -m compileall -q lib bin` が exit=0
196 changes: 127 additions & 69 deletions lib/devbase/commands/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,38 @@
# 共通ヘルパー
# ---------------------------------------------------------------------------

def _exit_code(ok: bool) -> int:
"""ビルド成否 (bool) をプロセス互換の終了コードへ写す (True=0 / False=1)。"""
return 0 if ok else 1


def _devbase_root() -> Optional[Path]:
root = os.environ.get('DEVBASE_ROOT')
return Path(root) if root else None


def _env_non_negative_int(env_name: str, default: int) -> int:
"""環境変数から非負整数を読み出す。

未設定・空文字なら default を返す。
負値または整数に変換できない場合は warning を出力して default にフォールバックする。
"""
raw = os.environ.get(env_name)
if not raw:
return default
try:
value = int(raw)
if value < 0:
raise ValueError
return value
except ValueError:
logger.warning(
"Invalid %s=%r, using default %d",
env_name, raw, default
)
return default


def _inject_secrets(*, required: bool):
"""機密を復号して自プロセスの環境変数へ載せ、載せた内容を返す。

Expand Down Expand Up @@ -208,14 +235,26 @@ def _previous_scale_compose():
backup.unlink(missing_ok=True)


def _compose_run(subcommand: str, *extra_args: str,
context: Optional[str] = None) -> int:
"""docker compose コマンドを実行する共通関数"""
def _prepare_compose(context: Optional[str]) -> None:
"""Compose の接続先を反映してから機密を任意注入する。"""
_apply_context(context)
_inject_secrets(required=False)


def _compose_base_args(compose_file: Optional[Path]) -> list[str]:
"""指定された override を付与した Compose のベース引数を返す。"""
cmd = ['docker', 'compose']
if _SCALE_COMPOSE_FILE.exists():
cmd.extend(['-f', str(_SCALE_COMPOSE_FILE)])
if compose_file is not None:
cmd.extend(['-f', str(compose_file)])
return cmd


def _compose_run(subcommand: str, *extra_args: str,
context: Optional[str] = None) -> int:
"""docker compose コマンドを実行する共通関数"""
_prepare_compose(context)
compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None
cmd = _compose_base_args(compose_file)
cmd.append(subcommand)
cmd.extend(extra_args)
return subprocess.run(cmd).returncode
Expand Down Expand Up @@ -431,6 +470,24 @@ def _load_project_env(env_file: Path) -> None:
os.environ[key] = value


def _unset_caller_only_env_keys(caller_keys: set, target_dir: Path) -> None:
"""呼び出し元 env にしか無いキーを os.environ から unset する。

別プロジェクトから `project up other` を直接起動した場合、呼び出し元 env に
しか無いキー (例: DEV_SERVICE_NAME) が os.environ に残留し対象へ誤って
引き継がれる。対象 (``target_dir`` = 現 CWD) の env を読み、呼び出し元にしか
無いキーを unset してクリーンにする
(codex 指摘 / wrapper の _CALLER_ENV_KEYS と同等のフォールバック)。

Args:
caller_keys: chdir 前に記録した呼び出し元 env のキー集合。
target_dir: 切替先プロジェクトのディレクトリ (既に chdir 済みの CWD)。
"""
target_env_keys = _env_var_keys(target_dir / 'env')
for key in caller_keys - target_env_keys:
os.environ.pop(key, None)


def _resolve_project_name(project_name: str) -> bool:
"""project name を $DEVBASE_ROOT/projects/<name> へ解決し chdir する。

Expand Down Expand Up @@ -468,12 +525,10 @@ def _resolve_project_name(project_name: str) -> bool:

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

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


def cmd_project(args) -> int:
Expand Down Expand Up @@ -597,20 +655,10 @@ def _snapshot_min_interval_minutes() -> int:
DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES で上書き可能 (0 で無効化=毎回取得)。
値が不正な場合は既定値にフォールバックする。
"""
raw = os.environ.get('DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES')
if not raw:
return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
try:
value = int(raw)
if value < 0:
raise ValueError
return value
except ValueError:
logger.warning(
"Invalid DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=%r, using default %d",
raw, _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
)
return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
return _env_non_negative_int(
'DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES',
_SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT,
)


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

def cmd_login(index: str = '1', context: Optional[str] = None) -> int:
"""Login to container"""
_apply_context(context)
_inject_secrets(required=False)
_prepare_compose(context)
dev_service = get_dev_service_name()

if _SCALE_COMPOSE_FILE.exists():
cmd = ['docker', 'compose', '-f', str(_SCALE_COMPOSE_FILE),
'exec', f'{dev_service}-{index}', 'bash']
compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None
cmd = _compose_base_args(compose_file)
if compose_file is not None:
cmd.extend(['exec', f'{dev_service}-{index}', 'bash'])
else:
cmd = ['docker', 'compose', 'exec', f'--index={index}',
dev_service, 'bash']
cmd.extend(['exec', f'--index={index}', dev_service, 'bash'])

return subprocess.run(cmd).returncode

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

if no_cache:
return 0 if _run_build(no_cache=True) else 1
return _exit_code(_run_build(no_cache=True))
if expires is None:
return 0 if _run_build() else 1
return _exit_code(_run_build())

# expires 指定: project イメージの作成日と dev サービス定義 (base 判定用) が必要。
dev_service = _resolve_dev_service()
if not dev_service:
logger.info("Unable to read compose config; building with cache")
return 0 if _run_build() else 1
return _exit_code(_run_build())
image_name = dev_service.get('image', '')
if not image_name:
return 0 if _run_build() else 1
return _exit_code(_run_build())
inspect = subprocess.run(
['docker', 'image', 'inspect', image_name],
capture_output=True, text=True, check=False
)
if inspect.returncode != 0:
# イメージ未存在 → キャッシュビルドで作成する。
logger.info("Container image '%s' not found; building...", image_name)
return 0 if _run_build() else 1
return 0 if _build_with_expires(expires, image_name, inspect.stdout, dev_service) else 1
return _exit_code(_run_build())
return _exit_code(_build_with_expires(expires, image_name, inspect.stdout, dev_service))


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

store = SecretStore(devbase_root)
store = _runtime.store_for(devbase_root)
has_global = store.exists(SecretRef.for_global())

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

if not has_project:
logger.info("Creating project .env...")
Expand All @@ -1365,20 +1421,32 @@ def _image_max_age_days() -> int:
Override via the DEVBASE_IMAGE_MAX_AGE_DAYS environment variable.
Falls back to the default on missing or malformed values.
"""
raw = os.environ.get('DEVBASE_IMAGE_MAX_AGE_DAYS')
if not raw:
return _IMAGE_MAX_AGE_DAYS_DEFAULT
try:
value = int(raw)
if value < 0:
raise ValueError
return value
except ValueError:
logger.warning(
"Invalid DEVBASE_IMAGE_MAX_AGE_DAYS=%r, using default %d",
raw, _IMAGE_MAX_AGE_DAYS_DEFAULT
)
return _IMAGE_MAX_AGE_DAYS_DEFAULT
return _env_non_negative_int(
'DEVBASE_IMAGE_MAX_AGE_DAYS',
_IMAGE_MAX_AGE_DAYS_DEFAULT,
)


def _read_compose_services() -> tuple[int, dict]:
"""Compose 設定の終了コードと services を取得する。"""
result = subprocess.run(
['docker', 'compose', 'config', '--format', 'json'],
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
return result.returncode, {}
config = json.loads(result.stdout)
return result.returncode, config.get('services', {})


def _dev_image_spec(services: dict, dev_service_name: str) -> tuple[dict, str, bool]:
"""dev サービスと、そのイメージ名・ビルド定義の有無を取り出す。"""
dev_service = services.get(dev_service_name, {})
image_name = dev_service.get('image', '')
has_build = bool(dev_service.get('build'))
return dev_service, image_name, has_build


def _ensure_images() -> bool:
Expand Down Expand Up @@ -1410,23 +1478,13 @@ def _ensure_images() -> bool:
dev_service_name = get_dev_service_name()

try:
result = subprocess.run(
['docker', 'compose', 'config', '--format', 'json'],
capture_output=True,
text=True,
check=False
)

if result.returncode != 0:
returncode, services = _read_compose_services()
if returncode != 0:
logger.info("Unable to check image status")
logger.info("Running 'devbase container build' to ensure images exist...")
return _run_build()

config = json.loads(result.stdout)
services = config.get('services', {})
dev_service = services.get(dev_service_name, {})
image_name = dev_service.get('image', '')
has_build = bool(dev_service.get('build'))
dev_service, image_name, has_build = _dev_image_spec(services, dev_service_name)

if not image_name:
logger.warning("No image specified for %s service", dev_service_name)
Expand Down
Loading
Loading