diff --git a/README.md b/README.md index 5295b3e..3d08d0d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ 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)) ## クイックスタート @@ -149,8 +148,8 @@ 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) | カテゴリ別の問題と解決策 | +| [Orca 削除の移行ガイド](docs/user/orca-removal-migration.md) | 旧 Orca(SSH) 接続の廃止と Remote-SSH への移行手順 | ### プラグイン開発者向け diff --git a/bin/devbase b/bin/devbase index aafa84c..8b2db6e 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 orca up down login build rebuild ps scale list help" + local commands="init status project container ct env plugin pl snapshot ss 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|orca|up|down|login|ps|scale|rebuild|list) + init|status|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|rebuild|list) run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands # diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index 8078ec4..4fdf716 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -13,7 +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 \ + openssh-client \ curl ca-certificates gnupg lsb-release \ libnss3 libxrandr2 libxss1 \ fonts-noto-cjk fonts-noto-cjk-extra; \ @@ -124,12 +124,6 @@ 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 c5186d9..77db99e 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -261,55 +261,6 @@ 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 - # 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 - 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 - # ~/.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 "$@" をブロックしない) - 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/README.md b/docs/README.md index 90d9588..182e3ca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,8 +48,8 @@ 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) | カテゴリ別の問題と解決策 | +| [Orca 削除の移行ガイド](user/orca-removal-migration.md) | 旧 Orca(SSH) 接続の廃止と Remote-SSH への移行手順 | **推奨の読み順:** @@ -96,7 +96,6 @@ docs/ │ ├── environment-variables.md ← 環境変数ガイド │ ├── container-operations.md ← コンテナ操作ガイド │ ├── snapshot-guide.md ← スナップショットガイド -│ ├── orca.md ← Orca 接続ガイド │ └── troubleshooting.md ← トラブルシューティング ├── plugin-dev/ ← プラグイン開発者向け │ ├── quickstart.md ← クイックスタート @@ -119,8 +118,8 @@ docs/ | 環境変数を設定する | [環境変数ガイド](user/environment-variables.md#環境変数の操作) | | 複数コンテナで並行開発する | [コンテナ操作ガイド](user/container-operations.md#並行開発) | | データをバックアップ・復元する | [スナップショットガイド](user/snapshot-guide.md) | -| Orca からコンテナへ接続する | [Orca 接続ガイド](user/orca.md) | | エラーが発生した | [トラブルシューティング](user/troubleshooting.md) | +| 旧 Orca 接続から移行する | [Orca 削除の移行ガイド](user/orca-removal-migration.md) | | プラグインを作りたい | [プラグイン開発クイックスタート](plugin-dev/quickstart.md) | | devbase 本体に貢献したい | [コントリビューション](developer/contributing.md) | diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 37e16f5..c5b1838 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -218,15 +218,6 @@ 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 は初回起動時に install ごとに新規生成され(イメージ焼き込みの共通鍵は破棄)、`/persistent/ai/ssh/` に永続化されて再ビルド/再作成後も維持されます(Orca の known_hosts が壊れない)。 - -> **Note:** `openssh-server` の追加は base イメージの変更のため、既存イメージには `devbase build --no-cache`(base 再ビルド)が必要です。 - ### AI CLI エイリアス general イメージ以降のコンテナ内では、以下の AI CLI ツールがエイリアスとして利用可能です。 diff --git a/docs/user/orca-removal-migration.md b/docs/user/orca-removal-migration.md new file mode 100644 index 0000000..1aeb273 --- /dev/null +++ b/docs/user/orca-removal-migration.md @@ -0,0 +1,41 @@ +# Orca / コンテナ内 sshd 廃止に伴う移行ガイド(breaking change) + +## 要旨 + +base image から **sshd を廃止**し、`devbase orca` 連携および `ENABLE_SSH` / `SSH_AUTHORIZED_KEYS` +などの SSH 公開まわりの CLI・compose・env を削除しました。 +これにより、**コンテナへ直接 SSH で入る従来の Orca 接続は使えなくなります**(breaking change)。 + +## 影響 + +- 「**Windows Orca → Mac → コンテナ内 sshd**」でコンテナに接続していた利用者。 +- 旧 project env に残る `ENABLE_SSH=true` / `SSH_AUTHORIZED_KEYS` 等は**無視されます**(エラーにはなりません)。 + +## 暫定の代替(agent orchestration 実装前) + +コンテナ内 sshd の代わりに、**Windows の VS Code を Mac へ Remote-SSH** し、Mac 上から +コンテナに入る運用に切り替えてください。 + +1. Mac 側で **Remote Login (sshd)** を有効化する(システム設定 > 一般 > 共有 > リモートログイン。 + 既に有効な環境なら不要)。 +2. Windows の VS Code に **Remote-SSH 拡張**を入れ、Mac(`takemi_ohama@`)へ接続する。 +3. 接続した Mac 上で対象プロジェクトに移動してコンテナへ入る。従来どおり git worktree / AI CLI が使える。 + + ```bash + cd $DEVBASE_ROOT/projects/ + devbase up # 未起動なら + devbase login # 既定は index 1。複数コンテナなら `devbase login 2` のように index を指定 + ``` + + (または `devbase list` の TUI からプロジェクト/コンテナを選んで login することもできる。) + +## 今後 + +Issue 34 の agent orchestration(`docker exec` + tmux + VS Code Extension、 +詳細は [`issues/i34-orcalike.md`](../../issues/i34-orcalike.md))が、 +単一 window でコンテナに入る標準経路になる予定です。 + +## クリーンアップ(任意) + +生成済みの `~/.config/devbase/orca/ssh_config` と `/persistent/ai/ssh` は**自動削除しません**。 +不要であれば手動で削除できます。 diff --git a/docs/user/orca.md b/docs/user/orca.md deleted file mode 100644 index 08165de..0000000 --- a/docs/user/orca.md +++ /dev/null @@ -1,236 +0,0 @@ -# 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`) - -`SSH_AUTHORIZED_KEYS` には **Orca を動かすマシンの公開鍵**を登録します。この値は entrypoint がコンテナ内の `~/.ssh/authorized_keys` へ展開し、Orca からの公開鍵認証に使われます。認証に使う秘密鍵は Orca 側(下記 config の `IdentityFile`)にあるため、両者が対になっている必要があります。 - -`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=...` で直接設定できます。**複数行(複数鍵)に対応**するため、Mac と Windows の両方から接続する場合は 1 行に 1 鍵ずつ両方を登録できます。 -> -> 生成 config には `IdentityFile` を出力しません。SSH クライアント / Orca が既定の秘密鍵(`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`, …)を順に試行するため、`id_ed25519` でも `id_rsa` でも登録した公開鍵と対応する秘密鍵が使われます。特定の鍵を強制したい場合は、接続元マシンの `~/.ssh/config` で該当 `Host` に `IdentityFile` を追記してください。 - -### 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 - 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 から直結できる | -| `DEVBASE_ORCA_USER` | `ubuntu` | 生成 config の `User`。コンテナのログインユーザーは常に `ubuntu` なので通常は設定不要。コンテナの `USERNAME` build arg を上書きしたプロジェクトでのみ設定する(ホストの `USERNAME` は参照しない) | - -## 接続パターン - -接続元が同一 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` の不一致警告は出ません。 - -host key は初回起動時に、イメージに焼き込まれた鍵を破棄したうえで install ごとに新規生成されます(イメージ由来の予測可能な共通鍵は使いません)。生成と永続化は `flock` で直列化しているため、同一ボリュームを共有する複数インスタンスの競合でも安全です。 - -初回接続時は生成 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) — 全コマンドの構文・オプション diff --git a/issues/PLAN32_multi-repo-project.md b/issues/PLAN32_multi-repo-project.md new file mode 100644 index 0000000..32d5581 --- /dev/null +++ b/issues/PLAN32_multi-repo-project.md @@ -0,0 +1,180 @@ +# PLAN32: 1 project = 1 container = 複数リポジトリ構成への変更 + +> 元 issue: `issues/i32.md` +> 種別: 構成変更 (multi-PR) / base branch: `main` / release branch: `release/PLAN32` + +## 1. 背景と目的 + +現在の devbase は **1 プロジェクト = 1 コンテナ = 1 リポジトリ** を原則とする構成になっている。 + +- プロジェクトごとの repo 指定は `projects//env` の `GIT_USER` / `GIT_REPO` (単一ペア) で行う。 +- コンテナ起動時、`containers/base/entrypoint.sh` が `https://$GIT_HOST/$GIT_USER/$GIT_REPO.git` を **1 本だけ** `/work` に clone し、`cd` する。 +- VS Code は `WORK_DIR=/work/$GIT_REPO` (単一 repo) を開く。 + +これを **1 プロジェクト = 1 コンテナ = 複数リポジトリ** に拡張したい。あわせて、repo 指定を env の文字列変数で表現するのは配列表現力に限界があるため、**YAML (`projects//repos.yml`) を新たな正とする**。 + +### 解決したい課題 +1. 1 つの開発コンテナで複数 repo (例: `carmo` 本体 + `carmo-batch` + `carmo-cdk`) を同時にチェックアウトして横断作業したい。 +2. repo ごとの host / owner / branch / clone 先ディレクトリ / init 実行有無 を宣言的に、増減しやすい形で管理したい。 +3. 既存の単一 repo プロジェクト (env `GIT_USER`/`GIT_REPO` ベース) を壊さず移行できること。 + +## 2. 現状アーキテクチャ (調査結果) + +| レイヤ | ファイル | 役割 | 単一 repo 前提の箇所 | +|---|---|---|---| +| プロジェクト設定 | `projects//env` | `GIT_USER`/`GIT_REPO`/`WORK_DIR` を定義 | 単一ペアのみ | +| プロジェクト compose | `projects//compose.yml` | `env_file: [root .env, env, .env]` で dev サービスへ注入 | — | +| 起動フロー (host) | `lib/devbase/commands/container.py` | `_load_project_env` (env 解析+`$VAR`展開), `_run_pre_up_hook` (`./pre-up`), `generate_scaled_compose` | env は単一 repo キーのみ想定 | +| clone (container) | `containers/base/entrypoint.sh:265-289` | `GIT_USER`+`GIT_REPO` を 1 本 clone → `init.sh` → `cd` | **中核**: 単一 clone/cd | +| エディタ起動 | `lib/devbase/editor/opener.py:resolve_workdir` | `WORK_DIR` or `/work/$GIT_REPO` を開く | 単一フォルダのみ | +| scale 生成 | `lib/devbase/volume/compose.py` | dev を N 台へ複製、`/work` を named volume 化 | repo 数と直交 (影響小) | + +観測: 認証情報は既に **base64 env blob** (`GCP_CREDENTIALS_BASE64__*` 等) としてコンテナへ渡す実装パターンが確立している。repo リストの transport にも同じ手法が使える。 + +## 3. 設計方針 + +### 3.1 YAML スキーマ (新規 `projects//repos.yml`) + +```yaml +# 任意: 各 repo のデフォルト値 (DRY 用) +defaults: + host: github.com + owner: volareinc + +repos: + - repo: carmo # 必須。clone 先 dir 名 (default: repo 名) + primary: true # 任意: cd 先 & エディタ既定フォルダ (未指定なら先頭要素) + branch: main # 任意: clone 後に checkout + - repo: carmo-batch # host/owner は defaults を継承 + - repo: carmo-cdk + owner: volareinc # defaults を個別上書き可 + dir: cdk # 任意: /work 配下の clone 先 dir 名を明示指定 + init: false # 任意: clone 後の init.sh 実行有無 (default: true) +``` + +- **正規化ルール**: `host` default `github.com`、`dir` default = `repo`、`init` default `true`、`primary` 未指定なら先頭 repo。 +- **バリデーション**: `owner`/`repo` 必須、`dir` 重複禁止、`primary` は最大 1 件。 + +### 3.2 config → container の transport (推奨: host 正規化 → env blob) + +**YAML を人間向けの正とし、コンテナへは正規化済みの「clone プラン」を base64 env blob で渡す**ハイブリッド構成を採用する。 + +``` +[人間] repos.yml (YAML, 表現力) + │ devbase up (host / Python) + ▼ +[PR1 loader] parse + validate + 正規化 + │ → clone プラン (JSON) を base64 化 + ▼ +DEVBASE_REPOS 環境変数 (compose 経由でコンテナへ) + │ devbase up → docker compose + ▼ +[PR2 entrypoint] DEVBASE_REPOS を decode → repo ごとに clone/checkout/init → primary へ cd +``` + +採用理由: +- パース/バリデーションを **テスト可能な Python** 側に集約でき、entrypoint (bash) を単純に保てる。 +- コンテナ内に YAML パーサ (yq / pyyaml) を新規依存として持ち込まずに済む (既存の base64 env パターンと一致)。 +- env は内部 wire format にすぎず、**人間が触る正は YAML** という issue の要求を満たす。 + +> 代替案 (不採用): `repos.yml` をコンテナへ bind mount し entrypoint 内で `python -c` パース。/work は named volume でありプロジェクト dir は未マウントのため mount 経路の追加が必要で、transport が複雑化する。将来 in-container で再 clone したいニーズが出た場合に再検討する。 + +### 3.3 後方互換 + +- `repos.yml` が **無い**プロジェクトは、従来どおり env の `GIT_USER`/`GIT_REPO` から **単一要素の clone プラン**を loader が合成する。既存 40+ プロジェクトは無変更で動作する。 +- `repos.yml` が **有る**場合は env の `GIT_USER`/`GIT_REPO` を無視 (YAML 優先)。両方あるときは warning を出す。 +- entrypoint は `DEVBASE_REPOS` があればそれを、無ければ従来の `GIT_USER`/`GIT_REPO` 分岐 (現行コード) をそのまま使う二段構え。→ entrypoint 単体でも後方互換。 + +### 3.4 エディタ (複数 repo のワークスペース) + +- `primary` repo を `resolve_workdir` の既定フォルダにする (単一 repo 時と同じ挙動)。 +- 複数 repo 時は `.code-workspace` (multi-root) をコンテナ内 `/work` に生成し、全 repo フォルダを 1 ウィンドウで開けるようにする。`DEVBASE_WORKSPACE` (既存機構, `resolve_workspace`) 経由で開く。 + +## 4. PR 分割計画 + +| PR # | branch 名 | 概要 | 依存 | 並行可否 | +|---|---|---|---|---| +| 1 | `feature/PLAN32-config-loader` | `repos.yml` スキーマ定義 + Python loader (`lib/devbase/repos/config.py`): parse・validate・正規化・env 合成フォールバック + 単体テスト。**挙動変更なしの純ライブラリ** | なし | ○ | +| 2 | `feature/PLAN32-up-transport` | `devbase up` で loader を呼び `DEVBASE_REPOS` (base64 clone プラン) をコンテナ環境へ注入。`container.py`/compose 生成への配線 | PR1 | × (PR1 の loader API 確定後) | +| 3 | `feature/PLAN32-entrypoint` | `entrypoint.sh` を複数 repo clone ループへ拡張 (`DEVBASE_REPOS` decode → clone/checkout/init → primary cd)。`GIT_USER`/`GIT_REPO` 後方互換分岐を保持。**要 base image 再ビルド** | PR1 (clone プラン形式の契約のみ) | ○ (PR2 と mock 契約で並行可) | +| 4 | `feature/PLAN32-editor` | 複数 repo の `.code-workspace` 生成 + `resolve_workdir`/opener の primary 対応 + 単体テスト | PR1 | ○ (mock で先行可) | +| 5 | `feature/PLAN32-migrate-docs` | env→`repos.yml` 変換ヘルパ + README/docs 更新 + サンプルプロジェクト (`repos.yml` 例) + CHANGELOG | PR1〜4 | × (最後に統合) | + +``` +release branch: release/PLAN32 +base branch: main +``` + +依存グラフ: PR1 が全ての土台。PR2/PR3/PR4 は PR1 の **clone プラン JSON 形式の契約**さえ固定すれば並行開発可 (PR3 は entrypoint 側、PR2 は host 側で同じ契約の両端)。PR5 は結合・ドキュメントで最後。 + +## 5. PR ごとの実装詳細 + +### PR1: config loader (foundation) +- 新規 `lib/devbase/repos/__init__.py`, `lib/devbase/repos/config.py`。 +- API 案: + - `load_repo_plan(project_dir: Path, environ: Mapping) -> list[RepoSpec]` + - `repos.yml` があれば YAML を読み、`defaults` 継承 → 正規化 → validate。 + - 無ければ env の `GIT_USER`/`GIT_REPO`/`GIT_HOST` から単一 `RepoSpec` を合成。両方あれば warning。 + - `RepoSpec` = `{host, owner, repo, dir, branch, init, primary}` (dataclass)。 + - `encode_repo_plan(specs) -> str` (JSON→base64) / `decode_repo_plan(str)` は PR2/PR3 の契約テストで共有。 +- clone プラン JSON 契約 (PR2 が生成 / PR3 が消費) を **このPRで確定**し docstring に明記: + ```json + [{"url":"https://github.com/volareinc/carmo.git","dir":"carmo","branch":"main","init":true,"primary":true}, ...] + ``` +- テスト: 正常系 (defaults 継承 / dir 明示 / primary 指定)、異常系 (owner 欠落 / dir 重複 / primary 複数)、env フォールバック、YAML+env 併存 warning。 + +### PR2: up transport (host wiring) +- `container.py:cmd_up` (および必要なら `generate_scaled_compose` 前処理) で `load_repo_plan` → `encode_repo_plan` → `DEVBASE_REPOS` を **生成 compose の dev サービス environment もしくは補助 env_file** へ注入。 + - secret 露出回避のため既存方針 (`environment` を除去し `env_file` 優先) と整合させる。base64 blob を書き出す一時 env ファイル方式が安全。 +- 単一 repo (env フォールバック) でも同じ `DEVBASE_REPOS` を注入し、経路を一本化。 +- テスト: プロジェクト固定で `DEVBASE_REPOS` が期待 JSON を base64 で持つこと。 + +### PR3: entrypoint 複数 clone (container) +- `entrypoint.sh:265-289` を置換: + - `DEVBASE_REPOS` があれば base64 decode → 各要素で `git clone ` → `branch` 指定時 `git -C checkout` → `init: true` なら `(cd && [ -f init.sh ] && ./init.sh)` → `primary` の dir へ最後に `cd`。 + - `DEVBASE_REPOS` 無し時は現行の `GIT_USER`/`GIT_REPO` 単一 clone を維持 (後方互換)。 + - clone 失敗は現行同様 warning で継続 (fail-soft)。 + - decode/iterate は bash + `base64 -d` + 小さな Python one-liner (base image に uv/python 有) で JSON→行変換。新規 apt 依存を増やさない。 +- **base image 再ビルドが必要** ([[entrypoint-change-needs-rebuild]]): 検証は `devbase build --no-cache` 必須。`devbase up` だけでは反映されない点を PR body / テスト手順に明記。 + +### PR4: editor 複数 repo +- `opener.py`: `resolve_workdir` は primary repo を返す。複数 repo 時は `/work/.code-workspace` (全 repo フォルダを含む multi-root JSON) を生成し `DEVBASE_WORKSPACE` を設定 → `resolve_workspace` 経由で開く。 +- 生成タイミング: entrypoint (コンテナ内 `/work` 実体を見て生成) が素直。PR3 の clone 後段に組み込むか、opener 側で attach 時生成するかを PR4 冒頭で確定。 +- テスト: single repo→従来フォルダ、multi repo→workspace パス解決。 + +### PR5: migration + docs +- `env`→`repos.yml` 変換ヘルパ (既存プロジェクトの `GIT_USER`/`GIT_REPO` を読み `repos.yml` を生成、`--dry-run` 付き)。一括移行は任意 (後方互換があるため強制しない)。 +- `README.md` / `docs/` に複数 repo 構成の手順・スキーマ・移行方法を追記。 +- サンプル: `projects/` にマルチ repo の `repos.yml` 例、または `docs/examples/`。 +- `CHANGELOG.md` 追記。 + +## 6. テスト / 検証計画 + +### 単体 (各個別 PR) +- PR1: loader の正常/異常/フォールバック (pytest)。 +- PR2: `DEVBASE_REPOS` 注入内容の検証。 +- PR4: workspace パス解決。 + +### 結合 (release PR / Step 7 相当) +- [ ] `repos.yml` (2〜3 repo) を持つ検証用プロジェクトで `devbase build --no-cache` → `up` → コンテナ内 `/work` に全 repo が clone され、primary に cd していること。 +- [ ] `repos.yml` 内 `branch` 指定が反映されること。 +- [ ] 既存 env-only プロジェクト (`GIT_USER`/`GIT_REPO`) が無変更で従来どおり単一 clone されること (後方互換の回帰確認)。 +- [ ] `DEVBASE_OPEN_EDITOR=1` で複数 repo が multi-root workspace として開くこと。 +- [ ] clone 失敗時 (存在しない repo) に fail-soft で他 repo が継続 clone されること。 + +## 7. リスク / 留意点 + +| リスク | 対策 | +|---|---| +| entrypoint 変更が `up` では反映されず古い挙動が残る | [[entrypoint-change-needs-rebuild]]。PR3/結合テストで `build --no-cache` 必須を明記 | +| base64 blob に repo URL 以上の機密は含めない | URL/dir/branch のみ。認証は既存の git credentials 機構を流用 | +| YAML+env 併存時の優先順位の混乱 | YAML 優先 + warning。docs に明記 | +| 既存 40+ プロジェクトへの回帰 | 後方互換フォールバックを PR1 で担保し、結合テストに env-only 回帰を含める | +| `dir` 衝突 / primary 複数 | PR1 の validate で早期 fail | + +## 8. 次のアクション + +本 plan は **作成フェーズ**の成果物。実装に進む場合は `/ndf:issue-plan-strategy` の実行フェーズ (Step 3〜) に従い: +1. `release/PLAN32` ブランチ + release Draft PR を先行作成。 +2. PR1〜5 の個別 Draft PR を作成 (PR1 を最優先で着手)。 +3. PR1 完了・merge 後に PR2/PR3/PR4 を worktree 並行開発、PR5 を最後に統合。 diff --git a/issues/i32.md b/issues/i32.md new file mode 100644 index 0000000..1af6c8f --- /dev/null +++ b/issues/i32.md @@ -0,0 +1,9 @@ +# 1project複数リポジトリに構成変更 + +## 目的 +* 現在、devbaseは1プロジェクト=1コンテナ=1リポジトリが原則的な構成となっていた +* 1プロジェクト=1コンテナ=複数リポジトリ に変更したい +* プロジェクトでのリポジトリの指定はenvファイルで行っていたが、配列の表現力に限界があるので、yamlに変更したい + * その他のenv記載の環境変数(WORK_DIRやCONTAINER_SCALE)はyamlまたはenvに振り分け。 + 多分CONTAINER_SCALEはyamlの方がふさわしそう。 + * diff --git a/issues/i34-orcalike.md b/issues/i34-orcalike.md new file mode 100644 index 0000000..41529c0 --- /dev/null +++ b/issues/i34-orcalike.md @@ -0,0 +1,597 @@ +# Issue 34 検討: devbase 独自の Orca-like オーケストレーション + +> 元 issue: `issues/i34.md` +> 調査日: 2026-07-16 +> 改訂: 2026-07-17(レビュー反映: 削除順序の後置、Windows 移行、LSP trade-off、metadata 分割) +> 結論: **CLI を制御基盤、単一ウィンドウの VS Code Extension を主 UI とする二層構成**を推奨する。 +> ただし「単一 window(仮想 FS)」は手段であり、LSP 喪失が目的に見合わなければ「attach を残す +> dashboard」路線へ切り替える。 +> +> 削除順序について(実施済み・当初方針からの変更): 当初は「破壊的な sshd/Orca 削除は Phase 0 の +> Go 判定後に独立 PR で行う」方針だったが、接続性インシデントと base image/entrypoint/compose を +> 複雑化させる維持コストを機に、**sshd/Orca 経路の削除を先行して実施した(本対応で撤去済み)**。 +> tmux + FileSystemProvider の feasibility spike は削除とは独立に Phase 0 で行う。理想は Go 判定後の +> 削除だったが、代替が未実装の間 Windows Orca 利用者には Remote-SSH への移行を案内する(§3.1)。 + +## 1. 結論 + +devbase が独自に作るべきものは「もう一つの IDE」ではなく、既存のコンテナ、AI CLI、 +VS Code を束ねる **軽量なオーケストレーター**である。 + +- `devbase agent ...`(仮称)を UI 非依存の共通バックエンドにする。 +- AI セッションは各コンテナ内の `tmux` で実行し、UI を閉じても継続・再接続できるようにする。 +- VS Code Extension は **1つのウィンドウ内**にコンテナ/セッション一覧、複数ターミナル、 + ファイル一覧、エディタ、通知を提供する。 +- CLI 版は一覧、起動、attach、send、stop を提供する。複数表示は `tmux` の window/pane に委譲する。 +- コンテナを切り替えるたびに新しい VS Code window を開かない。常時多数の window が残る現状を解消する。 +- ファイルは Extension が提供する仮想ファイルシステムを通じて、同じ editor area に開く。 +- コンテナへの操作は **`docker exec` を唯一の標準経路**とし、コンテナ内に SSH server は置かない。 +- Issue 33 の Orca 対応(sshd、SSH port publish、SSH config 生成、Orca relay)は削除する。 +- Issue 31 の container attach URI は既存 `devbase up --open` の互換機能として残すが、 + Orca-like UI の標準経路にはしない。 + +優先順位は **共通 CLI → VS Code Extension → CLI TUI 強化**とする。CLI と Extension を別々に +実装するとセッション管理が二重化するため、先に JSON 出力可能な CLI/API を固める。 + +## 2. Orca の調査結果 + +Orca の価値は単なる複数ターミナルではなく、次の機能が一つの「worktree」に束ねられている点にある。 + +| 分類 | Orca の機能 | devbase での扱い | +|---|---|---| +| 分離 | タスクごとの Git worktree | 初期版では既存の複数コンテナを分離単位とする。worktree は第2段階 | +| 実行 | 複数 AI CLI、通常 shell、terminal tab/pane | `tmux` session + VS Code terminal で実現 | +| 状態 | working / waiting / idle、終了通知、Agents feed | 初期版は running / exited / attention。OSC 対応は段階導入 | +| 継続 | UI 切断後も remote agent が継続し、再接続 | コンテナ内 `tmux` により実現 | +| 編集 | Monaco editor、file search、autosave | 仮想ファイルシステムを VS Code editor に接続 | +| レビュー | diff、stage、commit、PR | Git を container 内で実行し、段階的に Source Control へ統合 | +| 遠隔 | SSH worktree、file sync、port forwarding | Orca 互換は対象外。必要なら Docker 接続先の host 側で扱う | +| 自動化 | CLI から terminal create/read/send/wait、file open | 共通 CLI の JSON API として重要度高 | +| 通知 | 完了/入力待ちを横断表示 | Extension の TreeView と VS Code notification で実現 | + +参考(公式情報): + +- Worktrees: https://www.onorca.dev/docs/model/worktrees +- Agents & sessions: https://www.onorca.dev/docs/model/agents-sessions +- Terminal: https://www.onorca.dev/docs/terminal +- SSH worktrees: https://www.onorca.dev/docs/ssh +- Monaco editor: https://www.onorca.dev/docs/editing/monaco +- Orca CLI reference: https://www.onorca.dev/docs/cli/reference + +### 2.1 真似るべき機能 + +1. 全コンテナ/全 AI セッションの状態を一画面で確認できる。 +2. セッションを選ぶと即座に terminal へ再接続できる。 +3. UI を閉じても処理が継続する。 +4. 新しい agent を少ない操作で起動できる。 +5. attention/終了を通知し、対象セッションへジャンプできる。 +6. agent が言及したファイル、または指定したファイルをすぐ開ける。 +7. 機械可読 CLI により、将来の自動オーケストレーションにも使える。 + +### 2.2 初期版では真似ない機能 + +- 独自コードエディタ、独自 Language Server、PR UI、埋め込みブラウザ +- GitHub/Linear/Jira の統合 +- AI の会話内容を解析した高度な blocked 判定 +- 独自の SSH server、SSH file sync/relay +- 複数ユーザー向けサーバー、権限管理、クラウド同期、モバイル UI + +これらは VS Code、Docker CLI、既存 CLI で代替でき、devbase の中核ではない。 + +## 3. 現行 devbase で再利用できるもの + +| 既存機能 | 再利用方法 | +|---|---| +| `project scale` と `dev-1..N` | agent の実行先一覧にする | +| Docker label (`dev.devbase.*`) | project、index、user の安定した識別子にする | +| `devbase list` TUI | CLI dashboard の入口として拡張する | +| `devbase login [index]` | shell attach の互換入口として残す | +| `editor/opener.py` | 従来の別 window attach 用として互換維持。新 UI の標準経路にはしない | +| 共通永続 volume `/persistent/ai` | agent 設定の共有に使う。session socket 自体は置かない | +| AI CLI(Codex、Claude、Gemini 等) | agent profile の既定値にする | + +Issue 33 の Orca 対応は再利用しない。Orca relay の prebuilt バイナリは Orca の版と Node ABI に +依存し、sshd と公開ポートも base image/entrypoint/compose を複雑化する。最終的には +これらを削除して `docker exec` 経路へ一本化する。 + +**削除は Go 判定を待たず先行して実施した(本対応で撤去済み)。** 当初方針は「Phase 0 の Go 判定後に +独立 PR で削除」だったが、接続性インシデントと、sshd/公開ポートが base image/entrypoint/compose を +複雑化させ続ける維持コストを踏まえ、agent orchestration の実装とは**独立した削除 PR** を先行させた。 +sshd/Orca 対応は稼働中のワークフロー(Windows Orca → Mac → コンテナへ SSH トンネル接続する運用)を +支えていたため、削除により Windows Orca 利用者は一時的に代替(tmux + FileSystemProvider)が未実装の +状態となる。この利用者には Remote-SSH への移行を案内する(§3.1)。tmux + FileSystemProvider の +feasibility spike は削除とは独立に Phase 0 で行う(§8 Phase 0)。理想は Go 判定後の削除だったという +教訓は残すが、本 doc は実施済みの判断(先行削除)に整合させている。 + +### 3.1 コンテナ SSH/Orca 対応の削除方針 + +削除対象は「コンテナへ入るための SSH server」とその Orca 専用連携である。ホスト自身への +Remote-SSH や、コンテナから Git host へ接続するための SSH client/`~/.ssh` は別機能なので残す。 + +| 削除対象 | 内容 | +|---|---| +| base image | `openssh-server`、Orca 用 sshd config、`/run/sshd` 準備 | +| entrypoint | `ENABLE_SSH` ブロック、host key 永続化、`authorized_keys` 展開、sshd 起動 | +| compose 生成 | container `:22` publish、SSH port 計算、SSH 専用 label | +| env | `ENABLE_SSH`、`SSH_AUTHORIZED_KEYS`、`DEVBASE_SSH_BIND`、`DEVBASE_SSH_PORT_BASE`、`DEVBASE_ORCA_HOSTNAME` | +| CLI | `devbase orca sync/prune/status`、up/down/scale の自動 sync hook | +| collector | Orca 接続鍵を収集する collector | +| relay | `.orca-remote` prebuilt COPY、Node ABI 固定理由、`README-orca-relay.md` | +| docs/tests | Orca 接続ガイド、README の Orca 対応、専用テスト | + +`openssh-client`、`HOST_SSH_USER` / `HOST_SSH_HOST`、Issue 31 の +`DEVBASE_EDITOR_SSH_HOST` は目的が異なるため、一括削除しない。実装時に参照元を確認して判断する。 + +古い project env に残る Orca 用変数は warning を出して無視するか、release note で breaking change +として明示する。生成済みの `~/.config/devbase/orca/ssh_config` と `/persistent/ai/ssh` は +自動削除しない。ユーザーデータを勝手に消さず、不要なら削除できる手順だけを案内する。 + +#### 既存 Windows Orca 利用者の移行 + +sshd を削除すると、現行の「Windows Orca → SSH トンネル → コンテナ内 sshd」経路は +使えなくなる。この利用者に対する新方式の代替は、 +**Windows の VS Code を Mac へ Remote-SSH し、その Mac host 上の Extension が Docker daemon を +`docker exec` する**構成である(§6.1)。これは「Orca をやめて VS Code + Remote-SSH に乗り換える」 +運用変更であり、無視できない前提変更として扱う。 + +- Phase 0 の Go 条件に「Windows → Mac Remote-SSH 経由で `agent attach` が動く」ことを含める(§8)。 +- 現行 Orca 運用から新方式への移行手順を breaking change note に紐付けて残す。 + 移行ガイド: [`docs/user/orca-removal-migration.md`](../docs/user/orca-removal-migration.md)。 +- sshd/Orca 経路は既に撤去済みのため、Windows Orca 利用者は代替 UI の完成を待たずに Remote-SSH + への移行が必要になる。移行が完了するまで削除を保留する当初制約は、先行削除の判断により解除した。 + 影響を受ける利用者へは breaking change note で Remote-SSH 移行手順を優先的に案内する。 + +## 4. 提案アーキテクチャ + +```text +1つの VS Code window +├─ DEVBASE view: project > container > agent session +├─ Explorer: devbase:////work/... +├─ Editor: 選択した container のファイル +└─ Terminal: container ごとの agent / shell(tab・split) + | + ├─ VS Code Extension + └─ CLI / script: devbase agent ... --json + | + 共通コマンド/状態モデル + lib/devbase/agent/ + | + Docker API / docker exec + | + devbase project container dev-N + ├─ /work(container ごとの named volume) + └─ tmux session + └─ codex / claude / gemini / shell +``` + +VS Code の Remote - Containers / Dev Containers は原則として「1 window = 1 remote authority」である。 +その機能で container ごとに attach すると現在と同じく window が増えるため、新 UI では使用しない。 +Extension 自身は Docker daemon を操作できる host、WSL、または Remote-SSH 先で動作する。 + +### 4.1 セッションの実体 + +コンテナへの到達手段は常に `docker exec` とする。ただし AI process 自体を foreground の +`docker exec` に直結すると、呼び出し側終了時の挙動、再接続、複数 viewer、scrollback の扱いが +難しい。そこで `docker exec` で container 内の tmux server を操作する。 + +```bash +docker exec tmux new-session -d -s dbx-a1b2c3 -c /work/repo -- codex ... +``` + +- session ID はランダム ID とし、表示名とは分離する。 +- `docker exec -it tmux attach-session ...` で CLI/VS Code terminal から再接続する。 +- `remain-on-exit` を有効にし、終了コードと末尾出力を確認できるようにする。 +- `docker exec tmux capture-pane ...` で bounded transcript を取得する。 +- `docker exec tmux send-keys -l ...` と Enter を別操作にし、文字列を shell command として再評価しない。 +- tmux socket はコンテナごとに閉じ、共有 volume に置かない。異なるコンテナから同じ socket を + 共有すると PID namespace が違うため成立しない。 +- `remain-on-exit` で残った dead pane は tmux server に蓄積するため、`prune` および起動時 + reconcile で終了コード取得後に kill し、ゴミ session を溜めない(§11)。 + +初期版ではコンテナ停止により session も停止する。コンテナ再作成後の「会話再開」は各 agent +CLI の resume 機能に依存し、devbase が PTY を永続化したように見せない。 + +### 4.2 状態モデル + +最低限、次の状態に正規化する。 + +```text +starting -> running -> attention | idle -> exited + \-> unknown +``` + +初期判定: + +- `starting`: tmux session 作成直後 +- `running`: pane process が生存 +- `exited`: pane dead。終了コードを保持 +- `attention`: agent hook/OSC status が明示した場合 +- `idle`: agent hook/OSC status が明示した場合 +- `unknown`: container、tmux、metadata の一部が読めない場合 + +CPU 使用率や「一定時間出力がない」だけで idle/blocked を断定しない。Codex 等の OSC title や +公式 hook を使える profile から順に adapter を追加し、未対応 agent は running/exited の二値でも +正常動作する設計にする。 + +### 4.3 metadata + +ホスト側の `~/.config/devbase/agents/sessions/.json` に UI 用 metadata を atomic write する。 +プロセスの生死は常に Docker + tmux を正とし、JSON だけを見て running と判断しない。 + +**session ごとに1ファイルとする**(単一 `sessions.json` にしない)。CLI と Extension が同時に +書き込むと、単一ファイルでは atomic rename で破損は防げても last-writer-wins で片方の更新が +消える(lost-update)。session 単位にファイルを分ければ書き込みが衝突せず、`prune` も +1ファイルの削除で済む。metadata は UI 補助情報であり真実は Docker/tmux という前提とも整合する。 + +主な項目: + +```json +{ + "schemaVersion": 1, + "id": "a1b2c3", + "project": "sample", + "containerIndex": 2, + "profile": "codex", + "title": "issue-34", + "cwd": "/work/repo", + "tmuxSession": "dbx-a1b2c3", + "createdAt": "2026-07-16T00:00:00Z", + "updatedAt": "2026-07-16T00:00:00Z", + "lastKnownContainerId": "sha256:..." +} +``` + +container ID は再作成で変わるので永続キーにしない。`project + index` から現在の container を +Docker label で再解決する。`lastKnownContainerId` は誤 attach 検出のヒントに留め、判断の正には +使わない。 + +### 4.4 agent profile + +コマンドを Extension 側へハードコードせず、CLI 側で profile として管理する。 + +```yaml +agents: + codex: + command: ["codex"] + claude: + command: ["claude"] + gemini: + command: ["gemini"] +``` + +配列形式で保持し、shell interpolation を避ける。permission bypass flag は Orca の既定をそのまま +採用せず opt-in にする。コンテナ分離は誤操作や認証情報流出まで防ぐ security boundary ではない。 + +## 5. CLI 版 + +### 5.1 コマンド案 + +```text +devbase agent list [--project NAME] [--json] +devbase agent start PROFILE --project NAME [--index N] [--cwd PATH] + [--title TITLE] [--prompt TEXT] [--attach] +devbase agent attach SESSION +devbase agent read SESSION [--lines N] [--json] +devbase agent send SESSION --text TEXT [--enter] +devbase agent stop SESSION [--force] +devbase agent open SESSION [PATH[:LINE]] +devbase agent prune +``` + +`list --json` を Extension と automation の公開契約にする。人間向け出力の parse は禁止する。 +書き込み系は session ID の完全一致を原則とし、曖昧 prefix は一意の場合だけ許可する。 + +### 5.2 ターミナル切り替え/複数表示 + +- 1 session: `agent attach` → 対象 tmux session に接続。 +- 複数 session: `agent dashboard`(第2段階)で選択後 attach。 +- 同時表示: 一時的な viewer 用 tmux session をホストまたは選択 container に作り、各 pane から + `tmux attach -t ` する。まずは利用者が tmux の split を使う形でもよい。 + +既存 questionary TUI に端末 emulator を埋め込むのは避ける。PTY rendering、resize、Unicode、 +mouse、copy mode を独自実装することになり、Orca-like の中核より保守負担が大きい。 + +### 5.3 ファイルを開く + +`agent open SESSION path:line` は session の project/index/cwd を解決し、Extension が登録した +`devbase:` URI を同じ window の editor に開く。 + +```text +devbase://sample/1/work/repository/src/main.py +``` + +CLI だけの環境ではファイル内容を標準出力へ流さず、container 内の絶対 path と、利用可能なら +host 側 editor で開くための案内を表示する。コンテナに SSH 接続する経路は設けない。 + +## 6. VS Code Extension 版 + +Extension は新規 `extensions/vscode-devbase/` に配置し、Python 内部 module を直接 import せず +`devbase agent ... --json` のみを呼ぶ。 + +### 6.1 MVP UI + +- Activity Bar に `DEVBASE` container を追加。 +- TreeView: `project > container > agent session`。 +- session 行に状態、profile、経過時間を表示。 +- container 行から `/work` のファイル一覧を展開し、選択したファイルを同じ editor area に開く。 +- command: Start Agent、Attach Terminal、Open File、Show Changes、Stop、Refresh。 +- 複数 session は VS Code の terminal tab と split terminal で表示。 +- container/session を切り替えても VS Code window を新規作成しない。 +- exited/attention の変化を `window.showInformationMessage` で通知。 +- 表示中は短周期、非表示時は長周期の polling を使い、container と session の状態を更新する。 + +TreeView の例: + +```text +DEVBASE + sample (running) + dev-1 + ● codex: issue-34 running + ! claude: tests attention + dev-2 + ○ gemini: review exited (0) +``` + +terminal は shell integration に依存せず、次のコマンドを起動するだけにする。 + +```text +devbase agent attach +``` + +これにより local VS Code、WSL、Remote-SSH のどこで Extension が動く場合でも、既存 devbase CLI が +見ている Docker daemon に `docker exec` する。Extension manifest では +`extensionKind: ["workspace"]` を基本とし、Remote-SSH 上では remote extension host で動作させる。 +これは「host へ Remote-SSH し、その host の Docker を操作する」構成であり、コンテナ内 sshd +への接続ではない。 + +### 6.2 ファイル操作 + +Extension は VS Code の `FileSystemProvider` を使い、`devbase:` scheme の読み書き可能な仮想 +ファイルシステムを登録する。 + +```text +devbase:////work/ +``` + +provider は URI から現在の container ID を Docker label で解決し、Docker 経由で次の操作を行う。 + +- `stat`、directory listing、read、write +- create、rename、delete +- container 停止/再作成の検出 +- file change polling と `onDidChangeFile` 通知 +- binary file と large file の上限/確認 +- 元の mode、owner を壊さない atomic save + +container ID は再作成で変わるため URI に含めない。`project + index` を永続的な authority とし、 +各操作時に現在の container を再解決する。path traversal を防ぎ、既定では `/work` の外を公開しない。 + +同時に複数 container の root を VS Code workspace folder として常設すると検索・Git・補完の対象が +混ざるため、MVP は DEVBASE view から必要なファイルを開く方式にする。必要なら選択中 container の +root だけを workspace folder として差し替える機能を第2段階で追加する。 + +### 6.3 Git、差分、検索 + +VS Code 標準 Git extension は通常の disk path を前提とするため、仮想ファイルをそのまま渡すだけでは +完全には動作しない。Git command は対象 container 内で実行し、次の順で統合する。 + +1. MVP: `status --porcelain`、変更ファイル一覧、HEAD との差分表示。 +2. 第2段階: VS Code Source Control API に変更一覧、stage、unstage、commit を接続。 +3. 第3段階: branch、conflict、push、pull、PR 連携を必要に応じて追加。 + +ファイル検索も host 側 filesystem を直接検索せず、選択 container 内で `rg` を実行して結果の +`devbase:` URI を開く。全 container 横断検索は明示操作にし、通常の検索対象を不用意に10倍にしない。 + +### 6.4 言語機能の制約 + +仮想ファイルシステムでは、syntax highlight や通常の編集は VS Code 本体で利用できる。一方、disk path +や container 内 executable を前提とする language extension、Language Server、debugger はそのままでは +動かない場合がある。MVP ではこの制約を明示し、次の順で対応する。 + +1. file edit、terminal、Git diff、検索を先に完成させる。 +2. 利用頻度の高い言語を調査し、仮想 URI 対応済み extension はそのまま利用する。 +3. container 内 Language Server との中継が必要な言語だけ adapter を追加する。 + +この制約を回避するために container ごとの別 window attach へ自動 fallback すると、今回の目的に反する。 +別 window で開く操作は明示的な互換 command としてのみ残す。 + +**この trade-off は本設計の成否を分ける中心論点である。** 現行の Dev Containers attach は完全な +LSP・補完・go-to-def・デバッグが効く。本提案は「単一 window」と引き換えにそれを失う。解決対象が +「window 切替の負担」である以上、「LSP を捨ててまで単一 window にする価値があるか」を実装判断の +前に評価する必要がある。§13 と §11 のリスク表でもこの点を正面から扱い、次の代替案と比較する。 + +- **代替案(低コスト):** 仮想 filesystem を作らず、Dev Containers attach は残したまま、 + window を新規生成せず既存 window に focus する dashboard を提供する。これなら + FileSystemProvider・Git 中継・検索中継という最も高コストな部分を丸ごと回避でき、LSP も維持できる。 + 「単一 window での編集」は諦めるが、「window 切替負担の軽減」という本来の目的は満たしうる。 +- Phase 0 spike の結果(仮想 FS の編集体験・LSP の欠落度)を見て、フル仮想 FS 路線とこの + dashboard 路線のどちらを主とするかを判断する。 + +## 7. worktree の位置付け + +devbase の現行 scale は「コンテナごとの作業領域」を提供するため、MVP の並列 agent 分離には使える。 +ただし複数 container が同じ repository path/volume を共有する plugin では agent 同士が衝突しうる。 +実装前に plugin ごとの mount を検査し、「1 container = 独立 checkout」が成立しない場合は警告する。 + +第2段階で次を追加する。 + +```text +devbase worktree create --project P --index N --from REF +devbase worktree list --json +devbase worktree remove +``` + +配置先は repository の mount 範囲内に限定し、container から作った sibling worktree が host/再作成後も +見えることを結合テストする。自動 branch 削除はデータ損失リスクがあるため、既定では行わない。 + +## 8. 実装フェーズ + +### Phase 0: feasibility spike + +**この Phase では代替(tmux + FileSystemProvider)の feasibility のみを検証する。** sshd/Orca 経路の +削除は、当初「Go 判定後に独立 PR で」実行する方針だったが、接続性インシデント等を機に**先行して +実施済み**(§3.1)。したがって本 spike は削除の可否を判断するものではなく、撤去後の代替経路が +成立するかを確認するものである。 + +- base image に `tmux` を追加して arm64/amd64 で確認(この `tmux` 追加は Phase 0/1 の作業であり、 + 本削除 PR には含まれない。追加なので既存機能に影響しない)。 +- Codex/Claude/Gemini を detached 起動し、attach、detach、resize、capture、send、exit code を確認。 +- container stop/restart/recreate の境界を文書化。 +- VS Code local、WSL、Remote-SSH から `agent attach` を terminal で実行確認。 + 特に **Windows → Mac Remote-SSH 経由**で動くことを既存 Windows Orca 利用者の移行条件として確認(§3.1)。 +- 最小 `FileSystemProvider` で named volume 内の text/binary file を同じ VS Code window から + read/write/rename し、container 再作成後も `project + index` で再解決できることを確認。 +- 仮想 FS 上での LSP 欠落度を実測し、§6.4 の「フル仮想 FS」路線と「dashboard」路線のどちらを + 主とするか判断する材料を得る。 + +**Go 条件:** 3 agent で detach 後も継続し、再 attach と終了コード取得が安定すること。加えて、 +別 window を開かず2つ以上の container のファイルを同じ editor area で安全に編集できること。 + +**削除との関係:** sshd/Orca relay の削除(§3.1)は本 spike の結果を待たず先行実施済みである。 +そのため Phase 0 は「削除の Go/No-Go 判定」ではなく、撤去後の代替(tmux + FileSystemProvider)が +feasible かを確認する検証に位置づけが変わった。spike が成立しない場合でも sshd/Orca は復活させず、 +Windows 利用者向けには Remote-SSH 経由の attach を代替経路として維持しつつ設計を見直す。base build と +既存 project の回帰、および Windows 利用者の Remote-SSH 移行状況は継続して確認する。 + +### Phase 1: 共通 CLI MVP + +**中核価値(永続 session + 横断状態一覧)は、この Phase 1 だけでほぼ得られる。** コストが跳ね上がる +のは Phase 2 の `FileSystemProvider` + Git 中継 + 検索中継である。したがって Phase 1 完了時点で +一度価値を確定し、Phase 2 の投資判断を段階的に下せるようにする。 + +- `lib/devbase/agent/` に model、Docker resolver、tmux adapter、metadata store(session 単位 + ファイル、§4.3)を追加。 +- `agent list/start/attach/read/send/stop/prune` と `--json` schema を実装。 +- base image への `tmux` 追加は Phase 0 で完了済み(前提)。 +- agent profile と安全な argv 構築を実装。 +- 単体テストと Docker 結合テストを追加。 + +### Phase 2: VS Code Extension MVP + +- scaffold、DEVBASE TreeView、commands、terminal attach、polling、通知を実装。 +- `devbase:` FileSystemProvider の stat/list/read/write/create/rename/delete/watch を実装。 +- 同じ window で container/session/file を切り替える navigation と shortcut を実装。 +- container 内 `git status` と diff viewer、`rg` file search を実装。 +- CLI version/schema version の互換チェックを実装。 + +### Phase 3: orchestration 強化 + +- worktree lifecycle。 +- Source Control API、stage/unstage/commit。 +- 必要な言語向けの container Language Server adapter。 +- agent hook/OSC による attention/idle。 +- activity feed、unread、完了通知の永続化。 +- Quick Command、prompt template、複数 session 一括起動。 +- 必要性を確認後、diff summary/GitHub issue 連携を検討。 + +## 9. 変更ファイル案 + +| パス | 内容 | +|---|---| +| `containers/base/Dockerfile` | sshd/Orca relay 削除(本 PR で実施済み)。`tmux` 追加は Phase 0/1 の別作業でありこの削除 PR には含まない | +| `containers/base/entrypoint.sh` | `ENABLE_SSH` と sshd 起動処理を削除 | +| `lib/devbase/commands/orca.py` | 削除 | +| `lib/devbase/volume/ports.py` | 削除(内容は SSH publish 専用。利用元は `compose.py` の `allocate_ssh_host_port` のみと確認済み) | +| `lib/devbase/volume/compose.py` | SSH port publish/専用 label を削除 | +| `lib/devbase/env/collectors/orca.py` | 削除 | +| `lib/devbase/agent/models.py` | session/profile/status model | +| `lib/devbase/agent/docker.py` | label ベースの container 解決 | +| `lib/devbase/agent/tmux.py` | start/attach/capture/send/stop | +| `lib/devbase/agent/store.py` | schema version 付き atomic metadata | +| `lib/devbase/commands/agent.py` | CLI command 実装 | +| `lib/devbase/cli.py`, `bin/devbase` | dispatcher、completion | +| `extensions/vscode-devbase/` | TreeView、terminal、仮想 filesystem、Git diff、検索 | +| `tests/agent/` | 純粋関数・subprocess adapter の単体テスト | +| `tests/integration/` | Docker + tmux の opt-in 結合テスト | +| `docs/user/agents.md` | 利用手順、制約、troubleshooting | + +## 10. テスト観点 + +- project 名/title/cwd に空白、Unicode、`-` があっても argv injection しない。 +- prompt に改行、引用符、shell metacharacter があっても `send-keys -l` でそのまま送られる。 +- 同じ project の scale 1..N を label から正しく識別する。 +- container 再作成後に古い session を orphan/exited として表示し、誤 attach しない。 +- 2 UI が同じ session を表示しても metadata を破損しない。 +- CLI の human output を変えても JSON schema が維持される。 +- Extension が CLI 不在、旧 version、Docker停止、container停止時に明確に縮退する。 +- terminal resize、CJK/絵文字、Ctrl-C、detach key が VS Code/CLI 双方で動く。 +- stop は通常終了を先に試し、明示的な `--force` なしに container 全体を停止しない。 +- API key、prompt、terminal transcript を log/notification に無制限に出さない。 +- `devbase:` URI の `..`、symlink、percent encoding で `/work` の外へ脱出できない。 +- text/binary/large file の read/write、atomic save、rename、delete、permission 維持。 +- 2 container で同名 path を同時に開いても content/event が混線しない。 +- container 再作成後、開いている URI が新しい container ID を安全に再解決する。 + +## 11. リスクと対策 + +| リスク | 対策 | +|---|---| +| tmux と agent TUI の keybinding 衝突 | prefix を既定のままにせず devbase 専用設定を検証。attach の escape を文書化 | +| agent ごとに状態通知方式が違う | adapter 化し、未対応は running/exited へ安全に縮退 | +| container と session metadata の不整合 | Docker/tmux を正とし、`prune` と起動時 reconcile を実施 | +| Extension と CLI schema の不一致 | `schemaVersion` と `devbase agent capabilities --json` を用意 | +| prompt の shell injection | argv 配列、stdin、`send-keys -l` を使用。`sh -c` 連結を避ける | +| permission bypass による破壊 | profile 既定では付与せず、ユーザーが project 単位で opt-in | +| VS Code 依存が強い | session 制御は CLI に保ち、仮想 filesystem 等の表示機能だけを Extension の責務にする | +| 仮想 filesystem で一部 extension が動かない | MVP の対応範囲を明示し、頻出言語だけ container Language Server adapter を追加 | +| **LSP/デバッグ喪失が単一 window の目的と衝突** | 本設計の中心論点。Phase 0 で欠落度を実測し、フル仮想 FS 路線と「Dev Containers attach を残し既存 window に focus する dashboard」路線を比較(§6.4) | +| **CLI と Extension の同時書き込みで metadata lost-update** | 単一 JSON をやめ session 単位ファイルにして書き込み衝突を回避。真実は Docker/tmux(§4.3) | +| 既存 Windows Orca 利用者の運用断絶 | sshd 削除を先行実施したため、代替 UI 完成前でも Remote-SSH 移行が必要。breaking change note で移行手順を優先案内し、Remote-SSH 経由の attach を Phase 0 Go 条件に含める(§3.1) | +| Go 判定前に既存機能を削除した(後戻りコスト) | 当初は Go 判定後の独立 PR に分離し spike 失敗時は削除しない方針だったが、接続性インシデントと維持コストを機に先行削除。spike 失敗時も sshd は復活させず Remote-SSH 経路で縮退(§8) | +| Docker 経由の file I/O が遅い | directory cache、差分更新、size 上限、bounded polling。計測してから最適化 | +| Orca と重複開発になる | Monaco 自体は VS Code を使い、filesystem/Git/terminal の中継に限定 | + +## 12. 受け入れ条件 + +受け入れ条件を Phase に対応づけ、どの段階で何が満たされるべきかを明確にする。 + +### Phase 0(feasibility spike の Go 判定) + +- [ ] 3 agent(Codex/Claude/Gemini)を detach 後も継続でき、再 attach と終了コード取得が安定する。 +- [ ] 別 window を開かず、2つ以上の container のファイルを同じ editor area で安全に編集できる。 +- [ ] Windows → Mac Remote-SSH 経由で `agent attach` が動く(既存 Windows Orca 利用者の移行条件)。 +- [ ] 仮想 FS 上の LSP 欠落度を実測し、フル仮想 FS 路線/dashboard 路線の判断材料が揃っている。 + +### Phase 1(共通 CLI MVP) + +- [ ] scale された任意の dev container で Codex/Claude/Gemini/shell を起動できる。 +- [ ] CLI を閉じても agent が継続し、別端末から再 attach できる。 +- [ ] 全 project/container/session の running/exited 状態を CLI JSON で確認できる。 +- [ ] session を graceful stop でき、終了コードと bounded transcript を取得できる。 +- [ ] CLI/Docker/container が利用不能な場合に明確に縮退する。 + +### Phase 2(VS Code Extension MVP) + +- [ ] 全 project/container/session の状態を Extension でも確認できる。 +- [ ] **すべての標準操作が1つの VS Code window 内で完結し、container 切替で新しい window が開かない。** +- [ ] VS Code で2つ以上の session terminal を tab/split 表示し切り替えられる。 +- [ ] 2つ以上の container の `/work` を `devbase:` URI で参照し、同じ editor area で編集・保存できる。 +- [ ] 選択した container 内の変更ファイル一覧、diff、ファイル検索を同じ window で利用できる。 +- [ ] Docker/container/CLI が利用不能な場合に Extension がクラッシュせず理由を表示する。 + +### 全体(回帰・削除) + +- [ ] 既存 `devbase up/login/list` と Issue 31 の editor open に回帰がない。 +- [x] (先行削除 PR で実施済み)base image/entrypoint/生成 compose に sshd、Orca relay、 + container `:22` publish が残っていない。 + +## 13. 最終判断 + +**実装する価値はあるが、Orca のクローンを目標にしない。** 解決対象は「常時10前後ある VS Code +window の切替負担」である。標準 UI は1つの window とし、そこへ「永続 agent session」 +「横断状態一覧」「仮想 filesystem」「terminal」「Git diff/検索」を統合する。 + +最初の着手は **Phase 0 の tmux と FileSystemProvider の両 spike**とする。tmux だけ成功しても、 +単一 window で安全に file edit できなければ目的を達成できない。両方の成立を確認後に JSON CLI と +Extension を実装する。Extension は単なる launcher ではなく、単一 window を成立させる中核 client +として扱う。 + +ただし2つの前提条件を実装判断の前に確定させる。 + +1. **LSP/デバッグ喪失の許容度。** 単一 window(仮想 FS)は Dev Containers attach が持つ完全な + LSP・補完・デバッグを失う。これが「window 切替負担の軽減」という目的に見合うかを Phase 0 で + 実測し、見合わなければ「attach を残し既存 window に focus する dashboard」路線(§6.4)へ + 切り替える。単一 window は手段であって目的ではない。 +2. **破壊的削除は先行実施済み(当初は Go 判定後の方針)。** sshd/Orca 対応は稼働中の Windows 運用を + 支えていたが、接続性インシデントと維持コストを機に、代替の feasibility 確認を待たず独立 PR で + 先行削除した。理想は Go 判定後の削除だったが、この判断により Windows Orca 利用者には代替 UI 完成 + 前でも Remote-SSH 移行を案内する。spike 失敗時も sshd は復活させず Remote-SSH 経路で縮退する。 diff --git a/issues/i34.md b/issues/i34.md new file mode 100644 index 0000000..e5bf148 --- /dev/null +++ b/issues/i34.md @@ -0,0 +1,14 @@ +# devbase オーケストレーションツール + +* issues/i33.md でOrcaを導入しようとしたが、設定が複雑になりすぎ、断念した +* devbase はすでにコンテナで分離開発できる基盤を持っているので、これにオーケストレーション機能を追加したい + +## 要件 +* Orcaのように、複数マシン(コンテナ)のAIを制御できること +* ターミナルを切り替え、または複数標示できること +* ファイルが開けること +* その他、Orcaを調査して必要そうな機能は提案 + +## 実装方針 +* cli版、vscode extension版を検討すること + diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index d4fc3fc..734c7ce 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -55,7 +55,6 @@ ('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 が複数候補にマッチする場合に、特定の入力を特定のサブコマンドに @@ -453,20 +452,6 @@ 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. @@ -550,7 +535,6 @@ def _create_parser(): _add_env_parser(subparsers) _add_plugin_parser(subparsers) _add_snapshot_parser(subparsers) - _add_orca_parser(subparsers) _add_shortcuts(subparsers) return parser @@ -585,7 +569,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', 'orca', 'up', 'down', 'login', 'ps', 'scale', 'rebuild', 'list', 'help'] + 'snapshot', 'ss', '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('-'): @@ -634,7 +618,6 @@ 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 8ace306..8c7c65e 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -17,7 +17,6 @@ from devbase.volume.compose import ( generate_scaled_compose, get_dev_service_name, - get_running_published_host_ports, ) from devbase.utils.docker import ( docker_compose_down, @@ -440,50 +439,6 @@ 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 が有効」または「Orca config が既に存在する」。 - 後者は ENABLE_SSH を true→false に切り替えて再 up した場合に、停止した - コンテナのエントリが古いまま残るのを剪定するため (config が存在する = 以前 - Orca を設定済み)。ENABLE_SSH の gate を無条件に外すと、Orca を一切使わない - ユーザーの up でも毎回 config ファイルを新規生成してしまうため、config 未作成 - (= 純粋な非 Orca ユーザー) のときは従来どおり何もしない。失敗しても warning - のみで up の戻り値には影響させない。import は遅延させて起動コストを避ける。 - """ - from devbase.commands.orca import config_exists, regenerate_config - if not _ssh_enabled() and not config_exists(): - return - try: - 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)。ただし config が未作成の純粋な非 Orca ユーザーでは、剪定と称して - 毎回 config ファイル (親ディレクトリ + ヘッダ) を新規生成してしまうため、 - config が既に存在するときのみ実行する (_maybe_orca_sync と同じゲート)。 - 失敗しても warning のみで down の戻り値には影響させない。 - """ - try: - 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) - - def _resolve_open_index(open_index: Optional[int], scale: int) -> int: """開く dev インスタンス番号を解決する (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 @@ -600,14 +555,7 @@ def cmd_up(project_name: str = None, scale: int = None, docker_compose_down() logger.info("[3/6] Generating scaled compose file...") - # 他プロジェクトが稼働 publish 済みのホストポートを best-effort でシードし、 - # SSH publish ポートの跨ぎ衝突 (bind 失敗) を回避する。自プロジェクトの - # 稼働ポートは除外し、既存 dev-1..N の決定的ポートがずれないようにする。 - override_file = generate_scaled_compose( - scale, project_name, - external_ports_provider=lambda: get_running_published_host_ports( - exclude_project=project_name), - ) + override_file = generate_scaled_compose(scale) logger.info("Generated: %s", override_file) logger.info("[4/6] Starting containers...") @@ -629,9 +577,6 @@ 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 @@ -661,9 +606,6 @@ def cmd_down() -> int: except Exception as e: logger.warning("スナップショットのローテーションに失敗: %s", e) - # Orca 連携: 停止したコンテナのエントリを隔離 SSH config から剪定する (PLAN33)。 - _maybe_orca_prune() - return 0 @@ -745,14 +687,7 @@ 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=lambda: get_running_published_host_ports( - exclude_project=project_name), - ) + override_file = generate_scaled_compose(new_scale) logger.info("Generated: %s", override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) @@ -780,10 +715,6 @@ 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 deleted file mode 100644 index 1431c1c..0000000 --- a/lib/devbase/commands/orca.py +++ /dev/null @@ -1,337 +0,0 @@ -"""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 -from devbase.volume.compose import ( - DEVBASE_INDEX_LABEL, DEVBASE_SSH_LABEL, DEVBASE_USER_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. " - "Import this file into Orca (Settings → SSH)." -) - - -@dataclass(frozen=True) -class SSHTarget: - """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: - """Orca 用隔離 SSH config の絶対パス (``~/.config/devbase/orca/ssh_config``)。""" - 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 はしない) -# --------------------------------------------------------------------------- - -def _parse_index(raw) -> int: - """devbase index ラベルを 1 始まり index に変換する (不正値は 1 にフォールバック)。""" - 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 を抽出する純関数。 - - 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 を含みうるため)。ラベルから直接読む。 - """ - targets: 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 - 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 は 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") - ) - # ログインユーザーは各コンテナ自身のラベルから読む (集約側で一律適用しない)。 - # ラベル未設定の旧コンテナは既定 ubuntu へフォールバックする。 - user = labels.get(DEVBASE_USER_LABEL) or DEFAULT_USER - targets.append( - SSHTarget(project=project, index=index, port=host_port, user=user) - ) - 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() -> Optional[List[SSHTarget]]: - """稼働中の devbase SSH コンテナを docker から列挙する (best-effort)。 - - ``docker ps -q`` で稼働中コンテナ id を集め、``docker inspect`` の JSON を - :func:`_parse_inspect` に渡す。 - - Returns: - - ``List[SSHTarget]``: 列挙に成功した場合 (0 件なら空リスト)。 - - ``None``: docker が無い / 実行失敗 / 出力解析失敗など、**列挙自体に失敗** - した場合。「稼働 0 件」(空リスト) と区別し、呼び出し側が既存 config を - 保持できるようにする。 - """ - ps_out = _docker_json(["ps", "-q"]) - if ps_out is None: - return None - ids = ps_out.split() - if not ids: - return [] - inspect_out = _docker_json(["inspect", *ids]) - if inspect_out is None: - return None - try: - containers = json.loads(inspect_out) - except json.JSONDecodeError as e: - logger.warning("docker inspect の出力を解析できませんでした (Orca 同期をスキップ): %s", e) - return None - 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) -> str: - """SSH target 群から config テキストを生成する純関数。 - - エントリは (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 {t.user}") - # 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" - - -def _write_config(targets: Sequence[SSHTarget]) -> Path: - """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 - path.write_text(_render_config(targets, hostname), encoding="utf-8") - return path - - -def regenerate_config( - 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 - result = provider() - if result is None: - raise OrcaEnumerationError( - "稼働中コンテナの列挙に失敗しました (docker 応答なし)。既存 config を保持します。" - ) - targets = list(result) - path = _write_config(targets) - return targets, path - - -# --------------------------------------------------------------------------- -# サブコマンド -# --------------------------------------------------------------------------- - -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: - 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[[], Optional[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 deleted file mode 100644 index 27b6d41..0000000 --- a/lib/devbase/env/collectors/orca.py +++ /dev/null @@ -1,101 +0,0 @@ -"""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 _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 公開鍵) ===") - - # 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: - # 公開鍵は長大・複数行になり得るので、プロンプト表示は短縮する - # (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: - 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 64931bd..0a61cd1 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -51,17 +51,6 @@ 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) -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)。 # 他はプロジェクト env / グローバル .env に手書きする devbase 動作設定。 diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 4b8b5ca..e4d5cac 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -2,46 +2,17 @@ import copy import os -import re -import subprocess import yaml from pathlib import Path -from typing import Any, Callable, Dict, Optional, Set +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, DEVBASE_ORCA_USER, -) from .manager import get_work_volume_for_index, get_ai_volume_for_index -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' - -# 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' - -# コンテナのログインユーザー (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'""" @@ -170,63 +141,10 @@ 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 get_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', - '{{.Label "com.docker.compose.project"}}\t{{.Ports}}'], - capture_output=True, text=True, check=False, - ) - except (OSError, subprocess.SubprocessError): - return set() - if result.returncode != 0: - return set() - ports: Set[int] = set() - # 例: "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 - - def _build_dev_instance( - dev_service: dict, dev_service_name: str, index: int, project_name: str, - used_ports: Set[int], + dev_service: dict, dev_service_name: str, index: int, ) -> dict: - """Build the service definition for one scaled dev instance (dev-). - - ``used_ports`` は既に割り当て済み / 使用中のホストポート集合。SSH publish の - ポートを確保したら、この集合に追加して後続インスタンスとの衝突を防ぐ。 - """ + """Build the service definition for one scaled dev instance (dev-).""" service = copy.deepcopy(dev_service) service['container_name'] = f"${{COMPOSE_PROJECT_NAME}}-{dev_service_name}-{index}" @@ -244,58 +162,13 @@ def _build_dev_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() が 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 隔離の必須条件)。 - _add_ssh_label(service, DEVBASE_SSH_LABEL) - # 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 def _build_scaled_services( services: dict, dev_service: dict, dev_service_name: str, scale: int, - project_name: str, used_ports: Optional[Set[int]] = None, ) -> dict: - """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() + """Build the services section: non-dev services + dev-1..dev-N instances.""" scaled_services = {} # Copy non-dev services (mysql, valkey, etc.) — rewriting any @@ -314,32 +187,23 @@ 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, used_ports, + dev_service, dev_service_name, i, ) return scaled_services def generate_scaled_compose( scale: int, - project_name: str, compose_file: Path = None, dev_service_name: str = None, - external_ports_provider: Optional[Callable[[], Set[int]]] = None, ) -> Path: """ Generate scaled docker-compose file with per-instance volumes Args: scale: Number of container instances - 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') - external_ports_provider: 他プロジェクトが稼働 publish 済みのホストポート集合を - 返す関数 (SSH ポート衝突回避のシード)。None (既定) のときは外部ポートを - シードしない (= 決定的ポートをそのまま使う。単体テストは docker 非依存)。 - 実行時の up 経路は :func:`get_running_published_host_ports` を注入して - 他プロジェクトとの衝突を best-effort で回避する。 Returns: Path to generated .docker-compose.scale.yml @@ -357,14 +221,9 @@ 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, used_ports, + services, dev_service, dev_service_name, scale, ), '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 deleted file mode 100644 index 961ba53..0000000 --- a/lib/devbase/volume/ports.py +++ /dev/null @@ -1,70 +0,0 @@ -"""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 -from typing import Set - - -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 - - -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_container_orca_sync.py b/tests/commands/test_container_orca_sync.py deleted file mode 100644 index 46f9ef4..0000000 --- a/tests/commands/test_container_orca_sync.py +++ /dev/null @@ -1,114 +0,0 @@ -"""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() - - -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() diff --git a/tests/commands/test_orca.py b/tests/commands/test_orca.py deleted file mode 100644 index e294929..0000000 --- a/tests/commands/test_orca.py +++ /dev/null @@ -1,390 +0,0 @@ -"""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("DEVBASE_ORCA_USER", 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") - - 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 - # IdentityFile は出力しない (id_ed25519 / id_rsa 両対応のため既定解決に委ねる)。 - assert "IdentityFile" not 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") - - 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") - - 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") - assert out.strip() == orca._HEADER - assert "Host " not in out - - -# --------------------------------------------------------------------------- -# HostName の env 上書き / User の per-target 出力 -# --------------------------------------------------------------------------- - -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_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 行を出力する。 - - 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") - - -def test_host_username_is_ignored_for_user(home_in_tmp, monkeypatch): - """ホストの ambient な USERNAME (Windows アカウント名等) は User に使わない。 - - Windows 上の Orca ホストでは USERNAME が Windows アカウント名になるが、User は - 各 target 自身の値 (既定 ubuntu) を使うため、ホスト USERNAME は一切参照しない。 - """ - 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 のときヘッダのみ / 停止済みは消える -# --------------------------------------------------------------------------- - -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: [ - 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, - ssh_label=True, index=None, user=None): - 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" - # 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}] - 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", 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) == [] - - -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", - "dev.devbase.ssh": "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)] - - -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") - 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)] - - -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/env/test_collector_orca.py b/tests/env/test_collector_orca.py deleted file mode 100644 index b76c37e..0000000 --- a/tests/env/test_collector_orca.py +++ /dev/null @@ -1,70 +0,0 @@ -"""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 diff --git a/tests/volume/test_compose.py b/tests/volume/test_compose.py index da7f22c..b461b3d 100644 --- a/tests/volume/test_compose.py +++ b/tests/volume/test_compose.py @@ -42,7 +42,7 @@ def test_dev_and_non_dev_services_get_init_true(in_tmp_cwd): "mysql": {"image": "mysql:8"}, }) - compose.generate_scaled_compose(scale=1, project_name="proj") + compose.generate_scaled_compose(scale=1) scaled = _load_scaled(in_tmp_cwd)["services"] assert scaled["dev-1"]["init"] is True @@ -53,7 +53,7 @@ def test_init_injected_for_every_scaled_instance(in_tmp_cwd): """scale>1 でも各 dev-i 全てに init: true が付く。""" _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) - compose.generate_scaled_compose(scale=3, project_name="proj") + compose.generate_scaled_compose(scale=3) scaled = _load_scaled(in_tmp_cwd)["services"] for i in (1, 2, 3): @@ -67,7 +67,7 @@ def test_explicit_init_false_is_preserved(in_tmp_cwd): "mysql": {"image": "mysql:8", "init": False}, }) - compose.generate_scaled_compose(scale=1, project_name="proj") + compose.generate_scaled_compose(scale=1) scaled = _load_scaled(in_tmp_cwd)["services"] assert scaled["dev-1"]["init"] is False diff --git a/tests/volume/test_compose_ssh_ports.py b/tests/volume/test_compose_ssh_ports.py deleted file mode 100644 index 6991649..0000000 --- a/tests/volume/test_compose_ssh_ports.py +++ /dev/null @@ -1,468 +0,0 @@ -"""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.errors import DockerError -from devbase.volume import compose -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 - - -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 -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) - monkeypatch.delenv("DEVBASE_ORCA_USER", 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"] - - -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"]) - - -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"]) - - -# --- 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' などを大文字小文字を問わず有効と解釈する。""" - 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 - - -# --- 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"] - - -# --- get_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.get_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.get_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.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.get_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.get_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.get_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"]