From a4a35f7f8cefe6a510d2a6ec001d5cb13855fb46 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 08:58:44 +0900 Subject: [PATCH 01/13] =?UTF-8?q?chore:=20release/PLAN33=20Draft=20PR=20?= =?UTF-8?q?=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 12fde8d7ca82abfb97d1fb2fd7efe791e35cf6da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 14 Jul 2026 09:05:54 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20PLAN33-port-publish=20=E3=82=B3?= =?UTF-8?q?=E3=83=B3=E3=83=86=E3=83=8A=20SSH=20=E3=83=9D=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=81=AE=20publish=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PLAN33-port-publish Draft PR 作成 * feat(compose): ENABLE_SSH 時に SSH ポートを決定的に publish (PLAN33 PR2) Orca からコンテナへ SSH 接続できるよう、ENABLE_SSH=true のとき generate_scaled_compose が各 dev- サービスへ ::22 を publish する。 - env/keys.py: ENABLE_SSH / DEVBASE_SSH_BIND / DEVBASE_SSH_PORT_BASE を追加 - volume/ports.py (新規): sha1 ベースの安定ハッシュで (project, index) → host port を決定的に算出 (down→up を跨いで一定) - volume/compose.py: project_name を _build_scaled_services / _build_dev_instance へ通し、ENABLE_SSH 有効時のみ ports を注入 - tests/volume/test_compose_ssh_ports.py (新規): 有効/無効・bind・base・ 決定性・衝突回避の単体テスト Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- lib/devbase/env/keys.py | 8 ++ lib/devbase/volume/compose.py | 21 ++- lib/devbase/volume/ports.py | 41 ++++++ tests/volume/test_compose_ssh_ports.py | 174 +++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 lib/devbase/volume/ports.py create mode 100644 tests/volume/test_compose_ssh_ports.py diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index 0a61cd1..4439ae4 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -51,6 +51,14 @@ def gcp_credentials_key(profile: str) -> str: HOST_SSH_USER = "HOST_SSH_USER" HOST_SSH_HOST = "HOST_SSH_HOST" # 任意。default: host.docker.internal +# --- SSH server (Orca 連携 / PLAN33) --- +# ENABLE_SSH=true のとき entrypoint が sshd を起動し、compose 生成が :22 を publish する。 +# publish ポートはプロジェクト名+index から決定的に算出する (lib/devbase/volume/ports.py)。 +# 詳細: docs/user/orca.md +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) + # --- Editor (devbase up 後の自動オープン / PLAN31_3) --- # DEVBASE_OPEN_EDITOR は env init (collectors/editor.py) で対話設定する (既定 1)。 # 他はプロジェクト env / グローバル .env に手書きする devbase 動作設定。 diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 599797c..2360a33 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -7,8 +7,10 @@ from typing import Any, Dict, Optional from devbase.errors import DockerError +from devbase.env.keys import ENABLE_SSH, DEVBASE_SSH_BIND, DEVBASE_SSH_PORT_BASE from .manager import get_work_volume_for_index, get_ai_volume_for_index +from .ports import ssh_host_port # 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する _DEPRECATED_TARGET = '/home/ubuntu' @@ -142,7 +144,7 @@ def _load_compose_config(compose_file: Path) -> dict: def _build_dev_instance( - dev_service: dict, dev_service_name: str, index: int, + dev_service: dict, dev_service_name: str, index: int, project_name: str, ) -> dict: """Build the service definition for one scaled dev instance (dev-).""" service = copy.deepcopy(dev_service) @@ -161,11 +163,21 @@ def _build_dev_instance( service['volumes'] = _replace_volumes_for_instance( service.get('volumes', []), ai_volume, work_volume, ) + + # Publish the container's sshd (:22) to a deterministic host port so Orca + # can attach as a plain SSH host (PLAN33). Opt-in via ENABLE_SSH. + if os.environ.get(ENABLE_SSH, '').lower() in ('true', '1'): + bind = os.environ.get(DEVBASE_SSH_BIND, '127.0.0.1') + base = int(os.environ.get(DEVBASE_SSH_PORT_BASE, '2200')) + port = ssh_host_port(project_name, index, base) + service.setdefault('ports', []).append(f"{bind}:{port}:22") + return service def _build_scaled_services( services: dict, dev_service: dict, dev_service_name: str, scale: int, + project_name: str, ) -> dict: """Build the services section: non-dev services + dev-1..dev-N instances.""" scaled_services = {} @@ -186,7 +198,7 @@ def _build_scaled_services( # Generate a service for each instance for i in range(1, scale + 1): scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance( - dev_service, dev_service_name, i, + dev_service, dev_service_name, i, project_name, ) return scaled_services @@ -202,7 +214,8 @@ def generate_scaled_compose( Args: scale: Number of container instances - project_name: Project name (unused, kept for backward compatibility) + project_name: Project name. Used for deterministic SSH port allocation + (PLAN33) when ENABLE_SSH is set. compose_file: Source compose file path (default: compose.yml) dev_service_name: Name of the development service to scale (default: from DEV_SERVICE_NAME env or 'dev') @@ -224,7 +237,7 @@ def generate_scaled_compose( scaled_config = { 'services': _build_scaled_services( - services, dev_service, dev_service_name, scale, + services, dev_service, dev_service_name, scale, project_name, ), 'volumes': _build_volumes_section(config, scale), 'networks': _build_networks_section(config), diff --git a/lib/devbase/volume/ports.py b/lib/devbase/volume/ports.py new file mode 100644 index 0000000..af99ff8 --- /dev/null +++ b/lib/devbase/volume/ports.py @@ -0,0 +1,41 @@ +"""SSH publish 用のホストポートを決定的に算出する (PLAN33)。 + +Orca は publish された `127.0.0.1:` を known_hosts / SSH config で参照するため、 +同じ `(project_name, index)` は **常に同じポート** に解決されなければならない +(`down` → `up` を跨いでも一定であること)。 + +そのため Python 組み込みの `hash()` は使わない。CPython は起動ごとに文字列ハッシュへ +salt を混ぜる (PYTHONHASHSEED) ため、プロセスを跨ぐと値が変わり決定性が崩れる。 +代わりに `hashlib.sha1` ベースの安定ハッシュ (`_stable_hash`) を用いる。 + +異なるプロジェクト / index はほぼ衝突しないようオフセットを分散させる。 +""" + +import hashlib + + +def _stable_hash(value: str) -> int: + """プロセスや実行を跨いで一定な非負整数ハッシュを返す。 + + 組み込み `hash()` は salt されるため使わず、SHA-1 ダイジェストを整数化する。 + """ + digest = hashlib.sha1(value.encode("utf-8")).hexdigest() + return int(digest, 16) + + +def ssh_host_port(project_name: str, index: int, base: int = 2200) -> int: + """`(project_name, index)` から publish 先ホストポートを決定的に算出する。 + + Args: + project_name: プロジェクト名 (COMPOSE_PROJECT_NAME)。 + index: dev インスタンス番号 (1 始まり)。 + base: ポート算出の起点 (既定 2200)。 + + Returns: + `base + offset` のホストポート。同じ引数は常に同じ値を返す。 + offset = (stable_hash(project_name) % 100) * 10 + (index - 1) + により、プロジェクト間は 10 刻みで分散し、同一プロジェクト内の + index 差分は +1 ずつずれる (0..990 + 0..9 の範囲)。 + """ + offset = (_stable_hash(project_name) % 100) * 10 + (index - 1) + return base + offset diff --git a/tests/volume/test_compose_ssh_ports.py b/tests/volume/test_compose_ssh_ports.py new file mode 100644 index 0000000..9e593d6 --- /dev/null +++ b/tests/volume/test_compose_ssh_ports.py @@ -0,0 +1,174 @@ +"""compose.py: ENABLE_SSH 時の SSH ポート publish 挙動 (PLAN33 / PR2) + +`_build_dev_instance()` は ENABLE_SSH が有効なとき、各 dev- サービスへ +`::22` の publish を注入する。ポートは `ssh_host_port()` により +プロジェクト名 + index から決定的に算出され、`down`→`up` を跨いでも一定である。 +""" + +from __future__ import annotations + +import yaml +import pytest + +from devbase.volume import compose +from devbase.volume.ports import ssh_host_port, _stable_hash + + +@pytest.fixture +def in_tmp_cwd(tmp_path, monkeypatch): + """生成物 (.docker-compose.scale.yml) が散らからないよう CWD を tmp に移す。""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + # SSH 系 env を既定で無効化 (外部環境に左右されないよう明示的に消す) + monkeypatch.delenv("ENABLE_SSH", raising=False) + monkeypatch.delenv("DEVBASE_SSH_BIND", raising=False) + monkeypatch.delenv("DEVBASE_SSH_PORT_BASE", raising=False) + return tmp_path + + +def _write_compose(tmp_path, services: dict) -> None: + (tmp_path / "compose.yml").write_text( + yaml.safe_dump({"services": services}, sort_keys=False), + encoding="utf-8", + ) + + +def _load_scaled(tmp_path) -> dict: + return yaml.safe_load((tmp_path / ".docker-compose.scale.yml").read_text()) + + +def _ssh_ports(service: dict) -> list: + """service の ports から `:22` を publish するエントリだけ抜き出す。""" + return [p for p in service.get("ports", []) if str(p).endswith(":22")] + + +# --- ENABLE_SSH 無効時 --- + +def test_no_ssh_ports_when_enable_ssh_unset(in_tmp_cwd): + """ENABLE_SSH 未設定なら :22 の publish は注入されない。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=2, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + for i in (1, 2): + assert _ssh_ports(scaled[f"dev-{i}"]) == [] + + +def test_no_ssh_ports_when_enable_ssh_false(in_tmp_cwd, monkeypatch): + """ENABLE_SSH=false なら :22 の publish は注入されない。""" + monkeypatch.setenv("ENABLE_SSH", "false") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + assert _ssh_ports(scaled["dev-1"]) == [] + + +# --- ENABLE_SSH 有効時 --- + +def test_ssh_ports_injected_when_enabled(in_tmp_cwd, monkeypatch): + """ENABLE_SSH=true なら各 dev- に 127.0.0.1::22 が付く。""" + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=2, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + for i in (1, 2): + port = ssh_host_port("proj", i, 2200) + assert _ssh_ports(scaled[f"dev-{i}"]) == [f"127.0.0.1:{port}:22"] + + +@pytest.mark.parametrize("truthy", ["true", "True", "TRUE", "1"]) +def test_enable_ssh_truthy_values(in_tmp_cwd, monkeypatch, truthy): + """'true'/'True'/'1' などを大文字小文字を問わず有効と解釈する。""" + monkeypatch.setenv("ENABLE_SSH", truthy) + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + assert len(_ssh_ports(scaled["dev-1"])) == 1 + + +def test_existing_ports_are_preserved(in_tmp_cwd, monkeypatch): + """既存の ports は保持され、SSH publish が追記される。""" + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, { + "dev": {"image": "dev:latest", "ports": ["8080:8080"]}, + }) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + ports = scaled["dev-1"]["ports"] + assert "8080:8080" in ports + assert len(_ssh_ports({"ports": ports})) == 1 + + +# --- bind / base の env 反映 --- + +def test_ssh_bind_is_honored(in_tmp_cwd, monkeypatch): + """DEVBASE_SSH_BIND が publish の bind 先に反映される。""" + monkeypatch.setenv("ENABLE_SSH", "true") + monkeypatch.setenv("DEVBASE_SSH_BIND", "0.0.0.0") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + port = ssh_host_port("proj", 1, 2200) + assert _ssh_ports(scaled["dev-1"]) == [f"0.0.0.0:{port}:22"] + + +def test_ssh_port_base_is_honored(in_tmp_cwd, monkeypatch): + """DEVBASE_SSH_PORT_BASE がポート算出の起点に反映される。""" + monkeypatch.setenv("ENABLE_SSH", "true") + monkeypatch.setenv("DEVBASE_SSH_PORT_BASE", "3000") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + port = ssh_host_port("proj", 1, 3000) + assert port >= 3000 + assert _ssh_ports(scaled["dev-1"]) == [f"127.0.0.1:{port}:22"] + + +# --- ssh_host_port() の決定性・衝突回避 --- + +def test_ssh_host_port_is_deterministic(): + """同じ (project, index) は毎回同じポートに解決する (純粋関数)。""" + a = ssh_host_port("carmo", 1, 2200) + b = ssh_host_port("carmo", 1, 2200) + assert a == b + + +def test_stable_hash_is_not_builtin_hash_salted(): + """_stable_hash は既知の固定値を返す (プロセス跨ぎで一定)。""" + # sha1('proj') の整数化を 100 で割った剰余は実装非依存に確定する。 + assert _stable_hash("proj") == _stable_hash("proj") + assert isinstance(_stable_hash("proj"), int) + assert _stable_hash("proj") >= 0 + + +def test_different_projects_get_different_ports(): + """別プロジェクトは (ほぼ) 別ポートに解決する。""" + ports = {ssh_host_port(name, 1, 2200) + for name in ("carmo", "alpha", "bravo", "charlie", "delta")} + # 5 個中 4 個以上はユニーク (100 バケットなので衝突は稀) + assert len(ports) >= 4 + + +def test_index_shifts_port_within_project(): + """同一プロジェクト内では index が +1 ずつポートをずらす。""" + p1 = ssh_host_port("proj", 1, 2200) + p2 = ssh_host_port("proj", 2, 2200) + assert p2 == p1 + 1 + + +def test_base_offsets_port(): + """base を変えるとポートも同じ差分だけずれる。""" + assert ssh_host_port("proj", 1, 3000) == ssh_host_port("proj", 1, 2200) + 800 From 9ebc281545f5d7de3f1c761475bad83bc7585d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 14 Jul 2026 09:09:04 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20PLAN33-base-sshd=20base=20?= =?UTF-8?q?=E3=81=AB=20openssh-server=20+=20sshd=20=E8=B5=B7=E5=8B=95=20(#?= =?UTF-8?q?82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PLAN33-base-sshd Draft PR 作成 * feat(base): openssh-server 追加と ENABLE_SSH による sshd 起動 (PLAN33 PR1) Orca 連携のため base イメージを SSH target 化する。 - containers/base/Dockerfile: 最初の apt-get に openssh-server を追加。 root のうちに /run/sshd と /etc/ssh/sshd_config.d/10-devbase-orca.conf (公開鍵認証のみ・Password 無効・TcpForwarding 有効)を作成。 - containers/base/entrypoint.sh: ENABLE_SSH=true/1 で sshd を起動する ブロックを追加。AI 設定 symlink 生成後(~/.ssh が /persistent/ai/.ssh へ張り替え済み)かつ exec "$@" 前に配置。host key を /persistent/ai/ssh に永続化・復元し、SSH_AUTHORIZED_KEYS を authorized_keys へ展開。 - docs/user/container-operations.md: openssh-server 同梱と ENABLE_SSH、 host key 永続化、base 変更に build --no-cache が要る旨を追記。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- containers/base/Dockerfile | 7 ++++++ containers/base/entrypoint.sh | 36 +++++++++++++++++++++++++++++++ docs/user/container-operations.md | 9 ++++++++ 3 files changed, 52 insertions(+) diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index 4d70183..8078ec4 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -13,6 +13,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ apt-get update; \ apt-get install -y --no-install-recommends \ locales git wget vim sudo nano jq make unzip \ + openssh-server \ curl ca-certificates gnupg lsb-release \ libnss3 libxrandr2 libxss1 \ fonts-noto-cjk fonts-noto-cjk-extra; \ @@ -123,6 +124,12 @@ RUN groupadd -f users; \ echo "$USERNAME ALL=(ALL:ALL) NOPASSWD: ALL" > /etc/sudoers.d/$USERNAME; \ chmod 0440 /etc/sudoers.d/users /etc/sudoers.d/$USERNAME +# sshd 設定(Orca 向け: 公開鍵認証のみ・Password 無効・TcpForwarding 有効) +RUN set -eux; \ + mkdir -p /run/sshd /etc/ssh/sshd_config.d; \ + printf 'PasswordAuthentication no\nPubkeyAuthentication yes\nAllowTcpForwarding yes\nX11Forwarding no\nPermitRootLogin no\n' \ + > /etc/ssh/sshd_config.d/10-devbase-orca.conf + USER ${USERNAME} WORKDIR /tmp diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 77db99e..24672b9 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -261,6 +261,42 @@ done echo "AI agent settings symlinks setup completed" # ======================================== +# ======================================== +# SSH server (Orca 連携) — enabled by ENABLE_SSH=true +# ======================================== +# NOTE: ~/.ssh は上の symlink setup で /persistent/ai/.ssh に張り替え済みのため、 +# authorized_keys の書き込みはこのブロック(symlink 生成後)で行う必要がある。 +if [ "$ENABLE_SSH" = "true" ] || [ "$ENABLE_SSH" = "1" ]; then + echo "Starting sshd for Orca..." + + # host key を永続領域から復元、無ければ生成して保存する。 + # 再ビルド/再作成で host key が変わると Orca 側 known_hosts が壊れるのを防ぐ。 + # HOST_KEY_DIR は .ssh symlink とは別の独立ディレクトリ(ドット無し)。 + HOST_KEY_DIR="/persistent/ai/ssh" + sudo mkdir -p "$HOST_KEY_DIR" || true + if ! sudo ls "$HOST_KEY_DIR"/ssh_host_*_key >/dev/null 2>&1; then + echo "Generating new sshd host keys..." + sudo ssh-keygen -A + sudo cp /etc/ssh/ssh_host_*_key* "$HOST_KEY_DIR"/ 2>/dev/null || true + fi + # 永続領域から /etc/ssh へ復元(毎回) + sudo cp "$HOST_KEY_DIR"/ssh_host_*_key* /etc/ssh/ 2>/dev/null || true + + # authorized_keys の展開(.ssh は /persistent/ai/.ssh へ symlink 済み) + if [ -n "$SSH_AUTHORIZED_KEYS" ]; then + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_AUTHORIZED_KEYS" > ~/.ssh/authorized_keys + chmod 600 ~/.ssh/authorized_keys + echo "authorized_keys installed" + else + echo "Warning: SSH_AUTHORIZED_KEYS is empty; public-key login will not work" + fi + + # sshd 起動(daemonize するため exec "$@" をブロックしない) + sudo /usr/sbin/sshd -e + echo "sshd started" +fi + # Git operations (optional, don't fail if they error) if [ -n "$GIT_USER" ] && [ -n "$GIT_REPO" ]; then # Clone repository only if it doesn't exist diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index c5b1838..b6469d0 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -218,6 +218,15 @@ graph TD | **go** | base | Go 開発環境 | Go 開発 | | **snapshot** | Ubuntu Noble | zstd のみ(約 80MB) | スナップショット専用 | +### SSH サーバー(Orca 連携) + +base イメージには `openssh-server` が含まれます。環境変数 `ENABLE_SSH=true`(または `1`)を指定してコンテナを起動すると、entrypoint が sshd を起動します(既定は無効)。 + +- 認証は公開鍵のみ(`SSH_AUTHORIZED_KEYS` に手元の公開鍵を設定)。 +- host key は `/persistent/ai/ssh/` に永続化され、再ビルド/再作成後も維持されます(Orca の known_hosts が壊れない)。 + +> **Note:** `openssh-server` の追加は base イメージの変更のため、既存イメージには `devbase build --no-cache`(base 再ビルド)が必要です。 + ### AI CLI エイリアス general イメージ以降のコンテナ内では、以下の AI CLI ツールがエイリアスとして利用可能です。 From 8db770adfc6ff30abf25a953c83ce89af69da329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 14 Jul 2026 09:09:08 +0900 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20PLAN33-docs=20Orca=20=E6=8E=A5?= =?UTF-8?q?=E7=B6=9A=E3=82=AC=E3=82=A4=E3=83=89=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PLAN33-docs Draft PR 作成 * docs: Orca 接続ガイドを追加し README/docs 索引に導線を追記 コンテナ内 sshd を publish して Orca の SSH target として接続する エンドツーエンドの手順を docs/user/orca.md に追加。base 再ビルド → env init(公開鍵)→ ENABLE_SSH で up → devbase orca sync → Orca への import → worktree 作成までを網羅。macOS 直結 / Windows→macOS(SSH トンネル・Tailscale)の 2 パターン、環境変数一覧、 隔離設計、Ports tab、トラブルシュート(docker exec/ProxyCommand を 採らない理由・host key 永続化)を記載。README.md と docs/README.md の 機能一覧・ドキュメント索引に導線を追記。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- README.md | 2 + docs/README.md | 3 + docs/user/orca.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 docs/user/orca.md diff --git a/README.md b/README.md index 20df646..5295b3e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ devbaseは、Docker Composeを使った再現性の高い開発環境を提供 - **環境変数の自動収集**: `devbase env init`でAWS/Git/GCP認証情報を対話的に設定 - **階層メニュー TUI**: `devbase list` のプロジェクト一覧(矢印キー移動・名前絞り込み対応)から起動・操作(up / down / login / ps / logs / scale / build / rebuild)を選択。画面最下部の常設メニュー(環境変数 / プラグイン / スナップショット / ステータス)へは ←→ キーで移動できます - **イメージ再ビルド**: `devbase build [name] --no-cache` でキャッシュ無効の完全再ビルド。`devbase rebuild [name]`(= `build --expires=7`)はイメージが既定 7 日より古いときのみ再ビルドします +- **Orca 対応**: `ENABLE_SSH=true` でコンテナ内 sshd を publish し、[Orca](https://www.onorca.dev/) の SSH target として接続。`devbase orca sync` が隔離 SSH config を生成し、コンテナ内で worktree / AI エージェントを動かせます([Orca 接続ガイド](docs/user/orca.md)) ## クイックスタート @@ -148,6 +149,7 @@ devbaseのコマンドは4つのグループにまとめられています。 | [環境変数の export/import ガイド](docs/user/env-export-import.md) | バンドル形式・age 暗号化・S3 連携・merge/replace の運用 | | [コンテナ操作ガイド](docs/user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | | [スナップショットガイド](docs/user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 | +| [Orca 接続ガイド](docs/user/orca.md) | コンテナを SSH target として Orca から接続する手順 | | [トラブルシューティング](docs/user/troubleshooting.md) | カテゴリ別の問題と解決策 | ### プラグイン開発者向け diff --git a/docs/README.md b/docs/README.md index e29532b..90d9588 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,7 @@ graph TD | [環境変数ガイド](user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | | [コンテナ操作ガイド](user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | | [スナップショットガイド](user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 | +| [Orca 接続ガイド](user/orca.md) | コンテナを SSH target として Orca から接続する手順 | | [トラブルシューティング](user/troubleshooting.md) | カテゴリ別の問題と解決策 | **推奨の読み順:** @@ -95,6 +96,7 @@ docs/ │ ├── environment-variables.md ← 環境変数ガイド │ ├── container-operations.md ← コンテナ操作ガイド │ ├── snapshot-guide.md ← スナップショットガイド +│ ├── orca.md ← Orca 接続ガイド │ └── troubleshooting.md ← トラブルシューティング ├── plugin-dev/ ← プラグイン開発者向け │ ├── quickstart.md ← クイックスタート @@ -117,6 +119,7 @@ docs/ | 環境変数を設定する | [環境変数ガイド](user/environment-variables.md#環境変数の操作) | | 複数コンテナで並行開発する | [コンテナ操作ガイド](user/container-operations.md#並行開発) | | データをバックアップ・復元する | [スナップショットガイド](user/snapshot-guide.md) | +| Orca からコンテナへ接続する | [Orca 接続ガイド](user/orca.md) | | エラーが発生した | [トラブルシューティング](user/troubleshooting.md) | | プラグインを作りたい | [プラグイン開発クイックスタート](plugin-dev/quickstart.md) | | devbase 本体に貢献したい | [コントリビューション](developer/contributing.md) | diff --git a/docs/user/orca.md b/docs/user/orca.md new file mode 100644 index 0000000..5a2daee --- /dev/null +++ b/docs/user/orca.md @@ -0,0 +1,216 @@ +# Orca 接続ガイド + +[Orca](https://www.onorca.dev/) から devbase が起動したコンテナへ SSH 接続し、コンテナ内で `git worktree` や AI エージェント CLI(claude / codex / gemini など)を動かすための手順を解説します。 + +## 概要 + +Orca はリモート開発を **「SSH target 上に worktree を作り、agent も SSH target 側で動かし、editor/diff は手元で使う」** モデル([SSH worktrees](https://www.onorca.dev/docs/ssh))で提供します。 + +devbase はこのモデルに合わせ、**コンテナ内の `sshd` をホストのポートへ publish して、Orca からは普通の `HostName + Port` の SSH host として見せる**構成を採ります。`docker exec` でターミナルだけコンテナへ入れる方式や `ProxyCommand` 方式は採りません(理由は[トラブルシューティング](#トラブルシューティング)を参照)。 + +構成は次の 3 層になります。 + +```text +Laptop (Windows / macOS) + └─ Orca + └─ SSH target: devbase-- ← Orca は普通の SSH host として認識 + └─ macOS 上の Docker container (devbase) + ├─ sshd (:22 → host 127.0.0.1: に publish) + ├─ git / claude / codex / gemini / worktree + └─ repo (/work) +``` + +devbase 側は次のように動作します。 + +```mermaid +flowchart TD + subgraph host["ホスト (macOS)"] + up["devbase up
ENABLE_SSH=true"] --> gen["compose 生成で
127.0.0.1:<port>:22 を publish"] + up --> orcasync["devbase orca sync
~/.config/devbase/orca/ssh_config"] + down["devbase down"] --> orcaprune["devbase orca prune"] + end + subgraph ctr["container (devbase)"] + entry["entrypoint.sh"] --> sshd["sshd :22
authorized_keys / host key 復元"] + end + gen --> entry + subgraph laptop["Laptop の Orca"] + import["Settings → SSH に
ssh_config を import"] --> target["SSH target:
devbase-project-1"] + end + orcasync -. import .-> import + target -. "127.0.0.1:port(直結 or トンネル/Tailscale)" .-> sshd +``` + +## 前提 + +- コンテナ内 `sshd` は base イメージに含まれます。**base イメージの変更を反映するには再ビルドが必要**です。既存イメージを使っている場合は、必ず一度 `devbase build --no-cache` を実行してください。`devbase up` だけでは反映されません。 + + ```bash + devbase build --no-cache + ``` + + > **Warning:** base の Dockerfile / entrypoint の変更は `devbase up` では取り込まれません。SSH 接続が確立できないときは、まず base の再ビルド漏れを疑ってください。 + +- Orca が手元の Laptop(macOS または Windows)にインストールされていること。 +- 公開鍵認証で接続します。手元に SSH 鍵ペア(例 `~/.ssh/id_ed25519` / `~/.ssh/id_ed25519.pub`)があること。無ければ `ssh-keygen -t ed25519` で作成してください。 + +## 手順 + +### 1. 公開鍵を収集する(`devbase env init`) + +`devbase env init` を実行すると、手元の公開鍵(`~/.ssh/id_ed25519.pub` など)を `SSH_AUTHORIZED_KEYS` として収集します。この値は entrypoint がコンテナ内の `~/.ssh/authorized_keys` へ展開し、Orca からの公開鍵認証に使われます。 + +```bash +devbase env init +``` + +> **Note:** すでに `env init` 済みで公開鍵だけ追加・更新したい場合は `devbase env sync` を実行するか、`devbase env set SSH_AUTHORIZED_KEYS=...` で直接設定できます。複数行(複数鍵)に対応します。 + +### 2. SSH を有効にして起動する(`ENABLE_SSH=true`) + +`ENABLE_SSH=true` を設定して `devbase up` すると、entrypoint が `sshd` を起動し、compose 生成時にコンテナの `:22` がホストの `127.0.0.1:` へ publish されます。 + +```bash +# プロジェクトの env に設定する場合 +devbase env set ENABLE_SSH=true -p + +devbase up +``` + +publish 先ポートは **プロジェクト名 + index から決定的に算出**されます(既定 base `2200`)。`down` → `up` しても同じポートに戻るため、Orca 側の設定が壊れません。 + +### 3. Orca 用 SSH config を生成する(`devbase orca sync`) + +`devbase orca sync` は、稼働中のコンテナと publish ポートを解決して、Orca 専用の SSH config を生成します。 + +```bash +devbase orca sync +``` + +生成先は次の**専用ファイル**です(ホストの `~/.ssh/config` は一切変更しません)。 + +```text +~/.config/devbase/orca/ssh_config +``` + +生成される内容の例: + +```sshconfig +# Managed by devbase — do not edit. Import this file into Orca (Settings → SSH). +Host devbase-carmo-1 + HostName 127.0.0.1 + Port 2231 + User ubuntu + IdentityFile ~/.ssh/id_ed25519 + StrictHostKeyChecking accept-new +``` + +関連コマンド: + +| コマンド | 説明 | +|---------|------| +| `devbase orca sync` | 全プロジェクト横断で稼働中コンテナを集約し、config を再生成(毎回上書き) | +| `devbase orca prune` | 停止済みコンテナのエントリを config から除去 | +| `devbase orca status` | 現在の import 対象一覧と Orca への登録手順を表示 | + +> **Note:** `devbase up` の完了後に sync、`devbase down` 時に prune が自動で呼ばれます。手動で最新化したいときのみ上記コマンドを使ってください。 + +### 4. Orca に config を import する + +Orca の **Settings → SSH** を開き、生成された `~/.config/devbase/orca/ssh_config` を import します。このファイルには devbase コンテナのエントリしか含まれないため、Orca からは devbase コンテナ以外の SSH ホストは見えません([隔離](#隔離-ホストの-sshconfig-を汚さない)を参照)。 + +### 5. SSH target に repo / worktree を作成する + +Orca 上で、import した SSH target(例 `devbase-carmo-1`)を location に選び、repo / worktree を作成します。`git worktree add` などの操作は SSH target 側、つまり devbase コンテナ内で実行されます。 + +## 環境変数一覧 + +Orca 連携に関わる環境変数です。`ENABLE_SSH` / `SSH_AUTHORIZED_KEYS` は `devbase env init` で設定でき、その他は必要に応じてプロジェクトの `env` などに設定します。 + +| 変数 | 既定値 | 説明 | +|------|--------|------| +| `ENABLE_SSH` | (未設定 = 無効) | `true` / `1` で entrypoint が `sshd` を起動し、compose で SSH ポートを publish する | +| `SSH_AUTHORIZED_KEYS` | (未設定) | 手元の公開鍵。entrypoint がコンテナ内 `~/.ssh/authorized_keys` へ展開する。複数行可。`devbase env init` で収集 | +| `DEVBASE_SSH_BIND` | `127.0.0.1` | publish の bind 先。既定は外部非公開。LAN/Tailscale 直結時に上書きする | +| `DEVBASE_SSH_PORT_BASE` | `2200` | publish ポートの算出起点。プロジェクト + index からのオフセットを加算する | +| `DEVBASE_ORCA_HOSTNAME` | `127.0.0.1` | 生成 config の `HostName`。Tailscale 名や Mac の LAN IP へ上書きすると Windows から直結できる | + +## 接続パターン + +接続元が同一 Mac かどうかで 2 通りの構成があります。 + +### パターン A: macOS(同一 Mac・直結) + +Orca と devbase コンテナが同じ Mac 上にある場合、Docker Desktop が `127.0.0.1:` を公開しているため**追加設定なしで直結**できます。生成された config をそのまま import すれば接続できます。 + +```text +Orca (macOS) ──▶ 127.0.0.1: ──▶ container sshd +``` + +### パターン B: Windows → macOS + +手元の Windows の Orca から、macOS 上のコンテナへ接続する場合は、`127.0.0.1` へ到達させる経路が必要です。次のいずれかを使います。 + +**B-1. SSH トンネル** + +Windows から Mac へ SSH トンネルを張り、ローカルの同一ポートをコンテナのポートへ転送します。 + +```bash +# Windows 側で実行( は devbase orca status で確認) +ssh -L :127.0.0.1: mac-host +``` + +トンネルを張ったまま、Orca には既定の `HostName 127.0.0.1` の config をそのまま import します。Orca は Windows の `127.0.0.1:` に接続し、トンネル経由で Mac 上のコンテナへ届きます。 + +**B-2. Tailscale / LAN 直結** + +Mac が Tailscale や LAN で Windows から到達可能な場合は、`DEVBASE_SSH_BIND` を広げて publish し、`DEVBASE_ORCA_HOSTNAME` を Mac の Tailscale 名 / LAN IP に上書きして sync します。 + +```bash +# Mac 側 +devbase env set DEVBASE_SSH_BIND=0.0.0.0 -p # 到達可能なインターフェースへ bind +devbase env set DEVBASE_ORCA_HOSTNAME=mac.tailnet.ts.net -p +devbase up +devbase orca sync +``` + +生成 config の `HostName` が指定した名前になるため、Windows の Orca はそのアドレスへ直結します。 + +> **Warning:** `DEVBASE_SSH_BIND=0.0.0.0` はコンテナの SSH ポートを外部インターフェースへ公開します。信頼できるネットワーク(Tailscale など)に限定し、公開鍵認証のみである点を確認してください。 + +## 隔離: ホストの `~/.ssh/config` を汚さない + +devbase は**専用ファイル `~/.config/devbase/orca/ssh_config` だけ**を生成し、Orca にはそれを import させます。ホストの `~/.ssh/config` は編集も `Include` もしません。 + +そのため Orca からは devbase コンテナのエントリしか見えず、**手元の `~/.ssh/config` に登録した他のホスト(本番サーバー等)は Orca に表示されません**。`Include` 方式ではメインの config にマージされ Orca が全ホストを読んでしまうため、devbase では採用していません。 + +## Ports tab / リモートポートフォワード + +sshd は `AllowTcpForwarding yes` で構成されているため、Orca の **Ports tab** による remote port forward / preview が利用できます。コンテナ内で起動した開発サーバー(例 `:3000`)を手元へフォワードしてプレビューする、といった使い方が可能です。 + +## トラブルシューティング + +### `docker exec` / `ProxyCommand` 方式を採らない理由 + +Orca は SSH target 上で file explorer / diff / worktree 管理を行います。`docker exec` でターミナルだけコンテナへ入れる方式や `ProxyCommand docker exec ... sshd -i` 方式では、**これらの機能がホスト側を向いてしまい**、コンテナ内のファイルを正しく扱えません。また file transfer に必要な SFTP が使えず **`SFTP is not available`** となります([Orca の open issue](https://github.com/stablyai/orca/issues/7781))。 + +devbase がコンテナ内 `sshd` を publish して「普通の SSH host」として見せるのは、これらの制約を回避し、file/diff/worktree をすべてコンテナ内で完結させるためです。 + +### `known_hosts` の警告が出る + +sshd の host key はコンテナの `/persistent/ai/ssh/` に**永続化**され、再ビルド / 再作成時も同じ key が復元されます。したがって、再ビルド後も Orca 側 `known_hosts` の不一致警告は出ません。 + +初回接続時は生成 config の `StrictHostKeyChecking accept-new` により、host key が自動で登録されます。 + +### 接続できないときの確認順 + +1. `devbase build --no-cache` で base を再ビルドしたか(sshd 入りイメージになっているか)。 +2. `ENABLE_SSH=true` で `devbase up` したか。 +3. `devbase orca status` で対象コンテナと publish ポートが表示されるか。 +4. 手元から素の SSH で疎通するか(`ssh -p ubuntu@127.0.0.1 whoami`)。 +5. Windows からの場合、SSH トンネル / Tailscale 経路が張れているか。 + +## 関連ドキュメント + +- [環境変数ガイド](environment-variables.md) — 環境変数の 3 レベル構造と操作 +- [コンテナ操作ガイド](container-operations.md) — ライフサイクル、並行開発、ボリューム構造 +- [CLI リファレンス](cli-reference.md) — 全コマンドの構文・オプション From df15fec650e7cb21dfcf68933b53f6f3e0d98e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 14 Jul 2026 09:15:30 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat(orca):=20=E3=83=9B=E3=82=B9=E3=83=88?= =?UTF-8?q?=E5=81=B4=20Orca=20=E9=9A=94=E9=9B=A2=20SSH=20config=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=20+=20up/down=20=E9=80=A3=E6=90=BA=20(PLAN33?= =?UTF-8?q?=20PR3)=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orca からコンテナへ SSH 接続するための、ホスト側の隔離 SSH config 生成と `devbase orca` コマンド、up/down フックを追加する。 - `devbase orca sync/prune/status`: 稼働中の SSH publish 済みコンテナを docker inspect で列挙し (コンテナ名の dash split はせず compose ラベルと 22/tcp publish で判定)、`~/.config/devbase/orca/ssh_config` を全再生成する。 他ホストを一切書かないため Orca への import で隔離が成立する。 - up 完了後に ENABLE_SSH 有効時のみ orca sync、down 後に orca prune を best-effort で呼ぶ (失敗しても up/down の戻り値は変えない)。 - collectors/orca.py: laptop の公開鍵を SSH_AUTHORIZED_KEYS として、 HostName 用 DEVBASE_ORCA_HOSTNAME を env init で収集する。 - keys.py に SSH_AUTHORIZED_KEYS / DEVBASE_ORCA_HOSTNAME を追加。 - cli.py / bin/devbase に orca サブコマンドを配線。 - tests/commands/test_orca.py: レンダリング・隔離・prune・列挙の単体テスト (実 docker は呼ばず fake targets / サンプル JSON を注入)。 Co-authored-by: Claude Opus 4.8 (1M context) --- bin/devbase | 4 +- lib/devbase/cli.py | 19 ++- lib/devbase/commands/container.py | 40 +++++ lib/devbase/commands/orca.py | 262 +++++++++++++++++++++++++++++ lib/devbase/env/collectors/orca.py | 74 ++++++++ lib/devbase/env/keys.py | 2 + tests/commands/__init__.py | 0 tests/commands/test_orca.py | 231 +++++++++++++++++++++++++ 8 files changed, 629 insertions(+), 3 deletions(-) create mode 100644 lib/devbase/commands/orca.py create mode 100644 lib/devbase/env/collectors/orca.py create mode 100644 tests/commands/__init__.py create mode 100644 tests/commands/test_orca.py 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)] From 51911048b7a9b6c4c725a9987d248f3bdeeba1a7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 09:17:11 +0900 Subject: [PATCH 06/13] =?UTF-8?q?docs(plan):=20PLAN33=20Orca=20=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=81=AE=E8=A8=AD=E8=A8=88=20plan=20=E3=81=A8?= =?UTF-8?q?=E5=85=83=20issue=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release ブランチが self-documenting になるよう、実装の設計根拠 (PR 分割計画・ 設計判断・E2E テスト計画) と元 issue / アドバイスを issues/ 配下に追加する。 Co-Authored-By: Claude Opus 4.8 (1M context) --- issues/PLAN33_orca-support.md | 316 ++++++++++++++++++++++++++++++++++ issues/i33-advice.md | 215 +++++++++++++++++++++++ issues/i33.md | 19 ++ 3 files changed, 550 insertions(+) create mode 100644 issues/PLAN33_orca-support.md create mode 100644 issues/i33-advice.md create mode 100644 issues/i33.md diff --git a/issues/PLAN33_orca-support.md b/issues/PLAN33_orca-support.md new file mode 100644 index 0000000..e62bdcd --- /dev/null +++ b/issues/PLAN33_orca-support.md @@ -0,0 +1,316 @@ +# PLAN33: devbase の Orca 対応(コンテナを SSH target として接続可能にする) + +## 関連リンク + +- 元 issue: `issues/i33.md`(Orca 対応) +- 参考アドバイス: `issues/i33-advice.md`(ChatGPT による構成案) +- Orca 公式: + - SSH worktrees — https://www.onorca.dev/docs/ssh + - Work on a remote machine over SSH(recipe)— https://www.onorca.dev/docs/recipes/remote-worktrees + - Remote Orca Servers — https://www.onorca.dev/docs/remote-servers + - file transfer over ProxyCommand の open issue — https://github.com/stablyai/orca/issues/7781 + +## 概要 + +Orca(https://www.onorca.dev/)から devbase が起動したコンテナへ接続し、コンテナ内で +`git worktree` / AI エージェント CLI(claude / codex / gemini など)を動かせるようにする。 + +Orca の remote 開発モデルは **「SSH target 上に worktree を作り、agent も SSH target 側で +動かし、editor/diff は手元で使う」** である(SSH worktrees)。したがって devbase コンテナを +**単純な `HostName + Port` の SSH host** として Orca に見せるのが最も素直で堅牢な構成となる。 + +`docker exec` を terminal だけコンテナに入れる方式や `ProxyCommand docker exec ... sshd -i` +方式は、Orca の file explorer / diff / worktree 管理がホスト側を見てしまう・SFTP が使えない +(上記 open issue)などの制約があるため **採らない**(`issues/i33-advice.md` の結論に従う)。 + +実現するのは次の 3 層: + +```text +Laptop (Windows / macOS) + └─ Orca + └─ SSH target: devbase-- ← Orca は普通の SSH host として認識 + └─ macOS 上の Docker container (devbase) + ├─ sshd (:22 → host 127.0.0.1: に publish) + ├─ git / claude / codex / gemini(既存)/ worktree + └─ repo (/work, /workspaces) +``` + +### やりたいこと(issue より)と本 plan の対応 + +| issue の要望 | 本 plan での実現方法 | +|---|---| +| base イメージに ssh 等をインストール | PR1: `openssh-server` を base に追加、`sshd_config` を Orca 向けに設定 | +| devbase 起動時に Orca の接続先へ追加 | PR3: `devbase up` 時に **Orca 専用の隔離 SSH config** を生成/更新 | +| `~/.ssh/config` を編集/include/配布のいずれか検討 | PR3: **ホストの `~/.ssh/config` は触らず**、専用ファイル `~/.config/devbase/orca/ssh_config` を生成し Orca にはそれだけを import させる(下記「設計判断」) | +| Orca から他の SSH ホストを見せたくない | 上記のとおり **専用ファイル方式**で自然に隔離(`~/.ssh/config` を混ぜない) | +| Windows → macOS 上コンテナへ接続 | PR3/PR4: publish 先を `127.0.0.1:` にし、Windows からは SSH トンネル or Tailscale で到達。HostName は設定で切替可能 | +| 外出先で macOS 上の Orca から操作 | 同一 Mac 上なら Docker Desktop が `127.0.0.1:` を公開するため直結可能 | + +## 設計判断(確定事項) + +| 論点 | 決定 | 理由 | +|---|---|---| +| Orca への見せ方 | コンテナ内 `sshd` を publish して単純な SSH host にする | Orca の SSH worktree モデルと相性最良。`docker exec` / `ProxyCommand` 方式は Orca の file/diff/worktree がコンテナを向かない・SFTP 不可(advice の結論) | +| `~/.ssh/config` の扱い | **編集しない。専用ファイルを別途生成し Orca にだけ import させる** | 「Orca から他の SSH ホストを見せたくない」を満たす唯一の隔離手段。`Include` はメイン config にマージされ Orca が全ホストを読むため不採用 | +| 隔離 config の置き場所 | `~/.config/devbase/orca/ssh_config`(ホスト側) | devbase 管理の単一ファイル。Orca は Settings→SSH で OpenSSH config を import できるためこれだけを渡す | +| sshd の起動制御 | env `ENABLE_SSH=true` のとき entrypoint で起動(DinD と同じ opt-in パターン) | 全コンテナで無条件に sshd を上げない。既存 `ENABLE_DIND` の実装に倣う | +| 認証方式 | 公開鍵認証のみ(`PasswordAuthentication no` / `PubkeyAuthentication yes`) | パスワード認証は無効。laptop の公開鍵を authorized_keys に登録 | +| authorized_keys の供給 | env `SSH_AUTHORIZED_KEYS`(複数行可)を entrypoint で `~/.ssh/authorized_keys` へ展開 | 既存の env 収集機構・`.ssh` 永続化(`/persistent/ai/.ssh`)と整合。`devbase env init` で収集 | +| host key の永続化 | sshd host key を `/persistent/ai/ssh/` に生成・永続化し entrypoint で `/etc/ssh` へ復元 | 再ビルド/再作成で host key が変わると Orca 側 known_hosts が壊れるのを防ぐ | +| publish の bind 先 | 既定 `127.0.0.1`(外部非公開)。env で上書き可 | 安全寄り。Windows からはトンネル/Tailscale で到達させる | +| ポート割当 | プロジェクト+index から決定的に算出(既定 base `2200` + オフセット)。env で base 変更可 | 複数プロジェクト/scale で衝突しない。`down`→`up` で同じポートに戻る | +| Orca CLI / `orca serve`(Remote Orca Server) | **本 plan では対象外(将来検討)** | 一人開発では sshd + SSH worktree の方がシンプル(advice の推奨)。まず primary 経路を通す。base への orca CLI 追加は PR1 で導入可否のみ調査 | +| TcpForwarding | `AllowTcpForwarding yes` | Orca の Ports tab による remote port forward / preview を効かせるため | + +## アーキテクチャ整合 + +### 既存機構との対応 + +- **base イメージ**: `containers/base/Dockerfile`。APT で `openssh-server` を追加。DinD 用 + `dind` ラッパや entrypoint の DinD 起動と同じ「opt-in で常駐プロセスを起動」パターンを踏襲。 +- **entrypoint**: `containers/base/entrypoint.sh`。既存の DinD ブロック(`ENABLE_DIND`)と AI + 設定 symlink ブロックに倣い、`ENABLE_SSH` ブロックと authorized_keys/host key 復元を追加。 + `.ssh` は既に `/persistent/ai/.ssh` へ symlink 済み(AI_SETTINGS 配列)。 +- **ポート publish**: `lib/devbase/volume/compose.py` の `_build_dev_instance()` が + scale 生成時に各 `dev-` サービス定義を作る唯一の箇所。ここへ + `ports: ["::22"]` を注入する。 +- **up/down フック**: `lib/devbase/commands/container.py` の `cmd_up()`(`[5/6]` 完了後)と + `cmd_down()`。ここから Orca config の同期/剪定を呼ぶ。 +- **新規コマンド**: `devbase orca ...`(config の手動同期・状態確認)。`bin/devbase` の + ディスパッチ(`resolve_command` の commands 一覧)と Python parser に追加。 +- **env keys**: `lib/devbase/env/keys.py` に `ENABLE_SSH` / `SSH_AUTHORIZED_KEYS` / + `DEVBASE_SSH_BIND` / `DEVBASE_SSH_PORT_BASE` / `DEVBASE_ORCA_HOSTNAME` を追加。 + +```mermaid +flowchart TD + subgraph host["ホスト (macOS)"] + up["devbase up"] --> gen["generate_scaled_compose()
_build_dev_instance で
127.0.0.1:port:22 を publish"] + gen --> compose["docker compose up"] + up --> orcasync["orca config sync
~/.config/devbase/orca/ssh_config"] + down["devbase down"] --> orcaprune["orca config prune"] + end + subgraph ctr["container (devbase)"] + entry["entrypoint.sh
ENABLE_SSH=true"] --> sshd["sshd :22
authorized_keys / host key 復元"] + end + compose --> entry + subgraph laptop["Laptop の Orca"] + import["Settings→SSH に
ssh_config を import"] --> target["SSH target:
devbase-project-1"] + end + orcasync -. import .-> import + target -. 127.0.0.1:port (直結 or トンネル/Tailscale) .-> sshd +``` + +## 変更ファイル(PR 別) + +### PR1: base イメージへ sshd 追加 + +| ファイル | 種別 | 内容 | +|---|---|---| +| `containers/base/Dockerfile` | 変更 | APT に `openssh-server` 追加。`/etc/ssh/sshd_config.d/` に Orca 向け設定(Pubkey 認証・Password 無効・TcpForwarding 有効)を配置。`/run/sshd` 作成 | +| `containers/base/entrypoint.sh` | 変更 | `ENABLE_SSH` ブロック追加: host key を `/persistent/ai/ssh/` から復元 or 生成、`SSH_AUTHORIZED_KEYS` を `~/.ssh/authorized_keys` へ展開、sshd 起動 | +| `docs/user/*`(該当章) | 変更 | base ツール一覧に openssh-server / SSH 有効化手順を追記 | + +> ⚠ base の Dockerfile / entrypoint 変更は `devbase up` では反映されず +> **`devbase build --no-cache`(base 再ビルド)が必要**(`memory: entrypoint-change-needs-rebuild`)。 +> PR の Test plan / release PR の検証手順に明記する。 + +### PR2: コンテナ SSH ポートの publish + +| ファイル | 種別 | 内容 | +|---|---|---| +| `lib/devbase/env/keys.py` | 変更 | `ENABLE_SSH` / `DEVBASE_SSH_BIND` / `DEVBASE_SSH_PORT_BASE` 定数追加 | +| `lib/devbase/volume/compose.py` | 変更 | `_build_dev_instance()` で `ENABLE_SSH` 時に `ports: ["::22"]` を注入。ポート算出ヘルパ追加 | +| `lib/devbase/volume/ports.py`(新規 or util 内) | 新規 | プロジェクト名+index → host port の決定的算出(`DEVBASE_SSH_PORT_BASE` 起点) | +| `tests/volume/test_compose_ssh_ports.py` | 新規 | ENABLE_SSH 有無・bind・ポート決定性・衝突回避の単体テスト | + +### PR3: ホスト側 Orca SSH config の生成・隔離・up/down 連携 + +| ファイル | 種別 | 内容 | +|---|---|---| +| `lib/devbase/env/keys.py` | 変更 | `SSH_AUTHORIZED_KEYS` / `DEVBASE_ORCA_HOSTNAME` 追加 | +| `lib/devbase/commands/orca.py`(新規) | 新規 | `devbase orca sync` / `devbase orca prune` / `devbase orca status`。稼働中コンテナと publish ポートを解決し `~/.config/devbase/orca/ssh_config` を生成/剪定 | +| `lib/devbase/commands/container.py` | 変更 | `cmd_up()` 完了後に orca sync、`cmd_down()` で prune を呼ぶ(失敗しても本処理は止めない) | +| `lib/devbase/cli.py` / `bin/devbase` | 変更 | `orca` サブコマンドをディスパッチ(`resolve_command` の commands 一覧と parser) | +| `lib/devbase/env/collectors/orca.py`(新規, 任意) | 新規 | `devbase env init` で公開鍵(`~/.ssh/id_ed25519.pub` 等)を `SSH_AUTHORIZED_KEYS` として収集 | +| `tests/commands/test_orca.py` | 新規 | config 生成内容・隔離(他ホスト非混入)・prune・非稼働時の扱いの単体テスト | + +### PR4: ドキュメント(接続手順) + +| ファイル | 種別 | 内容 | +|---|---|---| +| `docs/user/orca.md`(新規) | 新規 | Orca 接続ガイド: base 再ビルド → `env init`(公開鍵)→ `ENABLE_SSH=true` で `up` → `devbase orca sync` → Orca に import → worktree 作成。Windows(トンネル/Tailscale) / macOS(直結) の両ケース、Ports tab、トラブルシュート(`SFTP is not available` を避ける理由)| +| `README.md` / `docs/README.md` | 変更 | Orca 対応の紹介と `docs/user/orca.md` への導線 | + +## 実装詳細 + +### PR1: sshd(base + entrypoint) + +**Dockerfile**(APT 行へ `openssh-server` を追加し、設定ファイルを配置): + +```dockerfile +# sshd 設定(Orca 向け: 公開鍵のみ・TcpForwarding 有効) +RUN set -eux; \ + mkdir -p /run/sshd /etc/ssh/sshd_config.d; \ + printf 'PasswordAuthentication no\nPubkeyAuthentication yes\nAllowTcpForwarding yes\nX11Forwarding no\nPermitRootLogin no\n' \ + > /etc/ssh/sshd_config.d/10-devbase-orca.conf +``` + +**entrypoint.sh**(`ENABLE_DIND` ブロックの近くに追加。`exec "$@"` の前): + +```bash +# SSH server (Orca 連携) — enabled by ENABLE_SSH=true +if [ "$ENABLE_SSH" = "true" ] || [ "$ENABLE_SSH" = "1" ]; then + echo "Starting sshd for Orca..." + HOST_KEY_DIR="/persistent/ai/ssh" + sudo mkdir -p "$HOST_KEY_DIR" + # host key を永続領域から復元、無ければ生成して保存(Orca の known_hosts 破壊防止) + if ! ls "$HOST_KEY_DIR"/ssh_host_*_key >/dev/null 2>&1; then + sudo ssh-keygen -A -f /tmp/hk >/dev/null 2>&1 || sudo ssh-keygen -A + sudo cp /etc/ssh/ssh_host_*_key* "$HOST_KEY_DIR"/ 2>/dev/null || true + fi + sudo cp "$HOST_KEY_DIR"/ssh_host_*_key* /etc/ssh/ 2>/dev/null || true + # authorized_keys(.ssh は /persistent/ai/.ssh に symlink 済み) + if [ -n "$SSH_AUTHORIZED_KEYS" ]; then + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_AUTHORIZED_KEYS" > ~/.ssh/authorized_keys + chmod 600 ~/.ssh/authorized_keys + fi + sudo /usr/sbin/sshd -e + echo "sshd started" +fi +``` + +(host key 生成コマンド・権限まわりは実装時に実機検証。`ssh-keygen -A` は既存 key を上書き +しないため冪等。) + +### PR2: ポート publish(`_build_dev_instance`) + +```python +# ports.py(決定的ポート算出) +def ssh_host_port(project_name: str, index: int, base: int) -> int: + # project 名のハッシュ下位 + index で base からのオフセットを決める。 + # 同じ (project, index) は常に同じポートに解決する。 + offset = (_stable_hash(project_name) % 100) * 10 + (index - 1) + return base + offset +``` + +`_build_dev_instance()` 内(`ENABLE_SSH` が有効なときのみ): + +```python +if _ssh_enabled(): + bind = os.environ.get('DEVBASE_SSH_BIND', '127.0.0.1') + base = int(os.environ.get('DEVBASE_SSH_PORT_BASE', '2200')) + port = ssh_host_port(os.environ['COMPOSE_PROJECT_NAME'], index, base) + service.setdefault('ports', []).append(f"{bind}:{port}:22") +``` + +- `_build_dev_instance` は現状 `project_name` を受け取らないため、`COMPOSE_PROJECT_NAME` + env(wrapper が設定済み)を参照するか、シグネチャに project 名を通す(後者が明示的で望ましい)。 +- ポート衝突は「別プロジェクトが既に同ポートを publish していないか」を up 前に検査し、 + 衝突時は次の空きへずらす(もしくは警告)。テストで決定性と衝突回避を担保。 + +### PR3: Orca config 同期(隔離ファイル) + +`~/.config/devbase/orca/ssh_config` を **devbase 管理ブロック**として全生成(毎回上書き): + +```sshconfig +# Managed by devbase — do not edit. Import this file into Orca (Settings → SSH). +Host devbase-carmo-1 + HostName 127.0.0.1 + Port 2231 + User ubuntu + IdentityFile ~/.ssh/id_ed25519 + StrictHostKeyChecking accept-new + +Host devbase-carmo-2 + HostName 127.0.0.1 + Port 2232 + ... +``` + +- `HostName` は既定 `127.0.0.1`。`DEVBASE_ORCA_HOSTNAME`(Tailscale 名 / Mac の LAN IP)で上書き可 + → Windows から Tailscale/LAN 直結する構成に対応。 +- 稼働中コンテナと publish ポートは `docker compose ps` / compose override(`.docker-compose.scale.yml`) + から解決。`cmd_up` 直後は override ファイルが存在するため確実。 +- **他の SSH ホストは一切書かない**ため、Orca にこのファイルを import すれば devbase コンテナ + だけが見える(issue の隔離要件を満たす)。 +- `devbase orca sync`: 全プロジェクト横断で稼働中コンテナを集約して再生成。 + `devbase orca prune`: 停止済みエントリを除去(`down` から呼ぶ)。 + `devbase orca status`: 現在の import 対象一覧と Orca への登録手順を表示。 + +### 認証情報の流れ(authorized_keys) + +```text +laptop: ~/.ssh/id_ed25519.pub + → devbase env init (collectors/orca.py) が SSH_AUTHORIZED_KEYS に格納 → .env + → entrypoint が ~/.ssh/authorized_keys に展開(/persistent/ai/.ssh に永続化) + → Orca (同じ id_ed25519 で接続) が公開鍵認証で入る +``` + +## テスト計画 + +### 単体テスト(各 PR) + +- PR2 `tests/volume/test_compose_ssh_ports.py`: + - `ENABLE_SSH` 無効時に `ports` が注入されない + - 有効時に `127.0.0.1::22` が各 `dev-` に付く + - `DEVBASE_SSH_BIND` / `DEVBASE_SSH_PORT_BASE` の反映 + - 同一 (project, index) が常に同一ポート(決定性)/別 project で非衝突 +- PR3 `tests/commands/test_orca.py`: + - 生成 config に devbase ホストのみ含まれ、他ホストが混入しない(隔離) + - `HostName` の env 上書き + - prune で停止エントリが消える/稼働エントリは残る + - 稼働コンテナ 0 のとき空(or ヘッダのみ)を安全に生成 +- 既存一式の回帰(`pytest tests/`)。 + +### 結合(release ブランチ / 手動・実機) + +個別 PR では検出できない End-to-End をここで確認する: + +- [ ] `devbase build --no-cache`(base 再ビルド)後に sshd 入りイメージができる +- [ ] `ENABLE_SSH=true` で `devbase up` → `ssh -p ubuntu@127.0.0.1 'whoami; git --version'` が通る +- [ ] `ssh ... 'claude --version || codex --version || gemini --version'` が通る +- [ ] `devbase orca sync` 生成ファイルを Orca に import → SSH target Test 成功 +- [ ] Orca で SSH target を location に repo/worktree 作成 → `git worktree add` がコンテナ側で走る +- [ ] scale 2 で 2 つの target が別ポートで登録され、両方接続できる +- [ ] `devbase down` 後に `devbase orca` エントリが prune される +- [ ] host key 永続化: 再ビルド後も Orca の known_hosts 警告が出ない +- [ ] Windows→Mac: `ssh -L :127.0.0.1: mac-host` トンネル経由で Orca 接続成功 +- [ ] (Tailscale 構成)`DEVBASE_ORCA_HOSTNAME=` で Windows から直結成功 + +## 受け入れ条件(issue より) + +- [ ] base コンテナイメージに sshd(+ 必要な SSH ツール)がインストールされている +- [ ] `devbase up` で起動したコンテナへ Orca から SSH 接続でき、worktree/agent がコンテナ内で動く +- [ ] devbase 起動時に Orca の接続先(隔離 SSH config)へコンテナが追加される +- [ ] Orca からは devbase コンテナ以外の `~/.ssh/config` のホストが見えない(隔離) +- [ ] Windows(手元)→ macOS 上コンテナへ Orca から接続できる手順が用意されている +- [ ] macOS 上の Orca からも同一手順で接続できる +- [ ] base 変更の反映に `build --no-cache` が要る旨がドキュメント化されている + +## PR 分割計画 + +| PR # | branch | 概要 | 依存 | 並行可否 | +|---|---|---|---|---| +| 1 | `feature/PLAN33-base-sshd` | base に openssh-server + entrypoint の sshd 起動 | なし | ○ | +| 2 | `feature/PLAN33-port-publish` | compose 生成で SSH ポート publish + ポート算出 | なし(PR1 と概念依存のみ) | ○(mock で並行可)| +| 3 | `feature/PLAN33-orca-config` | ホスト側 Orca 隔離 config + up/down 連携 + `devbase orca` | PR2(ポート解決) | △(PR2 merge 後が安全)| +| 4 | `feature/PLAN33-docs` | 接続ガイド(Windows/macOS)+ README 導線 | なし | ○ | + +```text +release branch: release/PLAN33 +base branch: main +``` + +- 個別 PR は `/ndf:cross-review` でセルフレビュー → release へ squash merge。 +- release ブランチで **結合観点のみ**(上記 E2E チェックリスト)を実機/手動で検証。 +- release PR body は self-contained(背景=Orca からコンテナ接続したい / 変更内容=sshd + publish + + 隔離 config + docs)で記述し、個別 PR は `
` 内の開発用情報に留める。 + +## 未確定・要判断(実装前に確認したい点) + +- **Orca CLI / `orca serve`(Remote Orca Server)** を base に入れるか: 本 plan は primary の + sshd + SSH worktree のみ対象。将来 `orca serve` 経路を足す場合は別 issue/PLAN で。 +- **User 名**: コンテナのログインユーザーは `ubuntu`(`USERNAME` ARG 既定)。Orca config の + `User` もこれに合わせる。プロジェクトが `USERNAME` を変える場合の追随は PR3 で吸収。 +- **ポート base 既定値 `2200`** と算出方式(ハッシュ)で十分か、衝突検査の厳密さ。 diff --git a/issues/i33-advice.md b/issues/i33-advice.md new file mode 100644 index 0000000..7767b42 --- /dev/null +++ b/issues/i33-advice.md @@ -0,0 +1,215 @@ +結論からいうと、**Orca では「remote machine」ではなく「コンテナそのもの」を SSH target として見せる**のが一番まともです。 +公式 docs 上の remote 開発の入口は **SSH worktrees** と **Remote Orca Servers** で、VS Code の Dev Containers みたいに Docker container / `devcontainer.json` を直接選ぶ方式は、私が確認した範囲では見つかりませんでした。Orca の SSH worktree は、SSH target 上で `git worktree` を作り、agent も SSH target 側で動かし、editor/diff は手元で使うモデルです。([Orca][1]) + +## おすすめ構成: コンテナ内で `sshd` を立てる + +つまりこうします。 + +```text +Laptop + └─ Orca + └─ SSH target: orca-container + └─ remote machine 上の Docker container + ├─ git + ├─ codex / claude / opencode など + ├─ repo / worktrees + └─ sshd +``` + +Orca 側から見ると、これは普通の SSH host です。Orca は Settings → SSH で target を追加でき、OpenSSH config も import できます。worktree 作成時に Local ではなく SSH target を選ぶと、remote 側で worktree 作成・agent 実行・file event 同期を行います。([Orca][1]) + +### 1. コンテナに `sshd`, `git`, agent CLI を入れる + +例として Ubuntu ベースならこんな感じです。 + +```Dockerfile +# Dockerfile.orca-dev +FROM ubuntu:24.04 + +ARG USER=dev +ARG UID=1000 +ARG GID=1000 + +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + openssh-server git curl ca-certificates sudo bash tini \ + && groupadd -g ${GID} ${USER} \ + && useradd -m -u ${UID} -g ${GID} -s /bin/bash ${USER} \ + && echo "${USER} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${USER} \ + && mkdir -p /run/sshd /workspaces /home/${USER}/.ssh \ + && chown -R ${USER}:${USER} /workspaces /home/${USER}/.ssh \ + && chmod 700 /home/${USER}/.ssh \ + && printf '\nPasswordAuthentication no\nPubkeyAuthentication yes\nAllowTcpForwarding yes\n' >> /etc/ssh/sshd_config + +COPY authorized_keys /home/dev/.ssh/authorized_keys +RUN chown dev:dev /home/dev/.ssh/authorized_keys \ + && chmod 600 /home/dev/.ssh/authorized_keys + +EXPOSE 22 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/usr/sbin/sshd", "-D", "-e"] +``` + +`authorized_keys` には、Orca を動かす laptop 側の公開鍵を入れます。 + +```bash +cp ~/.ssh/id_ed25519.pub authorized_keys +docker build -f Dockerfile.orca-dev -t orca-dev . +``` + +### 2. remote machine 上で container を起動する + +remote machine 側で、repo/worktree を永続化したい親ディレクトリを mount します。`git worktree` が sibling directory を作る可能性があるので、repo 1 個だけでなく親ディレクトリごと mount するのが無難です。 + +```bash +docker run -d \ + --name orca-dev \ + --restart unless-stopped \ + -p 127.0.0.1:2222:22 \ + -v /srv/repos:/workspaces \ + orca-dev +``` + +この例では container の SSH port を **remote machine の localhost:2222** にだけ bind しています。外部公開しないので安全寄りです。 + +laptop からは SSH tunnel を張ります。 + +```bash +ssh -N -L 2222:127.0.0.1:2222 remote-host +``` + +そして laptop の `~/.ssh/config` にこう書きます。 + +```sshconfig +Host orca-container + HostName 127.0.0.1 + Port 2222 + User dev + IdentityFile ~/.ssh/id_ed25519 + StrictHostKeyChecking accept-new +``` + +動作確認: + +```bash +ssh orca-container 'whoami; hostname; git --version; pwd' +``` + +ここで入れれば、Orca の **Settings → SSH** に `orca-container` を追加または import して、Test します。 + +### 3. Orca では「SSH target として repo/folder を開く」 + +Orca 側では次の流れです。 + +1. Settings → SSH で `orca-container` を追加。 +2. repo を追加するとき、location としてその SSH target を選ぶ。 +3. path は container 内の path、たとえば `/workspaces/myrepo` を指定。 +4. worktree を作る。 +5. agent を起動する。 + +Orca の recipe でも、SSH target を追加して connection test し、SSH target を location として repo を追加、または remote folder を直接開く流れになっています。実行時は remote 側で `git worktree add` が走り、agent も remote 側で動き、保存は remote filesystem に stream されます。([Orca][2]) + +なので、SSH endpoint が container 内なら、**worktree も agent も shell も container 内**です。これが一番 “Orca らしい” remote container 開発です。 + +## 避けたい: host に SSH してから `docker exec` + +これは一見ラクですが、Orca 的には微妙です。 + +```text +Orca → SSH to remote host → terminal で docker exec +``` + +この形だと、Orca の file explorer / editor / diff / worktree 管理は基本的に **SSH target である host 側**を見ます。terminal だけ container に入っても、Orca の “開発環境” 全体が container になるわけではありません。docs 上も、Orca は SSH target 上に worktree を作って agent を動かすモデルです。([Orca][1]) + +## `ProxyCommand docker exec ... sshd -i` は現時点では注意 + +こういう SSH config も理屈としてはあります。 + +```sshconfig +Host orca-container + HostName remote-host.example.com + User dev + ProxyCommand ssh remote-host.example.com docker exec -i /usr/sbin/sshd -i +``` + +ただし Orca の GitHub issue に、まさにこの `ProxyCommand` + containerized remote environment パターンで、terminal は開けるが file upload/download/import が `SFTP is not available when using system SSH transport` で失敗する、という open issue があります。([GitHub][3]) + +なので今は、**Orca から見て単純な `HostName + Port` の SSH target にする**のがおすすめです。つまり、container の sshd を port publish する、Tailscale で到達させる、または上のように `ssh -L` で tunnel してから Orca は `127.0.0.1:2222` に接続する、という形です。 + +## port forwarding / preview はどうなる? + +コンテナ内で web server を立てる場合も、SSH target が container なら相性は良いです。Orca の remote worktrees では Ports tab が remote の `/proc/net/tcp` を scan して listening port を検出し、クリックで laptop に forward できます。([Orca][1]) + +つまり container 内で: + +```bash +npm run dev -- --host 0.0.0.0 +``` + +みたいに立てておけば、Orca の Ports tab から拾える可能性が高いです。`sshd_config` で `AllowTcpForwarding yes` は有効にしておくのが無難です。 + +## もう一つの選択肢: container 内で `orca serve` + +より “remote runtime 全部を container に閉じ込めたい” なら、container 内で **Remote Orca Server** を動かす手もあります。 + +```text +Laptop: Orca UI + └─ connects to +Container: orca serve + ├─ repos + ├─ worktrees + ├─ terminals + └─ agent sessions +``` + +Remote Orca Servers は、server machine が projects / worktrees / terminals / agent sessions を所有し、client は UI として接続するモデルです。docs では beta とされていて、LAN・Tailscale・SSH forwarding・tunnel など、client/server が到達できる network path が必要とされています。([Orca][4]) + +container 内で Orca CLI が使えるなら、概念的にはこうです。 + +```bash +orca serve --port 6768 --pairing-address +``` + +その後 laptop の Orca で Settings → Remote Orca Servers → Add Server から pairing URL を追加します。Remote server mode では terminal PTY、agent CLI process、worktree files が server 側にあり、laptop はそれを操作する UI になります。([Orca][4]) + +ただ、普通の一人開発で「remote machine 上の container 内で agent を走らせたい」くらいなら、まずは **container sshd + SSH worktree** の方がシンプルです。 + +## 実用チェックリスト + +container 内でこれが通れば、Orca でもだいたい戦えます。 + +```bash +ssh orca-container 'git --version' +ssh orca-container 'cd /workspaces/myrepo && git status' +ssh orca-container 'codex --version || claude --version || opencode --version' +ssh orca-container 'cd /workspaces/myrepo && npm test' +``` + +加えて、container 内で GitHub への clone/push、agent CLI の認証、必要な language runtime、package manager、GPU/CUDA などが揃っているかを確認してください。Remote agent は remote/container 側の `PATH`・home directory・credentials を使うため、laptop 側に入っている CLI や token はそのままでは使われません。Remote server docs でも、agent CLI と credentials は server 側に必要だと説明されています。([Orca][4]) + +私ならまずこの構成にします。 + +```text +remote machine: + docker container: + sshd on :22 + repo mounted at /workspaces + codex/claude/etc installed + +remote machine: + publishes container ssh to 127.0.0.1:2222 + +laptop: + ssh -L 2222:127.0.0.1:2222 remote-host + +Orca: + SSH target = 127.0.0.1:2222 + repo path = /workspaces/ +``` + +これで Orca から見た “remote machine” は実質 container になります。 + +[1]: https://www.onorca.dev/docs/ssh "SSH worktrees — Orca Docs" +[2]: https://www.onorca.dev/docs/recipes/remote-worktrees "Work on a remote machine over SSH — Orca Docs" +[3]: https://github.com/stablyai/orca/issues/7781 "[Feature]: Support file transfer over system SSH transport for ProxyCommand targets · Issue #7781 · stablyai/orca · GitHub" +[4]: https://www.onorca.dev/docs/remote-servers "Remote Orca Servers — Orca Docs" diff --git a/issues/i33.md b/issues/i33.md new file mode 100644 index 0000000..5391ee5 --- /dev/null +++ b/issues/i33.md @@ -0,0 +1,19 @@ +# Orca対応 + +* devbaseをorcaに対応させたい + * https://www.onorca.dev/ + +* ChatGPTからのアドバイスissues/i33-advice.md + +## やりたいこと +* Orcaでdevbaseの立ち上げたコンテナに接続できるようにしたい + * baseコンテナイメージにsshやorca cliなど必要なツールをinstall + * devbase で起動したらOrcaの接続先に追加 + * ~/.ssh/config を編集するなりincudeするなりその他のリストを配布するなり検討 + * Orcaからは他の~/.ssh/config に登録されているSSHホストは見えない方が良い(邪魔なので) + +* windows(手元のPC) → macOS上のDockerコンテナにOrcaから接続したい + * 操作はWindows上のOrcaで実施。 + * AIエージェントはMacOS上のコンテナで動く + * 外出先など、MacOS上のOrcaから操作する場合もある + From 8d1e553441c99df040167e4c3cbe74d19eca4ccd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 13:27:34 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix(orca):=20codex=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=204=20=E4=BB=B6=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=20(PR=20#81=20round=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 隔離: dev コンテナへ dev.devbase.ssh ラベルを付与し、_parse_inspect の 必須条件に追加。22/tcp を publish する他 Compose プロジェクトの混入を防ぐ。 - ポート衝突: allocate_ssh_host_port を追加し、決定的ポートを優先しつつ 同一生成内 + 他プロジェクト稼働 publish (best-effort docker 照会) と衝突する 場合は線形探索で回避。決定性は衝突が無い限り保つ。 - docs: env init が収集するのは Mac の公開鍵である点を明記し、Windows の Orca では Windows の公開鍵を SSH_AUTHORIZED_KEYS に登録する手順・複数鍵対応・ IdentityFile の対応関係を追記。 - エラーハンドリング: _running_ssh_targets が列挙失敗時に None を返し、 regenerate_config は OrcaEnumerationError を送出して既存 config を保持。 sync/prune CLI は非ゼロ終了、up/down フックは warning のみ。 tests: 889 passed (+10) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/user/orca.md | 28 ++++++-- lib/devbase/commands/container.py | 18 ++++- lib/devbase/commands/orca.py | 67 ++++++++++++++---- lib/devbase/env/collectors/orca.py | 7 +- lib/devbase/volume/compose.py | 87 ++++++++++++++++++++--- lib/devbase/volume/ports.py | 29 ++++++++ tests/commands/test_orca.py | 51 +++++++++++++- tests/volume/test_compose_ssh_ports.py | 96 +++++++++++++++++++++++++- 8 files changed, 347 insertions(+), 36 deletions(-) diff --git a/docs/user/orca.md b/docs/user/orca.md index 5a2daee..f78d147 100644 --- a/docs/user/orca.md +++ b/docs/user/orca.md @@ -57,13 +57,31 @@ flowchart TD ### 1. 公開鍵を収集する(`devbase env init`) -`devbase env init` を実行すると、手元の公開鍵(`~/.ssh/id_ed25519.pub` など)を `SSH_AUTHORIZED_KEYS` として収集します。この値は entrypoint がコンテナ内の `~/.ssh/authorized_keys` へ展開し、Orca からの公開鍵認証に使われます。 +`SSH_AUTHORIZED_KEYS` には **Orca を動かすマシンの公開鍵**を登録します。この値は entrypoint がコンテナ内の `~/.ssh/authorized_keys` へ展開し、Orca からの公開鍵認証に使われます。認証に使う秘密鍵は Orca 側(下記 config の `IdentityFile`)にあるため、両者が対になっている必要があります。 -```bash -devbase env init -``` +`devbase env init` は **Mac 上で実行される**ため、自動収集されるのは Mac の公開鍵(`~/.ssh/id_ed25519.pub` など)です。したがって登録手順は接続元によって変わります。 + +- **パターン A: macOS 上の Orca**(同一 Mac) — 自動収集された Mac の公開鍵がそのまま Orca の鍵になるため、`devbase env init` だけで完了します。 + + ```bash + devbase env init + ``` + +- **パターン B: Windows 上の Orca** — Orca は Windows 側の秘密鍵で接続するため、**Windows の公開鍵**を登録する必要があります(Mac の公開鍵では認証できません)。Windows 側で公開鍵を取得し、`SSH_AUTHORIZED_KEYS` に設定してください。 + + ```powershell + # Windows (PowerShell) — 公開鍵の内容を確認 + type $env:USERPROFILE\.ssh\id_ed25519.pub + ``` + + ```bash + # Mac 側 — 上で表示された Windows の公開鍵を登録 + devbase env set SSH_AUTHORIZED_KEYS="ssh-ed25519 AAAA... user@windows" + ``` -> **Note:** すでに `env init` 済みで公開鍵だけ追加・更新したい場合は `devbase env sync` を実行するか、`devbase env set SSH_AUTHORIZED_KEYS=...` で直接設定できます。複数行(複数鍵)に対応します。 +> **Note:** すでに `env init` 済みで公開鍵だけ追加・更新したい場合は `devbase env sync` を実行するか、`devbase env set SSH_AUTHORIZED_KEYS=...` で直接設定できます。**複数行(複数鍵)に対応**するため、Mac と Windows の両方から接続する場合は 1 行に 1 鍵ずつ両方を登録できます。 +> +> 生成 config の `IdentityFile`(既定 `~/.ssh/id_ed25519`)は **Orca を動かすマシン上の秘密鍵パス**です。Windows から接続する場合は Windows のホームにある秘密鍵を指すため、登録した Windows の公開鍵と対応します。 ### 2. SSH を有効にして起動する(`ENABLE_SSH=true`) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 702928e..1a3b0cb 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -14,7 +14,11 @@ from devbase.errors import DevbaseError from devbase.log import get_logger from devbase.volume.manager import ensure_volumes -from devbase.volume.compose import generate_scaled_compose, get_dev_service_name +from devbase.volume.compose import ( + generate_scaled_compose, + get_dev_service_name, + _running_published_host_ports, +) from devbase.utils.docker import ( docker_compose_down, docker_compose_up, @@ -586,7 +590,12 @@ def cmd_up(project_name: str = None, scale: int = None, docker_compose_down() logger.info("[3/6] Generating scaled compose file...") - override_file = generate_scaled_compose(scale, project_name) + # 他プロジェクトが稼働 publish 済みのホストポートを best-effort でシードし、 + # SSH publish ポートの跨ぎ衝突 (bind 失敗) を回避する。 + override_file = generate_scaled_compose( + scale, project_name, + external_ports_provider=_running_published_host_ports, + ) logger.info("Generated: %s", override_file) logger.info("[4/6] Starting containers...") @@ -724,7 +733,10 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: ensure_network('devbase_net') logger.info("[3/5] Generating scaled compose file...") - override_file = generate_scaled_compose(new_scale, project_name) + override_file = generate_scaled_compose( + new_scale, project_name, + external_ports_provider=_running_published_host_ports, + ) logger.info("Generated: %s", override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) diff --git a/lib/devbase/commands/orca.py b/lib/devbase/commands/orca.py index af2779f..49d858d 100644 --- a/lib/devbase/commands/orca.py +++ b/lib/devbase/commands/orca.py @@ -25,12 +25,22 @@ from devbase.env import keys from devbase.log import get_logger +from devbase.volume.compose import DEVBASE_SSH_LABEL logger = get_logger(__name__) DEFAULT_HOSTNAME = "127.0.0.1" DEFAULT_USER = "ubuntu" + +class OrcaEnumerationError(RuntimeError): + """稼働中コンテナの列挙 (docker 照会) に失敗したことを表す。 + + 「稼働 target 0 件」(docker は成功、SSH コンテナが無い) とは区別する。この例外が + 上がった場合は既存 config を **上書きしない** ことで、docker の一時的失敗により + 有効なエントリが消えるのを防ぐ。 + """ + # 生成ファイル先頭に置く管理ブロックのヘッダ (docs/user/orca.md と一致させる)。 _HEADER = ( "# Managed by devbase — do not edit. " @@ -90,10 +100,12 @@ def _pick_host_port(port_bindings: Sequence[dict], bind: Optional[str]) -> Optio 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 ではない) は除外する。 + devbase 専用ラベル (``dev.devbase.ssh``) を持ち、かつ compose project ラベルを + 持ち、かつ ``22/tcp`` を publish しているコンテナだけを対象にする。この 3 条件に + よるフィルタが隔離を担保する (devbase が SSH publish した dev コンテナだけが Orca + config に現れる)。専用ラベルを必須にすることで、同じ Docker daemon 上にある + devbase 以外の Compose SSH コンテナ (たまたま ``22/tcp`` を publish するもの) が + 混入するのを防ぐ。 コンテナ名を dash で split して project/index を得る方式は取らない (project 名自体が dash を含みうるため)。ラベルから直接読む。 @@ -102,6 +114,9 @@ def _parse_inspect(containers, bind: Optional[str] = None) -> List[SSHTarget]: for container in containers or []: config = container.get("Config") or {} labels = config.get("Labels") or {} + # devbase 専用ラベルが無いコンテナは対象外 (他 Compose プロジェクトの隔離)。 + if not labels.get(DEVBASE_SSH_LABEL): + continue project = labels.get("com.docker.compose.project") if not project: continue @@ -139,26 +154,32 @@ def _docker_json(args: Sequence[str]) -> Optional[str]: return result.stdout -def _running_ssh_targets() -> List[SSHTarget]: +def _running_ssh_targets() -> Optional[List[SSHTarget]]: """稼働中の devbase SSH コンテナを docker から列挙する (best-effort)。 ``docker ps -q`` で稼働中コンテナ id を集め、``docker inspect`` の JSON を - :func:`_parse_inspect` に渡す。docker が無い / 失敗した場合は空リストを返す。 + :func:`_parse_inspect` に渡す。 + + Returns: + - ``List[SSHTarget]``: 列挙に成功した場合 (0 件なら空リスト)。 + - ``None``: docker が無い / 実行失敗 / 出力解析失敗など、**列挙自体に失敗** + した場合。「稼働 0 件」(空リスト) と区別し、呼び出し側が既存 config を + 保持できるようにする。 """ ps_out = _docker_json(["ps", "-q"]) if ps_out is None: - return [] + return None ids = ps_out.split() if not ids: return [] inspect_out = _docker_json(["inspect", *ids]) if inspect_out is None: - return [] + return None try: containers = json.loads(inspect_out) except json.JSONDecodeError as e: logger.warning("docker inspect の出力を解析できませんでした (Orca 同期をスキップ): %s", e) - return [] + return None bind = os.environ.get(keys.DEVBASE_SSH_BIND) or None return _parse_inspect(containers, bind=bind) @@ -196,14 +217,24 @@ def _write_config(targets: Sequence[SSHTarget]) -> Path: def regenerate_config( - targets_provider: Optional[Callable[[], List[SSHTarget]]] = None, + targets_provider: Optional[Callable[[], Optional[List[SSHTarget]]]] = None, ) -> Tuple[List[SSHTarget], Path]: """稼働中コンテナを列挙して config を全再生成する。``(targets, path)`` を返す。 up/down フックからも呼べる共通エントリ。``targets_provider`` はテスト注入用。 + + 列挙が失敗した (provider が ``None`` を返した) 場合は :class:`OrcaEnumerationError` + を送出し、**既存 config を上書きしない**。docker の一時的失敗で有効なエントリが + ヘッダのみに消えるのを防ぐため、「稼働 0 件」(空リスト → ヘッダのみ書き出し) とは + 明確に区別する。 """ provider = targets_provider or _running_ssh_targets - targets = list(provider()) + result = provider() + if result is None: + raise OrcaEnumerationError( + "稼働中コンテナの列挙に失敗しました (docker 応答なし)。既存 config を保持します。" + ) + targets = list(result) path = _write_config(targets) return targets, path @@ -212,9 +243,17 @@ def regenerate_config( # サブコマンド # --------------------------------------------------------------------------- -def _cmd_regenerate(targets_provider: Optional[Callable[[], List[SSHTarget]]]) -> int: - """sync / prune 共通の再生成処理。停止済みは列挙から外れるため両者は同義。""" - targets, path = regenerate_config(targets_provider) +def _cmd_regenerate(targets_provider: Optional[Callable[[], Optional[List[SSHTarget]]]]) -> int: + """sync / prune 共通の再生成処理。停止済みは列挙から外れるため両者は同義。 + + 列挙に失敗した場合は既存 config を保持したまま非ゼロで終了する (既存エントリを + ヘッダのみに消さない)。 + """ + try: + targets, path = regenerate_config(targets_provider) + except OrcaEnumerationError as e: + logger.error("Orca SSH config の再生成に失敗しました (既存 config は保持しました): %s", e) + return 1 if targets: logger.info("Orca SSH config を生成しました (%d 件): %s", len(targets), path) else: diff --git a/lib/devbase/env/collectors/orca.py b/lib/devbase/env/collectors/orca.py index 2dcd240..acf4144 100644 --- a/lib/devbase/env/collectors/orca.py +++ b/lib/devbase/env/collectors/orca.py @@ -45,8 +45,11 @@ def collect_orca_info(env_file: EnvFile) -> None: """Orca 連携情報 (SSH 公開鍵 / HostName) を対話的に収集する""" print("\n=== Orca 連携 (SSH 公開鍵) ===") - # SSH_AUTHORIZED_KEYS: 既存値 > laptop の公開鍵 を既定として提示する。 - # 公開鍵が見つからず既存値も無い場合はスキップ (公開鍵認証は鍵設定まで不可)。 + # SSH_AUTHORIZED_KEYS: 既存値 > laptop (Mac) の公開鍵 を既定として提示する。 + # 登録すべきは「Orca を動かすマシンの公開鍵」。同一 Mac の Orca なら自動収集した + # Mac の鍵で足りるが、Windows の Orca からは Windows の公開鍵を登録する必要がある + # (詳細: docs/user/orca.md)。公開鍵が見つからず既存値も無い場合はスキップ。 + print(" ※ 登録するのは Orca を動かすマシンの公開鍵です (Windows の Orca なら Windows 側の鍵)。") 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) diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 2360a33..eb0c518 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -2,19 +2,26 @@ import copy import os +import re +import subprocess import yaml from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional, Set from devbase.errors import DockerError from devbase.env.keys import ENABLE_SSH, DEVBASE_SSH_BIND, DEVBASE_SSH_PORT_BASE from .manager import get_work_volume_for_index, get_ai_volume_for_index -from .ports import ssh_host_port +from .ports import allocate_ssh_host_port # 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する _DEPRECATED_TARGET = '/home/ubuntu' +# devbase が SSH publish する dev コンテナを他 Compose プロジェクトと識別するための +# 専用ラベル。Orca 隔離 config 生成 (commands/orca.py `_parse_inspect`) が対象を +# 絞り込む必須条件として参照する。ENABLE_SSH 有効時に :22 publish と同時に付与する。 +DEVBASE_SSH_LABEL = 'dev.devbase.ssh' + def get_dev_service_name() -> str: """Get development service name from environment variable or default to 'dev'""" @@ -143,10 +150,50 @@ def _load_compose_config(compose_file: Path) -> dict: raise DockerError(f"Failed to parse compose file: {e}") +def _add_ssh_label(service: dict, label: str, value: str = '1') -> None: + """service へラベルを付与する (labels の dict / list どちらの形式にも対応)。""" + labels = service.get('labels') + if isinstance(labels, list): + labels.append(f"{label}={value}") + elif isinstance(labels, dict): + labels[label] = value + else: + service['labels'] = {label: value} + + +def _running_published_host_ports() -> Set[int]: + """稼働中コンテナが publish 済みのホストポート集合を best-effort で返す。 + + 別プロジェクトのコンテナが既に握っているホストポートとの衝突を避けるため、 + compose 生成時に docker から現況を収集して :func:`allocate_ssh_host_port` の + ``used_ports`` に混ぜる。docker が無い / 失敗しても空集合を返して生成を止めない + (その場合は決定的ポートにそのままフォールバックする)。 + """ + try: + result = subprocess.run( + ['docker', 'ps', '--format', '{{.Ports}}'], + capture_output=True, text=True, check=False, + ) + except (OSError, subprocess.SubprocessError): + return set() + if result.returncode != 0: + return set() + ports: Set[int] = set() + # 例: "127.0.0.1:2231->22/tcp, 0.0.0.0:8080->80/tcp" + for match in re.finditer(r":(\d+)->", result.stdout): + ports.add(int(match.group(1))) + return ports + + def _build_dev_instance( dev_service: dict, dev_service_name: str, index: int, project_name: str, + used_ports: Set[int], ) -> dict: - """Build the service definition for one scaled dev instance (dev-).""" + """Build the service definition for one scaled dev instance (dev-). + + ``used_ports`` は既に割り当て済み / 使用中のホストポート集合。SSH publish の + ポートを確保したら、この集合に追加して後続インスタンスとの衝突を防ぐ。 + """ service = copy.deepcopy(dev_service) service['container_name'] = f"${{COMPOSE_PROJECT_NAME}}-{dev_service_name}-{index}" @@ -169,17 +216,28 @@ def _build_dev_instance( if os.environ.get(ENABLE_SSH, '').lower() in ('true', '1'): bind = os.environ.get(DEVBASE_SSH_BIND, '127.0.0.1') base = int(os.environ.get(DEVBASE_SSH_PORT_BASE, '2200')) - port = ssh_host_port(project_name, index, base) + # 決定的ポートを優先しつつ、同一生成内の他インスタンスや他プロジェクトの + # 稼働 publish と衝突する場合は空きポートへずらして bind 失敗を避ける。 + port = allocate_ssh_host_port(project_name, index, base, used_ports) + used_ports.add(port) service.setdefault('ports', []).append(f"{bind}:{port}:22") + # devbase の SSH publish コンテナを識別する専用ラベル (Orca 隔離の必須条件)。 + _add_ssh_label(service, DEVBASE_SSH_LABEL) return service def _build_scaled_services( services: dict, dev_service: dict, dev_service_name: str, scale: int, - project_name: str, + project_name: str, used_ports: Optional[Set[int]] = None, ) -> dict: - """Build the services section: non-dev services + dev-1..dev-N instances.""" + """Build the services section: non-dev services + dev-1..dev-N instances. + + ``used_ports`` は SSH publish のホストポート衝突回避に使う共有集合 + (省略時は空集合から開始)。dev-1..N の生成を通じて割り当て済みポートを蓄積する。 + """ + if used_ports is None: + used_ports = set() scaled_services = {} # Copy non-dev services (mysql, valkey, etc.) — rewriting any @@ -198,7 +256,7 @@ def _build_scaled_services( # Generate a service for each instance for i in range(1, scale + 1): scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance( - dev_service, dev_service_name, i, project_name, + dev_service, dev_service_name, i, project_name, used_ports, ) return scaled_services @@ -207,7 +265,8 @@ def generate_scaled_compose( scale: int, project_name: str, compose_file: Path = None, - dev_service_name: str = None + dev_service_name: str = None, + external_ports_provider: Optional[Callable[[], Set[int]]] = None, ) -> Path: """ Generate scaled docker-compose file with per-instance volumes @@ -218,6 +277,11 @@ def generate_scaled_compose( (PLAN33) when ENABLE_SSH is set. compose_file: Source compose file path (default: compose.yml) dev_service_name: Name of the development service to scale (default: from DEV_SERVICE_NAME env or 'dev') + external_ports_provider: 他プロジェクトが稼働 publish 済みのホストポート集合を + 返す関数 (SSH ポート衝突回避のシード)。None (既定) のときは外部ポートを + シードしない (= 決定的ポートをそのまま使う。単体テストは docker 非依存)。 + 実行時の up 経路は :func:`_running_published_host_ports` を注入して + 他プロジェクトとの衝突を best-effort で回避する。 Returns: Path to generated .docker-compose.scale.yml @@ -235,9 +299,14 @@ def generate_scaled_compose( if not dev_service: raise DockerError(f"No '{dev_service_name}' service found in compose file") + # SSH publish のポート衝突回避シード: 呼び出し側 (up 経路) が他プロジェクトの + # 稼働 publish ポートを注入した場合はそれを初期集合にする。既定 (None) では + # シードせず、決定的ポートをそのまま使う (単体テストを docker 非依存に保つ)。 + used_ports: Set[int] = set(external_ports_provider()) if external_ports_provider else set() + scaled_config = { 'services': _build_scaled_services( - services, dev_service, dev_service_name, scale, project_name, + services, dev_service, dev_service_name, scale, project_name, used_ports, ), 'volumes': _build_volumes_section(config, scale), 'networks': _build_networks_section(config), diff --git a/lib/devbase/volume/ports.py b/lib/devbase/volume/ports.py index af99ff8..961ba53 100644 --- a/lib/devbase/volume/ports.py +++ b/lib/devbase/volume/ports.py @@ -12,6 +12,7 @@ """ import hashlib +from typing import Set def _stable_hash(value: str) -> int: @@ -39,3 +40,31 @@ def ssh_host_port(project_name: str, index: int, base: int = 2200) -> int: """ offset = (_stable_hash(project_name) % 100) * 10 + (index - 1) return base + offset + + +def allocate_ssh_host_port( + project_name: str, index: int, base: int, used_ports: Set[int], +) -> int: + """決定的ポートを起点に、未使用の publish 先ホストポートを確保する。 + + :func:`ssh_host_port` が返す決定的な値を **優先** して返す (`down`→`up` を + 跨いでも一定であることを保つ)。ただしその値が既に ``used_ports`` に含まれる場合 + (= 同一生成内の他インスタンス、または他プロジェクトが稼働 publish 済みのポート) + は、空きが見つかるまで +1 ずつ線形探索して衝突を回避する。 + + 衝突が無ければ決定性は完全に保たれる。100 バケットへの縮約や index≥11 の + バケット重複による同時起動時の bind 失敗を、この確保段階で解消する。 + + Args: + project_name: プロジェクト名 (COMPOSE_PROJECT_NAME)。 + index: dev インスタンス番号 (1 始まり)。 + base: ポート算出の起点。 + used_ports: 既に割り当て済み / 使用中のホストポート集合。 + + Returns: + ``used_ports`` に含まれない確保済みホストポート。 + """ + port = ssh_host_port(project_name, index, base) + while port in used_ports: + port += 1 + return port diff --git a/tests/commands/test_orca.py b/tests/commands/test_orca.py index 144e7f7..394af6d 100644 --- a/tests/commands/test_orca.py +++ b/tests/commands/test_orca.py @@ -123,12 +123,43 @@ def test_user_defaults_to_ubuntu_when_unset(home_in_tmp): # --------------------------------------------------------------------------- def test_regenerate_zero_targets_writes_header_only(home_in_tmp): + """docker 成功で稼働 0 件 (空リスト) のときはヘッダのみを書き出す (正常系)。""" 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_regenerate_enumeration_failure_preserves_existing_config(home_in_tmp): + """列挙失敗 (provider が None) のときは既存 config を上書きせず例外を送出する。 + + docker の一時的失敗で有効なエントリがヘッダのみに消える事故を防ぐ。 + """ + # まず有効なエントリを書き込んでおく。 + orca.regenerate_config(targets_provider=lambda: [ + SSHTarget(project="carmo", index=1, port=2231)]) + before = orca._config_path().read_text(encoding="utf-8") + assert "Host devbase-carmo-1" in before + + # 列挙失敗 → OrcaEnumerationError。ファイルは一切変更されない。 + with pytest.raises(orca.OrcaEnumerationError): + orca.regenerate_config(targets_provider=lambda: None) + + after = orca._config_path().read_text(encoding="utf-8") + assert after == before + + +def test_cmd_regenerate_returns_nonzero_on_enumeration_failure(home_in_tmp): + """列挙失敗時 `devbase orca sync` は既存 config を保持したまま非ゼロで終了する。""" + orca.regenerate_config(targets_provider=lambda: [ + SSHTarget(project="carmo", index=1, port=2231)]) + before = orca._config_path().read_text(encoding="utf-8") + + rc = orca.cmd_orca(home_in_tmp, _args("sync"), targets_provider=lambda: None) + assert rc == 1 + assert orca._config_path().read_text(encoding="utf-8") == before + + def test_prune_drops_stale_entries(home_in_tmp): """一度 2 件書いた後、稼働 1 件で再生成すると停止分が消える (全上書き)。""" orca.regenerate_config(targets_provider=lambda: [ @@ -174,11 +205,15 @@ def test_cmd_orca_unknown_subcommand_returns_1(home_in_tmp): # _parse_inspect: 22/tcp publish + compose ラベルで隔離 # --------------------------------------------------------------------------- -def _container(project=None, number="1", ssh_port="2231", extra_ports=None): +def _container(project=None, number="1", ssh_port="2231", extra_ports=None, + ssh_label=True): labels = {} if project is not None: labels["com.docker.compose.project"] = project labels["com.docker.compose.container-number"] = number + if ssh_label: + # devbase が SSH publish 時に付ける専用ラベル (隔離の必須条件)。 + labels["dev.devbase.ssh"] = "1" ports = dict(extra_ports or {}) if ssh_port is not None: ports["22/tcp"] = [{"HostIp": "127.0.0.1", "HostPort": ssh_port}] @@ -205,7 +240,18 @@ def test_parse_inspect_excludes_container_without_ssh_port(): def test_parse_inspect_excludes_container_without_compose_project(): """compose project ラベルが無いコンテナは除外 (隔離)。""" - containers = [_container(project=None, ssh_port="2231")] + containers = [_container(project=None, ssh_port="2231", ssh_label=False)] + assert orca._parse_inspect(containers) == [] + + +def test_parse_inspect_excludes_container_without_devbase_label(): + """22/tcp を publish し compose project を持っても devbase 専用ラベルが無ければ除外。 + + 同じ Docker daemon 上の devbase 以外の Compose SSH コンテナ (たまたま 22/tcp を + publish するもの) が Orca config に混入しないことを保証する。 + """ + containers = [_container(project="other-app", number="1", ssh_port="2231", + ssh_label=False)] assert orca._parse_inspect(containers) == [] @@ -221,6 +267,7 @@ def test_parse_inspect_prefers_bind_matching_host_ip(): "Config": {"Labels": { "com.docker.compose.project": "carmo", "com.docker.compose.container-number": "1", + "dev.devbase.ssh": "1", }}, "NetworkSettings": {"Ports": {"22/tcp": [ {"HostIp": "0.0.0.0", "HostPort": "9999"}, diff --git a/tests/volume/test_compose_ssh_ports.py b/tests/volume/test_compose_ssh_ports.py index 9e593d6..1b5c66f 100644 --- a/tests/volume/test_compose_ssh_ports.py +++ b/tests/volume/test_compose_ssh_ports.py @@ -11,7 +11,20 @@ import pytest from devbase.volume import compose -from devbase.volume.ports import ssh_host_port, _stable_hash +from devbase.volume.compose import DEVBASE_SSH_LABEL +from devbase.volume.ports import ssh_host_port, allocate_ssh_host_port, _stable_hash + + +def _labels_dict(service: dict) -> dict: + """service の labels を dict 化して返す (list / dict / 未設定に対応)。""" + labels = service.get("labels") + if isinstance(labels, list): + out = {} + for item in labels: + k, _, v = str(item).partition("=") + out[k] = v + return out + return dict(labels or {}) @pytest.fixture @@ -81,6 +94,28 @@ def test_ssh_ports_injected_when_enabled(in_tmp_cwd, monkeypatch): assert _ssh_ports(scaled[f"dev-{i}"]) == [f"127.0.0.1:{port}:22"] +def test_ssh_label_injected_when_enabled(in_tmp_cwd, monkeypatch): + """ENABLE_SSH=true なら各 dev- に devbase 専用ラベルが付く (Orca 隔離用)。""" + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=2, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + for i in (1, 2): + assert _labels_dict(scaled[f"dev-{i}"]).get(DEVBASE_SSH_LABEL) == "1" + + +def test_ssh_label_absent_when_disabled(in_tmp_cwd): + """ENABLE_SSH 未設定なら devbase 専用ラベルは付かない。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + assert DEVBASE_SSH_LABEL not in _labels_dict(scaled["dev-1"]) + + @pytest.mark.parametrize("truthy", ["true", "True", "TRUE", "1"]) def test_enable_ssh_truthy_values(in_tmp_cwd, monkeypatch, truthy): """'true'/'True'/'1' などを大文字小文字を問わず有効と解釈する。""" @@ -172,3 +207,62 @@ def test_index_shifts_port_within_project(): def test_base_offsets_port(): """base を変えるとポートも同じ差分だけずれる。""" assert ssh_host_port("proj", 1, 3000) == ssh_host_port("proj", 1, 2200) + 800 + + +# --- allocate_ssh_host_port(): 衝突回避付き確保 --- + +def test_allocate_returns_deterministic_when_free(): + """used_ports に無ければ決定的ポートをそのまま返す (決定性を保つ)。""" + expected = ssh_host_port("proj", 1, 2200) + assert allocate_ssh_host_port("proj", 1, 2200, set()) == expected + + +def test_allocate_probes_upward_when_deterministic_port_taken(): + """決定的ポートが used_ports にあれば空きが見つかるまで +1 ずつずらす。""" + det = ssh_host_port("proj", 1, 2200) + used = {det} + got = allocate_ssh_host_port("proj", 1, 2200, used) + assert got == det + 1 + assert got not in used + + +def test_allocate_skips_run_of_taken_ports(): + """連続して埋まっている場合は最初の空きまで飛ばす。""" + det = ssh_host_port("proj", 1, 2200) + used = {det, det + 1, det + 2} + assert allocate_ssh_host_port("proj", 1, 2200, used) == det + 3 + + +def test_cross_project_collision_avoided_via_external_ports(in_tmp_cwd, monkeypatch): + """他プロジェクトが握るポートを external_ports_provider で渡すと衝突を避ける。 + + 別プロジェクトの稼働 publish が dev-1 の決定的ポートを占有している状況を模し、 + dev-1 が別ポートへずれること (かつ決定性は衝突が無い限り保たれること) を確認する。 + """ + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + det1 = ssh_host_port("proj", 1, 2200) + # 他プロジェクトが det1 を占有中。 + compose.generate_scaled_compose( + scale=1, project_name="proj", + external_ports_provider=lambda: {det1}, + ) + scaled = _load_scaled(in_tmp_cwd)["services"] + ports = _ssh_ports(scaled["dev-1"]) + assert ports == [f"127.0.0.1:{det1 + 1}:22"] # 衝突回避で +1 へずれる + + +def test_no_external_collision_keeps_deterministic_ports(in_tmp_cwd, monkeypatch): + """外部ポートと衝突しなければ決定的ポートがそのまま使われる。""" + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose( + scale=2, project_name="proj", + external_ports_provider=lambda: {9999}, # 無関係なポート + ) + scaled = _load_scaled(in_tmp_cwd)["services"] + for i in (1, 2): + port = ssh_host_port("proj", i, 2200) + assert _ssh_ports(scaled[f"dev-{i}"]) == [f"127.0.0.1:{port}:22"] From ea69642efb94a0c0b12f68659be21a2cb78dc274 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 13:41:44 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix(orca):=20scale=20=E3=81=AE=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=88=E8=A1=9D=E7=AA=81=E8=AA=A4=E5=88=A4=E5=AE=9A?= =?UTF-8?q?/Orca=E5=90=8C=E6=9C=9F=E6=BC=8F=E3=82=8C/IdentityFile=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=82=92=E4=BF=AE=E6=AD=A3=20(PR=20#81=20round=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cross-review round 2 の指摘 3 件に対応。 A [major/正確性] SSH ポート衝突回避が自プロジェクトの稼働ポートまで数え、 devbase scale で既存 dev-1..N の決定的ポートが +1 ずれて --no-recreate と 不整合になる問題を修正。_running_published_host_ports に exclude_project 引数を 追加し com.docker.compose.project ラベルで自プロジェクトを除外。up/scale の 呼び出し側から現在の project_name を渡す。 B [major/状態同期] cmd_scale がスケールアウト後に Orca config を再生成せず、 追加インスタンスが手動 sync まで現れない問題を修正。up 経路同様に成功時 best-effort で _maybe_orca_sync() を呼ぶ。 C [minor/UX] 生成 config の IdentityFile を ~/.ssh/id_ed25519 固定にしていたため id_rsa のみのユーザーで不一致警告が出る問題を修正。IdentityFile 行を出力せず SSH クライアント/Orca の既定鍵解決に委ねる。docs/user/orca.md も追随。 テスト: A の自プロジェクト除外/他プロジェクト衝突と C の IdentityFile 非出力を追加。 uv run pytest tests/ -q は 895 passed。 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/user/orca.md | 3 +- lib/devbase/commands/container.py | 16 +++- lib/devbase/commands/orca.py | 5 +- lib/devbase/volume/compose.py | 23 ++++-- tests/commands/test_orca.py | 3 +- tests/volume/test_compose_ssh_ports.py | 108 +++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 12 deletions(-) diff --git a/docs/user/orca.md b/docs/user/orca.md index f78d147..3dc6cfc 100644 --- a/docs/user/orca.md +++ b/docs/user/orca.md @@ -81,7 +81,7 @@ flowchart TD > **Note:** すでに `env init` 済みで公開鍵だけ追加・更新したい場合は `devbase env sync` を実行するか、`devbase env set SSH_AUTHORIZED_KEYS=...` で直接設定できます。**複数行(複数鍵)に対応**するため、Mac と Windows の両方から接続する場合は 1 行に 1 鍵ずつ両方を登録できます。 > -> 生成 config の `IdentityFile`(既定 `~/.ssh/id_ed25519`)は **Orca を動かすマシン上の秘密鍵パス**です。Windows から接続する場合は Windows のホームにある秘密鍵を指すため、登録した Windows の公開鍵と対応します。 +> 生成 config には `IdentityFile` を出力しません。SSH クライアント / Orca が既定の秘密鍵(`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`, …)を順に試行するため、`id_ed25519` でも `id_rsa` でも登録した公開鍵と対応する秘密鍵が使われます。特定の鍵を強制したい場合は、接続元マシンの `~/.ssh/config` で該当 `Host` に `IdentityFile` を追記してください。 ### 2. SSH を有効にして起動する(`ENABLE_SSH=true`) @@ -118,7 +118,6 @@ Host devbase-carmo-1 HostName 127.0.0.1 Port 2231 User ubuntu - IdentityFile ~/.ssh/id_ed25519 StrictHostKeyChecking accept-new ``` diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 1a3b0cb..3fd5336 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -591,10 +591,12 @@ def cmd_up(project_name: str = None, scale: int = None, logger.info("[3/6] Generating scaled compose file...") # 他プロジェクトが稼働 publish 済みのホストポートを best-effort でシードし、 - # SSH publish ポートの跨ぎ衝突 (bind 失敗) を回避する。 + # SSH publish ポートの跨ぎ衝突 (bind 失敗) を回避する。自プロジェクトの + # 稼働ポートは除外し、既存 dev-1..N の決定的ポートがずれないようにする。 override_file = generate_scaled_compose( scale, project_name, - external_ports_provider=_running_published_host_ports, + external_ports_provider=lambda: _running_published_host_ports( + exclude_project=project_name), ) logger.info("Generated: %s", override_file) @@ -733,9 +735,13 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: ensure_network('devbase_net') logger.info("[3/5] Generating scaled compose file...") + # scale では稼働中の自コンテナ (dev-1..現行数) が残ったまま compose を + # 再生成し --no-recreate する。自プロジェクトの publish を除外しないと + # 既存 dev-N の決定的ポートが「衝突」扱いでずれ、実コンテナと不一致になる。 override_file = generate_scaled_compose( new_scale, project_name, - external_ports_provider=_running_published_host_ports, + external_ports_provider=lambda: _running_published_host_ports( + exclude_project=project_name), ) logger.info("Generated: %s", override_file) @@ -764,6 +770,10 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: if deploy_script.exists() and deploy_script.is_file(): _run_deploy_script_for_instances(deploy_script, range(current_scale + 1, new_scale + 1)) + # Orca 連携: SSH 有効時に隔離 SSH config を再生成し、追加インスタンスを + # 反映する (up 経路と同様 best-effort。失敗しても scale の戻り値は変えない)。 + _maybe_orca_sync() + logger.info("=== Scale completed successfully ===") logger.info("Container scale: %d -> %d", current_scale, new_scale) logger.info("You can now login to the new containers:") diff --git a/lib/devbase/commands/orca.py b/lib/devbase/commands/orca.py index 49d858d..8366f3c 100644 --- a/lib/devbase/commands/orca.py +++ b/lib/devbase/commands/orca.py @@ -200,7 +200,10 @@ def _render_config(targets: Sequence[SSHTarget], hostname: str, user: str) -> st lines.append(f" HostName {hostname}") lines.append(f" Port {t.port}") lines.append(f" User {user}") - lines.append(" IdentityFile ~/.ssh/id_ed25519") + # IdentityFile はあえて出力しない。env init 側は id_ed25519 / id_rsa の + # いずれも公開鍵として収集するため、鍵種別を固定するとどちらか一方しか + # 持たないユーザーで不一致が起きる。SSH クライアント / Orca の既定の + # 秘密鍵解決 (id_ed25519, id_rsa, ... の順に試行) に委ねる。 lines.append(" StrictHostKeyChecking accept-new") lines.append("") return "\n".join(lines).rstrip("\n") + "\n" diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index eb0c518..a8da538 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -161,17 +161,25 @@ def _add_ssh_label(service: dict, label: str, value: str = '1') -> None: service['labels'] = {label: value} -def _running_published_host_ports() -> Set[int]: +def _running_published_host_ports(exclude_project: Optional[str] = None) -> Set[int]: """稼働中コンテナが publish 済みのホストポート集合を best-effort で返す。 別プロジェクトのコンテナが既に握っているホストポートとの衝突を避けるため、 compose 生成時に docker から現況を収集して :func:`allocate_ssh_host_port` の ``used_ports`` に混ぜる。docker が無い / 失敗しても空集合を返して生成を止めない (その場合は決定的ポートにそのままフォールバックする)。 + + ``exclude_project`` を指定すると、``com.docker.compose.project`` ラベルが一致する + コンテナ (= 現在のプロジェクト自身の dev-1..N) のポートは集合から除外する。 + これがないと ``devbase scale`` で稼働中の自コンテナの決定的ポートまで「衝突」と + 誤判定されて +1 ずれ、``--no-recreate`` で残る実コンテナと生成 compose が不一致に + なり (意図せぬ recreate / bind 失敗) を招くため、外部プロジェクトのポートだけを + 衝突回避シードにする。 """ try: result = subprocess.run( - ['docker', 'ps', '--format', '{{.Ports}}'], + ['docker', 'ps', '--format', + '{{.Label "com.docker.compose.project"}}\t{{.Ports}}'], capture_output=True, text=True, check=False, ) except (OSError, subprocess.SubprocessError): @@ -179,9 +187,14 @@ def _running_published_host_ports() -> Set[int]: if result.returncode != 0: return set() ports: Set[int] = set() - # 例: "127.0.0.1:2231->22/tcp, 0.0.0.0:8080->80/tcp" - for match in re.finditer(r":(\d+)->", result.stdout): - ports.add(int(match.group(1))) + # 例: "otherproj\t127.0.0.1:2231->22/tcp, 0.0.0.0:8080->80/tcp" + for line in result.stdout.splitlines(): + project, _, ports_field = line.partition('\t') + # 現在のプロジェクト自身の publish は衝突回避シードから除外する。 + if exclude_project is not None and project == exclude_project: + continue + for match in re.finditer(r":(\d+)->", ports_field): + ports.add(int(match.group(1))) return ports diff --git a/tests/commands/test_orca.py b/tests/commands/test_orca.py index 394af6d..c16c738 100644 --- a/tests/commands/test_orca.py +++ b/tests/commands/test_orca.py @@ -50,7 +50,8 @@ def test_render_config_basic_fields(): 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 + # IdentityFile は出力しない (id_ed25519 / id_rsa 両対応のため既定解決に委ねる)。 + assert "IdentityFile" not in out assert " StrictHostKeyChecking accept-new" in out diff --git a/tests/volume/test_compose_ssh_ports.py b/tests/volume/test_compose_ssh_ports.py index 1b5c66f..c506d86 100644 --- a/tests/volume/test_compose_ssh_ports.py +++ b/tests/volume/test_compose_ssh_ports.py @@ -266,3 +266,111 @@ def test_no_external_collision_keeps_deterministic_ports(in_tmp_cwd, monkeypatch for i in (1, 2): port = ssh_host_port("proj", i, 2200) assert _ssh_ports(scaled[f"dev-{i}"]) == [f"127.0.0.1:{port}:22"] + + +# --- _running_published_host_ports(): 自プロジェクト除外 (scale 誤 recreate 回避) --- + +class _FakePS: + """docker ps の CompletedProcess を模す軽量スタブ。""" + + def __init__(self, stdout: str, returncode: int = 0): + self.stdout = stdout + self.returncode = returncode + + +def _fake_docker_ps(monkeypatch, stdout: str, returncode: int = 0): + """compose.subprocess.run を差し替えて docker ps 出力を固定する。""" + def _run(cmd, *args, **kwargs): + return _FakePS(stdout, returncode) + monkeypatch.setattr(compose.subprocess, "run", _run) + + +def test_running_ports_excludes_own_project(monkeypatch): + """exclude_project に一致するコンテナ (自プロジェクト) のポートは除外される。 + + scale 再生成で自コンテナの決定的ポートを「衝突」と誤判定させないための要。 + """ + # 出力形式: '\t' + stdout = ( + "proj\t127.0.0.1:2231->22/tcp\n" + "proj\t127.0.0.1:2232->22/tcp\n" + ) + _fake_docker_ps(monkeypatch, stdout) + + got = compose._running_published_host_ports(exclude_project="proj") + assert got == set() # 自プロジェクトのポートはシードに含めない + + +def test_running_ports_includes_foreign_project(monkeypatch): + """他プロジェクトのポートは (exclude 指定があっても) 収集される。""" + stdout = ( + "proj\t127.0.0.1:2231->22/tcp\n" # 自プロジェクト → 除外 + "otherproj\t127.0.0.1:2299->22/tcp\n" # 他プロジェクト → 収集 + ) + _fake_docker_ps(monkeypatch, stdout) + + got = compose._running_published_host_ports(exclude_project="proj") + assert got == {2299} + + +def test_running_ports_no_exclude_collects_all(monkeypatch): + """exclude_project 未指定なら全コンテナのポートを収集する (up 経路の従来挙動)。""" + stdout = ( + "proj\t127.0.0.1:2231->22/tcp\n" + "otherproj\t0.0.0.0:8080->80/tcp\n" + ) + _fake_docker_ps(monkeypatch, stdout) + + got = compose._running_published_host_ports() + assert got == {2231, 8080} + + +def test_running_ports_empty_on_docker_failure(monkeypatch): + """docker ps が失敗 (returncode != 0) なら空集合を返し生成を止めない。""" + _fake_docker_ps(monkeypatch, stdout="", returncode=1) + assert compose._running_published_host_ports(exclude_project="proj") == set() + + +def test_scale_same_project_port_does_not_shift(in_tmp_cwd, monkeypatch): + """自プロジェクトの稼働ポートを除外するため既存 dev-N は決定的ポートを維持する。 + + docker ps が dev-1..2 の決定的ポートを publish 済みと報告しても、exclude により + シードから外れ、再生成 compose のポートは元の決定的値のまま (= recreate されない)。 + """ + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + det1 = ssh_host_port("proj", 1, 2200) + det2 = ssh_host_port("proj", 2, 2200) + stdout = ( + f"proj\t127.0.0.1:{det1}->22/tcp\n" + f"proj\t127.0.0.1:{det2}->22/tcp\n" + ) + _fake_docker_ps(monkeypatch, stdout) + + compose.generate_scaled_compose( + scale=2, project_name="proj", + external_ports_provider=lambda: compose._running_published_host_ports( + exclude_project="proj"), + ) + scaled = _load_scaled(in_tmp_cwd)["services"] + assert _ssh_ports(scaled["dev-1"]) == [f"127.0.0.1:{det1}:22"] + assert _ssh_ports(scaled["dev-2"]) == [f"127.0.0.1:{det2}:22"] + + +def test_scale_foreign_project_port_still_shifts(in_tmp_cwd, monkeypatch): + """他プロジェクトが dev-1 の決定的ポートを握る場合は従来どおり +1 へずらす。""" + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + det1 = ssh_host_port("proj", 1, 2200) + stdout = f"otherproj\t127.0.0.1:{det1}->22/tcp\n" + _fake_docker_ps(monkeypatch, stdout) + + compose.generate_scaled_compose( + scale=1, project_name="proj", + external_ports_provider=lambda: compose._running_published_host_ports( + exclude_project="proj"), + ) + scaled = _load_scaled(in_tmp_cwd)["services"] + assert _ssh_ports(scaled["dev-1"]) == [f"127.0.0.1:{det1 + 1}:22"] From 5b301d3fc1593a33866435f236e769bf9bac1179 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 13:53:06 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix(orca):=20sshd=20host=20key=20?= =?UTF-8?q?=E3=82=92=20install=20=E3=81=94=E3=81=A8=E3=81=AB=E4=B8=80?= =?UTF-8?q?=E6=84=8F=E7=94=9F=E6=88=90=20+=20=E5=91=BD=E5=90=8D/=E5=9E=8B?= =?UTF-8?q?=E6=B3=A8=E9=87=88=E3=81=AE=E6=95=B4=E5=90=88=20(round=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit クロスレビュー round 3 の指摘 3 件に対応。 - [major/security] entrypoint.sh: openssh-server がビルド時に生成する /etc/ssh/ssh_host_*_key を初回起動時に破棄してから ssh-keygen -A で再生成し、 イメージ由来の予測可能な共通 host key が全 install で共有される問題を修正。 生成/復元+永続化を flock で直列化し、同一ボリューム共有時の競合 (TOCTOU) を回避。 - [minor] compose._running_published_host_ports を public 名 get_running_published_host_ports へ改名 (定義/import/呼出/テスト全箇所)。 - [minor] cmd_orca の targets_provider 型注釈を Optional[Callable[[], Optional[List[SSHTarget]]]] へ修正 (None 返却に整合)。 - docs: host key が install ごとに一意生成される旨を追記 (orca.md / container-operations.md)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- containers/base/entrypoint.sh | 23 ++++++++++++++++------- docs/user/container-operations.md | 2 +- docs/user/orca.md | 2 ++ lib/devbase/commands/container.py | 6 +++--- lib/devbase/commands/orca.py | 2 +- lib/devbase/volume/compose.py | 4 ++-- tests/volume/test_compose_ssh_ports.py | 14 +++++++------- 7 files changed, 32 insertions(+), 21 deletions(-) diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 24672b9..e841f98 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -274,13 +274,22 @@ if [ "$ENABLE_SSH" = "true" ] || [ "$ENABLE_SSH" = "1" ]; then # HOST_KEY_DIR は .ssh symlink とは別の独立ディレクトリ(ドット無し)。 HOST_KEY_DIR="/persistent/ai/ssh" sudo mkdir -p "$HOST_KEY_DIR" || true - if ! sudo ls "$HOST_KEY_DIR"/ssh_host_*_key >/dev/null 2>&1; then - echo "Generating new sshd host keys..." - sudo ssh-keygen -A - sudo cp /etc/ssh/ssh_host_*_key* "$HOST_KEY_DIR"/ 2>/dev/null || true - fi - # 永続領域から /etc/ssh へ復元(毎回) - sudo cp "$HOST_KEY_DIR"/ssh_host_*_key* /etc/ssh/ 2>/dev/null || true + # host key の生成/復元と永続化を flock で直列化する。 + # 同一ボリュームを共有する複数インスタンス間の競合 (TOCTOU) を防ぐ。 + # flock は root 権限で lockfile を生成/取得する (HOST_KEY_DIR は root 所有)。 + sudo flock "$HOST_KEY_DIR/.hostkey.lock" -c ' + if ! ls /persistent/ai/ssh/ssh_host_*_key >/dev/null 2>&1; then + echo "Generating new install-unique sshd host keys..." + # イメージにビルド時焼き込みされた host key を先に除去してから再生成する。 + # 除去しないと ssh-keygen -A が既存キーを検出して何も生成せず、 + # イメージ由来の同一 (予測可能) な host key を全 install が共有してしまう。 + rm -f /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub + ssh-keygen -A + cp /etc/ssh/ssh_host_*_key* /persistent/ai/ssh/ 2>/dev/null || true + fi + # 永続領域から /etc/ssh へ復元(毎回) + cp /persistent/ai/ssh/ssh_host_*_key* /etc/ssh/ 2>/dev/null || true + ' || true # authorized_keys の展開(.ssh は /persistent/ai/.ssh へ symlink 済み) if [ -n "$SSH_AUTHORIZED_KEYS" ]; then diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index b6469d0..37e16f5 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -223,7 +223,7 @@ graph TD base イメージには `openssh-server` が含まれます。環境変数 `ENABLE_SSH=true`(または `1`)を指定してコンテナを起動すると、entrypoint が sshd を起動します(既定は無効)。 - 認証は公開鍵のみ(`SSH_AUTHORIZED_KEYS` に手元の公開鍵を設定)。 -- host key は `/persistent/ai/ssh/` に永続化され、再ビルド/再作成後も維持されます(Orca の known_hosts が壊れない)。 +- host key は初回起動時に install ごとに新規生成され(イメージ焼き込みの共通鍵は破棄)、`/persistent/ai/ssh/` に永続化されて再ビルド/再作成後も維持されます(Orca の known_hosts が壊れない)。 > **Note:** `openssh-server` の追加は base イメージの変更のため、既存イメージには `devbase build --no-cache`(base 再ビルド)が必要です。 diff --git a/docs/user/orca.md b/docs/user/orca.md index 3dc6cfc..8e2c963 100644 --- a/docs/user/orca.md +++ b/docs/user/orca.md @@ -216,6 +216,8 @@ devbase がコンテナ内 `sshd` を publish して「普通の SSH host」と sshd の host key はコンテナの `/persistent/ai/ssh/` に**永続化**され、再ビルド / 再作成時も同じ key が復元されます。したがって、再ビルド後も Orca 側 `known_hosts` の不一致警告は出ません。 +host key は初回起動時に、イメージに焼き込まれた鍵を破棄したうえで install ごとに新規生成されます(イメージ由来の予測可能な共通鍵は使いません)。生成と永続化は `flock` で直列化しているため、同一ボリュームを共有する複数インスタンスの競合でも安全です。 + 初回接続時は生成 config の `StrictHostKeyChecking accept-new` により、host key が自動で登録されます。 ### 接続できないときの確認順 diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 3fd5336..5dd8e70 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -17,7 +17,7 @@ from devbase.volume.compose import ( generate_scaled_compose, get_dev_service_name, - _running_published_host_ports, + get_running_published_host_ports, ) from devbase.utils.docker import ( docker_compose_down, @@ -595,7 +595,7 @@ def cmd_up(project_name: str = None, scale: int = None, # 稼働ポートは除外し、既存 dev-1..N の決定的ポートがずれないようにする。 override_file = generate_scaled_compose( scale, project_name, - external_ports_provider=lambda: _running_published_host_ports( + external_ports_provider=lambda: get_running_published_host_ports( exclude_project=project_name), ) logger.info("Generated: %s", override_file) @@ -740,7 +740,7 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: # 既存 dev-N の決定的ポートが「衝突」扱いでずれ、実コンテナと不一致になる。 override_file = generate_scaled_compose( new_scale, project_name, - external_ports_provider=lambda: _running_published_host_ports( + external_ports_provider=lambda: get_running_published_host_ports( exclude_project=project_name), ) logger.info("Generated: %s", override_file) diff --git a/lib/devbase/commands/orca.py b/lib/devbase/commands/orca.py index 8366f3c..2ff8900 100644 --- a/lib/devbase/commands/orca.py +++ b/lib/devbase/commands/orca.py @@ -282,7 +282,7 @@ def _cmd_status() -> int: def cmd_orca( devbase_root: Path, args, - targets_provider: Optional[Callable[[], List[SSHTarget]]] = None, + targets_provider: Optional[Callable[[], Optional[List[SSHTarget]]]] = None, ) -> int: """``devbase orca `` ディスパッチャ。 diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index a8da538..3b0611b 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -161,7 +161,7 @@ def _add_ssh_label(service: dict, label: str, value: str = '1') -> None: service['labels'] = {label: value} -def _running_published_host_ports(exclude_project: Optional[str] = None) -> Set[int]: +def get_running_published_host_ports(exclude_project: Optional[str] = None) -> Set[int]: """稼働中コンテナが publish 済みのホストポート集合を best-effort で返す。 別プロジェクトのコンテナが既に握っているホストポートとの衝突を避けるため、 @@ -293,7 +293,7 @@ def generate_scaled_compose( external_ports_provider: 他プロジェクトが稼働 publish 済みのホストポート集合を 返す関数 (SSH ポート衝突回避のシード)。None (既定) のときは外部ポートを シードしない (= 決定的ポートをそのまま使う。単体テストは docker 非依存)。 - 実行時の up 経路は :func:`_running_published_host_ports` を注入して + 実行時の up 経路は :func:`get_running_published_host_ports` を注入して 他プロジェクトとの衝突を best-effort で回避する。 Returns: diff --git a/tests/volume/test_compose_ssh_ports.py b/tests/volume/test_compose_ssh_ports.py index c506d86..7e9cdbb 100644 --- a/tests/volume/test_compose_ssh_ports.py +++ b/tests/volume/test_compose_ssh_ports.py @@ -268,7 +268,7 @@ def test_no_external_collision_keeps_deterministic_ports(in_tmp_cwd, monkeypatch assert _ssh_ports(scaled[f"dev-{i}"]) == [f"127.0.0.1:{port}:22"] -# --- _running_published_host_ports(): 自プロジェクト除外 (scale 誤 recreate 回避) --- +# --- get_running_published_host_ports(): 自プロジェクト除外 (scale 誤 recreate 回避) --- class _FakePS: """docker ps の CompletedProcess を模す軽量スタブ。""" @@ -297,7 +297,7 @@ def test_running_ports_excludes_own_project(monkeypatch): ) _fake_docker_ps(monkeypatch, stdout) - got = compose._running_published_host_ports(exclude_project="proj") + got = compose.get_running_published_host_ports(exclude_project="proj") assert got == set() # 自プロジェクトのポートはシードに含めない @@ -309,7 +309,7 @@ def test_running_ports_includes_foreign_project(monkeypatch): ) _fake_docker_ps(monkeypatch, stdout) - got = compose._running_published_host_ports(exclude_project="proj") + got = compose.get_running_published_host_ports(exclude_project="proj") assert got == {2299} @@ -321,14 +321,14 @@ def test_running_ports_no_exclude_collects_all(monkeypatch): ) _fake_docker_ps(monkeypatch, stdout) - got = compose._running_published_host_ports() + got = compose.get_running_published_host_ports() assert got == {2231, 8080} def test_running_ports_empty_on_docker_failure(monkeypatch): """docker ps が失敗 (returncode != 0) なら空集合を返し生成を止めない。""" _fake_docker_ps(monkeypatch, stdout="", returncode=1) - assert compose._running_published_host_ports(exclude_project="proj") == set() + assert compose.get_running_published_host_ports(exclude_project="proj") == set() def test_scale_same_project_port_does_not_shift(in_tmp_cwd, monkeypatch): @@ -350,7 +350,7 @@ def test_scale_same_project_port_does_not_shift(in_tmp_cwd, monkeypatch): compose.generate_scaled_compose( scale=2, project_name="proj", - external_ports_provider=lambda: compose._running_published_host_ports( + external_ports_provider=lambda: compose.get_running_published_host_ports( exclude_project="proj"), ) scaled = _load_scaled(in_tmp_cwd)["services"] @@ -369,7 +369,7 @@ def test_scale_foreign_project_port_still_shifts(in_tmp_cwd, monkeypatch): compose.generate_scaled_compose( scale=1, project_name="proj", - external_ports_provider=lambda: compose._running_published_host_ports( + external_ports_provider=lambda: compose.get_running_published_host_ports( exclude_project="proj"), ) scaled = _load_scaled(in_tmp_cwd)["services"] From 35cf788f7c10864e93c8d3a8bf7d5d633d10448b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 14:13:25 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix(orca):=20scale>=3D2=20=E3=81=AE=20Hos?= =?UTF-8?q?t=20=E8=A1=9D=E7=AA=81=20/=20=E9=8D=B5=E5=A4=B1=E5=8A=B9=20/=20?= =?UTF-8?q?User=20=E8=AA=A4=E8=A8=AD=E5=AE=9A=E3=82=92=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20(round4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit クロスレビュー round4 の major 指摘 3 件に対応。 1. [correctness] scale>=2 で Orca Host が衝突する問題を修正。 generate_scaled_compose は dev-1..N を別サービスとして展開するため compose の container-number が全て 1 になり、index が全 target で 1 に 潰れて Host devbase--1 が衝突していた。SSH publish 時に dev.devbase.index ラベルを付与し、_parse_inspect はこのラベルから index を読む (未設定時は container-number へフォールバック)。 2. [security] SSH_AUTHORIZED_KEYS を空にしても失効鍵で login できる問題を修正。 ~/.ssh は永続ストレージへの symlink のため、env を空にしても前回の authorized_keys が残っていた。空のときは rm -f で永続鍵を削除して失効を反映する。 3. [correctness] User にホストの USERNAME を使うと SSH 失敗する問題を修正。 Windows 上の Orca ホストでは USERNAME が Windows アカウント名になるが コンテナのログインユーザーは常に ubuntu。ambient な USERNAME は読まず ubuntu を既定とし、上書きは devbase 専用の DEVBASE_ORCA_USER のみに限定する。 keys.py に DEVBASE_ORCA_USER を追加し docs/user/orca.md にも記載。 Co-Authored-By: Claude Opus 4.8 (1M context) --- containers/base/entrypoint.sh | 6 ++- docs/user/orca.md | 1 + lib/devbase/commands/orca.py | 18 ++++++-- lib/devbase/env/keys.py | 1 + lib/devbase/volume/compose.py | 10 +++++ tests/commands/test_orca.py | 60 ++++++++++++++++++++++++-- tests/volume/test_compose_ssh_ports.py | 28 +++++++++++- 7 files changed, 115 insertions(+), 9 deletions(-) diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index e841f98..c5186d9 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -298,7 +298,11 @@ if [ "$ENABLE_SSH" = "true" ] || [ "$ENABLE_SSH" = "1" ]; then chmod 600 ~/.ssh/authorized_keys echo "authorized_keys installed" else - echo "Warning: SSH_AUTHORIZED_KEYS is empty; public-key login will not work" + # ~/.ssh は永続ストレージへの symlink のため、空にしただけでは前回書いた + # authorized_keys が残り、失効させたはずの鍵で login できてしまう。 + # env を空にしたら永続化された鍵を確実に削除して失効を反映する。 + rm -f ~/.ssh/authorized_keys + echo "Warning: SSH_AUTHORIZED_KEYS is empty; cleared persisted authorized_keys (public-key login disabled)" fi # sshd 起動(daemonize するため exec "$@" をブロックしない) diff --git a/docs/user/orca.md b/docs/user/orca.md index 8e2c963..08165de 100644 --- a/docs/user/orca.md +++ b/docs/user/orca.md @@ -150,6 +150,7 @@ Orca 連携に関わる環境変数です。`ENABLE_SSH` / `SSH_AUTHORIZED_KEYS` | `DEVBASE_SSH_BIND` | `127.0.0.1` | publish の bind 先。既定は外部非公開。LAN/Tailscale 直結時に上書きする | | `DEVBASE_SSH_PORT_BASE` | `2200` | publish ポートの算出起点。プロジェクト + index からのオフセットを加算する | | `DEVBASE_ORCA_HOSTNAME` | `127.0.0.1` | 生成 config の `HostName`。Tailscale 名や Mac の LAN IP へ上書きすると Windows から直結できる | +| `DEVBASE_ORCA_USER` | `ubuntu` | 生成 config の `User`。コンテナのログインユーザーは常に `ubuntu` なので通常は設定不要。コンテナの `USERNAME` build arg を上書きしたプロジェクトでのみ設定する(ホストの `USERNAME` は参照しない) | ## 接続パターン diff --git a/lib/devbase/commands/orca.py b/lib/devbase/commands/orca.py index 2ff8900..6c3a366 100644 --- a/lib/devbase/commands/orca.py +++ b/lib/devbase/commands/orca.py @@ -25,7 +25,7 @@ from devbase.env import keys from devbase.log import get_logger -from devbase.volume.compose import DEVBASE_SSH_LABEL +from devbase.volume.compose import DEVBASE_INDEX_LABEL, DEVBASE_SSH_LABEL logger = get_logger(__name__) @@ -66,7 +66,7 @@ def _config_path() -> Path: # --------------------------------------------------------------------------- def _parse_index(raw) -> int: - """compose の container-number ラベルを 1 始まり index に変換する。""" + """devbase index ラベルを 1 始まり index に変換する (不正値は 1 にフォールバック)。""" try: return int(raw) except (TypeError, ValueError): @@ -127,7 +127,14 @@ def _parse_inspect(containers, bind: Optional[str] = None) -> List[SSHTarget]: host_port = _pick_host_port(port_bindings, bind) if host_port is None: continue - index = _parse_index(labels.get("com.docker.compose.container-number")) + # index は devbase 専用ラベルから読む。generate_scaled_compose は dev-1..N を + # 別サービスとして展開するため compose の container-number は全て 1 となり、 + # それに頼ると scale>=2 で Host 名が衝突する (`dev.devbase.index` を SSH ラベルと + # 同時に付与している。念のため未設定時は container-number へフォールバック)。 + index = _parse_index( + labels.get(DEVBASE_INDEX_LABEL) + or labels.get("com.docker.compose.container-number") + ) targets.append(SSHTarget(project=project, index=index, port=host_port)) return targets @@ -214,7 +221,10 @@ def _write_config(targets: Sequence[SSHTarget]) -> Path: 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 + # コンテナのログインユーザーは常に ubuntu。ホストの ambient な USERNAME + # (Windows のアカウント名など) は読まない (User となり SSH 失敗する)。 + # コンテナ側で USERNAME build arg を上書きした場合のみ DEVBASE_ORCA_USER で明示上書きする。 + user = os.environ.get(keys.DEVBASE_ORCA_USER) or DEFAULT_USER path.write_text(_render_config(targets, hostname, user), encoding="utf-8") return path diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index e99218e..64931bd 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -60,6 +60,7 @@ def gcp_credentials_key(profile: str) -> str: 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) +DEVBASE_ORCA_USER = "DEVBASE_ORCA_USER" # 任意。生成 config の User (既定 ubuntu。コンテナの USERNAME build arg を上書きした時のみ設定) # --- Editor (devbase up 後の自動オープン / PLAN31_3) --- # DEVBASE_OPEN_EDITOR は env init (collectors/editor.py) で対話設定する (既定 1)。 diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 3b0611b..86f8631 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -22,6 +22,13 @@ # 絞り込む必須条件として参照する。ENABLE_SSH 有効時に :22 publish と同時に付与する。 DEVBASE_SSH_LABEL = 'dev.devbase.ssh' +# dev インスタンス番号 (1..N) を保持する devbase 専用ラベル。 +# generate_scaled_compose は各 dev- を「別サービス」として展開するため、 +# compose が付与する `com.docker.compose.container-number` は全インスタンスで 1 と +# なり index の識別に使えない。Orca 隔離 config 生成が Host 名の重複を避けられるよう、 +# ENABLE_SSH 有効時に SSH ラベルと同時にこのラベルで実 index を明示する。 +DEVBASE_INDEX_LABEL = 'dev.devbase.index' + def get_dev_service_name() -> str: """Get development service name from environment variable or default to 'dev'""" @@ -236,6 +243,9 @@ def _build_dev_instance( service.setdefault('ports', []).append(f"{bind}:{port}:22") # devbase の SSH publish コンテナを識別する専用ラベル (Orca 隔離の必須条件)。 _add_ssh_label(service, DEVBASE_SSH_LABEL) + # dev インスタンス番号を明示するラベル (compose の container-number は別サービス + # 展開のため全て 1 になり index 識別に使えないので、実 index をここで持たせる)。 + _add_ssh_label(service, DEVBASE_INDEX_LABEL, str(index)) return service diff --git a/tests/commands/test_orca.py b/tests/commands/test_orca.py index c16c738..7229ab3 100644 --- a/tests/commands/test_orca.py +++ b/tests/commands/test_orca.py @@ -29,6 +29,7 @@ def home_in_tmp(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.delenv("DEVBASE_ORCA_HOSTNAME", raising=False) monkeypatch.delenv("DEVBASE_SSH_BIND", raising=False) + monkeypatch.delenv("DEVBASE_ORCA_USER", raising=False) monkeypatch.delenv("USERNAME", raising=False) return tmp_path @@ -108,8 +109,9 @@ def test_hostname_defaults_to_loopback_when_unset(home_in_tmp): 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") +def test_orca_user_env_overrides_user(home_in_tmp, monkeypatch): + """devbase 専用の DEVBASE_ORCA_USER で User を上書きできる。""" + monkeypatch.setenv("DEVBASE_ORCA_USER", "devuser") path = orca._write_config([SSHTarget(project="carmo", index=1, port=2231)]) assert " User devuser" in path.read_text(encoding="utf-8") @@ -119,6 +121,19 @@ def test_user_defaults_to_ubuntu_when_unset(home_in_tmp): assert " User ubuntu" in path.read_text(encoding="utf-8") +def test_host_username_is_ignored_for_user(home_in_tmp, monkeypatch): + """ホストの ambient な USERNAME (Windows アカウント名等) は User に使わない。 + + Windows 上の Orca ホストでは USERNAME が Windows アカウント名になるが、 + コンテナのログインユーザーは常に ubuntu。ホスト USERNAME を読むと SSH 失敗する。 + """ + monkeypatch.setenv("USERNAME", "windows-account") + path = orca._write_config([SSHTarget(project="carmo", index=1, port=2231)]) + content = path.read_text(encoding="utf-8") + assert " User ubuntu" in content + assert "windows-account" not in content + + # --------------------------------------------------------------------------- # regenerate / prune: 稼働 0 のときヘッダのみ / 停止済みは消える # --------------------------------------------------------------------------- @@ -207,7 +222,7 @@ def test_cmd_orca_unknown_subcommand_returns_1(home_in_tmp): # --------------------------------------------------------------------------- def _container(project=None, number="1", ssh_port="2231", extra_ports=None, - ssh_label=True): + ssh_label=True, index=None): labels = {} if project is not None: labels["com.docker.compose.project"] = project @@ -215,6 +230,8 @@ def _container(project=None, number="1", ssh_port="2231", extra_ports=None, if ssh_label: # devbase が SSH publish 時に付ける専用ラベル (隔離の必須条件)。 labels["dev.devbase.ssh"] = "1" + # dev インスタンス番号を保持する専用ラベル (未指定なら number をそのまま使う)。 + labels["dev.devbase.index"] = number if index is None else str(index) ports = dict(extra_ports or {}) if ssh_port is not None: ports["22/tcp"] = [{"HostIp": "127.0.0.1", "HostPort": ssh_port}] @@ -277,3 +294,40 @@ def test_parse_inspect_prefers_bind_matching_host_ip(): }] targets = orca._parse_inspect(containers, bind="127.0.0.1") assert targets == [SSHTarget(project="carmo", index=1, port=2231)] + + +def test_parse_inspect_uses_devbase_index_label_not_container_number(): + """別サービス展開で container-number が全て 1 でも index が衝突しない。 + + generate_scaled_compose は dev-1..N を「別サービス」として展開するため、 + compose の container-number は全インスタンスで 1 になる。`dev.devbase.index` を + 読むことで scale>=2 でも Host devbase--1 / -2 と正しく分離できる。 + """ + containers = [ + _container(project="carmo", number="1", index=1, ssh_port="2231"), + _container(project="carmo", number="1", index=2, ssh_port="2232"), + ] + targets = sorted(orca._parse_inspect(containers), key=lambda t: t.index) + assert targets == [ + SSHTarget(project="carmo", index=1, port=2231), + SSHTarget(project="carmo", index=2, port=2232), + ] + 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-carmo-2"] + + +def test_parse_inspect_falls_back_to_container_number_without_index_label(): + """index ラベルが無い場合は従来どおり container-number へフォールバックする。""" + container = { + "Config": {"Labels": { + "com.docker.compose.project": "carmo", + "com.docker.compose.container-number": "3", + "dev.devbase.ssh": "1", + }}, + "NetworkSettings": {"Ports": {"22/tcp": [ + {"HostIp": "127.0.0.1", "HostPort": "2233"}, + ]}}, + } + assert orca._parse_inspect([container]) == [ + SSHTarget(project="carmo", index=3, port=2233)] diff --git a/tests/volume/test_compose_ssh_ports.py b/tests/volume/test_compose_ssh_ports.py index 7e9cdbb..03cb4ea 100644 --- a/tests/volume/test_compose_ssh_ports.py +++ b/tests/volume/test_compose_ssh_ports.py @@ -11,7 +11,7 @@ import pytest from devbase.volume import compose -from devbase.volume.compose import DEVBASE_SSH_LABEL +from devbase.volume.compose import DEVBASE_INDEX_LABEL, DEVBASE_SSH_LABEL from devbase.volume.ports import ssh_host_port, allocate_ssh_host_port, _stable_hash @@ -116,6 +116,32 @@ def test_ssh_label_absent_when_disabled(in_tmp_cwd): assert DEVBASE_SSH_LABEL not in _labels_dict(scaled["dev-1"]) +def test_index_label_injected_per_instance(in_tmp_cwd, monkeypatch): + """ENABLE_SSH=true なら各 dev- に実 index を持つ専用ラベルが付く。 + + compose の container-number は別サービス展開のため全て 1 になるので、Orca 隔離 + config が Host 名の重複を避けられるよう index を明示するラベルを持たせる。 + """ + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=3, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + for i in (1, 2, 3): + assert _labels_dict(scaled[f"dev-{i}"]).get(DEVBASE_INDEX_LABEL) == str(i) + + +def test_index_label_absent_when_disabled(in_tmp_cwd): + """ENABLE_SSH 未設定なら index ラベルも付かない。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + assert DEVBASE_INDEX_LABEL not in _labels_dict(scaled["dev-1"]) + + @pytest.mark.parametrize("truthy", ["true", "True", "TRUE", "1"]) def test_enable_ssh_truthy_values(in_tmp_cwd, monkeypatch, truthy): """'true'/'True'/'1' などを大文字小文字を問わず有効と解釈する。""" From 9c35679c966686dc212d56087b182b24c7a69791 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 14:29:18 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix(orca):=20User=20=E3=81=AE=20per-targe?= =?UTF-8?q?t=20=E5=8C=96=20/=20stale=20=E5=89=AA=E5=AE=9A=20/=20SSH=5FPORT?= =?UTF-8?q?=5FBASE=20=E6=A4=9C=E8=A8=BC=20(round5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cross-project sync が全エントリへ実行プロジェクトの User を一律適用し、 container user の異なる別プロジェクトのエントリがログイン不能になる問題を修正。 compose 生成時に dev.devbase.user ラベルへ各プロジェクト自身の解決済みユーザーを 焼き込み、_parse_inspect が per-target で読み取って SSHTarget.user に持たせる。 _render_config はエントリ毎に User を出力する。 - ENABLE_SSH を true→false に切り替えて再 up した際、停止したコンテナのエントリが Orca config に残る問題を修正。_maybe_orca_sync を「SSH 有効 または config 既存」で 再生成するよう変更。config 未作成の純粋な非 Orca ユーザーでは従来どおり何もしない (無用なファイル生成を避ける)。orca.config_exists() を追加。 - DEVBASE_SSH_PORT_BASE が非整数だと up が stacktrace で落ちる問題を修正。 int 変換を try/except で握り DockerError に変換 (変数名と値を明示)。確保後の ホストポートが 1..65535 を超える場合も DockerError にする。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/container.py | 13 +++- lib/devbase/commands/orca.py | 49 ++++++++---- lib/devbase/volume/compose.py | 39 +++++++++- tests/commands/test_container_orca_sync.py | 76 +++++++++++++++++++ tests/commands/test_orca.py | 87 ++++++++++++++++++---- tests/volume/test_compose_ssh_ports.py | 68 ++++++++++++++++- 6 files changed, 297 insertions(+), 35 deletions(-) create mode 100644 tests/commands/test_container_orca_sync.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 5dd8e70..1122540 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -448,13 +448,18 @@ def _ssh_enabled() -> bool: def _maybe_orca_sync() -> None: """up 完了後に Orca 用 SSH config を best-effort で再生成する (PLAN33)。 - ENABLE_SSH が有効なときのみ実行する (SSH 無効なら同期不要)。失敗しても - warning のみで up の戻り値には影響させない。import は遅延させて起動コストを避ける。 + 再生成の条件は「ENABLE_SSH が有効」または「Orca config が既に存在する」。 + 後者は ENABLE_SSH を true→false に切り替えて再 up した場合に、停止した + コンテナのエントリが古いまま残るのを剪定するため (config が存在する = 以前 + Orca を設定済み)。ENABLE_SSH の gate を無条件に外すと、Orca を一切使わない + ユーザーの up でも毎回 config ファイルを新規生成してしまうため、config 未作成 + (= 純粋な非 Orca ユーザー) のときは従来どおり何もしない。失敗しても warning + のみで up の戻り値には影響させない。import は遅延させて起動コストを避ける。 """ - if not _ssh_enabled(): + from devbase.commands.orca import config_exists, regenerate_config + if not _ssh_enabled() and not config_exists(): 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 を倒さない diff --git a/lib/devbase/commands/orca.py b/lib/devbase/commands/orca.py index 6c3a366..1431c1c 100644 --- a/lib/devbase/commands/orca.py +++ b/lib/devbase/commands/orca.py @@ -25,7 +25,9 @@ from devbase.env import keys from devbase.log import get_logger -from devbase.volume.compose import DEVBASE_INDEX_LABEL, DEVBASE_SSH_LABEL +from devbase.volume.compose import ( + DEVBASE_INDEX_LABEL, DEVBASE_SSH_LABEL, DEVBASE_USER_LABEL, +) logger = get_logger(__name__) @@ -50,10 +52,17 @@ class OrcaEnumerationError(RuntimeError): @dataclass(frozen=True) class SSHTarget: - """1 コンテナぶんの Orca SSH target。""" + """1 コンテナぶんの Orca SSH target。 + + ``user`` は各コンテナ自身のログインユーザー。sync は稼働中の全プロジェクトを + 横断集約するため、集約側で一律ユーザーを適用すると container user が異なる別 + プロジェクトのエントリで User が食い違う。そこで生成時に compose が焼き込んだ + ``dev.devbase.user`` ラベルから per-target で読み取り、エントリ毎に持たせる。 + """ project: str index: int port: int + user: str = DEFAULT_USER def _config_path() -> Path: @@ -61,6 +70,14 @@ def _config_path() -> Path: return Path.home() / ".config" / "devbase" / "orca" / "ssh_config" +def config_exists() -> bool: + """Orca 用 SSH config が既に存在するか (= 以前 Orca を設定済みか) を返す。 + + up フックが ENABLE_SSH 無効化後も剪定すべきかの判定に使う。 + """ + return _config_path().exists() + + # --------------------------------------------------------------------------- # コンテナ列挙 (docker inspect ベース。名前の dash split はしない) # --------------------------------------------------------------------------- @@ -135,7 +152,12 @@ def _parse_inspect(containers, bind: Optional[str] = None) -> List[SSHTarget]: labels.get(DEVBASE_INDEX_LABEL) or labels.get("com.docker.compose.container-number") ) - targets.append(SSHTarget(project=project, index=index, port=host_port)) + # ログインユーザーは各コンテナ自身のラベルから読む (集約側で一律適用しない)。 + # ラベル未設定の旧コンテナは既定 ubuntu へフォールバックする。 + user = labels.get(DEVBASE_USER_LABEL) or DEFAULT_USER + targets.append( + SSHTarget(project=project, index=index, port=host_port, user=user) + ) return targets @@ -195,18 +217,19 @@ def _running_ssh_targets() -> Optional[List[SSHTarget]]: # config レンダリング / 書き込み # --------------------------------------------------------------------------- -def _render_config(targets: Sequence[SSHTarget], hostname: str, user: str) -> str: +def _render_config(targets: Sequence[SSHTarget], hostname: str) -> str: """SSH target 群から config テキストを生成する純関数。 - エントリは (project, index) 昇順で安定ソートする。target が空でもヘッダのみの - 安全な空ファイルを返す。 + エントリは (project, index) 昇順で安定ソートする。``User`` は各 target 自身の + 値を出力する (プロジェクト毎に container user が異なりうるため一律適用しない)。 + 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(f" User {t.user}") # IdentityFile はあえて出力しない。env init 側は id_ed25519 / id_rsa の # いずれも公開鍵として収集するため、鍵種別を固定するとどちらか一方しか # 持たないユーザーで不一致が起きる。SSH クライアント / Orca の既定の @@ -217,15 +240,15 @@ def _render_config(targets: Sequence[SSHTarget], hostname: str, user: str) -> st def _write_config(targets: Sequence[SSHTarget]) -> Path: - """config を全再生成して書き込み、パスを返す。親ディレクトリは作成する。""" + """config を全再生成して書き込み、パスを返す。親ディレクトリは作成する。 + + User は各 target が自プロジェクトの ``dev.devbase.user`` ラベルから既に持って + いるため、集約側 (ここ) ではユーザーを解決しない。HostName のみ env で調整する。 + """ path = _config_path() path.parent.mkdir(parents=True, exist_ok=True) hostname = os.environ.get(keys.DEVBASE_ORCA_HOSTNAME) or DEFAULT_HOSTNAME - # コンテナのログインユーザーは常に ubuntu。ホストの ambient な USERNAME - # (Windows のアカウント名など) は読まない (User となり SSH 失敗する)。 - # コンテナ側で USERNAME build arg を上書きした場合のみ DEVBASE_ORCA_USER で明示上書きする。 - user = os.environ.get(keys.DEVBASE_ORCA_USER) or DEFAULT_USER - path.write_text(_render_config(targets, hostname, user), encoding="utf-8") + path.write_text(_render_config(targets, hostname), encoding="utf-8") return path diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 86f8631..4b8b5ca 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -9,7 +9,9 @@ from typing import Any, Callable, Dict, Optional, Set from devbase.errors import DockerError -from devbase.env.keys import ENABLE_SSH, DEVBASE_SSH_BIND, DEVBASE_SSH_PORT_BASE +from devbase.env.keys import ( + ENABLE_SSH, DEVBASE_SSH_BIND, DEVBASE_SSH_PORT_BASE, DEVBASE_ORCA_USER, +) from .manager import get_work_volume_for_index, get_ai_volume_for_index from .ports import allocate_ssh_host_port @@ -29,6 +31,17 @@ # ENABLE_SSH 有効時に SSH ラベルと同時にこのラベルで実 index を明示する。 DEVBASE_INDEX_LABEL = 'dev.devbase.index' +# コンテナのログインユーザー (SSH の User) を保持する devbase 専用ラベル。 +# Orca 隔離 config は稼働中の全プロジェクトを横断集約するため、実行プロジェクトの +# DEVBASE_ORCA_USER を全エントリに一律適用すると、container user が異なる別プロジェクト +# のエントリで User が食い違いログインできなくなる。生成時に各プロジェクト自身の +# 解決済みユーザーをこのラベルへ焼き込み、`_parse_inspect` が per-target で読み取る。 +DEVBASE_USER_LABEL = 'dev.devbase.user' + +# SSH publish のホストポートとして許容する範囲 (TCP ポート番号)。 +_MIN_TCP_PORT = 1 +_MAX_TCP_PORT = 65535 + def get_dev_service_name() -> str: """Get development service name from environment variable or default to 'dev'""" @@ -235,10 +248,27 @@ def _build_dev_instance( # can attach as a plain SSH host (PLAN33). Opt-in via ENABLE_SSH. if os.environ.get(ENABLE_SSH, '').lower() in ('true', '1'): bind = os.environ.get(DEVBASE_SSH_BIND, '127.0.0.1') - base = int(os.environ.get(DEVBASE_SSH_PORT_BASE, '2200')) + # base が整数でないと int() が ValueError を送出し up がスタックトレースで + # 落ちるため、明示的に握って変数名と値を含む DockerError に変換する。 + base_raw = os.environ.get(DEVBASE_SSH_PORT_BASE, '2200') + try: + base = int(base_raw) + except (TypeError, ValueError): + raise DockerError( + f"{DEVBASE_SSH_PORT_BASE} は整数で指定してください " + f"(指定値: {base_raw!r})" + ) # 決定的ポートを優先しつつ、同一生成内の他インスタンスや他プロジェクトの # 稼働 publish と衝突する場合は空きポートへずらして bind 失敗を避ける。 port = allocate_ssh_host_port(project_name, index, base, used_ports) + # 巨大な base (や衝突回避で +N ずれた結果) が 65535 を超えると compose が + # 無効になり up 時に bind 失敗するため、確保後のポートを範囲検証する。 + if not (_MIN_TCP_PORT <= port <= _MAX_TCP_PORT): + raise DockerError( + f"SSH publish のホストポート {port} が有効範囲 " + f"({_MIN_TCP_PORT}..{_MAX_TCP_PORT}) を超えました。" + f"{DEVBASE_SSH_PORT_BASE} を調整してください。" + ) used_ports.add(port) service.setdefault('ports', []).append(f"{bind}:{port}:22") # devbase の SSH publish コンテナを識別する専用ラベル (Orca 隔離の必須条件)。 @@ -246,6 +276,11 @@ def _build_dev_instance( # dev インスタンス番号を明示するラベル (compose の container-number は別サービス # 展開のため全て 1 になり index 識別に使えないので、実 index をここで持たせる)。 _add_ssh_label(service, DEVBASE_INDEX_LABEL, str(index)) + # このプロジェクト自身の解決済みログインユーザーをラベルへ焼き込む。Orca 同期は + # 全プロジェクトを横断集約するため、集約側で一律ユーザーを適用せず各エントリが + # 自プロジェクトの値を持てるよう per-target で保持する。 + resolved_user = os.environ.get(DEVBASE_ORCA_USER) or 'ubuntu' + _add_ssh_label(service, DEVBASE_USER_LABEL, resolved_user) return service diff --git a/tests/commands/test_container_orca_sync.py b/tests/commands/test_container_orca_sync.py new file mode 100644 index 0000000..a3c312e --- /dev/null +++ b/tests/commands/test_container_orca_sync.py @@ -0,0 +1,76 @@ +"""commands/container.py: up 後の Orca 同期ゲート (_maybe_orca_sync) (PLAN33 / round5) + +`_maybe_orca_sync` は「ENABLE_SSH 有効」または「Orca config が既に存在する」とき +再生成する。後者により、ENABLE_SSH を true→false へ切り替えて再 up した場合に停止した +コンテナの古いエントリが剪定される。config 未作成の純粋な非 Orca ユーザーでは何もしない +(無用なファイル生成を避ける)。実 docker は呼ばず、依存関数を monkeypatch して検証する。 +""" + +from __future__ import annotations + +import pytest + +from devbase.commands import container +from devbase.commands import orca + + +def _stub_regenerate(monkeypatch): + """orca.regenerate_config を呼び出し回数カウンタ付きスタブへ差し替える。""" + calls = {"n": 0} + + def _fake(): + calls["n"] += 1 + return [], orca._config_path() + + monkeypatch.setattr(orca, "regenerate_config", _fake) + return calls + + +def test_sync_skipped_when_disabled_and_no_config(monkeypatch): + """SSH 無効 + config 未作成 (純粋な非 Orca ユーザー) なら再生成しない。""" + monkeypatch.setattr(container, "_ssh_enabled", lambda: False) + monkeypatch.setattr(orca, "config_exists", lambda: False) + calls = _stub_regenerate(monkeypatch) + + container._maybe_orca_sync() + + assert calls["n"] == 0 + + +def test_sync_runs_when_disabled_but_config_exists(monkeypatch): + """SSH 無効でも config が既に存在すれば再生成する (停止コンテナの剪定)。 + + ENABLE_SSH=true → false の切り替えで残る stale エントリをヘッダのみへ剪定する経路。 + """ + monkeypatch.setattr(container, "_ssh_enabled", lambda: False) + monkeypatch.setattr(orca, "config_exists", lambda: True) + calls = _stub_regenerate(monkeypatch) + + container._maybe_orca_sync() + + assert calls["n"] == 1 + + +def test_sync_runs_when_ssh_enabled(monkeypatch): + """SSH 有効なら config の有無に依らず再生成する (従来挙動)。""" + monkeypatch.setattr(container, "_ssh_enabled", lambda: True) + monkeypatch.setattr(orca, "config_exists", lambda: False) + calls = _stub_regenerate(monkeypatch) + + container._maybe_orca_sync() + + assert calls["n"] == 1 + + +def test_sync_never_raises_on_regenerate_failure(monkeypatch): + """再生成が例外を投げても best-effort で握り潰し up を倒さない。""" + monkeypatch.setattr(container, "_ssh_enabled", lambda: True) + monkeypatch.setattr(orca, "config_exists", lambda: False) + + def _boom(): + raise RuntimeError("docker down") + + monkeypatch.setattr(orca, "regenerate_config", _boom) + + # 例外が伝播しないこと。 + container._maybe_orca_sync() diff --git a/tests/commands/test_orca.py b/tests/commands/test_orca.py index 7229ab3..e294929 100644 --- a/tests/commands/test_orca.py +++ b/tests/commands/test_orca.py @@ -44,7 +44,7 @@ def _args(subcommand): 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") + out = orca._render_config(targets, hostname="127.0.0.1") assert out.startswith("# Managed by devbase") assert "Host devbase-carmo-1" in out @@ -62,7 +62,7 @@ def test_render_config_sorts_by_project_then_index(): 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") + out = orca._render_config(targets, hostname="127.0.0.1") order = [ out.index("Host devbase-alpha-1"), @@ -78,7 +78,7 @@ def test_render_config_isolation_only_devbase_hosts(): 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") + out = orca._render_config(targets, hostname="127.0.0.1") host_lines = [ln for ln in out.splitlines() if ln.startswith("Host ")] assert host_lines == ["Host devbase-carmo-1", "Host devbase-orca-web-1"] @@ -86,13 +86,13 @@ def test_render_config_isolation_only_devbase_hosts(): def test_render_config_empty_targets_is_header_only(): - out = orca._render_config([], hostname="127.0.0.1", user="ubuntu") + out = orca._render_config([], hostname="127.0.0.1") assert out.strip() == orca._HEADER assert "Host " not in out # --------------------------------------------------------------------------- -# HostName / User の env 上書き (書き込み経由で確認) +# HostName の env 上書き / User の per-target 出力 # --------------------------------------------------------------------------- def test_orca_hostname_env_overrides_hostname(home_in_tmp, monkeypatch): @@ -109,14 +109,38 @@ def test_hostname_defaults_to_loopback_when_unset(home_in_tmp): assert " HostName 127.0.0.1" in path.read_text(encoding="utf-8") -def test_orca_user_env_overrides_user(home_in_tmp, monkeypatch): - """devbase 専用の DEVBASE_ORCA_USER で User を上書きできる。""" - monkeypatch.setenv("DEVBASE_ORCA_USER", "devuser") - path = orca._write_config([SSHTarget(project="carmo", index=1, port=2231)]) - assert " User devuser" in path.read_text(encoding="utf-8") +def test_render_uses_per_target_user(): + """User は各 target 自身の値を出力する (集約側で一律適用しない)。""" + out = orca._render_config( + [SSHTarget(project="carmo", index=1, port=2231, user="devuser")], + hostname="127.0.0.1", + ) + assert " User devuser" in out + +def test_render_distinct_users_per_target(): + """container user の異なる 2 プロジェクトはそれぞれ別の User 行を出力する。 -def test_user_defaults_to_ubuntu_when_unset(home_in_tmp): + sync が全プロジェクトを横断集約しても、実行プロジェクトのユーザーを全エントリに + 一律適用せず各エントリが自プロジェクトの User を持つことを保証する。 + """ + out = orca._render_config( + [ + SSHTarget(project="alpha", index=1, port=2231, user="ubuntu"), + SSHTarget(project="bravo", index=1, port=2331, user="devuser"), + ], + hostname="127.0.0.1", + ) + lines = out.splitlines() + alpha_i = lines.index("Host devbase-alpha-1") + bravo_i = lines.index("Host devbase-bravo-1") + # 各 Host ブロック内の User 行がそれぞれのユーザーになっている。 + assert " User ubuntu" in lines[alpha_i:bravo_i] + assert " User devuser" in lines[bravo_i:] + + +def test_user_defaults_to_ubuntu_when_label_absent(home_in_tmp): + """user 未指定の target (ラベル無し旧コンテナ相当) は既定 ubuntu を出力する。""" path = orca._write_config([SSHTarget(project="carmo", index=1, port=2231)]) assert " User ubuntu" in path.read_text(encoding="utf-8") @@ -124,8 +148,8 @@ def test_user_defaults_to_ubuntu_when_unset(home_in_tmp): def test_host_username_is_ignored_for_user(home_in_tmp, monkeypatch): """ホストの ambient な USERNAME (Windows アカウント名等) は User に使わない。 - Windows 上の Orca ホストでは USERNAME が Windows アカウント名になるが、 - コンテナのログインユーザーは常に ubuntu。ホスト USERNAME を読むと SSH 失敗する。 + Windows 上の Orca ホストでは USERNAME が Windows アカウント名になるが、User は + 各 target 自身の値 (既定 ubuntu) を使うため、ホスト USERNAME は一切参照しない。 """ monkeypatch.setenv("USERNAME", "windows-account") path = orca._write_config([SSHTarget(project="carmo", index=1, port=2231)]) @@ -222,7 +246,7 @@ def test_cmd_orca_unknown_subcommand_returns_1(home_in_tmp): # --------------------------------------------------------------------------- def _container(project=None, number="1", ssh_port="2231", extra_ports=None, - ssh_label=True, index=None): + ssh_label=True, index=None, user=None): labels = {} if project is not None: labels["com.docker.compose.project"] = project @@ -232,6 +256,9 @@ def _container(project=None, number="1", ssh_port="2231", extra_ports=None, labels["dev.devbase.ssh"] = "1" # dev インスタンス番号を保持する専用ラベル (未指定なら number をそのまま使う)。 labels["dev.devbase.index"] = number if index is None else str(index) + # ログインユーザーラベル (user 指定時のみ付与。未指定は旧コンテナ相当)。 + if user is not None: + labels["dev.devbase.user"] = user ports = dict(extra_ports or {}) if ssh_port is not None: ports["22/tcp"] = [{"HostIp": "127.0.0.1", "HostPort": ssh_port}] @@ -312,7 +339,7 @@ def test_parse_inspect_uses_devbase_index_label_not_container_number(): SSHTarget(project="carmo", index=1, port=2231), SSHTarget(project="carmo", index=2, port=2232), ] - out = orca._render_config(targets, hostname="127.0.0.1", user="ubuntu") + out = orca._render_config(targets, hostname="127.0.0.1") host_lines = [ln for ln in out.splitlines() if ln.startswith("Host ")] assert host_lines == ["Host devbase-carmo-1", "Host devbase-carmo-2"] @@ -331,3 +358,33 @@ def test_parse_inspect_falls_back_to_container_number_without_index_label(): } assert orca._parse_inspect([container]) == [ SSHTarget(project="carmo", index=3, port=2233)] + + +def test_parse_inspect_reads_user_label_per_target(): + """各コンテナの dev.devbase.user ラベルを per-target で読み取り User に反映する。 + + container user の異なる 2 プロジェクトを集約しても、実行プロジェクトのユーザーを + 一律適用せず各エントリが自プロジェクトの User を持つ (round5 の major fix)。 + """ + containers = [ + _container(project="alpha", number="1", ssh_port="2231", user="ubuntu"), + _container(project="bravo", number="1", ssh_port="2331", user="devuser"), + ] + targets = {t.project: t for t in orca._parse_inspect(containers)} + assert targets["alpha"].user == "ubuntu" + assert targets["bravo"].user == "devuser" + + out = orca._render_config(list(targets.values()), hostname="127.0.0.1") + lines = out.splitlines() + alpha_i = lines.index("Host devbase-alpha-1") + bravo_i = lines.index("Host devbase-bravo-1") + assert " User ubuntu" in lines[alpha_i:bravo_i] + assert " User devuser" in lines[bravo_i:] + + +def test_parse_inspect_missing_user_label_defaults_ubuntu(): + """dev.devbase.user ラベルが無い (旧コンテナ) 場合は既定 ubuntu になる。""" + containers = [_container(project="carmo", number="1", ssh_port="2231")] + targets = orca._parse_inspect(containers) + assert targets == [SSHTarget(project="carmo", index=1, port=2231, user="ubuntu")] + assert targets[0].user == "ubuntu" diff --git a/tests/volume/test_compose_ssh_ports.py b/tests/volume/test_compose_ssh_ports.py index 03cb4ea..6991649 100644 --- a/tests/volume/test_compose_ssh_ports.py +++ b/tests/volume/test_compose_ssh_ports.py @@ -10,8 +10,11 @@ import yaml import pytest +from devbase.errors import DockerError from devbase.volume import compose -from devbase.volume.compose import DEVBASE_INDEX_LABEL, DEVBASE_SSH_LABEL +from devbase.volume.compose import ( + DEVBASE_INDEX_LABEL, DEVBASE_SSH_LABEL, DEVBASE_USER_LABEL, +) from devbase.volume.ports import ssh_host_port, allocate_ssh_host_port, _stable_hash @@ -36,6 +39,7 @@ def in_tmp_cwd(tmp_path, monkeypatch): monkeypatch.delenv("ENABLE_SSH", raising=False) monkeypatch.delenv("DEVBASE_SSH_BIND", raising=False) monkeypatch.delenv("DEVBASE_SSH_PORT_BASE", raising=False) + monkeypatch.delenv("DEVBASE_ORCA_USER", raising=False) return tmp_path @@ -142,6 +146,68 @@ def test_index_label_absent_when_disabled(in_tmp_cwd): assert DEVBASE_INDEX_LABEL not in _labels_dict(scaled["dev-1"]) +# --- user ラベル (Orca per-target User の元データ) --- + +def test_user_label_defaults_to_ubuntu(in_tmp_cwd, monkeypatch): + """ENABLE_SSH=true で DEVBASE_ORCA_USER 未設定なら user ラベルは既定 ubuntu。""" + monkeypatch.setenv("ENABLE_SSH", "true") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=2, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + for i in (1, 2): + assert _labels_dict(scaled[f"dev-{i}"]).get(DEVBASE_USER_LABEL) == "ubuntu" + + +def test_user_label_reflects_orca_user_env(in_tmp_cwd, monkeypatch): + """DEVBASE_ORCA_USER を上書きすると user ラベルにそのプロジェクトの値が焼き込まれる。""" + monkeypatch.setenv("ENABLE_SSH", "true") + monkeypatch.setenv("DEVBASE_ORCA_USER", "devuser") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + assert _labels_dict(scaled["dev-1"]).get(DEVBASE_USER_LABEL) == "devuser" + + +def test_user_label_absent_when_disabled(in_tmp_cwd): + """ENABLE_SSH 未設定なら user ラベルも付かない。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1, project_name="proj") + scaled = _load_scaled(in_tmp_cwd)["services"] + + assert DEVBASE_USER_LABEL not in _labels_dict(scaled["dev-1"]) + + +# --- DEVBASE_SSH_PORT_BASE のバリデーション --- + +def test_non_integer_port_base_raises_docker_error(in_tmp_cwd, monkeypatch): + """非整数の DEVBASE_SSH_PORT_BASE は stacktrace ではなく DockerError にする。""" + monkeypatch.setenv("ENABLE_SSH", "true") + monkeypatch.setenv("DEVBASE_SSH_PORT_BASE", "not-a-number") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + with pytest.raises(DockerError) as exc: + compose.generate_scaled_compose(scale=1, project_name="proj") + # 変数名と不正値をメッセージに含める。 + assert "DEVBASE_SSH_PORT_BASE" in str(exc.value) + assert "not-a-number" in str(exc.value) + + +def test_port_base_over_max_raises_docker_error(in_tmp_cwd, monkeypatch): + """算出ホストポートが 65535 を超える巨大 base は DockerError にする。""" + monkeypatch.setenv("ENABLE_SSH", "true") + monkeypatch.setenv("DEVBASE_SSH_PORT_BASE", "70000") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + with pytest.raises(DockerError) as exc: + compose.generate_scaled_compose(scale=1, project_name="proj") + assert "65535" in str(exc.value) + + @pytest.mark.parametrize("truthy", ["true", "True", "TRUE", "1"]) def test_enable_ssh_truthy_values(in_tmp_cwd, monkeypatch, truthy): """'true'/'True'/'1' などを大文字小文字を問わず有効と解釈する。""" From 5c2641e08d374873c3ea4b96a2759b8169ca0483 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 14:38:17 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix(orca):=20=5Fmaybe=5Forca=5Fprune=20?= =?UTF-8?q?=E3=82=92=20config=5Fexists()=20=E3=81=A7=E3=82=B2=E3=83=BC?= =?UTF-8?q?=E3=83=88=20(round6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit down 後の Orca SSH config 剪定が regenerate_config() を無条件に呼んでいたため、 Orca/SSH を一切使わないユーザーの devbase down でも毎回 ~/.config/devbase/orca/ ssh_config (親ディレクトリ + ヘッダ) が新規生成されていた。round5 で _maybe_orca_sync に追加した config_exists() ゲートと同じ扱いにし、config が 既に存在するときのみ剪定するよう修正する。剪定ゲートの test を 3 件追加。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/commands/container.py | 9 +++-- tests/commands/test_container_orca_sync.py | 38 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 1122540..8ace306 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -470,10 +470,15 @@ def _maybe_orca_prune() -> None: """down 後に Orca 用 SSH config を best-effort で剪定する (PLAN33)。 稼働中コンテナから再生成するだけで停止済みエントリは自然に落ちる (prune ≡ - regenerate)。ENABLE_SSH の有無に依らず実行してよい。失敗しても warning のみ。 + regenerate)。ただし config が未作成の純粋な非 Orca ユーザーでは、剪定と称して + 毎回 config ファイル (親ディレクトリ + ヘッダ) を新規生成してしまうため、 + config が既に存在するときのみ実行する (_maybe_orca_sync と同じゲート)。 + 失敗しても warning のみで down の戻り値には影響させない。 """ try: - from devbase.commands.orca import regenerate_config + from devbase.commands.orca import config_exists, regenerate_config + if not config_exists(): + return regenerate_config() except Exception as e: # noqa: BLE001 - Orca 剪定で down を倒さない logger.warning("Orca SSH config の剪定に失敗しました: %s", e) diff --git a/tests/commands/test_container_orca_sync.py b/tests/commands/test_container_orca_sync.py index a3c312e..46f9ef4 100644 --- a/tests/commands/test_container_orca_sync.py +++ b/tests/commands/test_container_orca_sync.py @@ -74,3 +74,41 @@ def _boom(): # 例外が伝播しないこと。 container._maybe_orca_sync() + + +def test_prune_skipped_when_no_config(monkeypatch): + """down 後の剪定: config 未作成 (純粋な非 Orca ユーザー) なら再生成しない。 + + _maybe_orca_prune が無条件に regenerate_config を呼ぶと、Orca を一切使わない + ユーザーの down でも毎回 config ファイル (親ディレクトリ + ヘッダ) を生成して + しまう。config が存在しないときは何もしないことを保証する (round6)。 + """ + monkeypatch.setattr(orca, "config_exists", lambda: False) + calls = _stub_regenerate(monkeypatch) + + container._maybe_orca_prune() + + assert calls["n"] == 0 + + +def test_prune_runs_when_config_exists(monkeypatch): + """down 後の剪定: config が既に存在すれば再生成し停止済みエントリを剪定する。""" + monkeypatch.setattr(orca, "config_exists", lambda: True) + calls = _stub_regenerate(monkeypatch) + + container._maybe_orca_prune() + + assert calls["n"] == 1 + + +def test_prune_never_raises_on_regenerate_failure(monkeypatch): + """剪定の再生成が例外を投げても best-effort で握り潰し down を倒さない。""" + monkeypatch.setattr(orca, "config_exists", lambda: True) + + def _boom(): + raise RuntimeError("docker down") + + monkeypatch.setattr(orca, "regenerate_config", _boom) + + # 例外が伝播しないこと。 + container._maybe_orca_prune() From 107acd623e7d6c423104716a5eba4d2583c60d0b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 14 Jul 2026 14:45:48 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix(orca):=20SSH=5FAUTHORIZED=5FKEYS=20?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=83=B3=E3=83=97=E3=83=88=E3=81=AE=E5=85=AC?= =?UTF-8?q?=E9=96=8B=E9=8D=B5=E3=82=92=E7=9F=AD=E7=B8=AE=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公開鍵は数百文字・複数行になり得るため、そのまま safe_input の プロンプトへ埋め込むとターミナル表示が崩れる。鍵種別と末尾数文字 + (設定済み) の短縮表示に変更。Enter で維持される既定値 (default 引数) はフル鍵のままで、保存値のセマンティクスは不変。 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/devbase/env/collectors/orca.py | 26 ++++++++++- tests/env/test_collector_orca.py | 70 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/env/test_collector_orca.py diff --git a/lib/devbase/env/collectors/orca.py b/lib/devbase/env/collectors/orca.py index acf4144..27b6d41 100644 --- a/lib/devbase/env/collectors/orca.py +++ b/lib/devbase/env/collectors/orca.py @@ -41,6 +41,25 @@ def _default_public_key() -> str: return "" +def _abbrev_key_for_prompt(value: str) -> str: + """公開鍵をプロンプト表示用に短縮した文字列を返す。 + + 公開鍵は数百文字・複数行になり得るため、そのまま ``safe_input`` の + プロンプトへ ``[{value}]`` として埋め込むとターミナル表示が崩れる。 + 鍵種別 (``ssh-ed25519`` 等) と本体末尾の数文字だけを示し、後ろに + ``(設定済み)`` を付けて「Enter で既存値を維持できる」ことを伝える。 + 実際の既定値 (フル鍵) は呼び出し側で ``safe_input`` の ``default`` 引数 + として渡すため、表示を短縮しても Enter 時に返る値は変わらない。 + """ + first = value.strip().splitlines()[0] if value.strip() else "" + parts = first.split() + if len(parts) >= 2 and parts[0].startswith("ssh-"): + key_type, body = parts[0], parts[1] + tail = body[-6:] if len(body) > 6 else body + return f"{key_type} …{tail} (設定済み)" + return "(設定済み)" + + def collect_orca_info(env_file: EnvFile) -> None: """Orca 連携情報 (SSH 公開鍵 / HostName) を対話的に収集する""" print("\n=== Orca 連携 (SSH 公開鍵) ===") @@ -52,7 +71,12 @@ def collect_orca_info(env_file: EnvFile) -> None: print(" ※ 登録するのは Orca を動かすマシンの公開鍵です (Windows の Orca なら Windows 側の鍵)。") 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) + # 公開鍵は長大・複数行になり得るので、プロンプト表示は短縮する + # (Enter で維持される既定値は default_keys 全体のまま)。 + value = safe_input( + f"{keys.SSH_AUTHORIZED_KEYS} [{_abbrev_key_for_prompt(default_keys)}]: ", + default_keys, + ) if value: env_file.set(keys.SSH_AUTHORIZED_KEYS, value) else: diff --git a/tests/env/test_collector_orca.py b/tests/env/test_collector_orca.py new file mode 100644 index 0000000..b76c37e --- /dev/null +++ b/tests/env/test_collector_orca.py @@ -0,0 +1,70 @@ +"""collectors/orca.py: Orca 連携 (SSH 公開鍵) コレクタ""" + +from __future__ import annotations + +import builtins + +import pytest + +from devbase.env import keys +from devbase.env.store import EnvFile +from devbase.env.collectors import orca + + +@pytest.fixture +def env_file(tmp_path): + return EnvFile(tmp_path / ".env") + + +# 長大な (数百文字相当) ダミー公開鍵 +_LONG_KEY = "ssh-ed25519 " + ("A" * 400) + "1234ZZ user@host" + + +def _patch_input(monkeypatch, responses, captured=None): + """input() を順番に responses で返すモックに差し替える。 + + responses が尽きたら EOFError を送出し、非対話 (EOF) 経路を再現する。 + captured を渡すと呼び出し時の prompt 文字列を追記する。 + """ + it = iter(responses) + + def fake_input(prompt=""): + if captured is not None: + captured.append(prompt) + try: + return next(it) + except StopIteration: + raise EOFError + + monkeypatch.setattr(builtins, "input", fake_input) + + +def test_abbrev_key_for_prompt_shortens_ssh_key(): + """ssh- 形式の鍵は種別 + 末尾数文字 + (設定済み) に短縮される""" + out = orca._abbrev_key_for_prompt(_LONG_KEY) + assert out == "ssh-ed25519 …1234ZZ (設定済み)" + # 元の鍵より十分短い / 生の鍵本体を含まない + assert len(out) < 40 + assert "A" * 20 not in out + + +def test_abbrev_key_for_prompt_multiline_and_unknown(): + """複数行・非 ssh- 形式は (設定済み) にフォールバックする""" + assert orca._abbrev_key_for_prompt("garbage\nsecond line") == "(設定済み)" + assert orca._abbrev_key_for_prompt("") == "(設定済み)" + + +def test_prompt_is_abbreviated_but_default_is_full_key(monkeypatch, env_file): + """プロンプト表示は短縮され、Enter (EOF) では full 鍵が既定として保存される""" + env_file.set(keys.SSH_AUTHORIZED_KEYS, _LONG_KEY) + captured: list[str] = [] + _patch_input(monkeypatch, [], captured=captured) # 全入力 EOF + + orca.collect_orca_info(env_file) + + # プロンプトに生の鍵本体が漏れていない (短縮表示) + ssh_prompt = next(p for p in captured if keys.SSH_AUTHORIZED_KEYS in p) + assert "A" * 20 not in ssh_prompt + assert "(設定済み)" in ssh_prompt + # 保存 (既定) 値はフル鍵のまま + assert env_file.get(keys.SSH_AUTHORIZED_KEYS) == _LONG_KEY