From 6fbce36499b5f2415ede7d8bfee2c83d234f2656 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 13 Jun 2026 08:35:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(env):=20devbase=20env=20init=20=E3=81=A7?= =?UTF-8?q?=20HOST=5FSSH=5FUSER=20/=20HOST=5FSSH=5FHOST=20=E3=82=92?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コンテナ→ホスト SSH (ホスト側 GUI アプリ起動) ワークフロー向けに、ホストの ログインユーザー名 / SSH 先ホスト名を収集する host コレクタを追加。 - keys.py: HOST_SSH_USER / HOST_SSH_HOST 定数追加 - collectors/host.py: getpass.getuser() を既定値に提示、safe_input で上書き可 (非対話/CI は既定値設定、getuser 空時は安全スキップ) - cmd_env_sync: _sync_host() で欠落キーのみ既定値補完 (手動上書きは尊重) - docs: environment-variables.md に host 節追記 - tests: コレクタ + sync backfill の単体テスト 9 ケース Closes #48 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/user/environment-variables.md | 11 ++ issues/PLAN48_host-ssh-user-env.md | 195 +++++++++++++++++++++++++++++ lib/devbase/commands/env.py | 26 ++++ lib/devbase/env/collectors/host.py | 56 +++++++++ lib/devbase/env/keys.py | 4 + tests/env/test_collector_host.py | 129 +++++++++++++++++++ 6 files changed, 421 insertions(+) create mode 100644 issues/PLAN48_host-ssh-user-env.md create mode 100644 lib/devbase/env/collectors/host.py create mode 100644 tests/env/test_collector_host.py diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 431d570..1bebd49 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -127,6 +127,17 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 | `SLACK_CHANNEL_ID` | チャンネル ID | | `SLACK_USER_MENTION` | ユーザーメンション | +#### host -- ホスト接続情報 (SSH) + +コンテナからホストへ SSH してホスト側 GUI アプリ(例: Chrome をリモートデバッグモードで起動)を起動するワークフロー向けの設定です。`devbase env init` はホスト上で実行されるため、ホストのログインユーザー名を自動取得して既定値として提示します。 + +| キー | 説明 | +|------|------| +| `HOST_SSH_USER` | コンテナ→ホスト SSH 時のホストログインユーザー名(既定: `getpass.getuser()` で自動取得) | +| `HOST_SSH_HOST` | SSH 先ホスト名(既定: `host.docker.internal`、WSL2/Windows では上書き可) | + +ユーザー名のみで秘密情報ではありません。SSH 鍵やリモートログインの有効化はホスト側でユーザーが別途設定する前提です。`devbase env sync` 実行時には、未設定のキーのみ既定値で補完されます(既存値は上書きしません)。 + ## ソースファイル変更検出 devbase はソースファイル(`~/.aws/config` 等)のハッシュを `.env.sources.yml` で管理しています。 diff --git a/issues/PLAN48_host-ssh-user-env.md b/issues/PLAN48_host-ssh-user-env.md new file mode 100644 index 0000000..2d0b277 --- /dev/null +++ b/issues/PLAN48_host-ssh-user-env.md @@ -0,0 +1,195 @@ +# PLAN48: `devbase env init` で `HOST_SSH_USER` を自動設定する + +## 関連リンク + +- 元 issue: [#48](https://github.com/devbasex/devbase/issues/48) `feat: devbase env init で HOST_SSH_USER を自動設定する` +- 利用側 (参照のみ・本リポジトリ対象外): ai-plugins `plugins/ndf/skills/playwright-browser-connect/` + (`scripts/start-host-chrome.sh` がコンテナ→ホスト SSH のために `HOST_SSH_USER` を要求する) + +## 概要 + +`devbase env init` の対話セットアップに **ホスト接続情報 (SSH)** コレクタを追加し、 +ホスト (mac/Linux/WSL) のログインユーザー名を `HOST_SSH_USER` として `.env` に自動設定する。 +併せて SSH 先ホスト名 `HOST_SSH_HOST`(既定 `host.docker.internal`)も収集する。 + +これにより、コンテナからホストへ SSH してホスト側 GUI アプリ(例: Chrome をリモート +デバッグモードで起動)を起動するワークフローを、`HOST_SSH_USER=<名>` の手動指定なしで +利用できるようにする。 + +`devbase env init` は **ホスト上で実行される CLI** であり、ホストのユーザー名を確実に +取得できる立場にある(`getpass.getuser()`)。 + +## 設計判断(確定事項) + +| 論点 | 決定 | 理由 | +|---|---|---| +| 収集キー | `HOST_SSH_USER` + `HOST_SSH_HOST` 両方 | WSL2/Windows ではホストユーザーと SSH 先が一致しないケースがあり、`HOST_SSH_HOST` の上書き余地を残す(issue 補足) | +| `devbase env sync` 対応 | あり(欠落キーの補完) | 既存ユーザーの `.env` への後付け backfill。ただし手動上書きは尊重して上書きしない | +| 進め方 | 単一 PR | 変更 ~4 ファイル・~100 行・依存タスクなし。release ブランチ不要 | + +## アーキテクチャ整合 + +既存のコレクタ機構に倣う: + +- `lib/devbase/env/collector.py` の `CollectorRegistry` は `env/collectors/*.py` を走査し、 + モジュール直下の **`COLLECTOR` 定数**(大文字)を登録する。issue 例の `collector =` は + 実装規約と異なるため `COLLECTOR =` で定義する。 +- 既存 `git.py` / `slack.py` と同じ `Collector(name, display_name, collect_fn)` インターフェース。 +- 対話入力は `devbase.env.store.safe_input`(EOF 安全。非対話/CI では default を返す)を使う。 + +```mermaid +flowchart LR + init["devbase env init"] --> reg["CollectorRegistry.discover()"] + reg --> host["collectors/host.py
COLLECTOR"] + host --> collect["collect_host_info(env_file)"] + collect --> envfile[".env
HOST_SSH_USER / HOST_SSH_HOST"] + sync["devbase env sync"] --> backfill["_sync_host()
欠落キーのみ補完"] + backfill --> envfile +``` + +## 変更ファイル + +| ファイル | 種別 | 内容 | +|---|---|---| +| `lib/devbase/env/keys.py` | 変更 | `HOST_SSH_USER` / `HOST_SSH_HOST` 定数を追加 | +| `lib/devbase/env/collectors/host.py` | 新規 | `host` コレクタ(`collect_host_info` + `COLLECTOR`) | +| `lib/devbase/commands/env.py` | 変更 | `cmd_env_sync` に `_sync_host()` を追加 | +| `docs/user/environment-variables.md` | 変更 | `#### host` 節とコレクタ一覧へ追記 | +| `tests/env/test_collector_host.py` | 新規 | コレクタ + sync の単体テスト | + +## 実装詳細 + +### 1. `keys.py` + +```python +# --- Host (コンテナ→ホスト SSH 接続) --- +HOST_SSH_USER = "HOST_SSH_USER" +HOST_SSH_HOST = "HOST_SSH_HOST" # 任意。default: host.docker.internal +``` + +### 2. `collectors/host.py`(新規) + +```python +"""ホスト接続情報 (SSH) コレクター""" + +import getpass + +from devbase.log import get_logger +from devbase.env import keys +from devbase.env.store import EnvFile, safe_input +from devbase.env.collector import Collector + +logger = get_logger(__name__) + +DEFAULT_HOST_SSH_HOST = "host.docker.internal" + + +def _default_host_user() -> str: + """ホストのログインユーザー名。HOME/USER/LOGNAME 欠落時も例外を出さず "" を返す。""" + try: + return getpass.getuser() + except Exception: + return "" + + +def collect_host_info(env_file: EnvFile) -> None: + """ホスト接続情報 (SSH) を対話的に収集する""" + print("\n=== ホスト接続情報 (SSH) ===") + + default_user = env_file.get(keys.HOST_SSH_USER) or _default_host_user() + user = safe_input(f"{keys.HOST_SSH_USER} [{default_user}]: ", default_user) + if user: + env_file.set(keys.HOST_SSH_USER, user) + else: + logger.info("%s: 既定値が取得できずスキップ", keys.HOST_SSH_USER) + + default_host = env_file.get(keys.HOST_SSH_HOST) or DEFAULT_HOST_SSH_HOST + host = safe_input(f"{keys.HOST_SSH_HOST} [{default_host}]: ", default_host) + if host: + env_file.set(keys.HOST_SSH_HOST, host) + + +COLLECTOR = Collector( + name="host", + display_name="ホスト接続情報 (SSH)", + collect_fn=collect_host_info, +) +``` + +非対話 (EOF) 時: `safe_input` が default を返すため `HOST_SSH_USER=getpass.getuser()`・ +`HOST_SSH_HOST=host.docker.internal` が設定される(受け入れ条件「非対話/CI でも既定値で設定」)。 +`getpass.getuser()` が空を返す環境では `HOST_SSH_USER` は安全にスキップされる。 + +### 3. `cmd_env_sync` への `_sync_host()` 追加 + +ホスト情報はソースファイルを持たないため hash 比較は使わず、**欠落キーのみ既定値で補完**する +(既存値=WSL2 等での手動上書きは尊重して上書きしない)。既存ユーザーの `.env` への後付け +backfill として機能する。 + +```python +def _sync_host(env_file): + """ホスト接続情報の同期。欠落キーを既定値で補完する。更新件数を返す。""" + from devbase.env.collectors.host import _default_host_user, DEFAULT_HOST_SSH_HOST + updated = 0 + if not env_file.get(keys.HOST_SSH_USER): + user = _default_host_user() + if user: + env_file.set(keys.HOST_SSH_USER, user) + logger.info("HOST_SSH_USER: %s を設定", user) + updated += 1 + if not env_file.get(keys.HOST_SSH_HOST): + env_file.set(keys.HOST_SSH_HOST, DEFAULT_HOST_SSH_HOST) + logger.info("HOST_SSH_HOST: %s を設定", DEFAULT_HOST_SSH_HOST) + updated += 1 + return updated +``` + +`cmd_env_sync` 内で `updated += _sync_host(env_file)` を呼ぶ。`updated > 0` なら既存ロジック +どおり `env_file.save()` まで到達する。 + +### 4. ドキュメント (`docs/user/environment-variables.md`) + +「コレクター一覧」に `host` を追加し、slack 節の後に以下を追記: + +```markdown +#### host -- ホスト接続情報 (SSH) + +| キー | 説明 | +|------|------| +| `HOST_SSH_USER` | コンテナ→ホスト SSH 時のホストログインユーザー名(既定: `getpass.getuser()`) | +| `HOST_SSH_HOST` | SSH 先ホスト名(既定: `host.docker.internal`、WSL2/Windows では上書き可) | + +ユーザー名のみで秘密情報ではない。SSH 鍵・リモートログイン有効化はホスト側で別途設定する前提。 +``` + +## テスト計画 (`tests/env/test_collector_host.py`) + +`input` / `getpass.getuser` を monkeypatch した単体テスト(本コレクタは questionary を +使わない素の `input`/`safe_input` 経路のため、real TTY は不要): + +1. **既定値設定**: `getuser`→"alice"、入力 EOF → `HOST_SSH_USER=alice` / `HOST_SSH_HOST=host.docker.internal` +2. **上書き**: 入力で "bob" / "192.168.1.10" → その値で設定される +3. **getuser 例外**: `getuser` が例外 → `HOST_SSH_USER` 未設定・`HOST_SSH_HOST` は既定で設定 +4. **既存値優先**: env に既存 `HOST_SSH_USER=carol` → default 提示が carol +5. **`_sync_host` backfill**: 欠落時に補完・既存値は上書きしない・更新件数が正しい +6. **レジストリ登録**: `CollectorRegistry.discover()` 後に name=="host" が含まれる + +既存テスト一式が回帰しないこと(`pytest tests/`)も確認する。 + +## 受け入れ条件(issue より) + +- [ ] `devbase env init` 実行時に `HOST_SSH_USER` の既定値(ホストのユーザー名)が提示され、確認・変更できる +- [ ] `.env` に `HOST_SSH_USER` が書き出され、コンテナ内の環境変数として参照できる +- [ ] 非対話/CI でも既定値(`getpass.getuser()`)で設定される、もしくは安全にスキップされる +- [ ] `HOST_SSH_HOST`(既定 `host.docker.internal`)を収集し、上書きできる +- [ ] `devbase env sync` で欠落キーを既定値で補完する(既存値は尊重) +- [ ] ドキュメント(`docs/user/environment-variables.md`)に `HOST_SSH_USER` / `HOST_SSH_HOST` を追記 + +## PR 計画 + +| PR | branch | base | 概要 | +|---|---|---|---| +| 単一 | `feature/PLAN48-host-ssh-user` | `main` | 上記 4 ファイル変更 + テスト。`/ndf:cross-review` でセルフレビュー後 merge | + +release ブランチは作成しない(単一 PR・低結合)。`/ndf:implementation-plan` 確認 → +実装 → `/ndf:pr` → `/ndf:cross-review` の通常フローで進める。 diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 2c46b9c..9d5c039 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -116,6 +116,9 @@ def _encode_git(): # GCP(プロファイル管理があるため個別処理) updated += _sync_gcp(sources, env_file) + # Host 接続情報(ソースファイルを持たないため hash 比較せず欠落キーを補完) + updated += _sync_host(env_file) + if updated > 0: env_file.save() _update_source_metadata(devbase_root, env_file) @@ -129,6 +132,29 @@ def _encode_git(): return 0 +def _sync_host(env_file): + """ホスト接続情報の同期。更新件数を返す。 + + ホスト情報はソースファイルを持たないため hash 比較は使わず、**欠落キーのみ既定値で + 補完**する。既存値 (WSL2 等での手動上書き) は尊重して上書きしない。これにより本機能 + 導入前の ``.env`` への後付け backfill として機能する。 + """ + from devbase.env.collectors.host import _default_host_user, DEFAULT_HOST_SSH_HOST + + updated = 0 + if not env_file.get(keys.HOST_SSH_USER): + user = _default_host_user() + if user: + env_file.set(keys.HOST_SSH_USER, user) + logger.info("%s: %s を設定", keys.HOST_SSH_USER, user) + updated += 1 + if not env_file.get(keys.HOST_SSH_HOST): + env_file.set(keys.HOST_SSH_HOST, DEFAULT_HOST_SSH_HOST) + logger.info("%s: %s を設定", keys.HOST_SSH_HOST, DEFAULT_HOST_SSH_HOST) + updated += 1 + return updated + + def _sync_source(sources, env_file, name, label, encode_fn): """AWS/Gitなどの単一ソース同期の共通処理。更新件数(0 or 1)を返す。""" source = sources.get_source(name) diff --git a/lib/devbase/env/collectors/host.py b/lib/devbase/env/collectors/host.py new file mode 100644 index 0000000..f320bfc --- /dev/null +++ b/lib/devbase/env/collectors/host.py @@ -0,0 +1,56 @@ +"""ホスト接続情報 (SSH) コレクター + +コンテナからホストへ SSH してホスト側 GUI アプリ (例: Chrome をリモートデバッグ +モードで起動) を起動するワークフロー向けに、ホストのログインユーザー名 / SSH 先 +ホスト名を収集する。``devbase env init`` はホスト上で実行されるため、ホストの +ユーザー名を ``getpass.getuser()`` で確実に取得できる。 +""" + +import getpass + +from devbase.log import get_logger +from devbase.env import keys +from devbase.env.store import EnvFile, safe_input +from devbase.env.collector import Collector + +logger = get_logger(__name__) + +DEFAULT_HOST_SSH_HOST = "host.docker.internal" + + +def _default_host_user() -> str: + """ホストのログインユーザー名を返す。 + + ``getpass.getuser()`` は HOME/USER/LOGNAME 等が全て無い環境で例外を投げうるため、 + その場合は空文字を返して呼び出し側で安全にスキップできるようにする。 + """ + try: + return getpass.getuser() + except Exception: + return "" + + +def collect_host_info(env_file: EnvFile) -> None: + """ホスト接続情報 (SSH) を対話的に収集する""" + print("\n=== ホスト接続情報 (SSH) ===") + + # HOST_SSH_USER: 既存値 > getpass.getuser() を既定として提示し、上書き可能にする + default_user = env_file.get(keys.HOST_SSH_USER) or _default_host_user() + user = safe_input(f"{keys.HOST_SSH_USER} [{default_user}]: ", default_user) + if user: + env_file.set(keys.HOST_SSH_USER, user) + else: + logger.info("%s: 既定値が取得できずスキップ", keys.HOST_SSH_USER) + + # HOST_SSH_HOST: 任意。既定 host.docker.internal (WSL2/Windows では上書き可) + default_host = env_file.get(keys.HOST_SSH_HOST) or DEFAULT_HOST_SSH_HOST + host = safe_input(f"{keys.HOST_SSH_HOST} [{default_host}]: ", default_host) + if host: + env_file.set(keys.HOST_SSH_HOST, host) + + +COLLECTOR = Collector( + name="host", + display_name="ホスト接続情報 (SSH)", + collect_fn=collect_host_info, +) diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index 03a6702..e85cb6e 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -46,3 +46,7 @@ def gcp_credentials_key(profile: str) -> str: # --- Slack --- SLACK_KEYS = ("SLACK_BOT_TOKEN", "SLACK_TEAM_ID", "SLACK_CHANNEL_ID", "SLACK_USER_MENTION") + +# --- Host (コンテナ→ホスト SSH 接続) --- +HOST_SSH_USER = "HOST_SSH_USER" +HOST_SSH_HOST = "HOST_SSH_HOST" # 任意。default: host.docker.internal diff --git a/tests/env/test_collector_host.py b/tests/env/test_collector_host.py new file mode 100644 index 0000000..2432e85 --- /dev/null +++ b/tests/env/test_collector_host.py @@ -0,0 +1,129 @@ +"""collectors/host.py: ホスト接続情報 (SSH) コレクタ + sync backfill""" + +from __future__ import annotations + +import builtins + +import pytest + +from devbase.env import keys +from devbase.env.store import EnvFile +from devbase.env.collector import CollectorRegistry +from devbase.env.collectors import host +from devbase.commands import env as env_cmd + + +@pytest.fixture +def env_file(tmp_path): + return EnvFile(tmp_path / ".env") + + +def _patch_input(monkeypatch, responses): + """input() を順番に responses で返すモックに差し替える。 + + responses が尽きたら EOFError を送出し、非対話 (EOF) 経路を再現する。 + """ + it = iter(responses) + + def fake_input(prompt=""): + try: + return next(it) + except StopIteration: + raise EOFError + + monkeypatch.setattr(builtins, "input", fake_input) + + +def test_defaults_set_on_eof(monkeypatch, env_file): + """getuser 既定値 + 入力 EOF → USER/HOST が既定値で設定される (非対話/CI)""" + monkeypatch.setattr(host.getpass, "getuser", lambda: "alice") + _patch_input(monkeypatch, []) # 全入力 EOF + + host.collect_host_info(env_file) + + assert env_file.get(keys.HOST_SSH_USER) == "alice" + assert env_file.get(keys.HOST_SSH_HOST) == host.DEFAULT_HOST_SSH_HOST + + +def test_user_can_override(monkeypatch, env_file): + """対話入力でユーザー名・ホスト名を上書きできる""" + monkeypatch.setattr(host.getpass, "getuser", lambda: "alice") + _patch_input(monkeypatch, ["bob", "192.168.1.10"]) + + host.collect_host_info(env_file) + + assert env_file.get(keys.HOST_SSH_USER) == "bob" + assert env_file.get(keys.HOST_SSH_HOST) == "192.168.1.10" + + +def test_getuser_failure_skips_user(monkeypatch, env_file): + """getuser が例外 → USER は未設定 (安全スキップ)・HOST は既定で設定""" + def _boom(): + raise OSError("no username") + + monkeypatch.setattr(host.getpass, "getuser", _boom) + _patch_input(monkeypatch, []) + + host.collect_host_info(env_file) + + assert env_file.get(keys.HOST_SSH_USER) is None + assert env_file.get(keys.HOST_SSH_HOST) == host.DEFAULT_HOST_SSH_HOST + + +def test_existing_value_used_as_default(monkeypatch, env_file): + """既存値があれば getuser ではなく既存値が既定として採用される""" + env_file.set(keys.HOST_SSH_USER, "carol") + monkeypatch.setattr(host.getpass, "getuser", lambda: "alice") + _patch_input(monkeypatch, []) # EOF → default (=carol) が確定 + + host.collect_host_info(env_file) + + assert env_file.get(keys.HOST_SSH_USER) == "carol" + + +def test_default_host_user_returns_empty_on_exception(monkeypatch): + monkeypatch.setattr(host.getpass, "getuser", lambda: (_ for _ in ()).throw(KeyError())) + assert host._default_host_user() == "" + + +def test_collector_registered(): + """CollectorRegistry が host コレクタを自動検出する""" + registry = CollectorRegistry() + registry.discover() + names = {c.name for c in registry.collectors} + assert "host" in names + + +def test_sync_host_backfills_missing(monkeypatch, env_file): + """_sync_host: 欠落キーを既定値で補完し更新件数を返す""" + monkeypatch.setattr(host.getpass, "getuser", lambda: "dave") + + updated = env_cmd._sync_host(env_file) + + assert updated == 2 + assert env_file.get(keys.HOST_SSH_USER) == "dave" + assert env_file.get(keys.HOST_SSH_HOST) == host.DEFAULT_HOST_SSH_HOST + + +def test_sync_host_respects_existing(monkeypatch, env_file): + """_sync_host: 既存値 (手動上書き) は尊重して上書きしない""" + env_file.set(keys.HOST_SSH_USER, "manual") + env_file.set(keys.HOST_SSH_HOST, "10.0.0.1") + monkeypatch.setattr(host.getpass, "getuser", lambda: "dave") + + updated = env_cmd._sync_host(env_file) + + assert updated == 0 + assert env_file.get(keys.HOST_SSH_USER) == "manual" + assert env_file.get(keys.HOST_SSH_HOST) == "10.0.0.1" + + +def test_sync_host_skips_user_when_getuser_empty(monkeypatch, env_file): + """_sync_host: getuser が空 → USER はスキップ・HOST のみ補完""" + monkeypatch.setattr(host.getpass, "getuser", lambda: (_ for _ in ()).throw(OSError())) + + updated = env_cmd._sync_host(env_file) + + assert updated == 1 + assert env_file.get(keys.HOST_SSH_USER) is None + assert env_file.get(keys.HOST_SSH_HOST) == host.DEFAULT_HOST_SSH_HOST