diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 93130e16..65a6070c 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -109,6 +109,8 @@ Python 側のエントリーポイント。以下の責務を持つ。 | `dispatch.py` | 収集した属性を Namespace に詰めて `cmd_*` ハンドラを呼ぶ薄い委譲層 | | `actions_project.py` | プロジェクト行の処理(up / 起動中は操作サブメニュー) | | `actions_env.py` / `actions_plugin.py` / `actions_snapshot.py` / `actions_status.py` | 各カテゴリの操作フロー | +| `actions_env_keys.py` / `actions_env_openbao.py` | env カテゴリの画面(キーの一覧と編集・OpenBao の接続設定) | +| `env_delegate.py` | env の画面が `cmd_env` へ委譲する部品(委譲と、プロジェクトの置き場へ書くときの chdir + `PWD` の差し替え)。env メニューと各画面がここを使い、画面からメニューのモジュールを読まない | ### env/ -- 環境変数管理システム diff --git a/docs/specifications/secret-backend.md b/docs/specifications/secret-backend.md index 43fbc894..f69fce6b 100644 --- a/docs/specifications/secret-backend.md +++ b/docs/specifications/secret-backend.md @@ -813,7 +813,7 @@ TUI の「OpenBao の接続設定」は同じプロセスの中から `use openb | 見出し | 今の `url` と、ブートストラップ機密が `設定済み` / `未設定` か。`role_id` / `secret_id` の値は出さない | | 欄 | 接続先(`url`、1 行)、`role_id` と `secret_id`(伏せ字)。空の欄は変えない(`use` の引き継ぎ)。`role_id` を変えるときは `secret_id` も要る。`mount` / `user` / `layout` / `group_aliases` / キャッシュの設定は変えない | | 保存の後 | `test` と同じ確認を自動で行う。失敗しても保存した設定は残し、欄を入れ直せる。`use` が失敗したら `test` を呼ばずに欄へ戻る | -| 確認のグループ | グループ別の置き場(`version: 2`・`layout: group`)で、TUI の現在地(`PWD`)がプロジェクトの外のときだけ、保存の後・確認の直前に対象のグループを選ばせ、`test` へ属性 `group`(CLI の `--group` と同じ)で渡す。選択は sync / init と同じ部品(`actions_env._group_attrs`。候補は宣言済みのグループと「名前を入力」)。プロジェクトの中では選ばせず、`test` がグループの宣言から決める。`version: 1` では選ばせず `group` を渡さない | +| 確認のグループ | グループ別の置き場(`version: 2`・`layout: group`)で、TUI の現在地(`PWD`)がプロジェクトの外のときだけ、保存の後・確認の直前に対象のグループを選ばせ、`test` へ属性 `group`(CLI の `--group` と同じ)で渡す。選択は sync / init と同じ部品(`actions_env_keys.group_attrs`。候補は宣言済みのグループと「名前を入力」)。プロジェクトの中では選ばせず、`test` がグループの宣言から決める。`version: 1` では選ばせず `group` を渡さない | | 使えないグループ名 | 「名前を入力」の名前は `validate_account_group` で検証し、続けて CLI の `--group` と同じ `storage_group` の検査(置き場の予約語 `global` / `projects`、`group_aliases` の読み替え先)を通す。どちらかで落ちれば「--group に使えない名前です: ...」を出してグループの選択へ戻る(`test` を呼ばない)。sync / init の選択も同じ部品を通る | | グループの選択で戻る | Esc・← で「接続を確かめずに戻ります。保存した設定は残っています。」を出し、`test` を呼ばず、入れ直しの問いも出さずに env メニューへ戻る(`ARG_CANCEL`。一時停止しない)。Ctrl-C は TUI 全体を終える | | 入れ直した後 | 入れ直しの問いで「はい」を選ぶと欄へ戻り、次に保存した後でもう一度グループを選ぶ | @@ -839,7 +839,7 @@ graph TD 失敗した経路や変更の無い経路で使わない入力を求めないためである。入れ直すたびに選び直させるのは、確認の 失敗の原因が選んだグループの参照の 403(グループ単位のポリシー)である場合に、別のグループを選べるように するためである。プロジェクトの中かどうかの判定(CLI の `_target_group` と同じ `_current_project_name`)は -接続設定の画面の側(`_check_group_attrs`)に置き、sync / init と共有する `_group_attrs` の振る舞いを変えない。 +接続設定の画面の側(`_check_group_attrs`)に置き、sync / init と共有する `group_attrs` の振る舞いを変えない。 `test` の `--group` の規則(`_target_group`)は TUI のために変えない。対象のグループ・グループの宣言・ レイアウトの定義は [用語集](../glossary.md)、利用者向けの説明は [機密の保存先を選ぶ](../user/env-backend.md#3-接続を確かめる) にある。 diff --git a/docs/specifications/test-environment-isolation.md b/docs/specifications/test-environment-isolation.md index 205c79b3..4e935e14 100644 --- a/docs/specifications/test-environment-isolation.md +++ b/docs/specifications/test-environment-isolation.md @@ -140,7 +140,7 @@ sequenceDiagram の `options` など)より前に立つ。function scope の段だけでは、これらの fixture が継承した値を読む (偽の `DOCKER_HOST` へ `docker info` を打つなど) - **テストごとの段でも同じ一覧を消す。** `lib/devbase` は `os.environ` へ直接書く箇所を持つ - (`commands/container.py` の `PWD`・`COMPOSE_PROJECT_NAME` など、`tui/actions_env.py` の `PWD`)。 + (`commands/container.py` の `PWD`・`COMPOSE_PROJECT_NAME` など、`tui/env_delegate.py` の `PWD`)。 `monkeypatch` を通らない書き込みは、次のテストの開始で消える - **テストの側の設定は後勝ちになる。** root の `conftest.py` の function scope の autouse は、テストが明示に 求める fixture とテストの本体より先に立つ。テストの側の `monkeypatch.setenv` / `delenv` は同じ diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 5b634b60..ca050d2d 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -630,7 +630,7 @@ def _resolve_project_name(project_name: str) -> bool: # :func:`devbase.env.runtime.current_project_name`) は wrapper の cd を前提に # ``os.environ['PWD']`` を先に見るため、os.chdir だけだと切替前の PWD が残り、 # 切替先ではなく呼び出し元プロジェクトの機密を読んでしまう - # (TUI の ``_run_in_project`` が PWD を差し替えているのと同じ理由)。 + # (TUI の ``env_delegate.run_in_project`` が PWD を差し替えているのと同じ理由)。 os.environ['PWD'] = str(target) _unset_caller_only_env_keys(caller_env_keys, target) diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index d8a7ec14..7edf1c0b 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -2,8 +2,9 @@ import os import subprocess +from dataclasses import dataclass, field from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional import yaml @@ -13,6 +14,7 @@ from devbase.env.store import EnvFile, safe_input from devbase.env.sources import SourcesManager, file_hash, dir_hash from devbase.env.collector import CollectorRegistry +from devbase.commands.subcommands import run_subcommand logger = get_logger(__name__) @@ -282,12 +284,7 @@ def cmd_env(devbase_root: Path, args) -> int: assume_yes=getattr(args, 'assume_yes', False)), } - handler = handlers.get(subcmd) - if handler: - return handler() - - logger.error("サブコマンドを指定してください: %s", ', '.join(handlers)) - return 1 + return run_subcommand(handlers, subcmd, missing_rc=1, logger=logger) def _ops(): @@ -636,8 +633,46 @@ def save_dirty(self) -> list: return saved -def _open_sync_targets(devbase_root: Path, user: bool, group: Optional[str]): - """``sync`` の書き込み先を開く。``(SyncTargets, store, None)`` か ``(None, None, 終了コード)``。 +class _SyncAborted(Exception): + """``sync`` の書き込み先を開けなかった (理由は出力済み)。``exit_code`` で終える""" + + def __init__(self, exit_code: int): + super().__init__(exit_code) + self.exit_code = exit_code + + +class _SyncCounts: + """``sync`` の集計 (更新した件数・控えへ新たに登録するソースがあったか)""" + + def __init__(self): + self.updated = 0 + self.registered = False + #: AWS の取り込みの選択 (控えの更新へ渡す。#314) + self.aws_selection = _KEEP_SELECTION + + +@dataclass +class _SyncContext: + """1 回の ``sync`` で同期の関数が共に使うもの (控え・書き込み先・置き場・集計)。 + + 更新件数は同期の関数がそれぞれ :meth:`write` を通して ``counts`` へ足す。 + """ + + sources: SourcesManager + targets: SyncTargets + store: Any + counts: _SyncCounts = field(default_factory=_SyncCounts) + + def write(self, key: str, encoded: str, head: str) -> None: + """値を書き、``更新しました<接尾辞>`` を出して更新件数を 1 足す""" + tail = self.targets.suffix(key) + self.targets.set(key, encoded) + logger.info("%s更新しました%s", head, tail) + self.counts.updated += 1 + + +def _open_sync_targets(devbase_root: Path, user: bool, group: Optional[str]) -> _SyncContext: + """``sync`` の書き込み先と控えを開く。開けなければ理由を出して :class:`_SyncAborted` を送る。 2 つの参照は ``fresh`` で読み、控えへ落ちない (書き込みを伴うため)。 """ @@ -652,17 +687,17 @@ def _open_sync_targets(devbase_root: Path, user: bool, group: Optional[str]): has_user = team.has_user_refs() except GroupOptionError as e: logger.error("%s", e) - return None, None, e.exit_code + raise _SyncAborted(e.exit_code) from e except DevbaseError as e: logger.error("%s", e) - return None, None, 1 + raise _SyncAborted(1) from e if user and not has_user: # 決定 11: チーム共通へ落とさない。引数の誤りとして 2 logger.error( "%s backend は個人単位の機密を扱えません (--user)。" "個人単位の機密を置くにはサーバ backend を設定してください: " "devbase env backend use openbao ...", team.mode_name()) - return None, None, EXIT_USAGE + raise _SyncAborted(EXIT_USAGE) user_file = (_global_env(devbase_root, user=True, store=store, fresh=True, group=target_group) if has_user else None) targets = SyncTargets(team, user_file, forced_user=user) @@ -672,8 +707,10 @@ def _open_sync_targets(devbase_root: Path, user: bool, group: Optional[str]): except DevbaseError as e: logger.error("%sを読めないため、同期しません: %s", store.display_label(env_file.ref), e) - return None, None, 1 - return targets, store, None + raise _SyncAborted(1) from e + sources = SourcesManager(devbase_root, store.storage_group(team.ref.group)) + sources.load() + return _SyncContext(sources, targets, store) def cmd_env_sync(devbase_root: Path, user: bool = False, group: Optional[str] = None) -> int: @@ -683,18 +720,16 @@ def cmd_env_sync(devbase_root: Path, user: bool = False, group: Optional[str] = 同期済みのハッシュは対象のグループの控えを使う (PLAN56 決定 13)。 ``user`` (``--user``) は全てのキーの宛先を個人共通にする。``group`` は ``--group``。 """ - targets, store, rc = _open_sync_targets(devbase_root, user, group) - if targets is None: - return rc - - sources = SourcesManager(devbase_root, store.storage_group(targets.team.ref.group)) - sources.load() + try: + ctx = _open_sync_targets(devbase_root, user, group) + except _SyncAborted as e: + return e.exit_code + counts, targets = ctx.counts, ctx.targets - counts = _SyncCounts() - _sync_credential_sources(sources, targets, store, counts) + _sync_credential_sources(ctx) # GCP(プロファイル管理があるため個別処理) - counts.updated += _sync_gcp(sources, targets, store=store, counts=counts) + _sync_gcp(ctx) # Host 接続情報(ソースファイルを持たないため hash 比較せず欠落キーを補完) counts.updated += _sync_host(targets) @@ -707,23 +742,22 @@ def cmd_env_sync(devbase_root: Path, user: bool = False, group: Optional[str] = return 1 _update_source_metadata(devbase_root, *targets.files, aws_selection=counts.aws_selection) - _report_sync_result(counts, sources) + _report_sync_result(counts, ctx.sources) return 0 -def _sync_credential_sources(sources, targets, store, counts) -> None: - """AWS と Git の認証情報をソースから同期する (``counts`` に集計する)""" +def _sync_credential_sources(ctx: _SyncContext) -> None: + """AWS と Git の認証情報をソースから同期する (``ctx.counts`` に集計する)""" # AWS: 取り込みの選択の範囲だけで入れ直す (#314 I7) - plan = _aws_sync_plan(sources, targets, store) + plan = _aws_sync_plan(ctx.sources, ctx.targets, ctx.store) if plan is None: # 書き込みを見送ったら控えの登録も見送る (丸ごとで登録すると次の変更で選択外まで送るため) - counts.aws_selection = _SKIP_SELECTION + ctx.counts.aws_selection = _SKIP_SELECTION else: - encode_aws, counts.aws_selection = plan - _sync_source(sources, targets, 'aws', 'AWS認証', encode_aws, - env_key=keys.AWS_CONFIG_BASE64, store=store, counts=counts, - same=_same_aws_payload) + encode_aws, ctx.counts.aws_selection = plan + _sync_source(ctx, 'aws', 'AWS認証', encode_aws, + env_key=keys.AWS_CONFIG_BASE64, same=_same_aws_payload) # Git def _encode_git(): @@ -734,8 +768,8 @@ def _encode_git(): return base64.b64encode(content.encode('utf-8')).decode('ascii') return None - _sync_source(sources, targets, 'git_credentials', 'Git認証', _encode_git, - env_key=keys.GIT_CREDENTIALS_BASE64, store=store, counts=counts) + _sync_source(ctx, 'git_credentials', 'Git認証', _encode_git, + env_key=keys.GIT_CREDENTIALS_BASE64) def _report_sync_result(counts, sources) -> None: @@ -749,16 +783,6 @@ def _report_sync_result(counts, sources) -> None: logger.info("同期完了 (変更なし)") -class _SyncCounts: - """``sync`` の集計 (更新した件数・控えへ新たに登録するソースがあったか)""" - - def __init__(self): - self.updated = 0 - self.registered = False - #: AWS の取り込みの選択 (控えの更新へ渡す。#314) - self.aws_selection = _KEEP_SELECTION - - def _sync_host(target): """ホスト接続情報の同期。更新件数を返す。 @@ -899,53 +923,45 @@ def _aws_selection_covers(targets, key, payload) -> bool: return set(in_dest) <= set(in_payload or []) -def _sync_unregistered(targets, store, counts, key, label, encode_fn, same=None): - """控えに項目の無いソースのキーが参照にあるとき (I10・決定 2)。更新件数 (0 or 1) を返す。 +def _sync_unregistered(ctx: _SyncContext, key, label, encode_fn, same=None) -> None: + """控えに項目の無いソースのキーが参照にあるとき (I10・決定 2)。``ctx.counts`` に集計する。 黙って飛ばさず 1 行を出し、今のファイルの値と比べて違えば書く。比べた後は控えに 登録する (以後はハッシュで検出する)。 """ + targets = ctx.targets holder = targets.holder(key) if holder is None: - return 0 - head = f"{label}: ソース未登録({store.display_label(holder.ref)}にキーがあります)。" + return + head = f"{label}: ソース未登録({ctx.store.display_label(holder.ref)}にキーがあります)。" encoded = encode_fn() if not encoded: logger.info("%s元のファイルがありません", head) - return 0 - counts.registered = True + return + ctx.counts.registered = True same = same or (lambda a, b: a == b) if same(targets.get(key), encoded): logger.info("%s今のファイルと比べて変更なし", head) - return 0 - tail = targets.suffix(key) - targets.set(key, encoded) - logger.info("%s今のファイルと比べて更新しました%s", head, tail) - return 1 + return + ctx.write(key, encoded, f"{head}今のファイルと比べて") -def _sync_source(sources, targets, name, label, encode_fn, *, env_key, store, counts, - same=None): - """AWS/Gitなどの単一ソース同期の共通処理。``counts`` へ更新件数を足す。""" - source = sources.get_source(name) +def _sync_source(ctx: _SyncContext, name, label, encode_fn, *, env_key, same=None) -> None: + """AWS/Gitなどの単一ソース同期の共通処理。``ctx.counts`` に集計する。""" + source = ctx.sources.get_source(name) if not source: - counts.updated += _sync_unregistered(targets, store, counts, env_key, label, - encode_fn, same) + _sync_unregistered(ctx, env_key, label, encode_fn, same) return - if targets.holder(source.get('env_key') or env_key) is None: + if ctx.targets.holder(source.get('env_key') or env_key) is None: # I5: 控えに項目があっても、参照に無いキーは書かない (取り込むかは init が決める) logger.info("%s: %s", label, _NO_KEY_HINT) return - changed = sources.check_changed(name) + changed = ctx.sources.check_changed(name) if changed: encoded = encode_fn() if encoded: - key = source['env_key'] - tail = targets.suffix(key) - targets.set(key, encoded) - logger.info("%s: 更新しました%s", label, tail) - counts.updated += 1 + ctx.write(source['env_key'], encoded, f"{label}: ") else: logger.warning("%s: エンコードに失敗", label) elif changed is False: @@ -954,56 +970,73 @@ def _sync_source(sources, targets, name, label, encode_fn, *, env_key, store, co logger.info("%s: 控えと比べられません(ハッシュか元のファイルがありません)", label) -def _sync_gcp(sources, targets, *, store, counts): - """GCPプロファイルの同期処理。更新件数を返す。 +def _gcp_credentials(files) -> dict: + """参照に GCP の認証のキーがあるプロファイル → その認証ファイル (見つからなければ ``None``)。 - 控えへの登録はプロファイル単位で見る。参照にあって控えに無いプロファイルは、 - 今のファイルと比べてから控えへ登録する (I10)。 + キー ``GCP_CREDENTIALS_BASE64_<名前>`` から名前を取り出し、``~/gcp-credentials/`` で引く + (同期と控えの登録が同じ求め方を使う)。並びは後に渡した参照のキーが先。 """ - import base64 + from devbase.env.collectors.google import find_credential_files - gcp_source = sources.get_source('gcp') or {} - registered = gcp_source.get('profiles', {}) prefix = keys.GCP_CREDENTIALS_BASE64_PREFIX - from devbase.env.collectors.google import find_credential_files resolve = find_credential_files().lookup - names = sorted({k[len(prefix):] for f in targets.files for k in f.get_all() - if k.startswith(prefix)} - set(registered)) - updated = 0 - for profile_name in names: - def encode(profile_name=profile_name): - path = resolve(profile_name) - return base64.b64encode(path.read_bytes()).decode('ascii') if path else None - - updated += _sync_unregistered(targets, store, counts, - keys.gcp_credentials_key(profile_name), - f"GCP認証 ({profile_name})", encode) - if not gcp_source: - return updated - - gcp_changes = sources.check_gcp_changed() - for profile_name, changed in gcp_changes.items(): - if targets.holder(keys.gcp_credentials_key(profile_name)) is None: + names = dict.fromkeys(key[len(prefix):] for f in reversed(files) for key in f.get_all() + if key.startswith(prefix)) + return {name: resolve(name) for name in names} + + +def _encode_file(path: Optional[Path]) -> Optional[str]: + """ファイルの中身の base64。``path`` が無ければ ``None``""" + import base64 + + return base64.b64encode(path.read_bytes()).decode('ascii') if path else None + + +def _registered_gcp_file(profile_info: dict) -> Optional[Path]: + """控えのプロファイルの項目が指す認証ファイル。項目に無い・ファイルが無ければ ``None``""" + file_str = profile_info.get('file', '') + if not file_str: + return None + path = Path(file_str).expanduser() + return path if path.exists() else None + + +def _sync_gcp(ctx: _SyncContext) -> None: + """GCPプロファイルの同期処理。``ctx.counts`` に集計する。 + + 控えへの登録はプロファイル単位で見る。参照にあって控えに無いプロファイルは、 + 今のファイルと比べてから控えへ登録する (I10)。 + """ + gcp_source = ctx.sources.get_source('gcp') or {} + registered = gcp_source.get('profiles', {}) + _sync_unregistered_gcp(ctx, registered) + if gcp_source: + _sync_registered_gcp(ctx, registered) + + +def _sync_unregistered_gcp(ctx: _SyncContext, registered) -> None: + """参照にあって控えに無いプロファイル (I10)""" + found = _gcp_credentials(ctx.targets.files) + for name in sorted(set(found) - set(registered)): + _sync_unregistered(ctx, keys.gcp_credentials_key(name), f"GCP認証 ({name})", + lambda path=found[name]: _encode_file(path)) + + +def _sync_registered_gcp(ctx: _SyncContext, registered) -> None: + """控えにあるプロファイルを、控えのハッシュと比べて入れ直す""" + for name, changed in ctx.sources.check_gcp_changed().items(): + label = f"GCP認証 ({name})" + key = keys.gcp_credentials_key(name) + if ctx.targets.holder(key) is None: # I5: 参照から消したプロファイルの鍵は書き戻さない - logger.info("GCP認証 (%s): %s", profile_name, _NO_KEY_HINT) + logger.info("%s: %s", label, _NO_KEY_HINT) continue - if changed: - profile_info = registered.get(profile_name, {}) - file_str = profile_info.get('file', '') - if not file_str: - continue - file_path = Path(file_str).expanduser() - if file_path.exists(): - encoded = base64.b64encode(file_path.read_bytes()).decode('ascii') - key = keys.gcp_credentials_key(profile_name) - tail = targets.suffix(key) - targets.set(key, encoded) - updated += 1 - logger.info("GCP認証 (%s): 更新しました%s", profile_name, tail) - else: - logger.info("GCP認証 (%s): 変更なし", profile_name) - - return updated + if not changed: + logger.info("%s: 変更なし", label) + continue + path = _registered_gcp_file(registered.get(name, {})) + if path is not None: + ctx.write(key, _encode_file(path), f"{label}: ") def _print_env_vars(vars_dict, keys_only, reveal): @@ -1112,14 +1145,27 @@ def _mode_suffix(env_file) -> str: return f' [{mode}]' +#: キー名にこれらを含む値は ``--reveal`` が無ければ伏せる +_SENSITIVE_KEY_PARTS = ('KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIALS', 'BASE64') +_MASK = "██████" +#: 伏せた値でも長さを添える文字数の下限 (これを超えたら添える) +_MASK_LENGTH_SHOWN_OVER = 100 +#: 平文で出す値の上限の文字数。超えたら先頭を切り出して ``...`` を足す +_VALUE_DISPLAY_MAX = 60 +_ELLIPSIS = "..." + + def _format_value(key: str, value: str, reveal: bool) -> str: """表示用に値をフォーマットする""" - sensitive_patterns = ('KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIALS', 'BASE64') - is_sensitive = any(p in key.upper() for p in sensitive_patterns) + is_sensitive = any(p in key.upper() for p in _SENSITIVE_KEY_PARTS) if is_sensitive and not reveal: - return f"██████ ({len(value)}文字)" if len(value) > 100 else "██████" - return f"{value[:57]}..." if len(value) > 60 else value + if len(value) > _MASK_LENGTH_SHOWN_OVER: + return f"{_MASK} ({len(value)}文字)" + return _MASK + if len(value) > _VALUE_DISPLAY_MAX: + return value[:_VALUE_DISPLAY_MAX - len(_ELLIPSIS)] + _ELLIPSIS + return value def cmd_env_set(devbase_root: Path, assignment: str, project: bool = False, @@ -1624,18 +1670,9 @@ def _record_git_source(sources: SourcesManager) -> None: def _record_gcp_source(sources: SourcesManager, files, get) -> None: """GCP の認証情報をプロファイルごとにソースとして登録する""" - all_vars = {} - for f in reversed(files): - all_vars.update(f.get_all()) - prefix = keys.GCP_CREDENTIALS_BASE64_PREFIX - from devbase.env.collectors.google import find_credential_files - _resolve_gcp_path = find_credential_files().lookup - gcp_profiles = { name: {'file': str(path), 'hash': file_hash(path)} - for key in all_vars if key.startswith(prefix) - for name in [key[len(prefix):]] - for path in [_resolve_gcp_path(name)] + for name, path in _gcp_credentials(files).items() if path and path.exists() } diff --git a/lib/devbase/commands/env_backend.py b/lib/devbase/commands/env_backend.py index 41660c18..ad0148db 100644 --- a/lib/devbase/commands/env_backend.py +++ b/lib/devbase/commands/env_backend.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import List, Optional, Sequence +from devbase.commands.subcommands import run_subcommand from devbase.env import backend_config as _bc from devbase.env import bootstrap as _bootstrap from devbase.env.secret_store import MODE_ABSENT, SecretRef, SecretStore @@ -46,11 +47,7 @@ def cmd_env_backend(devbase_root: Path, args) -> int: exclude_projects=getattr(args, 'exclude_projects', None) or (), group=getattr(args, 'group', None)), } - handler = handlers.get(action) - if handler is None: - logger.error("サブコマンドを指定してください: %s", ', '.join(handlers)) - return EXIT_USAGE - return handler() + return run_subcommand(handlers, action, missing_rc=EXIT_USAGE, logger=logger) # --------------------------------------------------------------------------- @@ -735,10 +732,6 @@ def __init__(self, root: Path, file_store: SecretStore, server_store: SecretStor #: ``--to age`` で移さずサーバ上に残す、他のグループの共通の参照 self.left_on_server: List[SecretRef] = [] - @property - def _grouped(self) -> bool: - return self.server_store.config.openbao.grouped - def _units(self) -> List[_MoveUnit]: """移す単位を組み、``--to age`` で移さない他のグループの共通の参照を控える。 @@ -910,7 +903,7 @@ def _heading_width(plan: _MigrationPlan) -> int: def _plan_heading(plan: _MigrationPlan, unit: _MoveUnit, width: int) -> str: """参照の見出し。グループ別の置き場ではサーバ上のパスを添える (値は出さない)""" label = pad(plan.server_store.display_label(unit.server_ref), width) - if plan._grouped: + if plan.server_store.grouped: label += f" {plan.server.display_path(unit.server_ref)}" return label diff --git a/lib/devbase/commands/env_rows.py b/lib/devbase/commands/env_rows.py index ebaf88fb..85afc4c2 100644 --- a/lib/devbase/commands/env_rows.py +++ b/lib/devbase/commands/env_rows.py @@ -39,10 +39,6 @@ class KeyListing: backend: str = '' -def is_grouped(store: SecretStore) -> bool: - return store.grouped - - def _project_group(store: SecretStore, devbase_root: Path, project: str) -> Optional[str]: """プロジェクトの参照に持たせるグループ。読む前に backend を問わず宣言を検査する (#315 決定 2。CLI の ``_target_group`` と同じ)。 @@ -116,7 +112,7 @@ def collect_key_rows(devbase_root: Path, project: Optional[str] = None, 接続・403・復号の失敗は ``DevbaseError`` のまま送る。 """ store = SecretStore(devbase_root) - grouped = is_grouped(store) + grouped = store.grouped target, option_group = _resolve_target(store, devbase_root, project, group) refs, has_user = _scope_refs(store, project, target) diff --git a/lib/devbase/commands/subcommands.py b/lib/devbase/commands/subcommands.py new file mode 100644 index 00000000..d4b5ed21 --- /dev/null +++ b/lib/devbase/commands/subcommands.py @@ -0,0 +1,18 @@ +"""サブコマンドの振り分け (``env`` と ``env backend`` が使う)""" + +import logging +from typing import Callable, Mapping, Optional + + +def run_subcommand(handlers: Mapping[str, Callable[[], int]], name: Optional[str], *, + missing_rc: int, logger: logging.Logger) -> int: + """``name`` のハンドラを呼んで終了コードを返す。 + + ``name`` が無い・知らない名前なら、選べるサブコマンドを ``logger`` (呼び出し元のもの) へ + 並べて ``missing_rc`` を返す。 + """ + handler = handlers.get(name) + if handler is None: + logger.error("サブコマンドを指定してください: %s", ', '.join(handlers)) + return missing_rc + return handler() diff --git a/lib/devbase/env/secret_store.py b/lib/devbase/env/secret_store.py index f27bf8ac..a64c8e5c 100644 --- a/lib/devbase/env/secret_store.py +++ b/lib/devbase/env/secret_store.py @@ -88,6 +88,11 @@ def _validate_project_name(name: str) -> str: OWNER_TEAM = 'team' OWNER_USER = 'user' + +#: 参照の適用範囲 (:attr:`SecretRef.kind`) +KIND_GLOBAL = 'global' +KIND_PROJECT = 'project' + _OWNERS = (OWNER_TEAM, OWNER_USER) @@ -129,20 +134,20 @@ class SecretRef: 1 つの ``SecretStore`` の中でグループが変わっても控え (``_seen``) を取り違えない。 読み替え (``group_aliases``) の前の名前を持ち、読み替えはパスを組むときに行う。 """ - kind: str # 'global' | 'project' + kind: str # KIND_GLOBAL | KIND_PROJECT name: Optional[str] = None owner: str = OWNER_TEAM # 'team' | 'user' group: Optional[str] = None @staticmethod def for_global(*, owner: str = OWNER_TEAM, group: Optional[str] = None) -> 'SecretRef': - return SecretRef(kind='global', owner=_validate_owner(owner), + return SecretRef(kind=KIND_GLOBAL, owner=_validate_owner(owner), group=_validate_group(group)) @staticmethod def for_project(name: str, *, owner: str = OWNER_TEAM, group: Optional[str] = None) -> 'SecretRef': - return SecretRef(kind='project', name=_validate_project_name(name), + return SecretRef(kind=KIND_PROJECT, name=_validate_project_name(name), owner=_validate_owner(owner), group=_validate_group(group)) @property diff --git a/lib/devbase/tui/actions_env.py b/lib/devbase/tui/actions_env.py index 92c9639d..6d24dbb9 100644 --- a/lib/devbase/tui/actions_env.py +++ b/lib/devbase/tui/actions_env.py @@ -13,26 +13,19 @@ - export/import は TUI から除外する (CLI で実行)。 引数収集は ``tui.menu`` のヘルパで CLI parser (cli.py ``_add_env_parser``) と -同じ属性値を集め、``tui.dispatch.dispatch_group`` 経由で既存ハンドラ -``cmd_env`` へ委譲する (ロジック二重実装なし)。 - -プロジェクトの置き場への書き込みは、CWD (環境変数 ``PWD``) のプロジェクトディレクトリで -動く ``set -p`` / ``delete -p`` へ委譲するため、chdir + ``PWD`` 差し替えしてからハンドラを -呼び、実行後は必ず元へ復帰する (``_run_in_project``)。``cmd_env_*`` は -``os.environ.get('PWD', os.getcwd())`` で現在地を判定するため、``os.chdir`` だけでなく -``PWD`` も併せて切り替える。 +同じ属性値を集め、``tui.env_delegate`` 経由で既存ハンドラ ``cmd_env`` へ委譲する +(ロジック二重実装なし)。プロジェクトの置き場への書き込みの chdir + ``PWD`` 差し替えも +``env_delegate.run_in_project`` が行う。 中止系の伝搬 (Ctrl-C / Esc / ``_ARG_CANCEL``) は ``tui.flow`` のナビ規約に従う。 """ from __future__ import annotations -import os from pathlib import Path from devbase.log import get_logger -from devbase.tui import flow, menu -from devbase.tui.dispatch import dispatch_group +from devbase.tui import actions_env_keys, actions_env_openbao, env_delegate, flow, menu logger = get_logger(__name__) @@ -49,17 +42,6 @@ _ARG_CANCEL = flow.ARG_CANCEL -def _dispatch(devbase_root: Path, subcommand: str, **attrs): - """``cmd_env`` への委譲 (dispatch_group の薄いラッパ)。 - - import を関数内で行うのは actions_project (dispatch_lifecycle) と同様、 - テストで ``devbase.commands.env.cmd_env`` を monkeypatch できるようにするため。 - """ - from devbase.commands import env as env_mod - - return dispatch_group(env_mod.cmd_env, devbase_root, subcommand, **attrs) - - def _select_action(): """env 操作を選ぶサブメニュー。 @@ -70,92 +52,25 @@ def _select_action(): list(_ENV_OPS), back=True, search=False) -def _run_in_project(devbase_root: Path, project_name: str, fn): - """``projects/`` へ chdir + ``PWD`` を切り替えて fn を実行し、必ず復帰する。 - - ``cmd_env_set --project`` / ``cmd_env_project`` は - ``os.environ.get('PWD', os.getcwd())`` で現在地を判定する (wrapper の cd を - 前提とした PLAN06 機構) ため、``os.chdir`` だけでは不十分で ``PWD`` も - プロジェクトパスへ差し替える。``PWD`` は symlink を解決しない - ``projects/`` を指す (projects/ 配下判定を成立させるため)。 - - 戻り値: fn の rc / ``_ARG_CANCEL`` (対象ディレクトリへ移動できない場合)。 - """ - target = Path(devbase_root) / "projects" / project_name - old_cwd = Path.cwd() - old_pwd = os.environ.get("PWD") - try: - os.chdir(target) - except OSError as exc: - logger.error("プロジェクトディレクトリへ移動できません: %s (%s)", target, exc) - return _ARG_CANCEL - os.environ["PWD"] = str(target) - try: - return fn() - finally: - # 実行結果に関わらず必ず元の CWD / PWD へ復帰する (plan 3.3)。 - os.chdir(old_cwd) - if old_pwd is None: - os.environ.pop("PWD", None) - else: - os.environ["PWD"] = old_pwd - - # --------------------------------------------------------------------------- # 各操作の引数収集 + dispatch (plan 2.3 契約) # --------------------------------------------------------------------------- -def _group_attrs(devbase_root: Path) -> dict: - """グループ別の置き場なら対象のグループを選ばせ、``{"group": 名前}`` を返す。 - - それ以外の設定では ``{}`` (グループの選択を出さずに今どおり実行する)。設定が読めない - ときも ``{}`` で委譲し、委譲先のコマンドが誤りを出す。使えない名前を入れたら、 - ``--group`` と同じ検証の文を出して選択へ戻る。名前の規則に加えて、``--group`` と同じく - 置き場のグループ名の検査 (``storage_group``: 予約語 ``global`` / ``projects`` と - ``group_aliases`` の読み替え先) も通し、通らない名前は委譲先へ渡さない。 - """ - from devbase.env.secret_store import SecretStore - from devbase.errors import DevbaseError - from devbase.volume.manager import validate_account_group - - keys_screen = _screen("actions_env_keys") - try: - grouped = keys_screen._grouped(devbase_root) - settings = SecretStore(devbase_root).config.openbao if grouped else None - except DevbaseError: - return {} - if not grouped: - return {} - while True: - name = keys_screen._select_group(devbase_root) - try: - name = validate_account_group(name) - settings.storage_group(name) - return {"group": name} - except DevbaseError as e: - logger.error("--group に使えない名前です: %s", e) - - _OP_HANDLERS = { # sync は引数なしで即実行 (ソースファイルから認証情報を再同期する)。 # init は --reset なし (CLI 既定) で即実行。セットアップ済みなら # cmd_env_init が案内を出して安全に終了し、やり直しは CLI --reset を使う。 - # どちらもグループ別の置き場では先にグループを選ぶ (_group_attrs)。 - "sync": lambda root: _dispatch(root, "sync", **_group_attrs(root)), - "init": lambda root: _dispatch(root, "init", reset=False, **_group_attrs(root)), - # 2 つの画面は自分の中で引数を集め、cmd_env へ委譲する (#273)。関数内で import するのは - # 画面のモジュールが本モジュールの _dispatch / _run_in_project を使うため (循環を避ける)。 - "keys": lambda root: _screen("actions_env_keys").run(root), - "openbao": lambda root: _screen("actions_env_openbao").run(root), + # どちらもグループ別の置き場では先にグループを選ぶ (actions_env_keys.group_attrs)。 + "sync": lambda root: env_delegate.dispatch(root, "sync", + **actions_env_keys.group_attrs(root)), + "init": lambda root: env_delegate.dispatch(root, "init", reset=False, + **actions_env_keys.group_attrs(root)), + # 2 つの画面は自分の中で引数を集め、cmd_env へ委譲する (#273) + "keys": lambda root: actions_env_keys.run(root), + "openbao": lambda root: actions_env_openbao.run(root), } -def _screen(name: str): - import importlib - - return importlib.import_module(f"devbase.tui.{name}") - - @flow.collect_args def _run_operation(devbase_root: Path, op: str): """選択された env 操作の引数を収集して ``cmd_env`` へ委譲する。 diff --git a/lib/devbase/tui/actions_env_keys.py b/lib/devbase/tui/actions_env_keys.py index b21622ee..4f742cea 100644 --- a/lib/devbase/tui/actions_env_keys.py +++ b/lib/devbase/tui/actions_env_keys.py @@ -20,7 +20,8 @@ from devbase.env import keys from devbase.errors import DevbaseError from devbase.log import get_logger -from devbase.tui import flow, menu +from devbase.env.secret_store import KIND_GLOBAL +from devbase.tui import env_delegate, flow, menu from devbase.utils.text_width import pad logger = get_logger(__name__) @@ -118,19 +119,17 @@ def delegate_attrs(ref) -> dict: プロジェクトは ``project=True``・``group=None`` で、実行時のディレクトリを ``projects/`` にして呼ぶ (``-p`` はそのプロジェクトのグループの置き場だけを読み書きするため)。 """ - if ref.kind == "global": + if ref.kind == KIND_GLOBAL: return {"project": False, "user": ref.is_user, "group": ref.group} return {"project": True, "user": ref.is_user, "group": None} def _dispatch(devbase_root: Path, subcommand: str, ref, **attrs): - from devbase.tui import actions_env - attrs.update(delegate_attrs(ref)) - call = lambda: actions_env._dispatch(devbase_root, subcommand, **attrs) # noqa: E731 - if ref.kind == "global": + call = lambda: env_delegate.dispatch(devbase_root, subcommand, **attrs) # noqa: E731 + if ref.kind == KIND_GLOBAL: return call() - rc = actions_env._run_in_project(devbase_root, ref.name, call) + rc = env_delegate.run_in_project(devbase_root, ref.name, call) return 1 if rc is flow.ARG_CANCEL else rc @@ -161,10 +160,9 @@ def _select_scope(devbase_root: Path): def _grouped(devbase_root: Path) -> bool: - from devbase.commands import env_rows from devbase.env.secret_store import SecretStore - return env_rows.is_grouped(SecretStore(devbase_root)) + return SecretStore(devbase_root).grouped def _select_group(devbase_root: Path) -> str: @@ -181,6 +179,36 @@ def _select_group(devbase_root: Path) -> str: return picked +def group_attrs(devbase_root: Path) -> dict: + """グループ別の置き場なら対象のグループを選ばせ、``{"group": 名前}`` を返す。 + + env メニューの sync / init と、OpenBao の接続設定の確認が使う。 + それ以外の設定では ``{}`` (グループの選択を出さずに今どおり実行する)。設定が読めない + ときも ``{}`` で委譲し、委譲先のコマンドが誤りを出す。使えない名前を入れたら、 + ``--group`` と同じ検証の文を出して選択へ戻る。名前の規則に加えて、``--group`` と同じく + 置き場のグループ名の検査 (``storage_group``: 予約語 ``global`` / ``projects`` と + ``group_aliases`` の読み替え先) も通し、通らない名前は委譲先へ渡さない。 + """ + from devbase.env.secret_store import SecretStore + from devbase.volume.manager import validate_account_group + + try: + grouped = _grouped(devbase_root) + settings = SecretStore(devbase_root).config.openbao if grouped else None + except DevbaseError: + return {} + if not grouped: + return {} + while True: + name = _select_group(devbase_root) + try: + name = validate_account_group(name) + settings.storage_group(name) + return {"group": name} + except DevbaseError as e: + logger.error("--group に使えない名前です: %s", e) + + def project_title(name: str, count, width: int) -> str: """対象プロジェクトの選択の 1 行 (名前・キーの数。読めなければ ``?``)""" return f"{name.ljust(width)} キー {'?' if count is None else count} 件" diff --git a/lib/devbase/tui/actions_env_openbao.py b/lib/devbase/tui/actions_env_openbao.py index 28db2dda..2e309668 100644 --- a/lib/devbase/tui/actions_env_openbao.py +++ b/lib/devbase/tui/actions_env_openbao.py @@ -17,7 +17,7 @@ from devbase.errors import DevbaseError from devbase.log import get_logger -from devbase.tui import flow, menu +from devbase.tui import actions_env_keys, env_delegate, flow, menu logger = get_logger(__name__) @@ -28,9 +28,7 @@ def _dispatch_backend(devbase_root: Path, action: str, **attrs) -> int: - from devbase.tui import actions_env - - return actions_env._dispatch(devbase_root, "backend", backend_action=action, **attrs) + return env_delegate.dispatch(devbase_root, "backend", backend_action=action, **attrs) def _backend_name(store) -> str: @@ -75,15 +73,14 @@ def _check_group_attrs(devbase_root: Path) -> dict: """確認 (``env backend test``) に渡すグループ。選ばせるのはプロジェクトの外のときだけ。 プロジェクトの中では ``{}`` を返し、``env backend test`` がグループの宣言から決める。 - 外では sync / init と同じ部品 (``actions_env._group_attrs``) で選ばせる。グループ別の + 外では sync / init と同じ部品 (``actions_env_keys.group_attrs``) で選ばせる。グループ別の 置き場でなければ、そこで ``{}`` が返る。戻る (Esc・←) と ``flow.BackOut`` が上がる。 """ from devbase.commands.env import _current_project_name - from devbase.tui import actions_env if _current_project_name(devbase_root) is not None: return {} - return actions_env._group_attrs(devbase_root) + return actions_env_keys.group_attrs(devbase_root) @flow.collect_args diff --git a/lib/devbase/tui/app.py b/lib/devbase/tui/app.py index d84e926e..4ce2b5f1 100644 --- a/lib/devbase/tui/app.py +++ b/lib/devbase/tui/app.py @@ -37,7 +37,7 @@ ) from devbase.log import get_logger from devbase.tui import (actions_env, actions_plugin, actions_project, - actions_snapshot, actions_status, menu) + actions_snapshot, actions_status, flow, menu) logger = get_logger(__name__) @@ -79,23 +79,11 @@ def _clear_screen() -> None: def _pause_for_review() -> bool: - """操作出力を読めるよう、一覧の再表示前に Enter を待つ。 - - 操作実行直後にトップ一覧を再描画すると、plugin list 等の表示系操作の出力が - 一瞬で流れて読めない。questionary 系プロンプトは画面を書き換えるため、 - stdlib の ``input()`` で素朴に待ち、出力をそのまま画面に残す。 + """操作出力を読めるよう、一覧の再表示前に Enter を待つ (``flow.pause_for_review``)。 戻り値: ``True`` = 一覧へ戻る / ``False`` = Ctrl-C (全体中止)。 - 非 TTY 等で stdin を読めない場合 (EOFError/OSError) は待たずに戻る。 """ - try: - input("Enter キーで一覧へ戻ります...") - except KeyboardInterrupt: - print() - return False - except (EOFError, OSError): - pass - return True + return flow.pause_for_review("Enter キーで一覧へ戻ります...") # プロジェクト 0 件時に一覧へ置くプレースホルダの value 番兵。questionary の diff --git a/lib/devbase/tui/env_delegate.py b/lib/devbase/tui/env_delegate.py new file mode 100644 index 00000000..300b4493 --- /dev/null +++ b/lib/devbase/tui/env_delegate.py @@ -0,0 +1,63 @@ +"""env の TUI の画面が ``cmd_env`` へ委譲する部品 (``actions_env`` と各画面が使う)。 + +env メニュー (``actions_env``) と画面 (``actions_env_keys`` / ``actions_env_openbao``) は、 +どちらもこのモジュールを通して ``cmd_env`` を呼ぶ。画面からメニューのモジュールを +読まないため、モジュールの間に循環が無い。 + +プロジェクトの置き場への書き込みは、CWD (環境変数 ``PWD``) のプロジェクトディレクトリで +動く ``set -p`` / ``delete -p`` へ委譲するため、chdir + ``PWD`` 差し替えしてからハンドラを +呼び、実行後は必ず元へ復帰する (:func:`run_in_project`)。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from devbase.log import get_logger +from devbase.tui import flow +from devbase.tui.dispatch import dispatch_group + +logger = get_logger(__name__) + + +def dispatch(devbase_root: Path, subcommand: str, **attrs): + """``cmd_env`` への委譲 (dispatch_group の薄いラッパ)。 + + import を関数内で行うのは actions_project (dispatch_lifecycle) と同様、 + テストで ``devbase.commands.env.cmd_env`` を monkeypatch できるようにするため。 + """ + from devbase.commands import env as env_mod + + return dispatch_group(env_mod.cmd_env, devbase_root, subcommand, **attrs) + + +def run_in_project(devbase_root: Path, project_name: str, fn): + """``projects/`` へ chdir + ``PWD`` を切り替えて fn を実行し、必ず復帰する。 + + ``cmd_env_set --project`` / ``cmd_env_project`` は + ``os.environ.get('PWD', os.getcwd())`` で現在地を判定する (wrapper の cd を + 前提とした PLAN06 機構) ため、``os.chdir`` だけでは不十分で ``PWD`` も + プロジェクトパスへ差し替える。``PWD`` は symlink を解決しない + ``projects/`` を指す (projects/ 配下判定を成立させるため)。 + + 戻り値: fn の rc / ``flow.ARG_CANCEL`` (対象ディレクトリへ移動できない場合)。 + """ + target = Path(devbase_root) / "projects" / project_name + old_cwd = Path.cwd() + old_pwd = os.environ.get("PWD") + try: + os.chdir(target) + except OSError as exc: + logger.error("プロジェクトディレクトリへ移動できません: %s (%s)", target, exc) + return flow.ARG_CANCEL + os.environ["PWD"] = str(target) + try: + return fn() + finally: + # 実行結果に関わらず必ず元の CWD / PWD へ復帰する (plan 3.3)。 + os.chdir(old_cwd) + if old_pwd is None: + os.environ.pop("PWD", None) + else: + os.environ["PWD"] = old_pwd diff --git a/lib/devbase/tui/flow.py b/lib/devbase/tui/flow.py index 809c1558..96e4ca76 100644 --- a/lib/devbase/tui/flow.py +++ b/lib/devbase/tui/flow.py @@ -133,18 +133,22 @@ def optional_int(message: str, *, min_value: int = 0): return value -def pause_for_review() -> bool: +PROMPT_BACK_TO_MENU = "Enter キーで操作メニューへ戻ります..." + + +def pause_for_review(prompt: str = PROMPT_BACK_TO_MENU) -> bool: """操作出力を読めるよう、メニュー再表示の前に Enter を待つ。 操作実行直後にメニューを再描画すると、list 等の表示系操作の出力が一瞬で 流れて読めない。questionary 系プロンプトは画面を書き換えるため、stdlib の - ``input()`` で素朴に待ち、出力をそのまま画面に残す。 + ``input()`` で素朴に待ち、出力をそのまま画面に残す。``prompt`` は戻り先を示す文言 + (既定はサブメニュー。トップ一覧へ戻る ``app`` は自分の文言を渡す)。 戻り値: ``True`` = 続行 (メニュー再表示) / ``False`` = Ctrl-C (全体中止)。 非 TTY 等で stdin を読めない場合 (EOFError/OSError) は待たずに続行する。 """ try: - input("Enter キーで操作メニューへ戻ります...") + input(prompt) except KeyboardInterrupt: print() return False diff --git a/tests/cli/tui/test_actions_env.py b/tests/cli/tui/test_actions_env.py index 33de2ae1..a390dc54 100644 --- a/tests/cli/tui/test_actions_env.py +++ b/tests/cli/tui/test_actions_env.py @@ -13,7 +13,7 @@ import pytest -from devbase.tui import actions_env, flow, menu +from devbase.tui import actions_env, env_delegate, flow, menu @pytest.fixture(autouse=True) @@ -180,7 +180,7 @@ def test_run_operation_init_runs_without_confirm(monkeypatch, tmp_path): # --------------------------------------------------------------------------- -# _run_in_project (プロジェクトの置き場への委譲の chdir + 復帰) +# env_delegate.run_in_project (プロジェクトの置き場への委譲の chdir + 復帰) # --------------------------------------------------------------------------- def test_run_in_project_restores_cwd_on_exception(monkeypatch, tmp_path): @@ -194,7 +194,7 @@ def _boom(): before = os.getcwd() with pytest.raises(RuntimeError): - actions_env._run_in_project(tmp_path, "myapp", _boom) + env_delegate.run_in_project(tmp_path, "myapp", _boom) assert os.getcwd() == before assert os.environ["PWD"] == "/original/pwd" @@ -211,7 +211,7 @@ def _probe(): seen["pwd"] = os.environ.get("PWD") return 0 - assert actions_env._run_in_project(tmp_path, "myapp", _probe) == 0 + assert env_delegate.run_in_project(tmp_path, "myapp", _probe) == 0 assert seen["pwd"] == str(target) assert "PWD" not in os.environ @@ -219,8 +219,8 @@ def _probe(): def test_run_in_project_missing_dir_cancels(monkeypatch, tmp_path): """対象ディレクトリへ移動できない場合は実行せず _ARG_CANCEL (メニューへ戻る)。""" called = [] - result = actions_env._run_in_project(tmp_path, "ghost", - lambda: called.append(1) or 0) + result = env_delegate.run_in_project(tmp_path, "ghost", + lambda: called.append(1) or 0) assert result is actions_env._ARG_CANCEL assert called == [] diff --git a/tests/cli/tui/test_actions_env_keys.py b/tests/cli/tui/test_actions_env_keys.py index 24b85f96..f3820f60 100644 --- a/tests/cli/tui/test_actions_env_keys.py +++ b/tests/cli/tui/test_actions_env_keys.py @@ -6,7 +6,6 @@ from __future__ import annotations -import inspect import io import logging import os @@ -313,13 +312,25 @@ def racing_save(self, ref, data): def test_the_tui_never_writes_the_store_itself(grouped, openbao, monkeypatch): - real = SecretStore.save + """置き場への書き込みは、委譲した cmd_env の実行中にだけ起きる (TUI は自分で書かない)""" + real_save = SecretStore.save + real_cmd_env = env_cmd.cmd_env + delegated = [] # 実行中の cmd_env の入れ子 + saves = [] + + def cmd_env(devbase_root, args): + delegated.append(args.subcommand) + try: + return real_cmd_env(devbase_root, args) + finally: + delegated.pop() def guarded(self, ref, data): - caller = inspect.stack()[1].frame.f_globals.get('__name__', '') - assert not caller.startswith('devbase.tui'), caller - return real(self, ref, data) + assert delegated, 'cmd_env の外で置き場へ書いた' + saves.append(delegated[-1]) + return real_save(self, ref, data) + monkeypatch.setattr(env_cmd, 'cmd_env', cmd_env) monkeypatch.setattr(SecretStore, 'save', guarded) Script(monkeypatch, select=['global', 'team-a', keys_ui.ADD, 'team', menu.MENU_BACK], text=['K'], secret=['v']) @@ -327,6 +338,7 @@ def guarded(self, ref, data): run(grouped) assert openbao.get(TEAM) == {'K': 'v'} + assert saves == ['set'] # ---------------------------------------------------------------------------