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