diff --git a/bin/devbase b/bin/devbase index 8b2db6e..aafa84c 100755 --- a/bin/devbase +++ b/bin/devbase @@ -246,7 +246,7 @@ run_python() { # Resolve abbreviated command to full command name via unique prefix matching resolve_command() { local input="$1" - local commands="init status project container ct env plugin pl snapshot ss up down login build rebuild ps scale list help" + local commands="init status project container ct env plugin pl snapshot ss orca up down login build rebuild ps scale list help" local matches=() for cmd in $commands; do [[ "$cmd" == "$input"* ]] && matches+=("$cmd") @@ -402,7 +402,7 @@ case "$_resolved_cmd" in # Python-implemented commands --version|-V) run_python "$@" ;; - init|status|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|rebuild|list) + init|status|project|container|ct|env|plugin|pl|snapshot|ss|orca|up|down|login|ps|scale|rebuild|list) run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands # diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 734c7ce..d4fc3fc 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -55,6 +55,7 @@ ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'], ('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'], + ('orca',): ['sync', 'prune', 'status'], } # 後方互換: prefix が複数候補にマッチする場合に、特定の入力を特定のサブコマンドに @@ -452,6 +453,20 @@ def _add_snapshot_parser(subparsers): s_rotate.add_argument('--keep', type=int, default=3, help='Generations to keep') +def _add_orca_parser(subparsers): + """Orca group parser (PLAN33)。 + + Orca 用の隔離 SSH config を生成/剪定/表示する。sync/prune/status いずれも + 追加の引数を取らない (稼働中コンテナから毎回全再生成する)。 + """ + orca_parser = subparsers.add_parser('orca', help='Manage the Orca SSH config') + orca_sub = orca_parser.add_subparsers(dest='subcommand') + + orca_sub.add_parser('sync', help='Regenerate the Orca SSH config from running containers') + orca_sub.add_parser('prune', help='Remove stopped-container entries (= regenerate)') + orca_sub.add_parser('status', help='Show the Orca SSH config path, contents, and import steps') + + def _add_shortcuts(subparsers): """Top-level shortcut parsers. @@ -535,6 +550,7 @@ def _create_parser(): _add_env_parser(subparsers) _add_plugin_parser(subparsers) _add_snapshot_parser(subparsers) + _add_orca_parser(subparsers) _add_shortcuts(subparsers) return parser @@ -569,7 +585,7 @@ def _expand_argv(): # bin/devbase が build を shell 実装に委譲するため Python 側には top-level # build parser が無い。project build / container build は引き続き利用可能。 commands = ['init', 'status', 'project', 'container', 'ct', 'env', 'plugin', 'pl', - 'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'rebuild', 'list', 'help'] + 'snapshot', 'ss', 'orca', 'up', 'down', 'login', 'ps', 'scale', 'rebuild', 'list', 'help'] repo_subcmds = ['add', 'remove', 'list', 'refresh'] if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'): @@ -618,6 +634,7 @@ def main(): 'env': ('devbase.commands.env', 'cmd_env', True), 'plugin': ('devbase.commands.plugin', 'cmd_plugin', True), 'snapshot': ('devbase.commands.snapshot', 'cmd_snapshot', True), + 'orca': ('devbase.commands.orca', 'cmd_orca', True), } diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 4d9394a..702928e 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -436,6 +436,40 @@ def _auto_snapshot() -> None: logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) +def _ssh_enabled() -> bool: + """ENABLE_SSH が真値 (true/1) かどうか (compose.py の判定と揃える)。""" + return os.environ.get('ENABLE_SSH', '').lower() in ('true', '1') + + +def _maybe_orca_sync() -> None: + """up 完了後に Orca 用 SSH config を best-effort で再生成する (PLAN33)。 + + ENABLE_SSH が有効なときのみ実行する (SSH 無効なら同期不要)。失敗しても + warning のみで up の戻り値には影響させない。import は遅延させて起動コストを避ける。 + """ + if not _ssh_enabled(): + return + try: + from devbase.commands.orca import regenerate_config + targets, path = regenerate_config() + logger.info("Orca SSH config を同期しました (%d 件): %s", len(targets), path) + except Exception as e: # noqa: BLE001 - Orca 同期で up を倒さない + logger.warning("Orca SSH config の同期に失敗しましたがデプロイは成功しています: %s", e) + + +def _maybe_orca_prune() -> None: + """down 後に Orca 用 SSH config を best-effort で剪定する (PLAN33)。 + + 稼働中コンテナから再生成するだけで停止済みエントリは自然に落ちる (prune ≡ + regenerate)。ENABLE_SSH の有無に依らず実行してよい。失敗しても warning のみ。 + """ + try: + from devbase.commands.orca import regenerate_config + regenerate_config() + except Exception as e: # noqa: BLE001 - Orca 剪定で down を倒さない + logger.warning("Orca SSH config の剪定に失敗しました: %s", e) + + def _resolve_open_index(open_index: Optional[int], scale: int) -> int: """開く dev インスタンス番号を解決する (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 @@ -574,6 +608,9 @@ def cmd_up(project_name: str = None, scale: int = None, _maybe_open_editor(project_name, open_editor, open_index, scale, compose_file=override_file) + # Orca 連携: SSH 有効時に隔離 SSH config を再生成する (PLAN33)。 + _maybe_orca_sync() + logger.info("=== Deploy completed successfully ===") return 0 @@ -603,6 +640,9 @@ def cmd_down() -> int: except Exception as e: logger.warning("スナップショットのローテーションに失敗: %s", e) + # Orca 連携: 停止したコンテナのエントリを隔離 SSH config から剪定する (PLAN33)。 + _maybe_orca_prune() + return 0 diff --git a/lib/devbase/commands/orca.py b/lib/devbase/commands/orca.py new file mode 100644 index 0000000..af2779f --- /dev/null +++ b/lib/devbase/commands/orca.py @@ -0,0 +1,262 @@ +"""devbase orca ... — Orca 用の隔離 SSH config を生成/剪定/表示する (PLAN33)。 + +Orca (https://www.onorca.dev/) から devbase コンテナへ SSH 接続するため、稼働中の +SSH publish 済みコンテナを列挙して専用ファイル ``~/.config/devbase/orca/ssh_config`` +を全生成する。ホストの ``~/.ssh/config`` は一切触らず、Orca にはこのファイルだけを +import させることで他ホストとの隔離を実現する。 + +サブコマンド: + - ``sync`` : 稼働中コンテナを集約して config を再生成 (毎回上書き)。 + - ``prune`` : 停止済みコンテナのエントリを除去する。稼働中コンテナから再生成 + するだけで停止済みは自然に落ちるため ``sync`` と同義。 + - ``status`` : 現在の config パス・内容・Orca への import 手順を表示する。 + +詳細: docs/user/orca.md +""" + +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, List, Optional, Sequence, Tuple + +from devbase.env import keys +from devbase.log import get_logger + +logger = get_logger(__name__) + +DEFAULT_HOSTNAME = "127.0.0.1" +DEFAULT_USER = "ubuntu" + +# 生成ファイル先頭に置く管理ブロックのヘッダ (docs/user/orca.md と一致させる)。 +_HEADER = ( + "# Managed by devbase — do not edit. " + "Import this file into Orca (Settings → SSH)." +) + + +@dataclass(frozen=True) +class SSHTarget: + """1 コンテナぶんの Orca SSH target。""" + project: str + index: int + port: int + + +def _config_path() -> Path: + """Orca 用隔離 SSH config の絶対パス (``~/.config/devbase/orca/ssh_config``)。""" + return Path.home() / ".config" / "devbase" / "orca" / "ssh_config" + + +# --------------------------------------------------------------------------- +# コンテナ列挙 (docker inspect ベース。名前の dash split はしない) +# --------------------------------------------------------------------------- + +def _parse_index(raw) -> int: + """compose の container-number ラベルを 1 始まり index に変換する。""" + try: + return int(raw) + except (TypeError, ValueError): + return 1 + + +def _pick_host_port(port_bindings: Sequence[dict], bind: Optional[str]) -> Optional[int]: + """``22/tcp`` の publish 一覧から採用するホストポートを 1 つ選ぶ。 + + ``bind`` (DEVBASE_SSH_BIND) に一致する HostIp のエントリを優先し、無ければ + 最初に見つかった HostPort を採用する。整数化できなければ None。 + """ + chosen = None + for entry in port_bindings or []: + host_port = entry.get("HostPort") + if not host_port: + continue + if bind and entry.get("HostIp") == bind: + chosen = host_port + break + if chosen is None: + chosen = host_port + if chosen is None: + return None + try: + return int(chosen) + except (TypeError, ValueError): + return None + + +def _parse_inspect(containers, bind: Optional[str] = None) -> List[SSHTarget]: + """``docker inspect`` の JSON (コンテナ配列) から SSH target を抽出する純関数。 + + compose project ラベルを持ち、かつ ``22/tcp`` を publish しているコンテナだけを + 対象にする。この 2 条件によるフィルタが隔離を担保する (devbase の SSH 有効 + コンテナだけが Orca config に現れる)。project ラベルが無い / ``22/tcp`` を + publish しないコンテナ (= Orca SSH target ではない) は除外する。 + + コンテナ名を dash で split して project/index を得る方式は取らない + (project 名自体が dash を含みうるため)。ラベルから直接読む。 + """ + targets: List[SSHTarget] = [] + for container in containers or []: + config = container.get("Config") or {} + labels = config.get("Labels") or {} + project = labels.get("com.docker.compose.project") + if not project: + continue + net = container.get("NetworkSettings") or {} + port_bindings = (net.get("Ports") or {}).get("22/tcp") + if not port_bindings: + continue + host_port = _pick_host_port(port_bindings, bind) + if host_port is None: + continue + index = _parse_index(labels.get("com.docker.compose.container-number")) + targets.append(SSHTarget(project=project, index=index, port=host_port)) + return targets + + +def _docker_json(args: Sequence[str]) -> Optional[str]: + """``docker `` を実行し stdout を返す。失敗時は warning を出して None。 + + docker が無い / 異常終了しても呼び出し側 (up/down フック) を倒さないため + 例外は握り、None を返す。 + """ + try: + result = subprocess.run( + ["docker", *args], capture_output=True, text=True, check=False + ) + except (OSError, subprocess.SubprocessError) as e: + logger.warning("docker %s に失敗しました (Orca 同期をスキップ): %s", args[0], e) + return None + if result.returncode != 0: + logger.warning( + "docker %s に失敗しました (Orca 同期をスキップ): %s", + args[0], (result.stderr or "").strip(), + ) + return None + return result.stdout + + +def _running_ssh_targets() -> List[SSHTarget]: + """稼働中の devbase SSH コンテナを docker から列挙する (best-effort)。 + + ``docker ps -q`` で稼働中コンテナ id を集め、``docker inspect`` の JSON を + :func:`_parse_inspect` に渡す。docker が無い / 失敗した場合は空リストを返す。 + """ + ps_out = _docker_json(["ps", "-q"]) + if ps_out is None: + return [] + ids = ps_out.split() + if not ids: + return [] + inspect_out = _docker_json(["inspect", *ids]) + if inspect_out is None: + return [] + try: + containers = json.loads(inspect_out) + except json.JSONDecodeError as e: + logger.warning("docker inspect の出力を解析できませんでした (Orca 同期をスキップ): %s", e) + return [] + bind = os.environ.get(keys.DEVBASE_SSH_BIND) or None + return _parse_inspect(containers, bind=bind) + + +# --------------------------------------------------------------------------- +# config レンダリング / 書き込み +# --------------------------------------------------------------------------- + +def _render_config(targets: Sequence[SSHTarget], hostname: str, user: str) -> str: + """SSH target 群から config テキストを生成する純関数。 + + エントリは (project, index) 昇順で安定ソートする。target が空でもヘッダのみの + 安全な空ファイルを返す。 + """ + lines = [_HEADER, ""] + for t in sorted(targets, key=lambda x: (x.project, x.index)): + lines.append(f"Host devbase-{t.project}-{t.index}") + lines.append(f" HostName {hostname}") + lines.append(f" Port {t.port}") + lines.append(f" User {user}") + lines.append(" IdentityFile ~/.ssh/id_ed25519") + lines.append(" StrictHostKeyChecking accept-new") + lines.append("") + return "\n".join(lines).rstrip("\n") + "\n" + + +def _write_config(targets: Sequence[SSHTarget]) -> Path: + """config を全再生成して書き込み、パスを返す。親ディレクトリは作成する。""" + path = _config_path() + path.parent.mkdir(parents=True, exist_ok=True) + hostname = os.environ.get(keys.DEVBASE_ORCA_HOSTNAME) or DEFAULT_HOSTNAME + user = os.environ.get("USERNAME") or DEFAULT_USER + path.write_text(_render_config(targets, hostname, user), encoding="utf-8") + return path + + +def regenerate_config( + targets_provider: Optional[Callable[[], List[SSHTarget]]] = None, +) -> Tuple[List[SSHTarget], Path]: + """稼働中コンテナを列挙して config を全再生成する。``(targets, path)`` を返す。 + + up/down フックからも呼べる共通エントリ。``targets_provider`` はテスト注入用。 + """ + provider = targets_provider or _running_ssh_targets + targets = list(provider()) + path = _write_config(targets) + return targets, path + + +# --------------------------------------------------------------------------- +# サブコマンド +# --------------------------------------------------------------------------- + +def _cmd_regenerate(targets_provider: Optional[Callable[[], List[SSHTarget]]]) -> int: + """sync / prune 共通の再生成処理。停止済みは列挙から外れるため両者は同義。""" + targets, path = regenerate_config(targets_provider) + if targets: + logger.info("Orca SSH config を生成しました (%d 件): %s", len(targets), path) + else: + logger.info("稼働中の SSH 対象コンテナがありません。ヘッダのみの config を書き出しました: %s", path) + logger.info("ENABLE_SSH=true で `devbase up` するとコンテナが対象になります。") + return 0 + + +def _cmd_status() -> int: + """現在の config パス・内容・import 手順を表示する。""" + path = _config_path() + print(f"Orca SSH config: {path}") + print("") + if path.exists(): + print("--- 現在の内容 ---") + print(path.read_text(encoding="utf-8"), end="") + else: + print("(まだ生成されていません。`devbase orca sync` を実行してください)") + print("") + print("Orca への登録: Orca の Settings → SSH でこのファイルを import してください。") + return 0 + + +def cmd_orca( + devbase_root: Path, args, + targets_provider: Optional[Callable[[], List[SSHTarget]]] = None, +) -> int: + """``devbase orca `` ディスパッチャ。 + + ``targets_provider`` はテスト用のコンテナ列挙注入口 (通常は None で + :func:`_running_ssh_targets` を使う)。 + """ + subcmd = getattr(args, "subcommand", None) + + handlers = { + "sync": lambda: _cmd_regenerate(targets_provider), + "prune": lambda: _cmd_regenerate(targets_provider), + "status": _cmd_status, + } + + handler = handlers.get(subcmd) + if not handler: + logger.error("サブコマンドを指定してください: %s", ", ".join(handlers)) + return 1 + return handler() diff --git a/lib/devbase/env/collectors/orca.py b/lib/devbase/env/collectors/orca.py new file mode 100644 index 0000000..2dcd240 --- /dev/null +++ b/lib/devbase/env/collectors/orca.py @@ -0,0 +1,74 @@ +"""Orca 連携 (SSH 公開鍵) コレクター (PLAN33) + +Orca からコンテナへ公開鍵認証で SSH 接続するため、laptop の公開鍵 +(``~/.ssh/id_ed25519.pub`` など) を ``SSH_AUTHORIZED_KEYS`` として収集する。 +この値は entrypoint がコンテナ内の ``~/.ssh/authorized_keys`` へ展開する。 + +併せて生成 config の ``HostName`` に使う ``DEVBASE_ORCA_HOSTNAME`` (Tailscale 名 / +Mac の LAN IP。Windows から直結する構成向け) を任意で収集する。 +詳細: docs/user/orca.md +""" + +from pathlib import Path + +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_ORCA_HOSTNAME = "127.0.0.1" + +# 公開鍵の探索順 (最初に存在したものを既定として提示する) +_PUBKEY_CANDIDATES = ("id_ed25519.pub", "id_rsa.pub") + + +def _default_public_key() -> str: + """laptop の公開鍵内容を返す。無ければ空文字。 + + ``~/.ssh/id_ed25519.pub`` → ``~/.ssh/id_rsa.pub`` の順に最初に存在した + ファイルの内容を返す (env export/import の既定鍵探索順と揃える)。 + """ + ssh_dir = Path.home() / ".ssh" + for name in _PUBKEY_CANDIDATES: + pub = ssh_dir / name + try: + if pub.is_file(): + return pub.read_text(encoding="utf-8").strip() + except OSError: + continue + return "" + + +def collect_orca_info(env_file: EnvFile) -> None: + """Orca 連携情報 (SSH 公開鍵 / HostName) を対話的に収集する""" + print("\n=== Orca 連携 (SSH 公開鍵) ===") + + # SSH_AUTHORIZED_KEYS: 既存値 > laptop の公開鍵 を既定として提示する。 + # 公開鍵が見つからず既存値も無い場合はスキップ (公開鍵認証は鍵設定まで不可)。 + default_keys = env_file.get(keys.SSH_AUTHORIZED_KEYS) or _default_public_key() + if default_keys: + value = safe_input(f"{keys.SSH_AUTHORIZED_KEYS} [{default_keys}]: ", default_keys) + if value: + env_file.set(keys.SSH_AUTHORIZED_KEYS, value) + else: + logger.info( + "%s: ~/.ssh/id_ed25519.pub / id_rsa.pub が見つからずスキップ " + "(設定するまで Orca の公開鍵認証は利用できません)", + keys.SSH_AUTHORIZED_KEYS, + ) + + # DEVBASE_ORCA_HOSTNAME: 任意。既定 127.0.0.1 (Tailscale 名 / LAN IP で上書き可 + # → Windows から直結する構成に対応)。 + default_host = env_file.get(keys.DEVBASE_ORCA_HOSTNAME) or DEFAULT_ORCA_HOSTNAME + host = safe_input(f"{keys.DEVBASE_ORCA_HOSTNAME} [{default_host}]: ", default_host) + if host: + env_file.set(keys.DEVBASE_ORCA_HOSTNAME, host) + + +COLLECTOR = Collector( + name="orca", + display_name="Orca 連携 (SSH 公開鍵)", + collect_fn=collect_orca_info, +) diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index 4439ae4..e99218e 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -58,6 +58,8 @@ def gcp_credentials_key(profile: str) -> str: ENABLE_SSH = "ENABLE_SSH" # 真偽。sshd を起動し :22 を publish するか DEVBASE_SSH_BIND = "DEVBASE_SSH_BIND" # 任意。publish の bind 先 (既定 127.0.0.1) DEVBASE_SSH_PORT_BASE = "DEVBASE_SSH_PORT_BASE" # 任意。ポート算出の起点 (既定 2200) +SSH_AUTHORIZED_KEYS = "SSH_AUTHORIZED_KEYS" # laptop 公開鍵。entrypoint が ~/.ssh/authorized_keys へ展開 (複数行可) +DEVBASE_ORCA_HOSTNAME = "DEVBASE_ORCA_HOSTNAME" # 任意。生成 config の HostName (既定 127.0.0.1。Tailscale 名 / LAN IP) # --- Editor (devbase up 後の自動オープン / PLAN31_3) --- # DEVBASE_OPEN_EDITOR は env init (collectors/editor.py) で対話設定する (既定 1)。 diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/commands/test_orca.py b/tests/commands/test_orca.py new file mode 100644 index 0000000..144e7f7 --- /dev/null +++ b/tests/commands/test_orca.py @@ -0,0 +1,231 @@ +"""commands/orca.py: Orca 用隔離 SSH config の生成/隔離/剪定/列挙 (PLAN33 / PR3) + +`_render_config` は純関数として、与えた devbase ホストだけを (project, index) 順で +出力する。`_parse_inspect` は docker inspect JSON から 22/tcp を publish しかつ +compose project ラベルを持つコンテナだけを SSH target として抽出する。 +本テストは実 docker を一切呼ばず、fake targets / サンプル JSON を注入して検証する。 +""" + +from __future__ import annotations + +import types + +import pytest + +from devbase.commands import orca +from devbase.commands.orca import SSHTarget + + +# --------------------------------------------------------------------------- +# fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def home_in_tmp(tmp_path, monkeypatch): + """HOME を tmp に移し、生成ファイルが実ホームを汚さないようにする。 + + HostName / User を左右する env も既定で消し、外部環境に依存させない。 + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("DEVBASE_ORCA_HOSTNAME", raising=False) + monkeypatch.delenv("DEVBASE_SSH_BIND", raising=False) + monkeypatch.delenv("USERNAME", raising=False) + return tmp_path + + +def _args(subcommand): + return types.SimpleNamespace(subcommand=subcommand) + + +# --------------------------------------------------------------------------- +# _render_config: ヘッダ + 指定ホストのみ + 並び順 +# --------------------------------------------------------------------------- + +def test_render_config_basic_fields(): + targets = [SSHTarget(project="carmo", index=1, port=2231)] + out = orca._render_config(targets, hostname="127.0.0.1", user="ubuntu") + + assert out.startswith("# Managed by devbase") + assert "Host devbase-carmo-1" in out + assert " HostName 127.0.0.1" in out + assert " Port 2231" in out + assert " User ubuntu" in out + assert " IdentityFile ~/.ssh/id_ed25519" in out + assert " StrictHostKeyChecking accept-new" in out + + +def test_render_config_sorts_by_project_then_index(): + targets = [ + SSHTarget(project="bravo", index=1, port=2300), + SSHTarget(project="alpha", index=2, port=2211), + SSHTarget(project="alpha", index=1, port=2210), + ] + out = orca._render_config(targets, hostname="127.0.0.1", user="ubuntu") + + order = [ + out.index("Host devbase-alpha-1"), + out.index("Host devbase-alpha-2"), + out.index("Host devbase-bravo-1"), + ] + assert order == sorted(order) + + +def test_render_config_isolation_only_devbase_hosts(): + """2 プロジェクトぶんの target を与えても devbase-* 以外の Host は現れない。""" + targets = [ + SSHTarget(project="carmo", index=1, port=2231), + SSHTarget(project="orca-web", index=1, port=2251), + ] + out = orca._render_config(targets, hostname="127.0.0.1", user="ubuntu") + + host_lines = [ln for ln in out.splitlines() if ln.startswith("Host ")] + assert host_lines == ["Host devbase-carmo-1", "Host devbase-orca-web-1"] + assert all(ln.startswith("Host devbase-") for ln in host_lines) + + +def test_render_config_empty_targets_is_header_only(): + out = orca._render_config([], hostname="127.0.0.1", user="ubuntu") + assert out.strip() == orca._HEADER + assert "Host " not in out + + +# --------------------------------------------------------------------------- +# HostName / User の env 上書き (書き込み経由で確認) +# --------------------------------------------------------------------------- + +def test_orca_hostname_env_overrides_hostname(home_in_tmp, monkeypatch): + monkeypatch.setenv("DEVBASE_ORCA_HOSTNAME", "mac.tailnet.ts.net") + targets = [SSHTarget(project="carmo", index=1, port=2231)] + + path = orca._write_config(targets) + content = path.read_text(encoding="utf-8") + assert " HostName mac.tailnet.ts.net" in content + + +def test_hostname_defaults_to_loopback_when_unset(home_in_tmp): + path = orca._write_config([SSHTarget(project="carmo", index=1, port=2231)]) + assert " HostName 127.0.0.1" in path.read_text(encoding="utf-8") + + +def test_username_env_overrides_user(home_in_tmp, monkeypatch): + monkeypatch.setenv("USERNAME", "devuser") + path = orca._write_config([SSHTarget(project="carmo", index=1, port=2231)]) + assert " User devuser" in path.read_text(encoding="utf-8") + + +def test_user_defaults_to_ubuntu_when_unset(home_in_tmp): + path = orca._write_config([SSHTarget(project="carmo", index=1, port=2231)]) + assert " User ubuntu" in path.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# regenerate / prune: 稼働 0 のときヘッダのみ / 停止済みは消える +# --------------------------------------------------------------------------- + +def test_regenerate_zero_targets_writes_header_only(home_in_tmp): + targets, path = orca.regenerate_config(targets_provider=lambda: []) + assert targets == [] + assert path == orca._config_path() + assert path.read_text(encoding="utf-8").strip() == orca._HEADER + + +def test_prune_drops_stale_entries(home_in_tmp): + """一度 2 件書いた後、稼働 1 件で再生成すると停止分が消える (全上書き)。""" + orca.regenerate_config(targets_provider=lambda: [ + SSHTarget(project="carmo", index=1, port=2231), + SSHTarget(project="carmo", index=2, port=2232), + ]) + + # prune ≡ 稼働中コンテナから再生成。carmo-2 が停止した想定。 + rc = orca.cmd_orca(home_in_tmp, _args("prune"), + targets_provider=lambda: [ + SSHTarget(project="carmo", index=1, port=2231)]) + assert rc == 0 + + content = orca._config_path().read_text(encoding="utf-8") + assert "Host devbase-carmo-1" in content + assert "Host devbase-carmo-2" not in content + + +def test_cmd_orca_sync_uses_injected_targets(home_in_tmp): + rc = orca.cmd_orca(home_in_tmp, _args("sync"), + targets_provider=lambda: [ + SSHTarget(project="carmo", index=1, port=2231)]) + assert rc == 0 + assert "Host devbase-carmo-1" in orca._config_path().read_text(encoding="utf-8") + + +def test_cmd_orca_status_reports_path(home_in_tmp, capsys): + orca.regenerate_config(targets_provider=lambda: [ + SSHTarget(project="carmo", index=1, port=2231)]) + + rc = orca.cmd_orca(home_in_tmp, _args("status")) + assert rc == 0 + out = capsys.readouterr().out + assert str(orca._config_path()) in out + assert "Host devbase-carmo-1" in out + + +def test_cmd_orca_unknown_subcommand_returns_1(home_in_tmp): + assert orca.cmd_orca(home_in_tmp, _args(None)) == 1 + + +# --------------------------------------------------------------------------- +# _parse_inspect: 22/tcp publish + compose ラベルで隔離 +# --------------------------------------------------------------------------- + +def _container(project=None, number="1", ssh_port="2231", extra_ports=None): + labels = {} + if project is not None: + labels["com.docker.compose.project"] = project + labels["com.docker.compose.container-number"] = number + ports = dict(extra_ports or {}) + if ssh_port is not None: + ports["22/tcp"] = [{"HostIp": "127.0.0.1", "HostPort": ssh_port}] + return { + "Config": {"Labels": labels}, + "NetworkSettings": {"Ports": ports}, + } + + +def test_parse_inspect_includes_ssh_publishing_compose_container(): + containers = [_container(project="carmo", number="1", ssh_port="2231")] + targets = orca._parse_inspect(containers) + assert targets == [SSHTarget(project="carmo", index=1, port=2231)] + + +def test_parse_inspect_excludes_container_without_ssh_port(): + """22/tcp を publish しないコンテナ (= Orca SSH target ではない) は除外。""" + containers = [ + _container(project="carmo", number="1", ssh_port=None, + extra_ports={"8080/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}]}), + ] + assert orca._parse_inspect(containers) == [] + + +def test_parse_inspect_excludes_container_without_compose_project(): + """compose project ラベルが無いコンテナは除外 (隔離)。""" + containers = [_container(project=None, ssh_port="2231")] + assert orca._parse_inspect(containers) == [] + + +def test_parse_inspect_project_name_with_dashes_preserved(): + """project 名の dash を壊さない (名前 split ではなくラベル直読み)。""" + containers = [_container(project="orca-web-app", number="2", ssh_port="2242")] + targets = orca._parse_inspect(containers) + assert targets == [SSHTarget(project="orca-web-app", index=2, port=2242)] + + +def test_parse_inspect_prefers_bind_matching_host_ip(): + containers = [{ + "Config": {"Labels": { + "com.docker.compose.project": "carmo", + "com.docker.compose.container-number": "1", + }}, + "NetworkSettings": {"Ports": {"22/tcp": [ + {"HostIp": "0.0.0.0", "HostPort": "9999"}, + {"HostIp": "127.0.0.1", "HostPort": "2231"}, + ]}}, + }] + targets = orca._parse_inspect(containers, bind="127.0.0.1") + assert targets == [SSHTarget(project="carmo", index=1, port=2231)]