diff --git a/issues/PLAN52_remote-docker-context-decisions.md b/issues/PLAN52_remote-docker-context-decisions.md new file mode 100644 index 00000000..0d61ec5c --- /dev/null +++ b/issues/PLAN52_remote-docker-context-decisions.md @@ -0,0 +1,191 @@ +# PLAN52 設計: 決定の記録とテスト設計 + +この文書は決定の記録とテスト設計を扱う。 + +| 内容 | 文書 | +| --- | --- | +| 要求と受け入れ条件 | [PLAN52_remote-docker-context.md](PLAN52_remote-docker-context.md) | +| 構成要素と処理の流れ | [PLAN52_remote-docker-context-design.md](PLAN52_remote-docker-context-design.md) | + +## 決定の記録 + +### 決定 1: context は環境変数 `DOCKER_CONTEXT` で全呼び出しへ伝える + +docker CLI と compose の両方が `DOCKER_CONTEXT` を読み、`docker context use` の既定より +優先する。`subprocess` で CLI を叩く箇所は 30 か所近くあり、`--context` を引数に足す形では +1 か所の漏れがそのまま「一部だけ手元の daemon を触る」事故になる。環境変数なら、解決した +直後に `os.environ` へ載せるだけで、フックや `bin/devbase build` のような子プロセスにも +同じ値が届く。 + +`--context` を各コマンド引数に足す形は、`docker compose` が `--context` を受け付けない +(グローバルオプションとしてのみ)ため一様に書けず、採らない。 + +docker は `DOCKER_HOST` があると `DOCKER_CONTEXT` を無視する(実測、Docker 29.4.3)。 +context を解決したときは `DOCKER_HOST` を警告つきで環境から取り除く。エラーで止める形は、 +シェルの rc に `DOCKER_HOST` を書いている利用者がプロジェクトごとの設定を使えなくなるため +採らない。取り除くのは devbase のプロセスとその子プロセスの中だけである。 + +### 決定 2: `project.local.yml` は `project.yml` へマージせず、別の型で読む + +issue は「深いマージ(local が勝つ)」を提案しているが、初期スコープの `docker` 節は +`project.yml` に存在しないキーであり、マージする対象が無い。`ProjectConfig` を変えなければ +`project.yml` の既存テストと検証がそのまま保たれ、`project.yml` に `docker:` を書いた事故を +「未知キー」として型で弾ける。`scale` / `open_editor` の個人上書きを足すときに、その時点で +マージの規則を決める。 + +### 決定 3: リモート扱いは「解決した context が現在の context と異なる」ことで決める + +「設定があればリモート」とすると、手元の context 名を書いただけで gid の `docker run` と +`~` の書き換えが走り、ローカルの振る舞いが変わる。「解決した context が現在の context と +同じなら従来どおり」とすれば、設定の有無ではなく接続先の違いで扱いが変わる。現在の context +は `docker context show` で取る。daemon に接続せず、context が未指定のときは呼ばない。 + +`docker context inspect` で endpoint が `unix://` かどうかを見る形は採らない。Docker Desktop の +`desktop-linux` のように手元でも VM 越しの構成があり、「手元かどうか」を endpoint から +一意に読めない。 + +### 決定 4: CLI / env で別の context へ向けたときは、ファイルの `home` / `gid` を使わない + +`home` と `gid` は機材の値であり、`docker.context` と組で意味を持つ。上書きで別ホストへ +向けた実行にファイルの値を持ち込むと、別ホストの HOME や gid が黙って入り、mount が空に +なるか docker.sock に触れない状態になる。上書き先が同じ名前なら(例: ファイルと同じ context +を CLI で明示した)ファイルの値を使う。 + +### 決定 5: gid は `docker run` の `stat` で取り、`.cache/docker-gid/` に控える + +リモート側の `/etc/group` は手元から読めない。docker.sock を bind mount したコンテナで +`stat -c %g` すれば、daemon が見ている gid がそのまま取れる。ssh 越しでも Docker Desktop の +VM でも同じ手順で済む。使うイメージは `alpine:3`。小さく、`stat` が `-c` を受け付ける。 +毎回の `up` で `docker run` を挟むと 1〜2 秒増えるため、成功した値をファイルに控える。 + +この取り方は、リモートの docker.sock が docker グループ所有(rootful の既定)であることを +前提にする。socket が `root:root` の構成では `0` が返り、rootless Docker では socket の場所も +所有者も違う。どちらも失敗の経路には掛からず `group_add: ["0"]` が黙って通るため、 +そうした構成では `docker.gid` を明示する。取得した gid が `0` のときは、その旨を警告に出す。 + +`docker info` は gid を出さない。`ssh getent group docker` は採らない。context の +ssh 設定を devbase が解釈し直すことになり、TCP+TLS の context では成り立たない。 + +### 決定 6: gid の控えは上書きし、履歴を持たない + +控えるのは再取得できる値で、過去の値に意味が無い。リモート側で gid が変わることは稀で、 +変わったら利用者がファイルを消すか `docker.gid` を書く。この文書の「未確認のまま残ること」には +載せず、ドキュメントの手順として書く。 + +### 決定 7: `~` の書き換えは生成物 `.docker-compose.scale.yml` に対して行う + +起動時に `-f` で渡すのは生成物だけなので、生成の段階で書き換えれば全サービスの mount に +効く。元の `compose.yml` は共有ファイルであり触らない。`~` を展開するのは compose +クライアントだが、生成物に絶対パスが書かれていれば展開は起きず、そのままリモートへ渡る。 + +`docker compose config` を通して展開済みの構成を得てから差し替える形は採らない。環境変数 +(機密を含む)が値へ展開された YAML を扱うことになる。`_load_compose_config` が YAML を +直接読む理由(機密を生成物へ書かない)と衝突する。 + +### 決定 8: `~user/...` と相対パスは書き換えず警告に留める + +`~user` は手元でもリモートでも別のユーザの HOME を指し、`docker.home` では代替できない。 +相対パスは compose クライアントが手元の絶対パスへ解決し、リモートには存在しない。どちらも +「正しい値」を devbase が推測できないため、黙って書き換えるより一覧で示す。 + +### 決定 9: shell の `build` は `--context` を `env exec --context` へ引数で渡し、docker 呼び出しを `env exec` 経由にする + +`bin/devbase` は YAML を読めない。`--context` を引数から取り除いて `env exec --context NAME` +へ渡せば、Python 側が CLI 由来として最優先で解決する。`docker buildx build` と +`docker image inspect` の直接呼び出しも `env exec` を通す。compose のビルドと同じ経路で +`DOCKER_CONTEXT` を受け取る。 + +環境変数 `DEVBASE_DOCKER_CONTEXT` に写す形は採らない。`cli.main()` は dispatch の前に機密を +`os.environ` へ注入し(`runtime.inject` は既存の値を上書きする)、`.env` に同名のキーが +あると写した値が消える。`bin/devbase` の冒頭で Python を 1 回呼んで `DOCKER_CONTEXT` を +export する形も、全コマンドに uv の起動が 1 回増えるため採らない。 + +### 決定 10: `up` からの自動ビルドは解決した context を `--context` で明示して渡す + +`up` は CLI の `--context` で上書きした値を `os.environ['DOCKER_CONTEXT']` に載せる。 +一方 `bin/devbase build` → `env exec` の経路は context を**再解決**する。`bin/devbase` は +起動時に root とプロジェクトの `env` を読み直し、Python 側は `.env` の機密を注入するため、 +環境変数で渡した値は `env` / `.env` の同名キーに負ける。`_run_build` が `--context ` を +引数で渡し、`build)` 分岐がそれを `env exec --context` へ引数のまま送れば(決定 9)、 +どちらのファイルに何があっても CLI の値が届く。 + +`DEVBASE_DOCKER_CONTEXT` を出力として載せる形は、上記のとおり `env` / `.env` に負けるため +採らない。 + +### 決定 11: エディタの `settings.context` は「明示 → devbase の解決結果 → 従来の推測」の順 + +`DEVBASE_EDITOR_DOCKER_CONTEXT` は「attach に使う context を手で決めたい」ための既存の +つまみで、devbase の解決結果より上に置く。解決結果があれば、ローカル端末でも +`settings.context` を付ける。無ければ従来どおり `ssh_host` があるときだけ +`docker context show` を使う。 + +### 決定 12: リモート扱いの `up` は自動スナップショットを飛ばす + +自動スナップショットは「これから使うボリューム」を手元のディレクトリへ控えるものである。 +リモート扱いではそのボリュームがリモートにある。`docker run -v <手元のパス>` は +リモートの空ディレクトリへ書く。飛ばして警告を出す。`devbase snapshot` の明示操作と +`down` のローテーションは context を解決せず、従来どおり手元を対象にする。 + +リモートのボリュームを `docker run ... | tar` の標準出力経由で手元へ運ぶ形は、差分世代の +仕組みごと作り直しになるため別課題にする。 + +### 決定 13: 接続先の反映は冪等にし、機密注入のたびに再適用する + +`runtime.inject` は機密ストアの値を `os.environ` へ無条件に上書きする。機密ストアに +`DOCKER_CONTEXT` / `DOCKER_GID` / `DOCKER_HOST` が入っていると、`up` の途中(構成生成の中の +`_inject_secrets`)で確定済みの接続先が戻り、volume 作成と `compose up` が別の daemon を +向く。反映を冪等な関数にして `_inject_secrets` の直後と `child_env()` の後に呼べば、 +何度注入されても最後に確定した接続先が残る。控えは `docker_context` モジュールが持ち、 +`_inject_secrets` が注入の直後に `reapply()` を呼ぶ(設計「処理の流れ」の責務表)。控えは +lifecycle 操作の単位で生き、`_dispatch_lifecycle` が前後で `reset()` して捨てる。1 プロセスで +操作を続ける TUI で、前の操作の接続先を次の操作へ持ち越さないためである。 + +`runtime.inject` に「上書きしないキー」の一覧を持たせる形は、機密の注入が接続先の都合を +知ることになり、責務が混ざるため採らない。 + +## テスト設計 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| `project.local.yml` が無い / 空のとき従来どおり | `tests/project/test_local_config.py`: 無いディレクトリと空ファイルで既定値。`tests/commands/test_container_context.py`: `subprocess.run` を差し替えて子プロセスの env に `DOCKER_CONTEXT` が無いこと | +| `docker.context` が全子プロセスへ載る | `test_container_context.py`: `up` の全 `subprocess.run` 呼び出しの `env`(または `os.environ`)に `DOCKER_CONTEXT` があること | +| 最上位・`docker` 節の未知キー、型・値の検証 | `test_local_config.py`: 各 `ConfigError` のメッセージに使えるキー・理由が含まれる | +| `project.yml` の `docker:` を案内付きで拒む | `tests/project/test_config.py` に 1 件追加 | +| 壊れた YAML | `test_local_config.py`: ファイル名を含む `ConfigError` | +| 優先順位 CLI > env > ファイル > 未指定 | `tests/utils/test_docker_context.py`: 組み合わせの表で `ContextChoice` を検証 | +| リモート判定が `DOCKER_CONTEXT` / `DOCKER_HOST` 反映後でも変わらない | `test_docker_context.py`: `runner` に渡る env に `DOCKER_CONTEXT` も `DOCKER_HOST` も無いこと。`os.environ` に載せた後に確定しても `remote` が真になり、`DOCKER_HOST` だけがある環境で同名の context を指定しても `remote` が偽になること | +| env の空文字は未指定 | 同上 | +| `DOCKER_HOST` があるとき context 解決時に外れる | `test_docker_context.py`: 反映後の環境に `DOCKER_HOST` が無く警告が出る。context が `None` なら残る | +| 操作の間で接続先が漏れない | `test_container_context.py`: 同じプロセスで `project up A`(`docker.context: a`)の後に `project down B`(設定なし)を呼ぶと、B の子プロセスに `DOCKER_CONTEXT` が無く、`DOCKER_GID` は `bin/devbase` の元の値に戻っている | +| プロジェクト切替の後に解決する | `test_container_context.py`: A の `.env` に `DEVBASE_DOCKER_CONTEXT=a`、B の `project.local.yml` に `docker.context: b` を置き、A で `project down B` を実行すると子プロセスに `DOCKER_CONTEXT=b` が届く | +| 機密注入の後も接続先が維持される | `test_container_context.py`: 機密ストアに `DOCKER_CONTEXT=x` / `DOCKER_GID=1` / `DOCKER_HOST=tcp://...` を置いた状態で `up --context b` を実行し、volume・compose・exec のすべての子プロセスに `DOCKER_CONTEXT=b`、確定した `DOCKER_GID`、`DOCKER_HOST` 無しで届く。`env exec` も同様 | +| `--context` を受け付けるコマンド | `tests/cli/test_project_dispatch.py` 系: parser が各サブコマンドで `--context` を取ること | +| 存在しない context は docker のエラーで止まる | 手動確認(docker の判定に委ねるため単体テストにしない) | +| ローカル扱いで `DOCKER_GID` が変わらない | `test_container_context.py`: 現在の context と同じ名前を設定し、`DOCKER_GID` が元のまま | +| `docker.gid` 明示 | `test_docker_context.py`: `DockerTarget.gid` と反映後の `os.environ` | +| gid の自動取得と控え | `test_docker_context.py`: `runner` を差し替え、1 回目は `docker run` が呼ばれて控えが書かれ、2 回目は呼ばれない | +| 取得した gid が `0` のときの警告 | `test_docker_context.py`: `runner` が `0` を返すと警告が出て値は採用される | +| gid 取得の失敗 | `test_docker_context.py`: 非ゼロ / 非整数の出力で `DevbaseError`。`test_container_context.py`: `up` が非ゼロで終わり compose を呼ばない | +| `~` の展開(短い書式・`~` 単独・長い書式) | `tests/volume/test_bind_mounts.py` | +| `~user` と相対パスは警告のみ | 同上 | +| ローカル扱いでは書き換えない | `tests/volume/test_compose.py` 系: `home` を渡さない生成で `~` が残る | +| `home` 無しのリモート扱いで警告 | `test_bind_mounts.py` + `caplog` | +| 絶対パスと named volume を触らない | `test_bind_mounts.py` | +| `down` / `ps` / `logs` / `login` の伝播 | `test_container_context.py`: 各 cmd の子プロセス env | +| リモート扱いの `scale` | `test_container_context.py`: `DOCKER_CONTEXT` / `DOCKER_GID` と生成物の bind mount | +| shell `build` の伝播 | `tests/cli/test_wrapper_build_context.py`: `docker` と `uv` を偽コマンドに差し替え、`--context` が引数から取り除かれてシェル変数に保持され、`env exec --context NAME` として引数で届くこと。`build --context NAME` と `--context=NAME` が単体ビルドへ誤分岐しないこと | +| `up` からの自動ビルド | `test_container_context.py`: `_run_build` が `bin/devbase build --context ` を起動する。`test_wrapper_build_context.py`: `env` に `DEVBASE_DOCKER_CONTEXT=a` があっても `--context b` が勝つ。`tests/cli/test_secret_injection.py` 系: `.env`(機密)に `DEVBASE_DOCKER_CONTEXT=a` があっても `env exec --context b` の子プロセスに `DOCKER_CONTEXT=b` が載る | +| `env exec` | `tests/cli/test_secret_injection.py` 系に追加: 子プロセス env の `DOCKER_CONTEXT` | +| 自動スナップショットの回避 | `test_container_context.py`: リモート扱いで `SnapshotManager.create` が呼ばれず警告が出る | +| `snapshot` 系と `down` のローテーションは変わらない | 既存テスト(`tests/snapshot/`)が書き換えなしで通る | +| ローカル端末 + context あり → `settings.context` 付きフラット URI | `tests/editor/test_opener.py` | +| ローカル端末 + context 無し → 従来 | 既存テストが通る | +| Remote-SSH + context あり → ネスト URI + `settings.context` | `test_opener.py` | +| Remote-SSH + context 無し → 従来の推測 | 既存テストが通る | +| `DEVBASE_EDITOR_DOCKER_CONTEXT` が優先 | `test_opener.py` | +| フラット URI の提示 | `test_opener.py` + `caplog` | +| `project.yml` 単体の検証が変わらない | `tests/project/test_config.py` が書き換えなしで通る | +| 機密の値が現れない | `test_container_context.py`: 機密を載せた状態で警告・控え・info に値が無い | +| Git から除外 | `git check-ignore .cache/docker-gid/x projects/x/project.local.yml` | +| `status` が変わらない | `tests/commands/test_status_account_group.py` が書き換えなしで通る | +| 性能(docker 呼び出し回数) | `test_container_context.py`: context 未指定の `up` では `docker context show` / `docker run alpine` のどちらも呼ばれない。context 指定ありで現在の context と一致する `up` では `docker context show` が 1 回だけ呼ばれ、`docker run alpine` は呼ばれない | diff --git a/issues/PLAN52_remote-docker-context-design.md b/issues/PLAN52_remote-docker-context-design.md new file mode 100644 index 00000000..05fcd919 --- /dev/null +++ b/issues/PLAN52_remote-docker-context-design.md @@ -0,0 +1,432 @@ +# PLAN52 設計: 構成要素とデータ構造と処理の流れ + +この文書は「どう作るか」だけを扱う。 + +| 内容 | 文書 | +| --- | --- | +| 要求と受け入れ条件 | [PLAN52_remote-docker-context.md](PLAN52_remote-docker-context.md) | +| 決定の記録とテスト設計 | [PLAN52_remote-docker-context-decisions.md](PLAN52_remote-docker-context-decisions.md) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | プロジェクトごとに接続先の docker context を個人設定に書く | 別ホストで dev コンテナを動かす利用者 | +| F2 | `devbase up / down / ps / logs / login / scale / build / rebuild` が設定した context の daemon を相手に動く | 同上 | +| F3 | 一時的に別の context へ向ける(CLI / 環境変数) | 同上 | +| F4 | リモート側の docker グループ gid を自動で決める | 同上(`docker.gid` を書かずに済ませたい人) | +| F5 | bind mount の `~` をリモート側の HOME で展開する | `~/.aws` 等を mount するプロジェクトの利用者 | +| F6 | `devbase up` が開く VS Code がリモートのコンテナへ attach する | 同上 | +| F7 | Remote-SSH 統合端末から、手元で直接 attach する URI も受け取る | Windows VS Code → Mac → WSL の一周を避けたい人 | + +## 構成要素 + +### 文脈 + +```mermaid +graph LR + 利用者 --> CLI[devbase] + CLI --> Local[手元の docker daemon] + CLI --> Remote[別ホストの docker daemon] + CLI --> Code[VS Code] + Code --> Remote +``` + +変えられないものは次の 3 つである。 + +| 外部の系 | 変えられない振る舞い | +| --- | --- | +| docker CLI | context の解決順(`DOCKER_CONTEXT` → `docker context use` → 既定) | +| compose クライアント | bind mount の `~` を手元の HOME へ展開する | +| VS Code Dev Containers 拡張 | attach URI の `settings.context` を attach 先の context 名として読む | + +### 構成要素図 + +```mermaid +graph TD + subgraph 設定 + LC[個人設定の読み込み
project.local.yml] + end + subgraph 解決 + RC[context の解決
優先順位と出所] + RT[接続先の確定
リモート判定・home・gid] + end + subgraph 適用 + AP[環境変数への反映
DOCKER_CONTEXT / DOCKER_GID] + BM[bind mount の書き換え] + ED[attach URI の組み立て] + end + subgraph 入口 + UP[up / scale] + OT[down / ps / logs / login / build / rebuild] + EX[env exec] + end + LC --> RC --> RT + RT --> AP + RT --> BM + RT --> ED + UP --> RT + OT --> RC + EX --> RC +``` + +| 要素 | 責務 | +| --- | --- | +| 個人設定の読み込み | `project.local.yml` を読み、`docker` 節を検証して `DockerSettings` にする。無い・空なら既定値 | +| context の解決 | CLI / env / ファイル / 未指定の順で 1 つに決め、出所を添える。**docker を呼ばない純粋な処理** | +| 接続先の確定 | 解決した context と現在の context を比べてリモート扱いを決め、`home` / `gid` を添える。現在の context は `docker context show` で 1 回だけ問い合わせる。**問い合わせは `DOCKER_CONTEXT` と `DOCKER_HOST` の両方を取り除いた環境で実行する**(`DOCKER_CONTEXT` が残ると設定先自身が返って常にローカル扱いになり、`DOCKER_HOST` が残ると `default` が返って常にリモート扱いになる。実測: `DOCKER_HOST=tcp://127.0.0.1:1 docker context show` → `default`) | +| 環境変数への反映 | `DOCKER_CONTEXT` を `os.environ` へ載せ、`DOCKER_HOST` があれば警告して取り除く(docker は `DOCKER_HOST` を `DOCKER_CONTEXT` より優先するため)。リモート扱いの up / scale では `DOCKER_GID` も載せる(gid が無ければリモートで取得し `.cache/` に控える)。**接続先の確定より後に行い、冪等にして機密注入の後に再適用する** | +| bind mount の書き換え | 生成物の各サービスの bind mount で `~` を `home` に置き換え、置き換えられないものを警告に集める | +| attach URI の組み立て | 解決した context を `settings.context` に載せる。既存の `ssh_host` との組み合わせを保つ | +| up / scale | 接続先を確定する。`up` は反映・書き換え・URI のすべてを使い、`scale` はエディタを開かないため反映と書き換えだけを使う | +| down / ps / logs / login / build / rebuild | context だけを解決して反映する(gid・home は使わない) | +| env exec | shell の `cmd_build` から呼ばれる。context だけを解決して子プロセスへ載せる | + +### 配置 + +```mermaid +graph TD + subgraph 手元 + SH[bin/devbase
bash] + PY[devbase.cli
Python] + DC[docker CLI / compose] + VS[VS Code] + end + subgraph 別ホスト + DD[dockerd] + CT[dev コンテナ] + end + SH -->|env exec 経由| PY + PY -->|DOCKER_CONTEXT 付きの環境| DC + DC -->|ssh: compose の構成・ビルド文脈| DD + DD --> CT + VS -->|settings.context を持つ attach URI| CT +``` + +境界をまたぐもの: docker CLI が ssh で送るのは compose の構成(変数展開済み。機密は +**環境変数の値として展開された結果**が含まれる)とビルド文脈(`containers/`)。 +`project.local.yml`・`env`・`.env`・age の鍵は手元に留まる。 + +### パッケージ・モジュール構成 + +```text +bin/devbase (変更: cmd_build の docker 直接呼び出しを env exec 経由へ、--context の受け取り) +lib/devbase/ +├── cli.py (変更: --context を lifecycle サブコマンドと env exec へ追加) +├── project/ +│ ├── config.py (変更: project.yml の docker: を案内付きで拒否) +│ └── local_config.py (新設: project.local.yml の読み込みと検証) +├── utils/ +│ └── docker_context.py (新設: context の解決・接続先の確定・環境変数への反映・gid 取得) +├── volume/ +│ ├── bind_mounts.py (新設: ~ の展開と警告の収集) +│ └── compose.py (変更: 生成時に bind_mounts を呼ぶ) +├── commands/ +│ ├── container.py (変更: 各 cmd で解決と反映、自動スナップショットの回避) +│ └── env.py (変更: env exec で context を子プロセスへ) +└── editor/ + └── opener.py (変更: docker_context の解決順とフラット URI の提示) +docs/user/ +├── project-yml.md (変更: project.local.yml の節) +├── environment-variables.md (変更: 「跨ホスト」→「リモート Docker」) +└── cli-reference/02-project.md (変更: --context) +tests/ +├── project/test_local_config.py (新設) +├── utils/test_docker_context.py (新設) +├── volume/test_bind_mounts.py (新設) +├── commands/test_container_context.py (新設) +├── cli/test_wrapper_build_context.py (新設) +└── editor/test_opener.py (変更) +``` + +## 構造 + +```mermaid +classDiagram + class DockerSettings { + +context: str? + +home: str? + +gid: int? + } + class ProjectLocalConfig { + +docker: DockerSettings + } + class ContextChoice { + +context: str? + +source: str + } + class DockerTarget { + +context: str? + +source: str + +remote: bool + +home: str? + +gid: int? + } + ProjectLocalConfig "1" --> "1" DockerSettings + ContextChoice ..> DockerSettings: 読む + DockerTarget ..> ContextChoice: 元にする + DockerTarget ..> DockerSettings: home と gid を取る + ProjectConfig ..> ProjectLocalConfig: 別ファイル・別型 +``` + +| 型 | 責務 | +| --- | --- | +| `DockerSettings` | `project.local.yml` の `docker` 節 1 つ分。すべて省略可 | +| `ProjectLocalConfig` | `project.local.yml` 1 ファイル分。いまは `docker` だけを持つ。将来 `scale` 等を足す器 | +| `ContextChoice` | 優先順位で決めた context と出所(`cli` / `env` / `file` / `default` の 4 値)。docker を呼ばずに決まる | +| `DockerTarget` | 接続先の確定結果。`remote` が偽なら `home` / `gid` は `None` | + +`ProjectConfig`(既存)は変えない。`project.local.yml` を `project.yml` へ深くマージする +形は採らない(決定 2)。 + +### 状態: リモート扱いの判定 + +```mermaid +stateDiagram-v2 + [*] --> 未指定: context が None + [*] --> 指定あり: context が非 None + 指定あり --> ローカル扱い: 現在の context と一致 + 指定あり --> リモート扱い: 現在の context と不一致 + 指定あり --> リモート扱い: 現在の context を取得できない + 未指定 --> [*] + ローカル扱い --> [*] + リモート扱い --> [*] +``` + +`home` / `gid` が `DockerTarget` に載る条件は 2 つある。リモート扱いであること、そして +**CLI / env で上書きされた context がファイルの `docker.context` と一致すること**(前提 4) +である。不一致なら両方 `None` にし、警告を 1 行出す。 + +## データ構造 + +永続化するのは 2 つで、どちらも Git 管理外である。 + +### `projects//project.local.yml` + +```yaml +docker: + context: gpu-wsl # 任意。docker context ls の名前 + home: /home/takemi # 任意。リモート側の HOME(絶対パス) + gid: 999 # 任意。リモート側の docker グループ gid +``` + +| キー | 型 | 必須 | 検証 | +| --- | --- | --- | --- | +| `docker` | マッピング | いいえ | 未知キーは `ConfigError` | +| `docker.context` | 文字列 | いいえ | 空・空白・制御文字を含むものは `ConfigError` | +| `docker.home` | 文字列 | いいえ | `/` で始まらないものは `ConfigError` | +| `docker.gid` | 整数 | いいえ | 真偽値・負数・非整数は `ConfigError` | + +最上位に `docker` 以外のキーがあれば `ConfigError`。空ファイル(`None`)は「無い」と同じ。 +`project.yml` 側は `_TOP_LEVEL_KEYS` を変えず、`docker` があったときだけ +「`project.local.yml` へ移す」案内を含むメッセージにする。 + +### `$DEVBASE_ROOT/.cache/docker-gid/` + +| 項目 | 内容 | +| --- | --- | +| 中身 | 10 進の gid 1 行 | +| 作る時 | リモート扱いの up / scale で `docker.gid` が無く、取得に成功したとき | +| 読む時 | 同じ条件で、ファイルがあり整数として読めるとき | +| 消す時 | 自動では消さない。リモート側の gid が変わったら利用者が消すか `docker.gid` を書く | +| ファイル名 | context 名をそのまま使う。context 名は docker が `/` を許さないため経路を壊さない | + +上書きして過去を失う構造だが、控えるのは再取得できる値であり履歴に意味が無い(決定 6)。 + +## 入出力の契約 + +### 設定ファイル + +上の「データ構造」が契約である。読み込みの入口は +`load_project_local_config(project_dir) -> ProjectLocalConfig`。ファイルが無ければ +既定値(`docker` の 3 項目とも `None`)を返し、例外にしない。 + +### CLI `--context` + +| 項目 | 内容 | +| --- | --- | +| 名前 | `--context NAME` | +| 付く場所 | `project` / `container` 配下の `up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild`。トップレベルはショートカットが既にある `up` / `down` / `ps` / `login` / `scale` / `build` / `rebuild` だけ(`logs` のショートカットは無く、新設しない)。加えて `env exec`(shell の `build` から渡すため) | +| 入力 | context 名(文字列)。空文字は `argparse` の型検査で拒む | +| 出力 | 無し。解決結果は `up` の冒頭の info 1 行に出る | +| 失敗の形 | 名前が存在しなければ docker CLI が非ゼロで止まり、devbase はその終了コードを返す | +| 互換性 | 既存の引数は変えない。`build` の shell 経路では `bin/devbase` の `build)` 分岐が、`_build_image` の走査より**前**に `--context NAME` / `--context=NAME` を取り除いてシェル変数に保持し、docker を叩く `env exec` へ `--context NAME` として**引数で**渡す(後にすると `NAME` が単体イメージ名として拾われ、Python の単体ビルドへ誤分岐する。環境変数に写すと Python 側の機密注入が `.env` の同名キーで上書きする) | + +### 環境変数 + +| 名前 | 向き | 意味 | +| --- | --- | --- | +| `DEVBASE_DOCKER_CONTEXT` | 入力 | env / `.env` / shell からの上書き。空文字は未指定。**出力としては載せない**(`bin/devbase` が `env` を読み直すと上書きされるため、子プロセスへ渡す手段にならない) | +| `DOCKER_CONTEXT` | 出力 | 解決した context。docker CLI と compose が読む。未指定なら載せない | +| `DOCKER_HOST` | 入力 | 解決した context が非 `None` のときは警告して `os.environ` から取り除く(残すと docker がこちらを優先し、context が効かない)。`None` なら触らない | +| `DOCKER_GID` | 出力 | リモート扱いの up / scale だけ上書き。他は `bin/devbase` の値のまま | +| `DEVBASE_EDITOR_DOCKER_CONTEXT` | 入力 | 既存。attach URI の `settings.context` を明示したいときだけ。解決した context より優先 | + +`up` から `bin/devbase build` を起動する経路(`_run_build`)は、解決した context を +**`--context ` として引数で渡す**。`bin/devbase` は起動時に root の `env` と +プロジェクトの `env` を読み直すため、環境変数で渡した値はそこで `env` の +`DEVBASE_DOCKER_CONTEXT` に戻される。引数なら `build)` 分岐がその後で写すので勝つ +(決定 10)。 + +### `env exec` + +`devbase env exec [--context NAME] -- CMD` は、カレントディレクトリをプロジェクトとして +`ContextChoice` を解決する(`--context` があればそれが CLI 由来として最優先)。 +`DOCKER_CONTEXT` を子プロセスの環境へ載せる(`child_env()` が機密を載せた**後**の辞書へ +適用し、`.env` の同名キーに負けない)。gid・home・リモート判定は行わない +(docker を呼ばない)。`--context` を引数で受けるのは、`cli.main()` が dispatch の前に +機密を `os.environ` へ注入し、`.env` に `DEVBASE_DOCKER_CONTEXT` があると環境変数で渡した +値が上書きされるためである。 + +### bind mount の書き換え + +入力: 生成物の `services` と `home`。出力: 書き換えた `services` と警告の一覧。 + +| bind mount の書き方 | `home` あり | `home` なし(リモート扱い) | +| --- | --- | --- | +| `~/x:/t`、`~:/t`、`{source: ~/x}` | `home/x` へ置き換え | 警告に載せる | +| `~user/x:/t` | 置き換えず警告に載せる | 警告に載せる | +| `./x:/t`、`../x:/t` | 置き換えず警告に載せる | 警告に載せる | +| `/abs:/t`、named volume、`{type: volume}` | 触らない | 触らない | + +警告は 1 回にまとめ、mount の一覧と `docker.home` の書き方を添える。 + +### attach URI + +`open_editor(..., docker_context: str | None)` を足す。`settings.context` の決め方: + +| `DEVBASE_EDITOR_DOCKER_CONTEXT` | `docker_context` 引数 | `ssh_host` | `settings.context` | +| --- | --- | --- | --- | +| 明示(非空) | 任意 | 任意 | 明示の値 | +| 明示(空文字) | 任意 | 任意 | 付けない | +| 無し | 非 None | 任意 | 引数の値 | +| 無し | None | あり | `docker context show`(従来) | +| 無し | None | 無し | 付けない(従来) | + +`ssh_host` と `settings.context` の両方が付くとき、同じペイロードで `@ssh-remote+` を +付けないフラット URI を info で 1 行添える。 + +## 処理の流れ + +### `devbase up` + +```mermaid +sequenceDiagram + participant U as up + participant R as 解決と確定 + participant D as docker + participant C as 構成生成 + U->>R: 解決(project_dir, --context, environ) + R->>D: docker context show(DOCKER_CONTEXT / DOCKER_HOST 抜きの環境) + D-->>R: 現在の context(失敗なら不明) + R-->>U: DockerTarget + U->>U: DOCKER_CONTEXT を載せる(確定の後) + alt リモート扱い + U->>R: gid を確定(target) + R->>D: docker run alpine stat(控えが無いとき) + D-->>R: gid(失敗なら DevbaseError) + R-->>U: DOCKER_GID を載せる + U->>U: 自動スナップショットを飛ばす(警告) + end + U->>C: 生成(scale, secrets, home, remote) + Note over U,C: 機密注入の直後に反映を再適用 + C-->>U: 生成物(警告があれば出す) + U->>D: compose down / up / exec(環境を継承) + U->>U: エディタ(docker_context=target.context) +``` + +失敗の経路: + +| どこで | 何が起きる | 結果 | +| --- | --- | --- | +| `project.local.yml` の検証 | `ConfigError` | `up` は `project.yml` を読む前に非ゼロで終了。コンテナに触らない | +| gid の取得 | docker が非ゼロ、または出力が整数でない | `DevbaseError`。docker の stderr と `docker.gid` の書き方を出して非ゼロ。既存コンテナは止めない(構成生成より前) | +| context が存在しない | 最初に daemon へ届く docker 呼び出し(`docker context show` は成功する。リモート扱いなら gid の `docker run`、そうでなければ `volume inspect`)で失敗 | docker のメッセージ(`context "x" does not exist`)を含めて非ゼロ | + +解決と反映は `_ensure_env_files` より前、`project.yml` の読み込みの直後に置く。 +`pre-up` フックも `DOCKER_CONTEXT` を継承する。 + +**反映は 1 回では足りない。** `_inject_secrets()`(`runtime.inject`)は機密ストアの値を +`os.environ` へ無条件に上書きするため、`.env` に `DOCKER_CONTEXT` / `DOCKER_GID` / +`DOCKER_HOST` があると、確定した接続先が途中で戻る。`cmd_up` は構成生成の中で +`_inject_secrets(required=True)` を呼び、その後に `compose down / up` を実行する。そのため +反映は**冪等な関数**にし、`_inject_secrets()` の直後に**必ず再適用する**。責務の置き方は +次のとおり。 + +| 要素 | 責務 | +| --- | --- | +| `utils/docker_context.apply(target, environ)` | 反映する。同時に「いま有効な接続先」をモジュール変数に控え、最初の適用時に `DOCKER_CONTEXT` / `DOCKER_GID` / `DOCKER_HOST` の元の値も控える。冪等 | +| `utils/docker_context.reapply(environ)` | 控えた接続先があれば `apply` を呼び直す。無ければ何もしない | +| `utils/docker_context.reset(environ)` | 控えた接続先を捨て、3 変数を元の値へ戻す(元々無かったものは消す)。控えが無ければ何もしない | +| `commands/container._dispatch_lifecycle()` | **開始時**(`_resolve_project_name` と機密の読み直しより前)と、`finally` で**終了時**に `reset()` を呼ぶ。1 プロセスで複数の lifecycle 操作を行う TUI で、前の操作の接続先が次へ漏れないようにする。開始時にも置くのは、前の操作が `finally` を通らずに終わった場合(`KeyboardInterrupt` を TUI が握った等)への備え | +| `commands/container._inject_secrets()` | 機密を注入した**直後に自分で** `reapply()` を呼ぶ。呼び出し側は何もしない | +| `commands/env.cmd_env_exec()` | `child_env()` が返した辞書へ `apply(choice, env)` を直接当てる(モジュール変数は使わない) | + +`_inject_secrets` は引数を取らない共通関数で、container.py の全 lifecycle コマンドが通る。 +確定した接続先を引数で配り直すより、`docker_context` 側が控えを持ち `_inject_secrets` が +それを呼ぶ方が、呼び出し側を変えずに漏れを塞げる。モジュール変数を持つのはこの 1 つだけで、 +`_dispatch_lifecycle` の前後とテストの `setUp` で `reset()` を通す。TUI(`tui/dispatch.py`)は +同じプロセスで `project up A` → `project down B` を続けて呼ぶため、A の控えが残ると B の +解決結果が `None`(環境を触らない)でも `_inject_secrets` → `reapply()` が A の接続先を +復活させる。`reset()` が元の値へ戻すので、B は利用者のシェルの環境だけを見て動く。 + +### 他のコマンド + +`_dispatch_lifecycle` は、`name` で対象プロジェクトへ切り替えた(`_resolve_project_name`) +**直後に `_inject_secrets(required=False)` を呼び、対象プロジェクトの機密で `os.environ` を +作り直してから** `ContextChoice` を解決する。`cli.main()` は dispatch の前に**現在地**の機密を +注入しており、`_resolve_project_name` は非機密の `env` のキーしか入れ替えない。そのため +プロジェクト A から `project down B` を実行すると、A の `.env` の `DEVBASE_DOCKER_CONTEXT` が +残ったまま B の context を解決してしまう。`_inject_secrets` は注入前に `clear_injected` を +通すので、ここで呼び直せば A の値は消える(まだ接続先が無いので `reapply()` は何もしない)。 + +その後 `ContextChoice` を解決して handler へ渡す。`down` / `ps` / `logs` / +`login` / `build`(Python 経路)/ `rebuild` の handler は `apply(choice, os.environ)` を呼ぶ +(`apply` は `DockerTarget` と `ContextChoice` のどちらも受け、`ContextChoice` なら context +だけを載せる)。**handler が環境変数へ直接代入する形は採らない。** `apply` を通さないと +`DOCKER_HOST` の除去と控えが行われず、`reapply()` / `reset()` が効かなくなる。`up` / `scale` の +handler は**載せる前に** `DockerTarget` を確定し、それを `apply` に渡す(上の +シーケンス図の順序)。`docker context show` を呼ぶのは `up` / `scale` だけである。 +確定の問い合わせは `DOCKER_CONTEXT` と `DOCKER_HOST` を取り除いた環境で行うため、呼び出し側が +先に載せてしまっても判定は変わらない。 + +### shell の `build` + +```mermaid +graph TD + A[bin/devbase build 引数] --> B{--context あり?} + B -->|はい| C[build 分岐の先頭で取り除き
シェル変数に保持] + B -->|いいえ| D[そのまま] + C --> G{image 指定 or --expires?} + D --> G + G -->|はい| H[Python の project build
--context を引数で渡す] + G -->|いいえ| E[shell の cmd_build] + E --> F[docker 呼び出しはすべて
env exec --context NAME 経由] + H --> F2[Python が context を解決] +``` + +`--context` の抽出は `bin/devbase` の `build)` 分岐の**先頭**、`_build_image` / `--expires` の +走査より前に置く。走査の後に置くと `--context` の値が単体イメージ名として拾われ、 +`project build ` へ誤分岐する。抽出した値は環境変数に写さず、`env exec` と +`project build` へ `--context NAME` として引数で渡す。`cmd_build` の `docker buildx build` と +`docker image inspect` を `compose_with_secrets`(= `env exec`)経由へ変える。関数名は役割に +合わせ `run_with_project_env` に改める。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | context 未指定(`None`)の `up` に新たな docker 呼び出しを足さない。context 指定ありの `up` で足すのは `docker context show` 1 回と、リモート扱いのときの gid 取得 1 回(初回のみ)まで | `docker context show` は context が非 None のときだけ呼ぶ。gid は `.cache/docker-gid/` に控える | `subprocess.run` を差し替えた結合テストで呼び出し回数を数える | +| 運用・保守性 | 解決した context と出所を `up` の冒頭に 1 行出す。飛ばした処理と書き換えなかった mount は警告に残す | `ContextChoice.source` を info に含める。警告は `logger.warning` に集約 | ログをキャプチャする単体テスト | +| 移行性 | 設定を書くまで挙動が変わらない。消せば戻る | context が `None` のとき環境変数を一切触らない。書き換えは `home` が非 None のときだけ | 既存テストが書き換えなしで通る | +| セキュリティ | 接続先の実体・鍵・トークンを設定に持たない。鍵・設定ファイル・平文ファイルは手元に留まり、復号済みの機密の**値**は compose の変数展開を通じて接続先の daemon とコンテナへ渡る | 設定は context 名だけ。機密注入の経路(`_inject_secrets` → compose の変数展開)は変えず、ファイルを送る経路を足さない | 受け入れ条件「起きてはいけないこと」の grep と、生成物に値が書かれないことの既存テスト | +| システム環境 | 手元 macOS / Linux / WSL、リモート Linux dockerd + sshd | 手元側で bash と Python 3.10 以降のみを前提にする。リモート側に devbase を要求しない | ドキュメントの手順で実機確認 | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| buildx とリモート context | `DOCKER_CONTEXT` 付きの `docker buildx build --load` が、その context の docker ドライバでビルドされビルド文脈を送ることは docker の仕様だが、`docker buildx use` で別のビルダーを固定している環境では変わる。実機で確かめる | +| `pre-up` フックの build 文脈 | ローカルへ clone して build context にするフックがリモートで動くかは、この計画では確かめない(対象範囲外) | +| `settings.context` のローカル VS Code での解釈 | ssh 先経由での実機確認はあるが、手元の Dev Containers 拡張が非既定 context で attach する経路は実機で確かめる(リリース後テスト) | +| alpine のタグ | `alpine:3` を使う。リモートに無ければ pull が走る(初回のみ) | diff --git a/issues/PLAN52_remote-docker-context.md b/issues/PLAN52_remote-docker-context.md new file mode 100644 index 00000000..163b88e8 --- /dev/null +++ b/issues/PLAN52_remote-docker-context.md @@ -0,0 +1,348 @@ +# PLAN52: 別ホストの Docker に dev コンテナを立ち上げ、VS Code もそこへ接続する + +- 発端: [devbasex/devbase#162](https://github.com/devbasex/devbase/issues/162)(2026-09-13) +- ワークフローモード: `standard` + - 根拠: 新しい設定ファイル `project.local.yml` と CLI フラグ `--context` を公開インタフェースへ + 追加する。`devbase up/down/ps/login/build` が相手にする Docker daemon の選び方(本番の + 振る舞い)が変わる。`bin/devbase`・`lib/devbase/commands/`・`project/`・`volume/`・ + `editor/` の複数モジュールにまたがり、対象には既存テストが十分にある。 + +## 目的 + +`projects//` に**個人・機材ごとの**設定を置くだけで、`devbase up` が別ホストの Docker +daemon に対して従来と同じ手順で動く状態を作る。手順とは volume・network・compose・フック・ +イメージ確保を指す。`devbase up` が開く VS Code は、そのホスト上のコンテナへ attach する。 + +「compose クライアントは手元、daemon はリモート」の docker context 方式を採る。リモートへ +配布するものを増やさない。devbase 一式・`projects/`・機密鍵をリモートへ複製しない。 + +## 依頼(原文) + +issue #162 の「背景・課題」と「想定用途」より引用する。 + +> `devbase up` は **コマンドを実行した環境 (Mac) の Docker** にしか dev コンテナを立てられない。設定で「このプロジェクトのコンテナは別ホストの Docker に立てる」と指定し、`devbase up` で立ち上がる VS Code もそのホスト上のコンテナへ接続できるようにしたい。 +> +> | やりたいこと | 理由 | +> |---|---| +> | Windows / WSL 上にコンテナを立てたい | CUDA が使える GPU は Windows にしかない | +> | 3 台目の自宅 PC のコンテナを操作したい | 負荷分散 | +> | AWS EC2 に入れた Docker ホストのコンテナを操作したい | クラウド側の計算資源 | +> +> いずれも「操作は手元 (Windows VS Code / Mac の端末)、コンテナは別ホスト」で、接続方法は **Docker のリモート制御 (`docker context` / `DOCKER_HOST`) か SSH**。 + +issue には調査結果・提案内容・代替案 A〜E が付いている。提案内容は設定ファイル +`project.local.yml`、`docker.context` / `docker.home` / `docker.gid`、優先順位、VS Code の +出し分け、4 段階の分割である。**提案内容は依頼の一部として扱い**、この仕様はそれを受け入れ +条件へ落とす。提案から外れる判断は「利用者が決めたこと」または「前提」に書く。 + +## 利用者が決めたこと + +issue #162 の提案として書かれているものを、この仕様の決定として扱う。 + +| 決めたこと | 内容 | +| --- | --- | +| 設定の置き場 | `projects//project.local.yml`(gitignore 対象)。`project.yml` には書かない | +| キー名 | `docker.context` / `docker.home` / `docker.gid`。`host` は `repos[].host` と紛らわしいので使わない | +| 接続先の実体 | 書かない。docker context の**名前**だけを書き、名前 → 接続先は各マシンの `docker context create` に委ねる | +| 優先順位 | CLI `--context` > env `DEVBASE_DOCKER_CONTEXT` > `project.local.yml` の `docker.context` > 現在の docker context | +| 伝え方 | 解決した context を `DOCKER_CONTEXT` 環境変数として全 `docker` / `docker compose` 呼び出しへ渡す | +| Windows 側の前提 | WSL2 内の dockerd + WSL 内の sshd。Docker Desktop の Windows 直結は前提にしない | +| 採らない案 | A(リモートで `devbase up`)/ B(`project.yml` に書く)/ C(env だけ)/ D(1 枚の個人設定)/ E(TCP+TLS 必須)。理由は issue の「代替案」 | + +## 調査で確定した事実 + +| 確認事項 | 結果 | 根拠 | +| --- | --- | --- | +| `docker` / `docker compose` の呼び出し方 | すべて `subprocess` で CLI を叩く。daemon の選択は CLI の既定(現在の context)に委ねている | `grep -rn "subprocess.run\|Popen" lib/devbase` → `commands/container.py`(14)`utils/docker.py`(3)`volume/manager.py`(2)`snapshot/manager.py`(3)`editor/opener.py`(4)`editor/window_title.py`(1)`commands/status.py`(1)ほか | +| `DOCKER_CONTEXT` 環境変数の効き方 | docker CLI と compose v2 の両方が読む。`docker context use` の既定より優先する。**ただし `DOCKER_HOST` が設定されているときはそちらが勝ち、`DOCKER_CONTEXT` は無視される** | 実測: `DOCKER_HOST=tcp://127.0.0.1:1 DOCKER_CONTEXT=desktop-linux docker version` が `Cannot connect to the Docker daemon at tcp://127.0.0.1:1` で失敗(Docker 29.4.3)。Docker Docs「Docker contexts」、docker/cli #6151 | +| `DOCKER_GID` の決め方 | `bin/devbase:37` で `uname` が Darwin なら `0`、それ以外はローカルの `/etc/group`。Python 側には決める処理が無い | `bin/devbase:37`、`grep -rn DOCKER_GID lib tests` → 0 件 | +| `DOCKER_GID` の使われ方 | サンプルの全プロジェクトが `group_add: ["${DOCKER_GID}"]` と `/var/run/docker.sock` の bind を持つ | `projects/*/compose.yml`(18 件)、`docs/plugin-dev/compose-yml-guidelines.md:131` | +| bind mount の `~` を展開するのは誰か | compose クライアント(手元)。生成物 `.docker-compose.scale.yml` には `~/...` のまま残り、`docker compose up -f <生成物>` の時点で手元の HOME へ展開される | `lib/devbase/volume/compose.py:_load_compose_config`(YAML を直接読み、`docker compose config` を通さない)、`_build_dev_instance` が volumes を文字列のまま複製 | +| `~` を使う bind mount を持つプロジェクト | `~/devbase:/work/devbase`(`projects/devbase`)、`~/.aws:/home/ubuntu/.aws`(`projects/with-ai-dev`)。他 15 件はコメントアウト | `grep -n "^\s*- ~/" projects/*/compose.yml` | +| 起動時に `-f` で渡す構成 | 生成物 `.docker-compose.scale.yml` **のみ**。元の `compose.yml` は渡さない。よって生成物を書き換えれば全サービスの mount に効く | `commands/container.py:cmd_up` → `docker_compose_up(compose_file=override_file)` | +| `build` の経路 | `devbase build`(引数なし / `--no-cache`)は shell の `cmd_build` が `docker buildx build --load` と `docker image inspect` を**直接**叩き、project image は `compose_with_secrets docker compose build` を通す。`up` の自動ビルドは Python から `bash bin/devbase build` を起動する | `bin/devbase:cmd_build`、`commands/container.py:_run_build` | +| `devbase env exec` | 機密を子プロセスの環境変数へ載せて任意コマンドを実行する。shell の compose 呼び出しはすべてここを通る | `commands/env.py:cmd_env_exec`、`bin/devbase:compose_with_secrets` | +| スナップショットの仕組み | `docker run -v <手元のディレクトリ>:/backup` で tar を書く。**daemon が別ホストだと手元にファイルが残らない** | `snapshot/manager.py:_run_docker_tar` | +| `devbase up` / `down` とスナップショット | `up` の前に `_auto_snapshot()`、`down` の後に `rotate()` が走る | `commands/container.py:cmd_up` / `cmd_down` | +| VS Code の attach URI | `build_attach_uri` が `settings.context` を埋められる。ただし `docker_context` を解決するのは `ssh_host` があるときだけ(`resolve_docker_context(env) if ssh_host else None`) | `editor/opener.py:683` | +| `settings.context` の実機確認 | Windows VS Code → Remote-SSH(Mac) → Mac の docker context で attach できている(VS Code 1.124 / Dev Containers 0.459) | `editor/opener.py` モジュール docstring、`docs/user/environment-variables.md` 「跨ホスト」 | +| `project.yml` の読み込み | `load_project_config` が 1 ファイルを読み、未知キーは `ConfigError`。ローカル上書きの仕組みは無い | `project/config.py:load_project_config` / `_reject_unknown_keys` | +| `projects/*` の実体 | devbase-samples / devbase-ext への symlink。`projects/*` は devbase 本体の `.gitignore` 済み | `ls -la projects`、`.gitignore` | +| `.cache/` | `$DEVBASE_ROOT/.cache/pulls/` を pull の目印に使っている。`.gitignore` 済み | `commands/container.py:_pull_marker_path`、`.gitignore` | +| 対象領域のテスト | 十分にある | `tests/project/test_config.py`、`tests/volume/test_compose*.py`、`tests/editor/test_opener.py`、`tests/commands/test_container_up_order.py`、`tests/cli/test_wrapper_*.py` | +| `devbase status` | `docker ps` で全プロジェクトのコンテナを列挙する。プロジェクトごとに daemon が違う構成は想定していない | `commands/status.py:37` | +| `devbase snapshot` の対象 | アカウントグループのボリューム(プロジェクト単位ではない)。`rotate` は手元のディレクトリ削除だけで docker を呼ばない | `snapshot/manager.py:__init__` / `rotate` | + +## 前提 + +- 前提 1: **リモートには docker CLI + dockerd + sshd だけがあればよい。** devbase・`projects/`・ + 機密鍵をリモートへ置かない。(成否の判定: 受け入れ条件のどれも、リモート側に devbase の + ファイルがあることを要求しない) +- 前提 2: **既定の挙動は変えない。** `project.local.yml` も `--context` も + `DEVBASE_DOCKER_CONTEXT` も無いとき、`docker` / `docker compose` に `DOCKER_CONTEXT` を + 付けない。`DOCKER_GID` も生成物の mount も従来と同じになる。(成否の判定: 既存テストが + 1 件も書き換えなしで通り、`DOCKER_CONTEXT` を設定する箇所が「解決した context が + `None` でないとき」だけであること) +- 前提 3: **`docker context use` で現在の context 自体をリモートへ向けた状態は扱わない。** + その状態は今日でも動く/動かないが決まっており、この変更は「解決した context が現在の + context と異なるとき」だけをリモート扱いにする。(成否の判定: 解決した context が + `None` のとき gid 解決・`~` 展開・スナップショットの扱いが変わらないこと) +- 前提 4: **`docker.home` / `docker.gid` は `docker.context` と同じホストの値である。** + CLI / env で context を `project.local.yml` の `docker.context` と**別の名前**へ上書きした + ときは、ファイルの `home` / `gid` を使わず、その旨を警告する。機材依存の値を別の機材へ + 持ち込まないため。(成否の判定: 上書き時の警告と、`~` が展開されないこと) +- 前提 5: **リモート扱いのとき `up` の自動スナップショットは作らない。** 手元のディレクトリを + bind mount する現在の仕組みでは別ホストの daemon からファイルが届かず、控えたい + ボリューム自体もリモートにあるため、警告を出して飛ばす。`devbase snapshot` の明示操作と + `down` のローテーション(手元のファイル整理のみ)は従来どおり手元を対象にし、context を + 解決しない。リモートのボリュームを手元へ運ぶ仕組みは別の課題にする。 + (成否の判定: リモート扱いの `up` が snapshots ディレクトリを変えず、`snapshot` 系の + コマンドに context 解決が入らないこと) +- 前提 6: **`devbase status` は従来どおり手元の daemon だけを見る。** プロジェクトごとに + daemon が違う構成での集約表示は扱わない。(成否の判定: `status.py` に context 解決を + 入れないこと) +- 前提 7: **`DOCKER_HOST` を直接指定する運用は扱わない。** 接続先は docker context の名前で + 表し、`DOCKER_HOST` を使いたい場合は `docker context create --docker host=...` で名前を + 付けてから指定する。docker は `DOCKER_HOST` があると `DOCKER_CONTEXT` を無視するため、 + context を解決したときは `DOCKER_HOST` を子プロセスから外す。(成否の判定: 新しい設定・ + フラグ・env のどれも `DOCKER_HOST` を受け取らず、context 解決時に `DOCKER_HOST` が子プロセス + へ渡らないこと) +- 前提 8: **イメージはホストごとに別物である。** リモート扱いの `up` はリモート側の + イメージを見て、無ければリモート側でビルドする。手元のイメージをリモートへ転送しない。 + (成否の判定: `_ensure_images` と `build` が `DOCKER_CONTEXT` 付きで動き、`docker save` + / `load` を呼ばないこと) + +## 対象範囲 + +含む: + +- `projects//project.local.yml` の読み込み(`project.yml` の後に読み、`docker` 節だけを + 受け付ける)と検証 +- context の解決(CLI `--context` / env `DEVBASE_DOCKER_CONTEXT` / `docker.context` / 未指定) +- 解決結果の `DOCKER_CONTEXT` としての伝播。届く先は Python 経由の全 docker 呼び出し、shell の + `cmd_build`、`up` からの自動ビルド、`devbase env exec` の 4 つ +- `DOCKER_GID` のリモート側での解決(`docker.gid` 明示 → 無ければリモートで 1 回取得して + `.cache/` に控える) +- 生成物 `.docker-compose.scale.yml` の bind mount の `~` を `docker.home` で展開する。 + `docker.home` 未指定のリモート扱いでは、`~` と相対パスの bind mount を列挙して警告する +- `devbase up / down / ps / logs / login / scale / build / rebuild` が同じ context 解決を通る +- リモート扱いでの `up` の自動スナップショットの回避(前提 5) +- VS Code の attach URI に、解決した context を `settings.context` として**ローカル端末でも** + 付ける。Remote-SSH 統合端末では既存のネスト URI に `settings.context` を組み合わせる。 + ネスト URI を出すときは、手元の VS Code に同名の context がある場合に直接 attach できる + フラット URI も添えて提示する +- ドキュメント。書く先と内容は次の 4 つ + + | 文書 | 内容 | + | --- | --- | + | `docs/user/project-yml.md` | `project.local.yml` の節 | + | `docs/user/environment-variables.md` | 「跨ホスト」を「リモート Docker」として再構成 | + | 同上 | WSL / EC2 の `docker context create` 手順 | + | `docs/user/cli-reference/02-project.md` | `--context` | + +- devbase-samples の `.gitignore` へ `project.local.yml` を足す(別リポジトリなので、 + この計画では**起票**にとどめる) + +含まない: + +- リモート扱いでのスナップショットの作成・復元(前提 5。別課題として起票する) +- `devbase status` の複数 daemon 集約(前提 6) +- `DOCKER_HOST` の直接指定(前提 7) +- イメージの手元 → リモート転送(前提 8) +- `project.local.yml` での `scale` / `open_editor` の個人上書き(issue が初期スコープ外と + している) +- Docker Desktop for Windows への直結(WSL2 内 dockerd を前提にする) +- CUDA / `--gpus` の設定(該当プロジェクトの `compose.yml` の話) +- ssh 鍵・TLS 証明書の配布、context の作成そのもの(docker 側の手順として文書に書くだけ) +- `pre-up` / `deploy` フックがローカルパスへ clone して build context にする用途の動作保証 + (buildx が context を送るため動く可能性はあるが、この計画では確かめない) + +## 受け入れ条件 + +### 設定の読み込み + +- [ ] `projects//project.local.yml` が無いとき、`devbase up` の振る舞いは従来と同じで、 + `docker` / `docker compose` の子プロセスの環境に `DOCKER_CONTEXT` が載らない +- [ ] `project.local.yml` に `docker: {context: gpu-wsl}` を書くと、`devbase up` が起動する + すべての `docker` / `docker compose` 子プロセスに `DOCKER_CONTEXT=gpu-wsl` が載る +- [ ] `project.local.yml` の最上位に `docker` 以外のキー(例 `scale`)があると、使えるキーを + 添えた `ConfigError` になり、コンテナは起動しない +- [ ] `project.local.yml` の `docker` に `context` / `home` / `gid` 以外のキーがあると、使える + キーを添えた `ConfigError` になる +- [ ] `docker.context` が空文字・非文字列・空白を含む値のときは `ConfigError` になる +- [ ] `docker.gid` が負の整数・真偽値・文字列のときは `ConfigError` になる +- [ ] `docker.home` が `/` で始まらない値のときは `ConfigError` になる(リモート側の絶対パス + だけを受け付ける) +- [ ] `project.yml` の最上位に `docker:` を書くと、`project.local.yml` へ移すよう案内する + `ConfigError` になる +- [ ] `project.local.yml` が YAML として壊れているとき、ファイル名を含む `ConfigError` になる +- [ ] `project.local.yml` が空ファイルのときはエラーにならず、無いときと同じに扱う + +### context の優先順位 + +- [ ] `project.local.yml` に `docker.context: a`、env `DEVBASE_DOCKER_CONTEXT=b`、 + CLI `--context c` があるとき、子プロセスへ載る `DOCKER_CONTEXT` は `c` +- [ ] 同じ状態で CLI 指定が無ければ `b`、env も無ければ `a` +- [ ] env `DEVBASE_DOCKER_CONTEXT` に空文字を設定すると「未指定」として扱い、 + `project.local.yml` の値へ落ちる +- [ ] `--context` は `project` / `container` 配下の `up` / `down` / `ps` / `logs` / `login` / + `scale` / `build` / `rebuild` と、トップレベルにショートカットがある `up` / `down` / + `ps` / `login` / `scale` / `build` / `rebuild` が受け付ける(`logs` にトップレベルの + ショートカットは無く、新設もしない) +- [ ] 解決した context が非 `None` で、実行時の環境に `DOCKER_HOST` があるとき、devbase は + `DOCKER_HOST` を子プロセスへ渡さず、その旨を警告する(渡すと docker が `DOCKER_CONTEXT` + を無視して `DOCKER_HOST` へ接続するため)。解決した context が `None` なら `DOCKER_HOST` + はそのまま渡り、従来どおり動く +- [ ] 解決した context が `docker context ls` に無い名前のとき、`devbase up` はコンテナを + 起動せずに非ゼロで終了する。表示は docker CLI のエラー(`context "x" does not exist`) + である。devbase 側で名前の存在を先に検証しない(docker の判定に委ねる) + +### `DOCKER_GID` + +- [ ] ローカル扱いのとき、compose に渡る `DOCKER_GID` は `bin/devbase` が決めた値のまま変わらない +- [ ] リモート扱いで `docker.gid: 999` があるとき、compose に渡る `DOCKER_GID` は `999` +- [ ] リモート扱いで `docker.gid` が無いとき、リモートで取得した gid が `DOCKER_GID` として + compose に渡る。取得は `DOCKER_CONTEXT` 付きの + `docker run --rm -v /var/run/docker.sock:/s <小さな公開イメージ> stat -c %g /s` 相当で行う +- [ ] 取得した値は `$DEVBASE_ROOT/.cache/docker-gid/` に残り、2 回目の `up` は + `docker run` を起動せずにその値を使う +- [ ] 取得に失敗した(docker が非ゼロ・出力が整数でない)とき、`up` は理由と + `docker.gid` の書き方を示して非ゼロで終了し、コンテナを起動しない +- [ ] 取得した gid が `0` のとき、`up` は続行するが、socket が root 所有か rootless の可能性と + `docker.gid` の書き方を警告に出す + +### bind mount の `~` + +- [ ] リモート扱いで `docker.home: /home/takemi` があるとき、`compose.yml` の + `~/.aws:/home/ubuntu/.aws` は生成物で `/home/takemi/.aws:/home/ubuntu/.aws` になる +- [ ] `~` 単独(`~:/x`)と長い書式(`source: ~/.aws`)も同じ規則で展開される +- [ ] `~user/...` のような他ユーザ指定の `~` は書き換えず、警告に載せる +- [ ] ローカル扱いでは `docker.home` があっても `~` を書き換えない(従来どおり compose の展開に + 委ねる) +- [ ] リモート扱いで `docker.home` が無く、`~` で始まる bind mount が 1 つ以上あるとき、 + 該当する mount の一覧と `docker.home` の書き方を警告として出す。`up` は続行する +- [ ] リモート扱いで `./` または `../` で始まる bind mount があるとき、該当する mount を + 「リモートには無いパス」として警告する。書き換えはしない +- [ ] `/var/run/docker.sock` のような絶対パスの bind mount と named volume は書き換えず、 + 警告にも載せない + +### 各コマンド + +- [ ] リモート扱いの `devbase down` / `ps` / `logs` / `login` が、`up` と同じ `DOCKER_CONTEXT` を + 子プロセスへ載せる(`.docker-compose.scale.yml` の有無によらない) +- [ ] リモート扱いの `devbase scale N` は、`up` と同じく `DOCKER_CONTEXT` と `DOCKER_GID` を + 載せ、bind mount を書き換えた生成物で新しいインスタンスを起動する +- [ ] リモート扱いの `devbase build`(shell 経路)で、`docker buildx build` / + `docker image inspect` / `docker compose build` のすべてが `DOCKER_CONTEXT` 付きで動く +- [ ] リモート扱いの `devbase up` からの自動ビルド(`_run_build` → `bin/devbase build`)も + `DOCKER_CONTEXT` 付きで動く。プロジェクトの `env` または `.env` に + `DEVBASE_DOCKER_CONTEXT=a` があり `up --context b` で起動した場合も、自動ビルドは `b` に + 対して行われる +- [ ] `devbase env exec [--context NAME] -- CMD` は、カレントプロジェクトの解決した context を + 子プロセスの `DOCKER_CONTEXT` へ載せる。解決結果が `None` なら載せない。`--context` を + 付けたときは、`env` / `.env` に `DEVBASE_DOCKER_CONTEXT` があってもその値が勝つ +- [ ] リモート扱いの `devbase up` は自動スナップショットを作らず、その旨を 1 行の警告で出す +- [ ] `devbase snapshot` 系のコマンドと `down` のローテーションは、`project.local.yml` の + 有無で呼び出す docker コマンドも対象ディレクトリも変わらない + +### VS Code + +- [ ] 前提: ローカル端末(SSH でない)、解決した context が `gpu-wsl` + 操作: `devbase up --open` + 結果: 起動する `code` の URI のペイロードが + `{"containerName":"/","settings":{"context":"gpu-wsl"}}` で、`@ssh-remote+` は付かない +- [ ] 前提: ローカル端末、解決した context が `None` + 操作: `devbase up --open` + 結果: ペイロードに `settings` が無い(従来どおり) +- [ ] 前提: Remote-SSH 統合端末(`ssh_host` が解決できる)、解決した context が `gpu-wsl` + 操作: `devbase up --open` + 結果: ネスト URI で、ペイロードの `settings.context` が `gpu-wsl` +- [ ] 前提: Remote-SSH 統合端末、解決した context が `None` + 操作: `devbase up --open` + 結果: 従来どおり `DEVBASE_EDITOR_DOCKER_CONTEXT` → `docker context show` の順で + `settings.context` を決める(振る舞いが変わらない) +- [ ] `DEVBASE_EDITOR_DOCKER_CONTEXT` が明示されているときは、解決した context より + そちらが `settings.context` に使われる +- [ ] ネスト URI を出すとき、同じペイロードのフラット URI を「手元の VS Code に同名の docker + context があれば直接 attach できる」旨と共に info で提示する + +### 起きてはいけないこと + +- [ ] `project.local.yml` の有無で `project.yml` の検証結果が変わらない。`project.yml` 単体の + 既存テストが書き換えなしで通る +- [ ] 機密の値が `DOCKER_CONTEXT` の解決・警告・キャッシュファイルのどこにも現れない +- [ ] `.cache/docker-gid/` と `project.local.yml` が Git から除外される(devbase 本体は + `.cache/` と `projects/*` で既に除外。devbase-samples 側は起票) +- [ ] `devbase status` の出力と呼び出す docker コマンドが変わらない +- [ ] 同じプロセス(TUI)で、`docker.context: a` を持つ A の `up` の後に設定の無い B の `down` を + 実行すると、B の子プロセスに `DOCKER_CONTEXT` は載らず、`DOCKER_GID` は元の値に戻っている +- [ ] プロジェクト A の `.env` に `DEVBASE_DOCKER_CONTEXT=a` があり、B の `project.local.yml` に + `docker.context: b` があるとき、A のディレクトリから `devbase project down B` を実行すると + 子プロセスに届く `DOCKER_CONTEXT` は `b` +- [ ] 機密ストア(`.env`)に `DOCKER_CONTEXT` / `DOCKER_GID` / `DOCKER_HOST` があっても、 + `up` が起動する volume・compose・exec のすべての子プロセスに、解決した context と + 確定した gid が届き、`DOCKER_HOST` は届かない。`env exec` も同じ + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | context 未指定(`None`)の `up` に新たな docker 呼び出しを足さない。context 指定ありの `up` で足すのは、現在の context を知るための `docker context show` 1 回と、リモート扱いのときの gid 取得の `docker run` 1 回(初回のみ)まで | +| 運用・保守性 | 解決した context と、その出所(CLI / env / ファイル / 未指定)を `up` の冒頭に info で 1 行出す。リモート扱いで飛ばした処理(スナップショット)と書き換えなかった mount は警告で残す | +| 移行性 | 設定を書くまで挙動が変わらない。`project.local.yml` を消せば元に戻る。データ移行は無い | +| セキュリティ | `project.local.yml` は接続先の実体(ホスト名・鍵・トークン)を持たない。機密の注入経路(`_inject_secrets` → compose の変数展開)は変えない。age の鍵・`.env`・`project.local.yml` は手元に留まるが、**復号済みの機密の値は従来のローカル構成と同じく compose の変数展開を通じて接続先の daemon とコンテナへ渡る**。接続先は機密を預けてよいホストに限る | +| システム環境 | 手元: macOS / Linux / WSL の bash + docker CLI(context 対応、19.03 以降)+ compose v2。リモート: Linux の rootful dockerd + sshd(docker.sock が docker グループ所有)。Windows は WSL2 内の dockerd。rootless Docker と socket が `root:root` の構成は gid の自動取得の対象外で、`docker.gid` を明示する | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | **増える**。設定ファイル `project.local.yml`(`docker.context` / `docker.home` / `docker.gid`)、CLI `--context`、env `DEVBASE_DOCKER_CONTEXT`。既存のフラグ・env・出力形式は変えない | +| データ | スキーマ変更は無い。`.cache/docker-gid/` が増える | +| 既存の振る舞い | 設定が無ければ変わらない。`bin/devbase` の `cmd_build` は `docker` の直接呼び出しを Python の `env exec` 経由へ変える(機密注入の経路と同じ)。`editor/opener.py` は解決した context があるときだけ `settings.context` の付け方が広がる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| 起動 | `devbase up` / `devbase build` / `devbase down` | +| テスト | `uv run pytest`(`pyproject.toml` の `testpaths = ["tests"]`。着手時 1793 件) | +| 静的解析 | `ruff check --select=E9,F63,F7,F82 lib`、`shellcheck --severity=error bin/devbase`、`python -m compileall -q lib bin`(CI と同じ) | +| 手動確認 | 実機(Mac → WSL2 の dockerd)で `docker context create` → `project.local.yml` → `devbase up --open` を通し、VS Code がリモートのコンテナへ attach すること。リリース後テストで行う | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 設定の読み込みは `lib/devbase/project/`、context の解決と伝播は `lib/devbase/utils/` または `project/`、mount の書き換えは `lib/devbase/volume/compose.py`。`bin/devbase`(シェル側)には YAML の解釈を置かない | +| コーディング規約 | 既存コードに合わせる(日本語の docstring、`devbase.log.get_logger`、例外は `DevbaseError` 派生)。`CONTRIBUTING.md`(PEP 8、bash/zsh 両対応) | +| テスト戦略 | 設定の読み込み・優先順位・mount 書き換え・URI は単体テスト(`tests/project/` `tests/volume/` `tests/editor/`)。`DOCKER_CONTEXT` の伝播は `subprocess.run` を差し替えた結合テスト(`tests/commands/` `tests/cli/`)。実 daemon への接続は手動確認 | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、`ruff` / `shellcheck`、設定が無いときの挙動が変わらないことの確認 | +| 確認してから行う | `pyproject.toml` への依存追加、既存 env 名の変更、`bin/devbase` の引数解釈の変更 | +| 行わない | 依頼範囲外のリファクタリング、`status` の集約、スナップショットのリモート対応 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| docker context | docker CLI が daemon への接続先を名前で切り替える仕組み(`docker context ls` に出る名前) | +| 現在の context | `docker context show` が返す名前。`DOCKER_CONTEXT` が無いとき CLI が使うもの | +| 解決した context | 優先順位に従って devbase が決めた context 名。**未指定なら `None`**(= 従来どおり CLI に委ねる) | +| リモート扱い | 解決した context が `None` でなく、かつ現在の context と**異なる**状態。この状態で「ローカルの事情」に依存する処理(gid・`~`・スナップショット)の扱いが変わる | +| ローカル扱い | 上記以外。従来と同じ振る舞い | +| `project.local.yml` | `projects//` に置く個人・機材ごとの設定。git 管理しない | +| `docker.home` | リモート側の HOME。bind mount の `~` をこの値で展開する | +| `docker.gid` | リモート側の docker グループの gid。`group_add` に渡す | +| フラット URI | `vscode-remote://attached-container+/` | +| ネスト URI | `vscode-remote://attached-container+@ssh-remote+/` | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| devbase-samples の `.gitignore` 更新の起票先と担当 | 利用者 | 実装 PR のマージまで |