Skip to content

Commit 12fde8d

Browse files
takemi-ohamaclaude
andauthored
feat: PLAN33-port-publish コンテナ SSH ポートの publish (#83)
* chore: PLAN33-port-publish Draft PR 作成 * feat(compose): ENABLE_SSH 時に SSH ポートを決定的に publish (PLAN33 PR2) Orca からコンテナへ SSH 接続できるよう、ENABLE_SSH=true のとき generate_scaled_compose が各 dev-<index> サービスへ <bind>:<port>: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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4a35f7 commit 12fde8d

4 files changed

Lines changed: 240 additions & 4 deletions

File tree

lib/devbase/env/keys.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ def gcp_credentials_key(profile: str) -> str:
5151
HOST_SSH_USER = "HOST_SSH_USER"
5252
HOST_SSH_HOST = "HOST_SSH_HOST" # 任意。default: host.docker.internal
5353

54+
# --- SSH server (Orca 連携 / PLAN33) ---
55+
# ENABLE_SSH=true のとき entrypoint が sshd を起動し、compose 生成が :22 を publish する。
56+
# publish ポートはプロジェクト名+index から決定的に算出する (lib/devbase/volume/ports.py)。
57+
# 詳細: docs/user/orca.md
58+
ENABLE_SSH = "ENABLE_SSH" # 真偽。sshd を起動し :22 を publish するか
59+
DEVBASE_SSH_BIND = "DEVBASE_SSH_BIND" # 任意。publish の bind 先 (既定 127.0.0.1)
60+
DEVBASE_SSH_PORT_BASE = "DEVBASE_SSH_PORT_BASE" # 任意。ポート算出の起点 (既定 2200)
61+
5462
# --- Editor (devbase up 後の自動オープン / PLAN31_3) ---
5563
# DEVBASE_OPEN_EDITOR は env init (collectors/editor.py) で対話設定する (既定 1)。
5664
# 他はプロジェクト env / グローバル .env に手書きする devbase 動作設定。

lib/devbase/volume/compose.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
from typing import Any, Dict, Optional
88

99
from devbase.errors import DockerError
10+
from devbase.env.keys import ENABLE_SSH, DEVBASE_SSH_BIND, DEVBASE_SSH_PORT_BASE
1011

1112
from .manager import get_work_volume_for_index, get_ai_volume_for_index
13+
from .ports import ssh_host_port
1214

1315
# 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する
1416
_DEPRECATED_TARGET = '/home/ubuntu'
@@ -142,7 +144,7 @@ def _load_compose_config(compose_file: Path) -> dict:
142144

143145

144146
def _build_dev_instance(
145-
dev_service: dict, dev_service_name: str, index: int,
147+
dev_service: dict, dev_service_name: str, index: int, project_name: str,
146148
) -> dict:
147149
"""Build the service definition for one scaled dev instance (dev-<index>)."""
148150
service = copy.deepcopy(dev_service)
@@ -161,11 +163,21 @@ def _build_dev_instance(
161163
service['volumes'] = _replace_volumes_for_instance(
162164
service.get('volumes', []), ai_volume, work_volume,
163165
)
166+
167+
# Publish the container's sshd (:22) to a deterministic host port so Orca
168+
# can attach as a plain SSH host (PLAN33). Opt-in via ENABLE_SSH.
169+
if os.environ.get(ENABLE_SSH, '').lower() in ('true', '1'):
170+
bind = os.environ.get(DEVBASE_SSH_BIND, '127.0.0.1')
171+
base = int(os.environ.get(DEVBASE_SSH_PORT_BASE, '2200'))
172+
port = ssh_host_port(project_name, index, base)
173+
service.setdefault('ports', []).append(f"{bind}:{port}:22")
174+
164175
return service
165176

166177

167178
def _build_scaled_services(
168179
services: dict, dev_service: dict, dev_service_name: str, scale: int,
180+
project_name: str,
169181
) -> dict:
170182
"""Build the services section: non-dev services + dev-1..dev-N instances."""
171183
scaled_services = {}
@@ -186,7 +198,7 @@ def _build_scaled_services(
186198
# Generate a service for each instance
187199
for i in range(1, scale + 1):
188200
scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance(
189-
dev_service, dev_service_name, i,
201+
dev_service, dev_service_name, i, project_name,
190202
)
191203
return scaled_services
192204

@@ -202,7 +214,8 @@ def generate_scaled_compose(
202214
203215
Args:
204216
scale: Number of container instances
205-
project_name: Project name (unused, kept for backward compatibility)
217+
project_name: Project name. Used for deterministic SSH port allocation
218+
(PLAN33) when ENABLE_SSH is set.
206219
compose_file: Source compose file path (default: compose.yml)
207220
dev_service_name: Name of the development service to scale (default: from DEV_SERVICE_NAME env or 'dev')
208221
@@ -224,7 +237,7 @@ def generate_scaled_compose(
224237

225238
scaled_config = {
226239
'services': _build_scaled_services(
227-
services, dev_service, dev_service_name, scale,
240+
services, dev_service, dev_service_name, scale, project_name,
228241
),
229242
'volumes': _build_volumes_section(config, scale),
230243
'networks': _build_networks_section(config),

lib/devbase/volume/ports.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""SSH publish 用のホストポートを決定的に算出する (PLAN33)。
2+
3+
Orca は publish された `127.0.0.1:<port>` を known_hosts / SSH config で参照するため、
4+
同じ `(project_name, index)` は **常に同じポート** に解決されなければならない
5+
(`down` → `up` を跨いでも一定であること)。
6+
7+
そのため Python 組み込みの `hash()` は使わない。CPython は起動ごとに文字列ハッシュへ
8+
salt を混ぜる (PYTHONHASHSEED) ため、プロセスを跨ぐと値が変わり決定性が崩れる。
9+
代わりに `hashlib.sha1` ベースの安定ハッシュ (`_stable_hash`) を用いる。
10+
11+
異なるプロジェクト / index はほぼ衝突しないようオフセットを分散させる。
12+
"""
13+
14+
import hashlib
15+
16+
17+
def _stable_hash(value: str) -> int:
18+
"""プロセスや実行を跨いで一定な非負整数ハッシュを返す。
19+
20+
組み込み `hash()` は salt されるため使わず、SHA-1 ダイジェストを整数化する。
21+
"""
22+
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()
23+
return int(digest, 16)
24+
25+
26+
def ssh_host_port(project_name: str, index: int, base: int = 2200) -> int:
27+
"""`(project_name, index)` から publish 先ホストポートを決定的に算出する。
28+
29+
Args:
30+
project_name: プロジェクト名 (COMPOSE_PROJECT_NAME)。
31+
index: dev インスタンス番号 (1 始まり)。
32+
base: ポート算出の起点 (既定 2200)。
33+
34+
Returns:
35+
`base + offset` のホストポート。同じ引数は常に同じ値を返す。
36+
offset = (stable_hash(project_name) % 100) * 10 + (index - 1)
37+
により、プロジェクト間は 10 刻みで分散し、同一プロジェクト内の
38+
index 差分は +1 ずつずれる (0..990 + 0..9 の範囲)。
39+
"""
40+
offset = (_stable_hash(project_name) % 100) * 10 + (index - 1)
41+
return base + offset
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""compose.py: ENABLE_SSH 時の SSH ポート publish 挙動 (PLAN33 / PR2)
2+
3+
`_build_dev_instance()` は ENABLE_SSH が有効なとき、各 dev-<index> サービスへ
4+
`<bind>:<port>:22` の publish を注入する。ポートは `ssh_host_port()` により
5+
プロジェクト名 + index から決定的に算出され、`down`→`up` を跨いでも一定である。
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import yaml
11+
import pytest
12+
13+
from devbase.volume import compose
14+
from devbase.volume.ports import ssh_host_port, _stable_hash
15+
16+
17+
@pytest.fixture
18+
def in_tmp_cwd(tmp_path, monkeypatch):
19+
"""生成物 (.docker-compose.scale.yml) が散らからないよう CWD を tmp に移す。"""
20+
monkeypatch.chdir(tmp_path)
21+
monkeypatch.delenv("DEV_SERVICE_NAME", raising=False)
22+
# SSH 系 env を既定で無効化 (外部環境に左右されないよう明示的に消す)
23+
monkeypatch.delenv("ENABLE_SSH", raising=False)
24+
monkeypatch.delenv("DEVBASE_SSH_BIND", raising=False)
25+
monkeypatch.delenv("DEVBASE_SSH_PORT_BASE", raising=False)
26+
return tmp_path
27+
28+
29+
def _write_compose(tmp_path, services: dict) -> None:
30+
(tmp_path / "compose.yml").write_text(
31+
yaml.safe_dump({"services": services}, sort_keys=False),
32+
encoding="utf-8",
33+
)
34+
35+
36+
def _load_scaled(tmp_path) -> dict:
37+
return yaml.safe_load((tmp_path / ".docker-compose.scale.yml").read_text())
38+
39+
40+
def _ssh_ports(service: dict) -> list:
41+
"""service の ports から `:22` を publish するエントリだけ抜き出す。"""
42+
return [p for p in service.get("ports", []) if str(p).endswith(":22")]
43+
44+
45+
# --- ENABLE_SSH 無効時 ---
46+
47+
def test_no_ssh_ports_when_enable_ssh_unset(in_tmp_cwd):
48+
"""ENABLE_SSH 未設定なら :22 の publish は注入されない。"""
49+
_write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}})
50+
51+
compose.generate_scaled_compose(scale=2, project_name="proj")
52+
scaled = _load_scaled(in_tmp_cwd)["services"]
53+
54+
for i in (1, 2):
55+
assert _ssh_ports(scaled[f"dev-{i}"]) == []
56+
57+
58+
def test_no_ssh_ports_when_enable_ssh_false(in_tmp_cwd, monkeypatch):
59+
"""ENABLE_SSH=false なら :22 の publish は注入されない。"""
60+
monkeypatch.setenv("ENABLE_SSH", "false")
61+
_write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}})
62+
63+
compose.generate_scaled_compose(scale=1, project_name="proj")
64+
scaled = _load_scaled(in_tmp_cwd)["services"]
65+
66+
assert _ssh_ports(scaled["dev-1"]) == []
67+
68+
69+
# --- ENABLE_SSH 有効時 ---
70+
71+
def test_ssh_ports_injected_when_enabled(in_tmp_cwd, monkeypatch):
72+
"""ENABLE_SSH=true なら各 dev-<index> に 127.0.0.1:<port>:22 が付く。"""
73+
monkeypatch.setenv("ENABLE_SSH", "true")
74+
_write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}})
75+
76+
compose.generate_scaled_compose(scale=2, project_name="proj")
77+
scaled = _load_scaled(in_tmp_cwd)["services"]
78+
79+
for i in (1, 2):
80+
port = ssh_host_port("proj", i, 2200)
81+
assert _ssh_ports(scaled[f"dev-{i}"]) == [f"127.0.0.1:{port}:22"]
82+
83+
84+
@pytest.mark.parametrize("truthy", ["true", "True", "TRUE", "1"])
85+
def test_enable_ssh_truthy_values(in_tmp_cwd, monkeypatch, truthy):
86+
"""'true'/'True'/'1' などを大文字小文字を問わず有効と解釈する。"""
87+
monkeypatch.setenv("ENABLE_SSH", truthy)
88+
_write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}})
89+
90+
compose.generate_scaled_compose(scale=1, project_name="proj")
91+
scaled = _load_scaled(in_tmp_cwd)["services"]
92+
93+
assert len(_ssh_ports(scaled["dev-1"])) == 1
94+
95+
96+
def test_existing_ports_are_preserved(in_tmp_cwd, monkeypatch):
97+
"""既存の ports は保持され、SSH publish が追記される。"""
98+
monkeypatch.setenv("ENABLE_SSH", "true")
99+
_write_compose(in_tmp_cwd, {
100+
"dev": {"image": "dev:latest", "ports": ["8080:8080"]},
101+
})
102+
103+
compose.generate_scaled_compose(scale=1, project_name="proj")
104+
scaled = _load_scaled(in_tmp_cwd)["services"]
105+
106+
ports = scaled["dev-1"]["ports"]
107+
assert "8080:8080" in ports
108+
assert len(_ssh_ports({"ports": ports})) == 1
109+
110+
111+
# --- bind / base の env 反映 ---
112+
113+
def test_ssh_bind_is_honored(in_tmp_cwd, monkeypatch):
114+
"""DEVBASE_SSH_BIND が publish の bind 先に反映される。"""
115+
monkeypatch.setenv("ENABLE_SSH", "true")
116+
monkeypatch.setenv("DEVBASE_SSH_BIND", "0.0.0.0")
117+
_write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}})
118+
119+
compose.generate_scaled_compose(scale=1, project_name="proj")
120+
scaled = _load_scaled(in_tmp_cwd)["services"]
121+
122+
port = ssh_host_port("proj", 1, 2200)
123+
assert _ssh_ports(scaled["dev-1"]) == [f"0.0.0.0:{port}:22"]
124+
125+
126+
def test_ssh_port_base_is_honored(in_tmp_cwd, monkeypatch):
127+
"""DEVBASE_SSH_PORT_BASE がポート算出の起点に反映される。"""
128+
monkeypatch.setenv("ENABLE_SSH", "true")
129+
monkeypatch.setenv("DEVBASE_SSH_PORT_BASE", "3000")
130+
_write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}})
131+
132+
compose.generate_scaled_compose(scale=1, project_name="proj")
133+
scaled = _load_scaled(in_tmp_cwd)["services"]
134+
135+
port = ssh_host_port("proj", 1, 3000)
136+
assert port >= 3000
137+
assert _ssh_ports(scaled["dev-1"]) == [f"127.0.0.1:{port}:22"]
138+
139+
140+
# --- ssh_host_port() の決定性・衝突回避 ---
141+
142+
def test_ssh_host_port_is_deterministic():
143+
"""同じ (project, index) は毎回同じポートに解決する (純粋関数)。"""
144+
a = ssh_host_port("carmo", 1, 2200)
145+
b = ssh_host_port("carmo", 1, 2200)
146+
assert a == b
147+
148+
149+
def test_stable_hash_is_not_builtin_hash_salted():
150+
"""_stable_hash は既知の固定値を返す (プロセス跨ぎで一定)。"""
151+
# sha1('proj') の整数化を 100 で割った剰余は実装非依存に確定する。
152+
assert _stable_hash("proj") == _stable_hash("proj")
153+
assert isinstance(_stable_hash("proj"), int)
154+
assert _stable_hash("proj") >= 0
155+
156+
157+
def test_different_projects_get_different_ports():
158+
"""別プロジェクトは (ほぼ) 別ポートに解決する。"""
159+
ports = {ssh_host_port(name, 1, 2200)
160+
for name in ("carmo", "alpha", "bravo", "charlie", "delta")}
161+
# 5 個中 4 個以上はユニーク (100 バケットなので衝突は稀)
162+
assert len(ports) >= 4
163+
164+
165+
def test_index_shifts_port_within_project():
166+
"""同一プロジェクト内では index が +1 ずつポートをずらす。"""
167+
p1 = ssh_host_port("proj", 1, 2200)
168+
p2 = ssh_host_port("proj", 2, 2200)
169+
assert p2 == p1 + 1
170+
171+
172+
def test_base_offsets_port():
173+
"""base を変えるとポートも同じ差分だけずれる。"""
174+
assert ssh_host_port("proj", 1, 3000) == ssh_host_port("proj", 1, 2200) + 800

0 commit comments

Comments
 (0)