Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/devbase/env/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 動作設定。
Expand Down
21 changes: 17 additions & 4 deletions lib/devbase/volume/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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-<index>)."""
service = copy.deepcopy(dev_service)
Expand All @@ -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 = {}
Expand All @@ -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

Expand All @@ -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')

Expand All @@ -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),
Expand Down
41 changes: 41 additions & 0 deletions lib/devbase/volume/ports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""SSH publish 用のホストポートを決定的に算出する (PLAN33)。

Orca は publish された `127.0.0.1:<port>` を 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
174 changes: 174 additions & 0 deletions tests/volume/test_compose_ssh_ports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""compose.py: ENABLE_SSH 時の SSH ポート publish 挙動 (PLAN33 / PR2)

`_build_dev_instance()` は ENABLE_SSH が有効なとき、各 dev-<index> サービスへ
`<bind>:<port>: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-<index> に 127.0.0.1:<port>: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