diff --git a/CHANGELOG.md b/CHANGELOG.md index 089137a8..9924fd76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ ## [Unreleased] +### Added + +- **別ホストの Docker に dev コンテナを立てられるようにしました(PLAN52 / #162)。** + `projects//project.local.yml`(gitignore 対象の個人・機材ごとの設定)に + `docker.context` / `docker.home` / `docker.gid` を書くと、`devbase up/down/ps/logs/login/ + scale/build/rebuild` がその docker context の daemon を相手に動きます。優先順位は + CLI `--context` > env `DEVBASE_DOCKER_CONTEXT` > `project.local.yml` > 現在の context です。 + - リモート扱い(解決した context が現在の context と異なる)では `DOCKER_GID` をリモート側で + 取得して `.cache/docker-gid/` に控え、bind mount の `~` を `docker.home` で展開し、 + 自動スナップショットを飛ばします + - `devbase up` が開く VS Code の attach URI に `settings.context` を付け、ローカル端末からも + リモートのコンテナへ attach できます。Remote-SSH 統合端末では手元で直接 attach する + フラット URI も表示します + - `devbase env exec --context NAME` を追加し、shell の `devbase build` はそこを通して docker を + 呼びます + - 詳細は `docs/user/environment-variables.md` の「リモート Docker」と + `docs/user/project-yml.md` の「`project.local.yml`」 + +### Changed + +- `project.yml` に `docker:` を書くと、`project.local.yml` へ移すよう案内するエラーになります +- docker context を解決したときは、docker が `DOCKER_CONTEXT` より優先する `DOCKER_HOST` を + 警告して子プロセスから外します(設定が無いときは従来どおり) + ## [3.2.2] - 2026-09-04 base イメージで AI CLI の alias 設定を一般ユーザーが読み込めない問題を修正しました。 diff --git a/bin/devbase b/bin/devbase index dbaf716b..2f77ba32 100755 --- a/bin/devbase +++ b/bin/devbase @@ -70,10 +70,21 @@ export DEVBASE_ROOT # Docker Compose の変数展開が機密を必要とする場合があるため、compose の呼び出しは # Python 経由で機密を注入して実行する (plan35 §4.4 / §11.2)。復号結果は子プロセスの # 環境変数としてだけ渡り、ファイルには書き出されない。 +# +# docker context (PLAN52) も同じ経路で届く。`env exec` がカレントプロジェクトの +# project.local.yml と env から context を解決して DOCKER_CONTEXT を載せるため、 +# shell から docker を叩く箇所 (buildx build / image inspect / compose build) は +# すべてここを通す。`devbase build --context NAME` の値は _BUILD_CONTEXT に保持し、 +# 環境変数ではなく引数で渡す。環境変数に写すと、Python 側が dispatch の前に注入する +# .env の同名キー (DEVBASE_DOCKER_CONTEXT) に上書きされる。 +# +# `--${_BUILD_CONTEXT:+...}` は、_BUILD_CONTEXT があれば `--context NAME -- "$@"`、 +# 無ければ `-- "$@"` に展開される (`--` の直後に context の語を続けるかどうか)。 +_BUILD_CONTEXT="" compose_with_secrets() { ensure_uv PYTHONPATH="${DEVBASE_ROOT}/lib:$PYTHONPATH" \ - uv run --project "$DEVBASE_ROOT" python -m devbase.cli env exec -- "$@" + uv run --project "$DEVBASE_ROOT" python -m devbase.cli env exec --${_BUILD_CONTEXT:+context "$_BUILD_CONTEXT" --} "$@" } cmd_build() { @@ -120,7 +131,7 @@ cmd_build() { fi echo "Building ${base_image}:latest..." - if docker buildx build --load -t "${base_image}:latest" "$container_dir" "$@"; then + if compose_with_secrets docker buildx build --load -t "${base_image}:latest" "$container_dir" "$@"; then echo "✓ ${base_image} built successfully" return 0 else @@ -211,7 +222,7 @@ cmd_build() { fi else # Fallback: check if devbase-base exists - if ! docker image inspect devbase-base:latest >/dev/null 2>&1; then + if ! compose_with_secrets docker image inspect devbase-base:latest >/dev/null 2>&1; then echo "" echo "[1/2] Building devbase-base..." if ! build_base_image "devbase-base" "$@"; then @@ -432,6 +443,33 @@ case "$_resolved_cmd" in # - --expires: イメージ作成日の判定が必要で、shell では RFC3339 日付パースが # 非可搬なため (build --expires=N / rebuild / up が共通の期限リゾルバを使う)。 build) + # `--context NAME` / `--context=NAME` は最初に抜き取る (PLAN52)。下の走査より + # 後に置くと NAME が単体イメージ名として拾われ、Python の単体ビルドへ誤分岐する。 + # 値は _BUILD_CONTEXT に保持し、compose_with_secrets (env exec) と Python の + # project build へ引数で渡す。 + _build_args=() + _expect_context=0 + _context_given=0 + for _ba in "${_DEVBASE_ARGS[@]}"; do + if [ "$_expect_context" = 1 ]; then + _BUILD_CONTEXT="$_ba"; _expect_context=0; continue + fi + case "$_ba" in + --context) _expect_context=1; _context_given=1 ;; + --context=*) _BUILD_CONTEXT="${_ba#--context=}"; _context_given=1 ;; + *) _build_args+=("$_ba") ;; + esac + done + # 値なし・空・空白のみは Python 側 (cli.py の _non_empty) と同じく exit 2 で止める。 + # 空のまま通すと ${_BUILD_CONTEXT:+...} が展開されず、手元の daemon でビルドが走る。 + _BUILD_CONTEXT="${_BUILD_CONTEXT#"${_BUILD_CONTEXT%%[![:space:]]*}"}" + _BUILD_CONTEXT="${_BUILD_CONTEXT%"${_BUILD_CONTEXT##*[![:space:]]}"}" + if [ "$_expect_context" = 1 ] || { [ "$_context_given" = 1 ] && [ -z "$_BUILD_CONTEXT" ]; }; then + echo "Error: --context requires a non-empty context name" >&2; exit 2 + fi + # bash 3.2 は空配列の "${arr[@]}" を set -u で未定義扱いするが、この wrapper は + # set -u を使わないので、抜き取った残りをそのまま _DEVBASE_ARGS へ戻してよい。 + _DEVBASE_ARGS=(${_build_args[@]+"${_build_args[@]}"}) _has_expires=0 _build_image="" for _ba in "${_DEVBASE_ARGS[@]}"; do @@ -442,7 +480,7 @@ case "$_resolved_cmd" in esac done if [ "$_has_expires" = 1 ] || [ -n "$_build_image" ]; then - run_python project build "${_DEVBASE_ARGS[@]}" + run_python project build "${_DEVBASE_ARGS[@]}" ${_BUILD_CONTEXT:+--context "$_BUILD_CONTEXT"} else cmd_build "${_DEVBASE_ARGS[@]}" fi diff --git a/docs/specifications/remote-docker-context.md b/docs/specifications/remote-docker-context.md new file mode 100644 index 00000000..722dadc4 --- /dev/null +++ b/docs/specifications/remote-docker-context.md @@ -0,0 +1,211 @@ +# 別ホストの Docker への dev コンテナ起動(docker context) + +## 概要 + +devbase は、プロジェクトごとの個人設定 `projects//project.local.yml` に docker context の +名前を書くと、そのプロジェクトの `up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / +`rebuild` を別ホストの docker daemon へ向ける。compose クライアントと機密の復号は手元で行い、 +daemon だけがリモートにある。リモートに要るのは docker CLI・dockerd・sshd で、devbase・ +`projects/`・機密鍵をリモートへ複製しない。`devbase up` が開く VS Code は、attach URI の +`settings.context` でそのホストのコンテナへ接続する。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| docker context | docker CLI が daemon への接続先を名前で切り替える仕組み(`docker context ls` の名前) | +| 現在の context | `DOCKER_CONTEXT` と `DOCKER_HOST` を外した環境で `docker context show` が返す名前 | +| 解決した context | 優先順位に従って devbase が決めた context 名。未指定なら `None`(従来どおり CLI に委ねる) | +| リモート扱い | 解決した context が `None` でなく、現在の context と異なる(または現在の context を取得できない)状態 | +| ローカル扱い | 上記以外。従来と同じ振る舞い | + +## 構成要素 + +| 要素 | 置き場所 | 責務 | +| --- | --- | --- | +| 個人設定の読み込み | `lib/devbase/project/local_config.py` | `project.local.yml` を読み、`docker` 節を検証して `DockerSettings` にする | +| context の解決・確定・反映 | `lib/devbase/utils/docker_context.py` | `choose_context` / `resolve_target` / `apply` / `reapply` / `reset` / `ensure_remote_gid` / `current_context` / `effective_context` | +| lifecycle コマンド | `lib/devbase/commands/container.py` | `--context` の受け取り、操作の前後の `reset`、`up` / `scale` での接続先の確定と gid | +| shell の `build` | `bin/devbase` | `--context` の抜き取りと `env exec --context` 経由の docker 呼び出し | +| `env exec` | `lib/devbase/commands/env.py` | 子プロセスの環境へ `DOCKER_CONTEXT` を載せる | +| bind mount の書き換え | `lib/devbase/volume/bind_mounts.py`、`compose.py` | 生成物の `~` を `docker.home` で展開し、書き換えられない mount を警告する | +| attach URI | `lib/devbase/editor/opener.py` | `settings.context` の決定とフラット URI の提示 | + +## 仕様 + +### context の解決 + +優先順位は **CLI `--context` > 環境変数 `DEVBASE_DOCKER_CONTEXT` > `project.local.yml` の +`docker.context` > 未指定** である。環境変数の空文字(空白のみを含む)は未指定として扱う。 +この段階では docker を呼ばない。 + +`--context` は `project` / `container` 配下の `up` / `down` / `ps` / `logs` / `login` / `scale` / +`build` / `rebuild`、トップレベルのショートカット `up` / `down` / `ps` / `login` / `scale` / +`build` / `rebuild`、および `env exec` が受け付ける。空文字と空白のみは終了コード 2 で拒む +(Python の parser と `bin/devbase` の両方)。 + +### リモート扱いの判定 + +`up` と `scale` は、解決した context を現在の context と比べる。現在の context の問い合わせは +`DOCKER_CONTEXT` と `DOCKER_HOST` を取り除いた環境で行う。どちらかが残ると docker は +それぞれ設定先自身・`default` を返し、判定が常に一方へ倒れるためである。 + +| 解決した context | 現在の context との関係 | 扱い | +| --- | --- | --- | +| `None` | 問い合わせない | ローカル | +| 非 `None` | 同じ | ローカル | +| 非 `None` | 異なる、または取得できない | リモート | + +`docker.home` / `docker.gid` はリモート扱いのときだけ使う。CLI / 環境変数で +`project.local.yml` の `docker.context` と**別の名前**へ向けたときは、ファイルの `home` / `gid` +を使わず警告する(別の機材の値を持ち込まない)。 + +### 環境への反映 + +解決した context は環境変数 `DOCKER_CONTEXT` として `os.environ` へ載せ、以降の `docker` / +`docker compose`・`pre-up` / `deploy` フック・`up` からの自動ビルドがすべて継承する。 +反映は次の条件を保つ。 + +- context が `None` なら環境を一切触らない +- `DOCKER_HOST` があれば警告して取り除く。docker は `DOCKER_HOST` を `DOCKER_CONTEXT` より + 優先するため、残すと context が効かない +- リモート扱いの `up` / `scale` では `DOCKER_GID` をリモート側の gid に置き換える +- 反映は冪等で、機密の注入(`_inject_secrets`)の直後に再適用する。機密ストアに + `DOCKER_CONTEXT` / `DOCKER_GID` / `DOCKER_HOST` があっても確定した接続先が残る +- 控えは lifecycle 操作の単位で生き、`_dispatch_lifecycle` が開始時と終了時に `reset` して + 3 変数を元の値へ戻す。1 プロセスで操作を続ける TUI で、前の操作の接続先を持ち越さない + +`name` でプロジェクトを切り替える経路(`project down B` 等)は、切替元の機密を落として +から切替先の `env` を読み、切替先の機密を注入した後に context を解決する。 + +### リモート側の gid + +`group_add: ["${DOCKER_GID}"]` に渡す gid は、`docker.gid` の明示 → 控え +`$DEVBASE_ROOT/.cache/docker-gid/` → `DOCKER_CONTEXT` 付きの +`docker run --rm -v /var/run/docker.sock:/s alpine:3 stat -c %g /s` の順で決める。取得した値は +控えに書く。取得に失敗した(docker が非ゼロ・出力が整数でない)ときは、docker のエラーと +`docker.gid` の書き方を示して `up` を非ゼロで終える。取得した値が `0` のときは、socket が +root 所有か rootless Docker の可能性を警告して続行する。控えは自動では消さない。 + +### bind mount の `~` + +リモート扱いの構成生成では、生成物 `.docker-compose.scale.yml` の全サービスの bind mount で +`~` と `~/...` を `docker.home` に置き換える。短い書式・長い書式(`type: bind`)の両方に効く。 +`~user/...` と相対パス(`/` でも `~` でも始まらない source)は書き換えず、一覧で警告する。 +`docker.home` が無いリモート扱いでは、`~` 系と相対パスの mount を一覧で警告し、`docker.home` +の指定を促す。ローカル扱いでは書き換えない。 + +### 自動スナップショット + +リモート扱いの `up` は自動スナップショットを作らず、警告を 1 行出す。`devbase snapshot` 系の +コマンドと `down` のローテーションは context を解決せず、従来どおり手元を対象にする。 + +### shell の `build` と `env exec` + +`bin/devbase` の `build)` 分岐は、単体イメージ名の走査より前に `--context NAME` / +`--context=NAME` を抜き取り、シェル変数に保持する。値は環境変数へ写さず、 +`compose_with_secrets`(`devbase env exec --context NAME -- ...`)と Python の +`project build --context NAME` へ引数で渡す。`cmd_build` の `docker buildx build` と +`docker image inspect` も `compose_with_secrets` を通す。 + +`env exec` はプロジェクト直下(`current_project_name` が決める `projects/`)の +`project.local.yml` と環境変数、`--context` から context を解決し、機密を載せた**後**の辞書へ +`DOCKER_CONTEXT` を載せる。`up` からの自動ビルド(`_run_build`)は解決済みの context を +`bin/devbase build --context ` として引数で渡す。 + +### VS Code の attach URI + +`settings.context` は「`DEVBASE_EDITOR_DOCKER_CONTEXT` の明示 → devbase が解決した context → +(ssh 先のときだけ)docker が実際に使う context(環境変数を外さない `docker context show`)」の +順で決める。解決した context があればローカル端末でもフラット URI に付ける。Remote-SSH 統合 +端末でネスト URI と `settings.context` の両方が付くときは、手元の VS Code に同名の context が +あれば直接 attach できるフラット URI を info で提示する。 + +```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 を載せる(DOCKER_HOST は外す) + alt リモート扱い + R->>D: docker run alpine stat(控えが無いとき) + D-->>R: gid + U->>U: DOCKER_GID を載せる / 自動スナップショットを飛ばす + end + U->>C: 生成(scale, secrets, docker_home, remote) + Note over U,C: 機密注入の直後に反映を再適用 + U->>D: compose down / up / exec + U->>U: エディタ(docker_context) +``` + +## データ・設定 + +### `projects//project.local.yml` + +git 管理しない。最上位に書けるのは `docker` だけで、他のキーは `ConfigError` になる。 +`project.yml` に `docker:` を書くと、このファイルへ移す案内付きの `ConfigError` になる。 +空ファイルは無いときと同じに扱う。 + +| キー | 型 | 検証 | +| --- | --- | --- | +| `docker.context` | 文字列 | 空・空白・制御文字を含むものは拒む | +| `docker.home` | 文字列 | `/` で始まる絶対パスのみ | +| `docker.gid` | 整数 | 0 以上。真偽値・文字列は拒む | + +### `$DEVBASE_ROOT/.cache/docker-gid/` + +10 進の gid を 1 行で持つ。リモート扱いで gid を取得したときに書き、次回はこれを読む。 +整数として読めない内容は無視して取り直す。 + +### 環境変数 + +| 名前 | 向き | 意味 | +| --- | --- | --- | +| `DEVBASE_DOCKER_CONTEXT` | 入力 | context の上書き(グローバル `.env` / プロジェクト `env` / shell)。空文字は未指定 | +| `DOCKER_CONTEXT` | 出力 | 解決した context。docker CLI と compose が読む | +| `DOCKER_GID` | 出力 | リモート扱いの `up` / `scale` でだけ上書き | +| `DOCKER_HOST` | 入力 | context を解決したときは警告して取り除く | +| `DEVBASE_EDITOR_DOCKER_CONTEXT` | 入力 | attach に使う context を手で決めたいときだけ。解決した context より優先 | + +## セキュリティ + +`project.local.yml` は接続先の実体(ホスト名・鍵・トークン)を持たず、context の名前だけを +持つ。接続先の実体は各マシンの `docker context create` が持つ。age の鍵・`.env`・ +`project.local.yml` は手元に留まるが、復号済みの機密の値は従来のローカル構成と同じく +compose の変数展開を通じて接続先の daemon とコンテナへ渡る。接続先は機密を預けてよい +ホストに限る。 + +## 運用 + +- 設定が無ければ挙動は変わらない。`project.local.yml` を消せば元に戻る +- `docker context use` で現在の context 自体をリモートへ向けた状態は補正の対象外 +- `devbase status` は手元の daemon だけを見る +- rootless Docker や socket が `root:root` の構成では `docker.gid` を明示する +- リモート側の gid が変わったら `.cache/docker-gid/` を消すか `docker.gid` を書く +- イメージはホストごとに別物で、リモート側に無ければリモートでビルドされる + +## テスト観点 + +- 設定の読み込みと検証(`tests/project/test_local_config.py`)、`project.yml` の `docker:` の拒否 +- 優先順位・リモート判定・反映・再適用・reset・gid 取得(`tests/utils/test_docker_context.py`) +- `up` / `scale` / `down` / `ps` / `logs` / `login` の子プロセスに届く `DOCKER_CONTEXT` / + `DOCKER_GID` / `DOCKER_HOST`、自動スナップショットの回避、TUI とプロジェクト切替での漏れ、 + 機密注入後の維持、`--context` を受け付ける parser(`tests/commands/test_container_context.py`) +- `env exec` の `--context` と機密ストアより優先すること、サブディレクトリからの実行 + (`tests/commands/test_env_exec_context.py`) +- shell の `build` が `--context` を抜き取り引数で渡すこと、空値の拒否、docker 直接呼び出しの + 不在(`tests/cli/test_wrapper_build_context.py`) +- bind mount の展開と警告(`tests/volume/test_bind_mounts.py`、`test_compose_remote_home.py`) +- attach URI の `settings.context` とフラット URI の提示(`tests/editor/test_opener.py`) +- 実 daemon への接続(存在しない context のエラー、リモートでのビルドと attach)は手動確認 + +## 関連リンク + +- [project.yml リファレンス](../user/project-yml.md) +- [環境変数ガイド「リモート Docker」](../user/environment-variables.md) +- [CLI リファレンス: project](../user/cli-reference/02-project.md) diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 7314eb3b..7022aaef 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -40,15 +40,38 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up > トレードオフです。**回避策:** 衝突する場合は対象プロジェクトのディレクトリ内で実行するか、 > 明示的にそのプロジェクトへ切り替えてから(`cd` 済みの状態で)コマンドを実行してください。 +## `--context NAME`(共通オプション) + +`up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild`(`project` / +`container` 配下と、トップレベルのショートカット)は `--context NAME` を受け付けます。 +そのコマンドの `docker` / `docker compose` を、指定した docker context の daemon へ向けます。 + +```bash +devbase up carmo --context gpu-wsl # この 1 回だけ gpu-wsl 上に立てる +devbase build --context gpu-wsl # イメージをリモート側でビルドする +``` + +優先順位は CLI > env `DEVBASE_DOCKER_CONTEXT` > `projects//project.local.yml` の +`docker.context` > 現在の context です。設定の書き方は +[`project.local.yml`](../project-yml.md#projectlocalyml個人機材ごとの設定)、動作の詳細は +[環境変数ガイドの「リモート Docker」](../environment-variables.md#リモート-docker別ホストの-daemon-にコンテナを立てる) +を参照してください。 + ## `devbase project up` コンテナを起動します。 ``` -devbase project up [name] -devbase up [name] +devbase project up [name] [--context NAME] +devbase up [name] [--context NAME] ``` +- 解決した docker context が現在の context と異なる(**リモート扱い**)とき: + - `DOCKER_CONTEXT` を全 docker 呼び出しへ渡し、`DOCKER_GID` はリモート側の値にする + - `project.local.yml` の `docker.home` で bind mount の `~` を展開する + - 自動スナップショットは作らない(控えたいボリュームがリモートにあるため) + - `DOCKER_HOST` が設定されていれば警告して外す(docker は `DOCKER_HOST` を `DOCKER_CONTEXT` より優先するため) + - 起動時にスナップショットを自動作成(新世代 or 差分追加) - 直近のスナップショット取得から既定 60 分以内のときはスキップします - 間隔は `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` 環境変数で上書き可能(既定 60、`0` で無効化=毎回取得、不正値は警告して既定値) diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index 878f22f3..faa038a3 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -209,11 +209,13 @@ devbase env decrypt 復号した機密を環境変数として渡した状態で、任意のコマンドを実行します。値はその子プロセスの環境変数としてのみ渡り、ファイルには書き出されません。 ``` -devbase env exec -- CMD [ARGS...] +devbase env exec [--context NAME] -- CMD [ARGS...] ``` 起動ラッパーは共通の機密ファイルを読み込まないため、ホスト側で機密を必要とする処理(Docker Compose の変数展開など)はこのコマンドを通します。devbase 自身の `devbase build` も内部でこれを使っています。 +カレントディレクトリがプロジェクトなら、その `project.local.yml` と env から docker context を解決して子プロセスの `DOCKER_CONTEXT` に載せます。`--context NAME` はそれを上書きします(`devbase build --context NAME` が内部で渡す口です)。 + ```bash # コンテナに渡る値を確認する devbase env exec -- printenv ANTHROPIC_API_KEY diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 6ff24664..35be85d9 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -277,8 +277,9 @@ DEVBASE_ACCOUNT_GROUP=kkg | `DEVBASE_EDITOR` | 起動コマンド(既定: `code`)。`cursor` / `code-insiders` 等も可 | | `DEVBASE_WORKSPACE` | 開く `*.code-workspace` ファイルの**コンテナ内絶対パス**を明示指定する(例 `/home/ubuntu/share/work/uttarov2-doc.workspace`)。**効くのはリポジトリ 1 件の構成だけ**です。2 件以上の構成では `devbase up` が自動生成した `/work/<プロジェクト名>.code-workspace` を直接開くため、この env を設定しても上書きできません。`~/share`(= 全コンテナ共有ボリューム `/persistent/ai/share` への symlink)配下に置けば全コンテナで共用可 | | `DEVBASE_OPEN_INDEX` | scale 時に開く dev インスタンス番号(既定: `1`) | -| `DEVBASE_EDITOR_SSH_HOST` | Remote-SSH 跨ホスト構成での ssh-remote ホスト名(例 `mac2`)。**通常は `~/.vscode-server` から自動検出**され不要。検出が外れる場合のみ明示。下記「跨ホスト」参照 | -| `DEVBASE_EDITOR_DOCKER_CONTEXT` | 跨ホスト時に ssh 先で使う docker context(既定: ホストの `docker context show`) | +| `DEVBASE_EDITOR_SSH_HOST` | Remote-SSH 跨ホスト構成での ssh-remote ホスト名(例 `mac2`)。**通常は `~/.vscode-server` から自動検出**され不要。検出が外れる場合のみ明示。下記「リモート Docker」参照 | +| `DEVBASE_EDITOR_DOCKER_CONTEXT` | attach に使う docker context を手で決めたいときだけ明示する。未設定なら devbase が解決した context(`--context` / `DEVBASE_DOCKER_CONTEXT` / `project.local.yml`)、それも無ければ跨ホスト時にホストの `docker context show` | +| `DEVBASE_DOCKER_CONTEXT` | `devbase up/down/ps/logs/login/scale/build/rebuild` が向ける docker context。`project.local.yml` の `docker.context` より優先し、CLI `--context` に負ける。グローバル `.env` に書くと全プロジェクトが同じホストへ向くため、通常は `project.local.yml` に書く。下記「リモート Docker」参照 | | `DEVBASE_WINDOW_TITLE` | attach 先 VS Code の `window.title` テンプレート。`{container}` が実コンテナ名(例 `nyle-dx-dev-1`)に置換される。既定は `{container}${separator}${dirty}${activeEditorShort}`。`0` / `false` / `off` / 空文字で無効化。下記「ウィンドウタイトル」参照 | 都度の上書きは CLI フラグで行います: `devbase up --open` / `--no-open` / `--open-index N`(env より優先)。 @@ -321,25 +322,107 @@ DEVBASE_WINDOW_TITLE=0 | ローカル端末(Mac/Linux) | ローカル VS Code が開く | | WSL 端末 | Windows 側 VS Code が開く(`code` ラッパ経由) | | VS Code の Remote-SSH 統合ターミナル(同一ホストの Docker) | **クライアント側(手元)の VS Code** が開く(`code` シムが委譲) | -| VS Code の Remote-SSH 統合ターミナル(**跨ホスト**: ssh 先の Docker にコンテナ) | `DEVBASE_EDITOR_SSH_HOST` 設定時にネスト URI で開く(下記「跨ホスト」参照) | +| VS Code の Remote-SSH 統合ターミナル(**跨ホスト**: ssh 先の Docker にコンテナ) | ネスト URI で開く(`DEVBASE_EDITOR_SSH_HOST` は通常は自動検出。下記「リモート Docker」参照) | +| ローカル端末 / Remote-SSH 統合ターミナルで、コンテナが **別ホストの docker context** 上にある | attach URI に `settings.context` を付け、Dev Containers 拡張にその context で attach させる(下記「リモート Docker」参照) | | 手元から素の SSH(VS Code 外)で接続中 | クライアントへ自動で開く公式手段が無いため、手元で実行する `code --folder-uri ...` コマンドを提示 | | tmux 経由のターミナル | `VSCODE_IPC_HOOK_CLI` が古くなっていても、tmux のセッション環境から生きた値を拾い直して開く。拾えなければ「コマンド提示」へ degrade(下記「tmux / screen 経由で使う場合」参照) | | CI / 非対話(非 TTY) / `code` 不在 | 理由を表示してスキップ(`up` 自体は成功) | -#### 跨ホスト(Windows VS Code → Remote-SSH → Mac のコンテナ) +#### リモート Docker(別ホストの daemon にコンテナを立てる) + +`devbase up` は既定で **コマンドを実行した環境の Docker** にコンテナを立てます。 +`projects//project.local.yml` に docker context の名前を書くと、そのプロジェクトの +`up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild` を**別ホストの daemon** +へ向けられます。用途は、CUDA が使える Windows(WSL2)の GPU、負荷分散のための 3 台目の PC、 +AWS EC2 の計算資源などです。 + +仕組みは「compose クライアントは手元、daemon はリモート」です。devbase・`projects/`・機密鍵を +リモートへ複製する必要はなく、リモートに要るのは **docker CLI + dockerd + sshd** だけです。 +手元で復号した機密の**値**は、従来のローカル構成と同じく compose の変数展開を通じて接続先の +daemon とコンテナへ渡ります(鍵・`.env`・`project.local.yml` は手元に留まります)。 +接続先は機密を預けてよいホストに限ってください。 + +##### 1. docker context を作る(手元で 1 回) + +接続先の実体(ホスト名・鍵)は docker context に持たせ、devbase には**名前だけ**を書きます。 +ssh 鍵・TLS・WSL / EC2 の違いは docker 側の問題として devbase から切り離せます。 + +```bash +# Windows の WSL2 内 dockerd(WSL 内で sshd を動かし、Docker Desktop の WSL 統合か +# WSL 内へ直接インストールした dockerd を使う。Windows 側 OpenSSH 経由にはしない) +docker context create gpu-wsl --docker "host=ssh://takemi@winpc" + +# EC2(ssh 経由なので TLS 証明書の配布は要らない) +docker context create ec2 --docker "host=ssh://ubuntu@ec2-host" + +docker context ls # 名前を確認する。`docker context use` で切り替える必要は無い +``` + +リモート側のユーザが docker グループに入っていること(`docker ps` が sudo 無しで通ること)を +確かめてください。 + +##### 2. `project.local.yml` に書く + +```yaml +# projects//project.local.yml(gitignore 対象。個人・機材ごとの設定) +docker: + context: gpu-wsl + home: /home/takemi # ~/.aws などを bind mount するプロジェクトだけ要る + # gid: 999 # 省略すると初回の up で自動取得して .cache/docker-gid/ に控える +``` + +キーの意味と優先順位は [`project.yml` リファレンス](project-yml.md#projectlocalyml個人機材ごとの設定)に +あります。一時的に別の context へ向けるには `devbase up --context ` か +env `DEVBASE_DOCKER_CONTEXT` を使います。 + +##### 3. `devbase up` する + +`up` の冒頭に `docker context: gpu-wsl (project.local.yml, リモート扱い)` と出ます。 +「リモート扱い」は、解決した context が `docker context show`(現在の context)と異なる +状態です。同じ名前なら従来どおりローカル扱いで、gid の取得も `~` の展開も行いません。 + +リモート扱いの `up` で変わること: + +| 項目 | 動き | +|------|------| +| `docker` / `docker compose` の宛先 | 環境変数 `DOCKER_CONTEXT` で全呼び出し(フック、`devbase build` の自動実行を含む)へ渡す | +| `DOCKER_GID` | `docker.gid` の値。無ければ `docker run --rm -v /var/run/docker.sock:/s alpine:3 stat -c %g /s` で取得して控える。取得した値が `0` のときは警告する(socket が root 所有か rootless Docker。その場合は `docker.gid` を明示) | +| bind mount の `~` | `docker.home` で展開して生成物 `.docker-compose.scale.yml` に書く。`docker.home` が無ければ該当する mount を警告する(リモートでは空ディレクトリになる)。`~user/...` と `./` の相対パスは書き換えず警告する | +| イメージ | リモート側のイメージを見て、無ければリモート側でビルドする(buildx がビルド文脈を送る)。ホストごとに別物なので初回は時間がかかる | +| 自動スナップショット | 作らない(控えたいボリュームがリモートにある)。`devbase snapshot` 系は従来どおり手元の daemon を対象にする | +| `DOCKER_HOST` | 設定されていれば警告して外す。docker は `DOCKER_HOST` を `DOCKER_CONTEXT` より優先するため、残すと context が効かない | + +gid の控え `$DEVBASE_ROOT/.cache/docker-gid/` は自動では消しません。リモート側の +gid が変わったらファイルを消すか `docker.gid` を書いてください。 + +##### 4. VS Code の開き方 + +`devbase up` が開く attach URI は、実行した場所とコンテナの場所で変わります。 + +| `devbase up` を実行する場所 | コンテナの場所 | 開き方 | +|---|---|---| +| ローカル端末(Mac / Linux / WSL) | 同じマシン(従来) | フラット URI | +| ローカル端末 | リモート context | フラット URI + `settings.context=`。手元の Dev Containers 拡張がその context 経由で attach する | +| Remote-SSH 統合ターミナル(Windows VS Code → Mac) | Mac | ネスト URI `…@ssh-remote+`(下記) | +| Remote-SSH 統合ターミナル | リモート context(WSL / EC2 など) | ネスト URI + `settings.context=`。Mac の Dev Containers が context 経由で attach する。あわせて、手元の VS Code に同名の context があれば直接 attach できるフラット URI も表示する(Windows → Mac → WSL(Windows) の一周を避けたいとき) | + +`settings.context` は「`DEVBASE_EDITOR_DOCKER_CONTEXT` の明示 → devbase が解決した context → +(ssh 先のときだけ)`docker context show`」の順で決まります。 + +##### 跨ホスト(Windows VS Code → Remote-SSH → Mac のコンテナ) 手元(例 Windows)の VS Code から Remote-SSH で別ホスト(例 Mac)へ入り、その統合ターミナルで `devbase up` を実行する構成では、コンテナは **ssh 先(Mac)の Docker** 上にあります。このとき `code` の開く要求はクライアント(Windows)へ委譲されるため、フラットな attach URI のままだと **クライアント側の Docker** を見に行きコンテナが見つかりません(「コンテナーにアタッチできません。すでに存在しません」)。 これを解決するには、ネスト URI `vscode-remote://attached-container+@ssh-remote+/work/...` を使い、docker ルックアップを ssh 先(コンテナのある Mac)で行わせます。`` は **手元 `~/.ssh/config` の `Host` 別名**(例 `mac2`)で、これは「今の VS Code 接続の authority ラベル」と完全一致する必要があります(ネスト attach は新規 ssh 接続を張らず既存接続を再利用するため。IP や `user@IP` は "Parent authority found without ExecServer" で不可)。 -このラベルは VS Code が ssh 先の端末 env に渡さない(`SSH_CONNECTION` は IP のみ)ものの、**devbase は ssh 先(Mac)の VS Code 系サーバーディレクトリ(`~/.vscode-server` / `~/.cursor-server` / `~/.vscode-server-insiders` 等)の File History から自動検出**します(`DEVBASE_EDITOR` で cursor 等を使う場合も横断)。よって**通常は設定不要**です。docker context は `docker context show` から自動取得します。 +このラベルは VS Code が ssh 先の端末 env に渡さない(`SSH_CONNECTION` は IP のみ)ものの、**devbase は ssh 先(Mac)の VS Code 系サーバーディレクトリ(`~/.vscode-server` / `~/.cursor-server` / `~/.vscode-server-insiders` 等)の File History から自動検出**します(`DEVBASE_EDITOR` で cursor 等を使う場合も横断)。よって**通常は設定不要**です。 自動検出が外れる場合(複数 ssh-remote ホストを使い分けている等)のみ明示します: ```sh # $DEVBASE_ROOT/env など(全プロジェクト共通にしたい場合) DEVBASE_EDITOR_SSH_HOST=mac2 -# 必要なら docker context も明示 +# attach に使う docker context を手で決めたい場合だけ # DEVBASE_EDITOR_DOCKER_CONTEXT=desktop-linux ``` @@ -347,6 +430,15 @@ DEVBASE_EDITOR_SSH_HOST=mac2 > 同一ホスト構成(手元 Mac/Linux で直接、または ssh 先の Docker にコンテナが無い場合)では ssh-remote ホストは付かず、従来どおりフラット URI で開きます。 +##### 制約 + +- `docker context use` で**現在の context 自体**をリモートへ向けた状態は、これまでどおりの + 動き(gid・`~`・スナップショットの補正なし)です。プロジェクトごとの設定を使ってください +- `devbase status` は手元の daemon だけを見ます。プロジェクトごとに daemon が違う構成の集約は + していません +- リモートのボリュームのスナップショットは扱いません +- Docker Desktop for Windows への直結は前提にしていません(WSL2 内の dockerd を使う) + #### tmux / screen 経由で使う場合 VS Code は統合ターミナルごとに `$TMPDIR/vscode-ipc-.sock` を作り、`VSCODE_IPC_HOOK_CLI` でその場所を伝えます。`code` はこのソケット経由でクライアント側の VS Code に依頼するため、**ソケットが死んでいると `code` は何もできません**。 diff --git a/docs/user/project-yml.md b/docs/user/project-yml.md index a72edf23..66e4cde7 100644 --- a/docs/user/project-yml.md +++ b/docs/user/project-yml.md @@ -124,11 +124,50 @@ clone は**コンテナ起動のたびに試行される**ので、後から権 | `project.yml` | devbase 自身の設定 | リポジトリ、コンテナ数、エディタの自動オープン | | `env` | コンテナへ渡す環境変数 | `ENABLE_SSH`、アプリが読む設定値 | | `.env` | プロジェクト固有の機密 | API キー、DB 接続情報 | +| `project.local.yml` | 個人・機材ごとの devbase 設定(git 管理しない) | 別ホストの docker context、リモート側の HOME / gid | `compose.yml` が `env_file: - env` で参照するため、`env` は**ファイル自体が必須**です。 渡したい環境変数が無ければ空ファイルで構いませんが、削除すると `devbase up` が compose の起動時に失敗します。 +## `project.local.yml`(個人・機材ごとの設定) + +`projects//project.local.yml` は、**同じプロジェクトを使う他の人には関係ない**設定を +置くファイルです。`project.yml` はチームで共有される正ですが、「このプロジェクトのコンテナは +別ホストの Docker に立てる」は個人の事情で、リモート側の HOME や docker グループの gid は +機材そのものに依存します。共有ファイルに混ぜると、同じ `project.yml` を使う他の人の +`devbase up` が壊れるため、別ファイルにします。 + +devbase-samples / devbase-ext などプロジェクト定義を持つリポジトリでは、`.gitignore` に +`project.local.yml` を加えてください(devbase 本体は `projects/*` ごと除外済みです)。 + +```yaml +# projects//project.local.yml +docker: + context: gpu-wsl # docker context ls に出る名前。未指定なら現在の context + home: /home/takemi # リモート側の HOME。bind mount の ~ をこの値で展開する + # gid: 999 # リモート側の docker グループ gid。省略時は初回の up で自動取得 +``` + +| キー | 必須 | 説明 | +|------|------|------| +| `docker.context` | いいえ | `docker` / `docker compose` を向ける docker context の名前。接続先の実体(`ssh://user@host` など)は書かず、各マシンの `docker context create` に委ねる | +| `docker.home` | いいえ | リモート側の HOME(絶対パス)。`compose.yml` の bind mount の `~` をこの値で展開する。未指定のままリモートへ向けると、`~` は手元の HOME に展開されてリモートでは空ディレクトリになるため、`devbase up` が該当する mount を警告する | +| `docker.gid` | いいえ | リモート側の docker グループの gid(`group_add: ["${DOCKER_GID}"]` に渡る値)。未指定なら初回の `up` で `docker run --rm -v /var/run/docker.sock:/s alpine:3 stat -c %g /s` により取得し、`$DEVBASE_ROOT/.cache/docker-gid/` に控える。rootless Docker や socket が `root:root` の構成では `0` が返るため明示する | + +最上位に `docker` 以外のキーは書けません(`scale` / `open_editor` の個人上書きは今後の課題)。 +`project.yml` に `docker:` を書くと、このファイルへ移すよう案内するエラーになります。 + +context の優先順位は **CLI `--context` > env `DEVBASE_DOCKER_CONTEXT`(グローバル `.env` / +プロジェクト `env`)> `project.local.yml` の `docker.context` > 現在の docker context** です。 +一時的に別ホストへ向けたいときは `devbase up --context ` を使います。CLI / env で +ファイルと**別の名前**へ向けたときは、ファイルの `home` / `gid` は使いません(別の機材の +値を持ち込まないため)。 + +使い方の全体像(WSL / EC2 への context の作り方、VS Code の attach、制約)は +[環境変数ガイドの「リモート Docker」](environment-variables.md#リモート-docker別ホストの-daemon-にコンテナを立てる) +を参照してください。 + ## 旧 `env` 形式からの移行 `GIT_USER` / `GIT_REPO` / `GIT_HOST` / `WORK_DIR` / `CONTAINER_SCALE` / diff --git a/issues/PLAN52_remote-docker-context-decisions.md b/issues/old/PLAN52_remote-docker-context-decisions.md similarity index 100% rename from issues/PLAN52_remote-docker-context-decisions.md rename to issues/old/PLAN52_remote-docker-context-decisions.md diff --git a/issues/PLAN52_remote-docker-context-design.md b/issues/old/PLAN52_remote-docker-context-design.md similarity index 100% rename from issues/PLAN52_remote-docker-context-design.md rename to issues/old/PLAN52_remote-docker-context-design.md diff --git a/issues/PLAN52_remote-docker-context.md b/issues/old/PLAN52_remote-docker-context.md similarity index 80% rename from issues/PLAN52_remote-docker-context.md rename to issues/old/PLAN52_remote-docker-context.md index 163b88e8..d7c32e13 100644 --- a/issues/PLAN52_remote-docker-context.md +++ b/issues/old/PLAN52_remote-docker-context.md @@ -346,3 +346,101 @@ issue #162 の提案として書かれているものを、この仕様の決定 | 項目 | 誰が決めるか | 期限 | | --- | --- | --- | | devbase-samples の `.gitignore` 更新の起票先と担当 | 利用者 | 実装 PR のマージまで | + +## 実装計画 + +設計は [PLAN52_remote-docker-context-design.md](PLAN52_remote-docker-context-design.md)、 +決定とテスト設計は [PLAN52_remote-docker-context-decisions.md](PLAN52_remote-docker-context-decisions.md) +にある。ここではタスクの分解と順序だけを書く。**1 本の実装 Pull Request**(`feature/remote-docker-context`)で +進める。設計の要素は互いに呼び合う(反映・再適用・reset を全コマンドが通る)ため、分けると +中間状態のマージが動かない。 + +### 修正対象 + +| 区分 | ファイル | +| --- | --- | +| 新設 | `lib/devbase/project/local_config.py`、`lib/devbase/utils/docker_context.py`、`lib/devbase/volume/bind_mounts.py` | +| 変更 | `bin/devbase`、`lib/devbase/cli.py`、`lib/devbase/project/config.py`、`lib/devbase/volume/compose.py`、`lib/devbase/commands/container.py`、`lib/devbase/commands/env.py`、`lib/devbase/editor/opener.py` | +| 文書 | `docs/user/project-yml.md`、`docs/user/environment-variables.md`、`docs/user/cli-reference/02-project.md`、`docs/user/cli-reference/03-env.md`、`CHANGELOG.md` | +| テスト | `tests/project/test_local_config.py`、`tests/utils/test_docker_context.py`、`tests/volume/test_bind_mounts.py`、`tests/commands/test_container_context.py`、`tests/cli/test_wrapper_build_context.py`、既存の `tests/project/test_config.py`、`tests/editor/test_opener.py`、`tests/cli/test_secret_injection.py` | + +### タスク分解 + +各タスクは失敗するテスト → 最小実装 → 整理の順で進める(`tdd-cycle`)。 + +#### Task 1: `project.local.yml` を読む + +- 対象: `project/local_config.py`(新設)、`project/config.py` +- 内容: `DockerSettings` / `ProjectLocalConfig` と `load_project_local_config()`。無い・空は既定値、未知キー・型・値の検証は `ConfigError`。`project.yml` の `docker:` は移す案内付きで拒む +- 満たす条件: 「設定の読み込み」の 10 件、「起きてはいけないこと」の `project.yml` 単体の検証 + +#### Task 2: context を解決し反映する + +- 対象: `utils/docker_context.py`(新設) +- 内容: `ContextChoice` / `DockerTarget`、`choose_context()`(CLI > env > ファイル > None)、`resolve_target()`(`docker context show` を `DOCKER_CONTEXT` / `DOCKER_HOST` 抜きの環境で 1 回、前提 4 の `home` / `gid` の扱い)、`apply()` / `reapply()` / `reset()`(`DOCKER_HOST` の除去と元の値の控え)、`ensure_remote_gid()`(`alpine:3` の `stat`、`.cache/docker-gid/`、gid 0 の警告) +- 満たす条件: 「context の優先順位」の env / ファイル / CLI の並び、「`DOCKER_GID`」の 6 件、`DOCKER_HOST` の 1 件 + +#### Task 3: lifecycle コマンドに通す + +- 対象: `commands/container.py`、`cli.py` +- 内容: `--context` を lifecycle の parser へ。`_dispatch_lifecycle` の開始時と `finally` で `reset()`、`_resolve_project_name` の直後に `_inject_secrets(required=False)`、`ContextChoice` の解決と `apply`。`_inject_secrets` の末尾で `reapply()`。`cmd_up` / `cmd_scale` は `resolve_target()` → `apply` → gid → 生成へ `home` / `remote` を渡す。`cmd_up` はリモート扱いで自動スナップショットを飛ばす。`_run_build` は `--context` を引数で渡す。`up` の冒頭に解決結果の info +- 満たす条件: 「各コマンド」の `down` / `ps` / `logs` / `login` / `scale` / 自動ビルド / 自動スナップショット、「起きてはいけないこと」の TUI・プロジェクト切替・機密ストアの 3 件、性能の条件 + +#### Task 4: bind mount の `~` を展開する + +- 対象: `volume/bind_mounts.py`(新設)、`volume/compose.py` +- 内容: `expand_home()`(短い書式・`~` 単独・長い書式)と `collect_warnings()`(`~user`、相対パス、`home` 無し)。`generate_scaled_compose(..., docker_home=None, remote=False)` から呼ぶ +- 満たす条件: 「bind mount の `~`」の 7 件 + +#### Task 5: shell の `build` と `env exec` + +- 対象: `bin/devbase`、`commands/env.py`、`cli.py` +- 内容: `build)` 分岐の先頭で `--context NAME` / `--context=NAME` を抜いてシェル変数へ。`compose_with_secrets` を `run_with_project_env` に改名し `env exec ${ctx:+--context "$ctx"} --` を経由。`cmd_build` の `docker buildx build` / `docker image inspect` をそこ経由に。Python 経路(image 指定 / `--expires`)へは `--context` を引数で渡す。`env exec` の parser に `--context`、`cmd_env_exec` は `child_env()` の後に `apply` +- 満たす条件: 「各コマンド」の shell `build` と `env exec`、「context の優先順位」の `--context` を受け付けるコマンド + +#### Task 6: VS Code の attach URI + +- 対象: `editor/opener.py`、`commands/container.py` +- 内容: `open_editor(docker_context=...)`、`resolve_docker_context(env, default=...)` の解決順、ネスト URI のときのフラット URI の info +- 満たす条件: 「VS Code」の 6 件 + +#### Task 7: 文書 + +- 対象: `docs/user/project-yml.md`、`docs/user/environment-variables.md`、`docs/user/cli-reference/02-project.md`、`03-env.md`、`CHANGELOG.md` +- 内容: `project.local.yml` の節、「跨ホスト」→「リモート Docker」、WSL / EC2 の `docker context create` 手順、`--context`、gid の控えの消し方、`DOCKER_HOST` の扱い +- 満たす条件: 対象範囲「ドキュメント」の 4 行。テスト駆動は適用しない(文書) + +### 順序と依存 + +```mermaid +graph TD + T1[Task 1 設定] --> T2[Task 2 解決と反映] + T2 --> T3[Task 3 lifecycle] + T2 --> T5[Task 5 shell build / env exec] + T3 --> T4[Task 4 bind mount] + T3 --> T6[Task 6 VS Code] + T4 --> T7[Task 7 文書] + T5 --> T7 + T6 --> T7 +``` + +### リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `commands/container.py` は 1555 行で、Task 3 が多くの関数に触る | 構造は保ち、タスクごとに既存テスト(1793 件)と新規テストを通す。実装後の `cross-refactoring` で責務の分割を検討する | +| `bin/devbase` の `build)` 分岐は引数の走査が入り組んでいる | `tests/cli/test_wrapper_build_context.py` で偽の `docker` / `uv` を PATH に置き、`--context` の抜き取りと誤分岐しないことを先に固定する | +| `docker context show` / `docker run` を実 docker で叩けないテスト環境 | すべて `runner` 引数で差し替える。実 daemon はリリース後テストで確かめる | +| 設定が無いときの退行 | 各タスクで既存テストを書き換えずに通すことを完了条件にする | + +### 切り戻し手順 + +`project.local.yml` を消せば設定前の挙動へ戻る。コードの切り戻しは Pull Request の revert で +済む(データ移行は無い。`.cache/docker-gid/` は消してよい)。 + +### 完了の定義 + +- [ ] 受け入れ条件 49 件のそれぞれに、テストか手動確認の結果が対応している +- [ ] `uv run pytest` / `ruff check --select=E9,F63,F7,F82 lib` / `shellcheck --severity=error bin/devbase` / `python -m compileall -q lib bin` が exit 0 +- [ ] `cross-refactoring` と `cross-review` を通し、未解決の指摘が 0 +- [ ] 文書 4 件が更新され、`CHANGELOG.md` の Unreleased に載っている diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index ffce2949..dc4cd125 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -110,6 +110,25 @@ def _add_name_arg(parser): return parser +def _add_context_arg(parser): + """lifecycle サブコマンドに `--context NAME` を登録する (PLAN52)。 + + docker context を一時的に上書きする。優先順位は CLI > env `DEVBASE_DOCKER_CONTEXT` + > `project.local.yml` の `docker.context` > 現在の context。空文字は受け付けない。 + """ + parser.add_argument('--context', dest='context', metavar='NAME', + type=_non_empty, default=None, + help='Docker context to use for this command ' + '(overrides DEVBASE_DOCKER_CONTEXT and project.local.yml)') + return parser + + +def _non_empty(value: str) -> str: + if not value.strip(): + raise argparse.ArgumentTypeError('--context には context 名を指定してください') + return value.strip() + + def _add_open_args(parser): """`up` に エディタ自動オープン関連フラグを登録する (PLAN31_3)。 @@ -140,6 +159,7 @@ def _add_login_subparser(sub): """ p = sub.add_parser('login', help='Login to container') p.add_argument('index', nargs='?', default='1', help='Container index') + _add_context_arg(p) def _add_build_subparser(sub): @@ -151,6 +171,7 @@ def _add_build_subparser(sub): """ p = sub.add_parser('build', help='Build container images') p.add_argument('image', nargs='?', default=None, help='Image name') + _add_context_arg(p) # `--no-cache` と `--expires` は仕様上併用しない (無条件 no-cache か期限判定の # いずれか)。併用すると no-cache が優先され --expires が黙殺されるため、 # add_mutually_exclusive_group で CLI レベルの排他制御を行い usage error で落とす。 @@ -171,24 +192,28 @@ def _add_container_parser(subparsers): help='Manage containers') ct_sub = ct_parser.add_subparsers(dest='subcommand') - _add_open_args(ct_sub.add_parser('up', help='Start containers')) - ct_sub.add_parser('down', help='Stop and remove containers') + _add_context_arg(_add_open_args(ct_sub.add_parser('up', help='Start containers'))) + _add_context_arg(ct_sub.add_parser('down', help='Stop and remove containers')) _add_login_subparser(ct_sub) ct_ps = ct_sub.add_parser('ps', help='Show container status') ct_ps.add_argument('--all', '-a', action='store_true', help='Show all containers') + _add_context_arg(ct_ps) ct_logs = ct_sub.add_parser('logs', help='Show container logs') ct_logs.add_argument('--follow', '-f', action='store_true', help='Follow log output') ct_logs.add_argument('--tail', type=int, default=None, help='Number of lines') + _add_context_arg(ct_logs) ct_scale = ct_sub.add_parser('scale', help='Scale containers online') ct_scale.add_argument('new_scale', type=int, help='New number of containers') + _add_context_arg(ct_scale) _add_build_subparser(ct_sub) - ct_sub.add_parser('rebuild', help='Rebuild stale images (= build --expires=7)') + _add_context_arg(ct_sub.add_parser( + 'rebuild', help='Rebuild stale images (= build --expires=7)')) def _add_project_parser(subparsers): @@ -211,25 +236,29 @@ def _add_project_parser(subparsers): pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)') pj_sub = pj_parser.add_subparsers(dest='subcommand') - _add_open_args(_add_name_arg(pj_sub.add_parser('up', help='Start containers'))) - _add_name_arg(pj_sub.add_parser('down', help='Stop and remove containers')) + _add_context_arg(_add_open_args(_add_name_arg( + pj_sub.add_parser('up', help='Start containers')))) + _add_context_arg(_add_name_arg(pj_sub.add_parser('down', help='Stop and remove containers'))) _add_login_subparser(pj_sub) pj_ps = pj_sub.add_parser('ps', help='Show container status') _add_name_arg(pj_ps) pj_ps.add_argument('--all', '-a', action='store_true', help='Show all containers') + _add_context_arg(pj_ps) pj_logs = pj_sub.add_parser('logs', help='Show container logs') _add_name_arg(pj_logs) pj_logs.add_argument('--follow', '-f', action='store_true', help='Follow log output') pj_logs.add_argument('--tail', type=int, default=None, help='Number of lines') + _add_context_arg(pj_logs) # NOTE: `[name]` optional + `new_scale` 必須 int の順。値が 1 個なら new_scale に、 # 2 個なら (name, new_scale) に割り当てられ曖昧にならない (tests/cli 参照)。 pj_scale = pj_sub.add_parser('scale', help='Scale containers online') _add_name_arg(pj_scale) pj_scale.add_argument('new_scale', type=int, help='New number of containers') + _add_context_arg(pj_scale) _add_build_subparser(pj_sub) @@ -237,8 +266,8 @@ def _add_project_parser(subparsers): # 省略可能な `[name]` を取り、name 指定時は _dispatch_lifecycle が chdir してから # 実行する。wrapper の _PROJECT_NAME_SUBCOMMANDS / _NAME_RESOLVABLE_SHORTCUTS にも # 追加すること。 - _add_name_arg(pj_sub.add_parser( - 'rebuild', help='Rebuild stale images (= build --expires=7)')) + _add_context_arg(_add_name_arg(pj_sub.add_parser( + 'rebuild', help='Rebuild stale images (= build --expires=7)'))) # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 @@ -325,6 +354,9 @@ def _add_env_parser(subparsers): env_exec = env_sub.add_parser( 'exec', help='Run a command with the decrypted secrets in its environment') + # shell の `devbase build --context NAME` がここへ引数で渡す (PLAN52 決定 9)。 + # 環境変数で渡すと dispatch 前の機密注入が .env の同名キーで上書きするため。 + _add_context_arg(env_exec) env_exec.add_argument('argv', nargs=argparse.REMAINDER, metavar='-- CMD [ARGS...]', help='Command to run (prefix with -- to pass flags)') @@ -562,19 +594,23 @@ def _add_shortcuts(subparsers): ps_sc = subparsers.add_parser('ps', help='Show container status') _add_name_arg(ps_sc) ps_sc.add_argument('--all', '-a', action='store_true', help='Show all containers') + _add_context_arg(ps_sc) - _add_open_args(_add_name_arg(subparsers.add_parser('up', help='Start containers'))) - _add_name_arg(subparsers.add_parser('down', help='Stop and remove containers')) + _add_context_arg(_add_open_args(_add_name_arg( + subparsers.add_parser('up', help='Start containers')))) + _add_context_arg(_add_name_arg( + subparsers.add_parser('down', help='Stop and remove containers'))) # `[name]` optional + `new_scale` 必須 int の順 (project scale と同じ規則)。 scale_sc = subparsers.add_parser('scale', help='Scale containers online') _add_name_arg(scale_sc) scale_sc.add_argument('new_scale', type=int, help='New number of containers') + _add_context_arg(scale_sc) # `rebuild` は project rebuild のトップレベルシノニム (Python 実装のため build と # 異なりショートカット可)。up/down と同じく `[name]` を受け付ける。 - _add_name_arg(subparsers.add_parser( - 'rebuild', help='Rebuild stale images (= build --expires=7)')) + _add_context_arg(_add_name_arg(subparsers.add_parser( + 'rebuild', help='Rebuild stale images (= build --expires=7)'))) # `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示 # のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 92b4e28f..9adefb08 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -27,7 +27,9 @@ ensure_network ) from devbase.utils.config import get_project_name +from devbase.utils import docker_context from devbase.project import runtime as project_runtime +from devbase.project.local_config import load_project_local_config logger = get_logger(__name__) @@ -85,14 +87,82 @@ def _inject_secrets(*, required: bool): raise logger.warning("機密を読み込めませんでした (続行します): %s", e) return _runtime.SecretEnv() + finally: + # 機密ストアに DOCKER_CONTEXT / DOCKER_GID / DOCKER_HOST があると、注入が + # 確定済みの接続先を上書きする。反映は冪等なので注入のたびに当て直す + # (PLAN52 決定 13)。接続先が無ければ何もしない。 + docker_context.reapply() + + +def _choose_context(cli_context: Optional[str] = None) -> docker_context.ContextChoice: + """カレントプロジェクトの ``project.local.yml`` を読み、context を 1 つに決める。 + + docker を呼ばない。CLI > env ``DEVBASE_DOCKER_CONTEXT`` > ファイル > 未指定 (PLAN52)。 + """ + settings = load_project_local_config(Path.cwd()).docker + return docker_context.choose_context(settings, cli_context=cli_context) + +def _apply_context(context: Optional[str] = None) -> None: + """context を 1 つに決めて環境へ反映する。docker を呼ばない (``_choose_context`` + apply)。 -def _generate_compose_for(scale: int, secrets, dev_environment=None) -> Path: + ``up`` / ``scale`` 以外の lifecycle コマンドが共通で通る入口。 + """ + docker_context.apply(_choose_context(context)) + + +def _resolve_docker_target(cli_context: Optional[str] = None) -> docker_context.DockerTarget: + """``up`` / ``scale`` 用: 接続先を確定し、環境へ反映し、リモート扱いなら gid も決める。 + + 順序は確定 → 反映 → gid。確定の問い合わせ (``docker context show``) は + ``DOCKER_CONTEXT`` を外した環境で行うので、反映の後に呼んでも判定は変わらないが、 + 設計どおり反映より前に置く。gid の取得はリモートの daemon に届く最初の呼び出しに + なるため、存在しない context はここで docker のメッセージと共に止まる。 + """ + settings = load_project_local_config(Path.cwd()).docker + choice = docker_context.choose_context(settings, cli_context=cli_context) + target = docker_context.resolve_target(choice, settings) + if target.context is None: + return target + logger.info("docker context: %s (%s, %s)", target.context, _SOURCE_LABELS[target.source], + "リモート扱い" if target.remote else "現在の context と同じ") + docker_context.apply(target) + if target.remote: + root = _devbase_root() + cache_dir = (root / '.cache') if root else Path('.cache') + gid = docker_context.ensure_remote_gid(target, cache_dir=cache_dir) + target = docker_context.DockerTarget( + target.context, target.source, True, target.home, gid) + docker_context.apply(target) + return target + + +def _remote_generate_kwargs(target: docker_context.DockerTarget) -> dict: + """リモート扱いのときだけ構成生成へ渡す引数。ローカル扱いでは空 (従来の呼び出しの形)。""" + if not target.remote: + return {} + return {'docker_home': target.home, 'remote': True} + + +_SOURCE_LABELS = { + 'cli': '--context', + 'env': 'DEVBASE_DOCKER_CONTEXT', + 'file': 'project.local.yml', + 'default': '既定', +} + + +def _generate_compose_for(scale: int, secrets, dev_environment=None, + docker_home: Optional[str] = None, + remote: bool = False) -> Path: """機密の内訳と devbase 由来の環境変数を渡してスケール構成を生成する。 ``dev_environment`` は ``project.yml`` から作った clone プラン等 (:func:`devbase.project.runtime.container_env`)。dev サービスへ載せることで、 entrypoint がコンテナ内で複数リポジトリを clone できる。 + + ``docker_home`` / ``remote`` はリモート扱いのときの bind mount の書き換えと警告 + (PLAN52)。ローカル扱いでは両方とも既定値のまま渡す。 """ return generate_scaled_compose( scale, @@ -100,6 +170,8 @@ def _generate_compose_for(scale: int, secrets, dev_environment=None) -> Path: global_env_names=secrets.global_names, project_env_names=secrets.project_names, dev_environment=dev_environment, + docker_home=docker_home, + remote=remote, ) @@ -136,8 +208,10 @@ def _previous_scale_compose(): backup.unlink(missing_ok=True) -def _compose_run(subcommand: str, *extra_args: str) -> int: +def _compose_run(subcommand: str, *extra_args: str, + context: Optional[str] = None) -> int: """docker compose コマンドを実行する共通関数""" + _apply_context(context) _inject_secrets(required=False) cmd = ['docker', 'compose'] if _SCALE_COMPOSE_FILE.exists(): @@ -439,37 +513,56 @@ def _dispatch_lifecycle(args) -> int: """ subcmd = getattr(args, 'subcommand', None) project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None) + context = getattr(args, 'context', None) - # name 指定時はディレクトリを解決して chdir する。解決失敗 (DEVBASE_ROOT 未設定 - # / 存在しない name) は候補提示の上でエラー終了する。 - if project_name: - if not _resolve_project_name(project_name): - return 1 - - handlers = { - 'up': lambda: cmd_up(project_name=project_name, - scale=getattr(args, 'scale', None), - open_editor=getattr(args, 'open_editor', None), - open_index=getattr(args, 'open_index', None)), - 'down': lambda: cmd_down(), - 'login': lambda: cmd_login(index=getattr(args, 'index', '1')), - 'ps': lambda: cmd_ps(all_containers=getattr(args, 'all', False)), - 'logs': lambda: cmd_logs(follow=getattr(args, 'follow', False), - tail=getattr(args, 'tail', None)), - 'scale': lambda: cmd_scale(new_scale=getattr(args, 'new_scale', None), - project_name=project_name), - 'build': lambda: cmd_build(image=getattr(args, 'image', None), - no_cache=getattr(args, 'no_cache', False), - expires=getattr(args, 'expires', None)), - 'rebuild': lambda: cmd_rebuild(), - } - - handler = handlers.get(subcmd) - if handler: - return handler() - - logger.error("サブコマンドを指定してください: %s", ', '.join(handlers)) - return 1 + # 接続先の控えは lifecycle 操作の単位で生きる (PLAN52 決定 13)。TUI は 1 プロセスで + # 操作を続けるため、開始時にも捨てて前の操作の接続先を持ち越さない。 + docker_context.reset() + try: + # name 指定時はディレクトリを解決して chdir する。解決失敗 (DEVBASE_ROOT 未設定 + # / 存在しない name) は候補提示の上でエラー終了する。 + if project_name: + # cli.main() は dispatch の前に**現在地**の機密を注入している。切替先の + # env を読む**前**に切替元の機密を落とす (PLAN52)。後に落とすと、 + # clear_injected が「注入前の値」へ戻す動きで、切替先の env が載せた + # 同名キー (DEVBASE_DOCKER_CONTEXT など) まで消してしまう。 + from devbase.env import runtime as _runtime + _runtime.clear_injected() + if not _resolve_project_name(project_name): + return 1 + # 切替先の機密で作り直してから context を解決する。 + _inject_secrets(required=False) + + # `--context` は指定されたときだけ渡す。各 handler の既定は None なので結果は + # 同じで、指定が無い経路は従来と同じ呼び出しの形を保つ。 + ctx = {'context': context} if context is not None else {} + handlers = { + 'up': lambda: cmd_up(project_name=project_name, + scale=getattr(args, 'scale', None), + open_editor=getattr(args, 'open_editor', None), + open_index=getattr(args, 'open_index', None), + **ctx), + 'down': lambda: cmd_down(**ctx), + 'login': lambda: cmd_login(index=getattr(args, 'index', '1'), **ctx), + 'ps': lambda: cmd_ps(all_containers=getattr(args, 'all', False), **ctx), + 'logs': lambda: cmd_logs(follow=getattr(args, 'follow', False), + tail=getattr(args, 'tail', None), **ctx), + 'scale': lambda: cmd_scale(new_scale=getattr(args, 'new_scale', None), + project_name=project_name, **ctx), + 'build': lambda: cmd_build(image=getattr(args, 'image', None), + no_cache=getattr(args, 'no_cache', False), + expires=getattr(args, 'expires', None), **ctx), + 'rebuild': lambda: cmd_rebuild(**ctx), + } + + handler = handlers.get(subcmd) + if handler: + return handler() + + logger.error("サブコマンドを指定してください: %s", ', '.join(handlers)) + return 1 + finally: + docker_context.reset() def cmd_project(args) -> int: @@ -520,11 +613,19 @@ def _snapshot_min_interval_minutes() -> int: return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT -def _auto_snapshot() -> None: +def _auto_snapshot(remote: bool = False) -> None: """デプロイ前の自動スナップショット (差分世代数ベース世代管理)。 失敗してもデプロイは続行する (warning のみ)。DEVBASE_ROOT 未設定なら no-op。 + リモート扱い (PLAN52 決定 12) では作らない。控えたいボリュームがリモートにあり、 + 手元のディレクトリを bind mount する仕組みではリモートの空ディレクトリへ書いて + しまうため。 """ + if remote: + logger.warning( + "[0/6] リモートの docker context ではスナップショットを扱えないため、" + "自動スナップショットを飛ばします") + return devbase_root = os.environ.get('DEVBASE_ROOT') if not devbase_root: return @@ -611,7 +712,8 @@ def _apply_window_titles(project_name: str, scale: int, dev_service_name: str, def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, - config, compose_file=None) -> None: + config, compose_file=None, + docker_context_name: Optional[str] = None) -> None: """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら @@ -658,6 +760,7 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], workspace=workspace, index=open_index, compose_file=compose_file, + docker_context=docker_context_name, ) except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) @@ -704,32 +807,21 @@ def _report_missing_repos(config, scale: int, dev_service_name: str, project_name) -def cmd_up(project_name: str = None, scale: int = None, - open_editor: Optional[bool] = None, - open_index: Optional[int] = None) -> int: - """Deploy containers with specified scale""" - if project_name is None: - project_name = get_project_name() - - # project.yml が唯一の正 (PLAN32)。読めなければ移行手順を案内して止まる。 - config = project_runtime.current_project_config() - - if scale is None: - scale = config.scale if config.scale is not None else project_runtime.DEFAULT_SCALE - - dev_service_name = get_dev_service_name() - - logger.info("Deploying project '%s' with scale=%d (dev service: %s)", - project_name, scale, dev_service_name) +def _run_pre_up_checks(config) -> bool: + """`up` の起動前チェック 3 つを順に実行する。 + 順序と早期 return はそのまま: (1) ``.env`` の存在確認、(2) ``./pre-up`` フック、 + (3) コンテナイメージの存在確認。どれかが失敗したら False を返し、``cmd_up`` は + 起動へ進まない。すべて満たせば True。 + """ # Pre-check 1: Ensure .env file exists with content if not _ensure_env_files(): logger.error("Failed to create .env file. Please run 'devbase env init' manually.") - return 1 + return False # Pre-step: Run ./pre-up hook (e.g. clone source repos used as build contexts) if not _run_pre_up_hook(config): - return 1 + return False # Pre-check 2: Ensure container images exist if not _ensure_images(): @@ -738,41 +830,95 @@ def cmd_up(project_name: str = None, scale: int = None, "Run 'devbase container build' for build-based services, " "or 'docker pull ' for image-only services." ) - return 1 + return False - # Pre-step: Auto snapshot(差分世代数ベース世代管理) - _auto_snapshot() + return True + +def _run_deploy_pipeline(project_name: str, scale: int, config, + target: docker_context.DockerTarget, + dev_service_name: str) -> Path: + """[1/6]〜[5/6] のデプロイ本体 (volume/network/compose 生成・down・up・wait)。 + + 復号と構成生成は既存コンテナを止める**前**に済ませる。鍵の紛失・権限不備・ + 暗号文の破損でここが失敗しても、稼働中の開発環境を落としたままにしないため。 + :func:`_previous_scale_compose` が退避した旧構成で停止し、生成した新構成で + 起動して ready を待つ。生成した override compose のパスを返す (後処理の + ``_report_missing_repos`` / ``_apply_window_titles`` / ``_maybe_open_editor`` + が同じファイルを ``-f`` で使う)。 + """ + logger.info("[1/6] Ensuring volumes exist...") + ensure_volumes(scale, project_name) + + logger.info("[1.5/6] Ensuring network exists...") + ensure_network('devbase_net') + + # 復号と構成生成は既存コンテナを止める**前**に済ませる。鍵の紛失・権限 + # 不備・暗号文の破損でここが失敗しても、稼働中の開発環境を落としたまま + # にしないため。 + with _previous_scale_compose() as down_compose_file: + logger.info("[2/6] Generating scaled compose file...") + override_file = _generate_compose_for( + scale, _inject_secrets(required=True), + dev_environment=project_runtime.container_env(config, project_name), + **_remote_generate_kwargs(target)) + logger.info("Generated: %s", override_file) + + logger.info("[3/6] Stopping existing containers...") + docker_compose_down(compose_file=down_compose_file) + + logger.info("[4/6] Starting containers...") + docker_compose_up(compose_file=override_file, detach=True) + + logger.info("[5/6] Waiting for containers to be ready...") + wait_for_containers_ready( + container_prefix=dev_service_name, + scale=scale, + compose_file=override_file, + timeout=60 + ) + return override_file + + +def cmd_up(project_name: str = None, scale: int = None, + open_editor: Optional[bool] = None, + open_index: Optional[int] = None, + context: Optional[str] = None) -> int: + """Deploy containers with specified scale""" + if project_name is None: + project_name = get_project_name() + + # project.yml が唯一の正 (PLAN32)。読めなければ移行手順を案内して止まる。 + config = project_runtime.current_project_config() + + # 接続先 (docker context) を確定して環境へ反映する (PLAN52)。以降の docker / + # docker compose / フックはすべて環境変数 DOCKER_CONTEXT を継承する。 try: - logger.info("[1/6] Ensuring volumes exist...") - ensure_volumes(scale, project_name) + target = _resolve_docker_target(context) + except DevbaseError as e: + logger.error("Deploy failed: %s", e) + return 1 - logger.info("[1.5/6] Ensuring network exists...") - ensure_network('devbase_net') + if scale is None: + scale = config.scale if config.scale is not None else project_runtime.DEFAULT_SCALE - # 復号と構成生成は既存コンテナを止める**前**に済ませる。鍵の紛失・権限 - # 不備・暗号文の破損でここが失敗しても、稼働中の開発環境を落としたまま - # にしないため。 - with _previous_scale_compose() as down_compose_file: - logger.info("[2/6] Generating scaled compose file...") - override_file = _generate_compose_for( - scale, _inject_secrets(required=True), - dev_environment=project_runtime.container_env(config, project_name)) - logger.info("Generated: %s", override_file) + dev_service_name = get_dev_service_name() - logger.info("[3/6] Stopping existing containers...") - docker_compose_down(compose_file=down_compose_file) + logger.info("Deploying project '%s' with scale=%d (dev service: %s)", + project_name, scale, dev_service_name) - logger.info("[4/6] Starting containers...") - docker_compose_up(compose_file=override_file, detach=True) + if not _run_pre_up_checks(config): + return 1 - logger.info("[5/6] Waiting for containers to be ready...") - wait_for_containers_ready( - container_prefix=dev_service_name, - scale=scale, - compose_file=override_file, - timeout=60 - ) + # Pre-step: Auto snapshot(差分世代数ベース世代管理)。リモート扱いでは飛ばす + if target.remote: + _auto_snapshot(remote=True) + else: + _auto_snapshot() + + try: + override_file = _run_deploy_pipeline( + project_name, scale, config, target, dev_service_name) # clone できなかった repo があれば伝える (揃っていれば何も出さない)。 _report_missing_repos(config, scale, dev_service_name, project_name, @@ -790,7 +936,8 @@ def cmd_up(project_name: str = None, scale: int = None, compose_file=override_file) _maybe_open_editor(project_name, open_editor, open_index, scale, - config, compose_file=override_file) + config, compose_file=override_file, + docker_context_name=target.context) logger.info("=== Deploy completed successfully ===") return 0 @@ -807,8 +954,9 @@ def cmd_up(project_name: str = None, scale: int = None, # cmd_down # --------------------------------------------------------------------------- -def cmd_down() -> int: +def cmd_down(context: Optional[str] = None) -> int: """Stop and remove containers""" + _apply_context(context) _inject_secrets(required=False) compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None docker_compose_down(compose_file=compose_file) @@ -829,8 +977,9 @@ def cmd_down() -> int: # cmd_login # --------------------------------------------------------------------------- -def cmd_login(index: str = '1') -> int: +def cmd_login(index: str = '1', context: Optional[str] = None) -> int: """Login to container""" + _apply_context(context) _inject_secrets(required=False) dev_service = get_dev_service_name() @@ -848,36 +997,43 @@ def cmd_login(index: str = '1') -> int: # cmd_ps # --------------------------------------------------------------------------- -def cmd_ps(all_containers: bool = False) -> int: +def cmd_ps(all_containers: bool = False, context: Optional[str] = None) -> int: """Show container status via docker compose ps""" extra = ['--all'] if all_containers else [] - return _compose_run('ps', *extra) + return _compose_run('ps', *extra, context=context) # --------------------------------------------------------------------------- # cmd_logs # --------------------------------------------------------------------------- -def cmd_logs(follow: bool = False, tail: Optional[int] = None) -> int: +def cmd_logs(follow: bool = False, tail: Optional[int] = None, + context: Optional[str] = None) -> int: """Show container logs via docker compose logs""" extra = [] if follow: extra.append('--follow') if tail is not None: extra.extend(['--tail', str(tail)]) - return _compose_run('logs', *extra) + return _compose_run('logs', *extra, context=context) # --------------------------------------------------------------------------- # cmd_scale # --------------------------------------------------------------------------- -def cmd_scale(new_scale: int, project_name: str = None) -> int: +def cmd_scale(new_scale: int, project_name: str = None, + context: Optional[str] = None) -> int: """Scale containers online without restarting existing ones""" if project_name is None: project_name = get_project_name() config = project_runtime.current_project_config() + try: + target = _resolve_docker_target(context) + except DevbaseError as e: + logger.error("Scale failed: %s", e) + return 1 dev_service_name = get_dev_service_name() current_scale = (config.scale if config.scale is not None else project_runtime.DEFAULT_SCALE) @@ -908,7 +1064,8 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: logger.info("[3/5] Generating scaled compose file...") override_file = _generate_compose_for( new_scale, _inject_secrets(required=True), - dev_environment=project_runtime.container_env(config, project_name)) + dev_environment=project_runtime.container_env(config, project_name), + **_remote_generate_kwargs(target)) logger.info("Generated: %s", override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) @@ -1016,7 +1173,8 @@ def _build_single_image(image: str, no_cache: bool = False) -> int: def cmd_build(image: Optional[str] = None, no_cache: bool = False, - expires: Optional[int] = None) -> int: + expires: Optional[int] = None, + context: Optional[str] = None) -> int: """Build container images. 引数の意味 (i07 の 3 モード): @@ -1033,6 +1191,10 @@ def cmd_build(image: Optional[str] = None, no_cache: bool = False, に統一する。``image`` 指定の単体ビルドはここが唯一の実装で、shell 側の dispatch (``devbase build ``) もここへ振り分けられる (PLAN49)。 """ + # 接続先を環境へ反映する (PLAN52)。単体ビルドも compose ビルドも、shell 経由の + # 自動ビルドも、以降の docker 呼び出しは DOCKER_CONTEXT を継承する。 + _apply_context(context) + if image is not None: # 単体ビルド (image 指定) では期限判定を行わないため --expires は無視される。 # 誤併用に気付けるよう警告を出す。 @@ -1104,7 +1266,7 @@ def _build_resolved(expires: Optional[int], no_cache: bool) -> int: return 0 if _build_with_expires(expires, image_name, inspect.stdout, dev_service) else 1 -def cmd_rebuild(expires: int = None) -> int: +def cmd_rebuild(expires: int = None, context: Optional[str] = None) -> int: """Rebuild project images honoring an expiry window (``build --expires=N`` synonym). ``devbase rebuild`` は ``devbase build --expires=7`` のシノニム (既定 7 日)。 @@ -1119,6 +1281,7 @@ def cmd_rebuild(expires: int = None) -> int: """ if expires is None: expires = _image_max_age_days() + _apply_context(context) logger.info("Rebuilding images (expires=%d days) from compose.yml ...", expires) return _build_resolved(expires=expires, no_cache=False) @@ -1481,6 +1644,12 @@ def _run_build(no_cache: bool = False, project_no_cache: bool = False) -> bool: return False cmd = ['bash', str(devbase_bin), 'build'] + # 確定済みの context は引数で渡す (PLAN52 決定 10)。bin/devbase は起動時に env を + # 読み直し、Python 側は .env の機密を注入するため、環境変数で渡した値は同名キーに + # 負ける。引数なら build) 分岐が最優先で解決する。 + active = docker_context.active_target() + if active is not None and active.context: + cmd.extend(['--context', active.context]) if project_no_cache: cmd.append('--project-no-cache') elif no_cache: diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 2d99f463..ece77585 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -102,7 +102,8 @@ def cmd_env(devbase_root: Path, args) -> int: 'export': lambda: cmd_env_export(devbase_root, args), 'import': lambda: cmd_env_import(devbase_root, args), 'exec': lambda: cmd_env_exec(devbase_root, - list(getattr(args, 'argv', []) or [])), + list(getattr(args, 'argv', []) or []), + context=getattr(args, 'context', None)), 'encrypt': lambda: _migrate(args).cmd_env_encrypt( devbase_root, dry_run=getattr(args, 'dry_run', False), @@ -147,15 +148,22 @@ def _migrate(_args=None): return env_migrate -def cmd_env_exec(devbase_root: Path, argv) -> int: +def cmd_env_exec(devbase_root: Path, argv, context: Optional[str] = None) -> int: """機密を環境変数として渡した状態でコマンドを実行する。 起動ラッパーは共通の機密ファイルを読み込まなくなったため、ホスト側で動く 処理のうち値を必要とするもの (Docker Compose の変数展開など) は、この コマンドを通して実行する (plan35 §4.4)。復号結果は子プロセスの環境変数 としてのみ渡り、ファイルには書き出さない。 + + docker context (PLAN52) もここで子プロセスへ載せる。カレントディレクトリの + ``project.local.yml`` と env、引数 ``--context`` から解決し、機密を載せた**後**の + 辞書へ反映するので、``.env`` の同名キーに負けない。gid・home・リモート判定は + 行わない (docker を呼ばない)。 """ from devbase.env import runtime as _runtime + from devbase.project.local_config import load_project_local_config + from devbase.utils import docker_context # argparse.REMAINDER は区切りの `--` も残すため、先頭のものだけ取り除く。 # 2 つ目以降はコマンド自身への引数なのでそのまま渡す。 @@ -166,8 +174,14 @@ def cmd_env_exec(devbase_root: Path, argv) -> int: logger.error("実行するコマンドを指定してください: devbase env exec -- CMD [ARGS...]") return 1 - env = _runtime.child_env(devbase_root, - _runtime.current_project_name(devbase_root)) + project = _runtime.current_project_name(devbase_root) + env = _runtime.child_env(devbase_root, project) + # project.local.yml は機密と同じくプロジェクト直下から読む。projects//sub から + # 実行しても、機密の対象 (current_project_name) と設定の対象が食い違わない。 + project_dir = (Path(devbase_root) / 'projects' / project) if project else Path.cwd() + settings = load_project_local_config(project_dir).docker + docker_context.apply(docker_context.choose_context(settings, cli_context=context, + environ=env), env, track=False) try: return subprocess.run(argv, env=env).returncode except FileNotFoundError: diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 0a9f7d96..70d02406 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -548,12 +548,14 @@ def resolve_editor_ssh_host(environ=None, return None -def resolve_docker_context(environ=None, runner: Optional[Callable] = None) -> Optional[str]: - """ssh 先で使う docker context を解決する。 - - ``DEVBASE_EDITOR_DOCKER_CONTEXT`` 明示があればそれ。無ければ devbase up を実行して - いるホスト (= コンテナのある Mac) の現在の docker context を ``docker context show`` - で取得する。docker 不在・非0・例外・空はすべて None (settings.context を付けない)。 +def resolve_docker_context(environ=None, runner: Optional[Callable] = None, + default: Optional[str] = None) -> Optional[str]: + """attach に使う docker context を解決する。 + + 順序は ``DEVBASE_EDITOR_DOCKER_CONTEXT`` 明示 → ``default`` (devbase が + ``project.local.yml`` / ``--context`` から解決した context、PLAN52) → devbase up を + 実行しているホストの現在の docker context (``docker context show``)。docker 不在・ + 非0・例外・空はすべて None (settings.context を付けない)。 """ env = os.environ if environ is None else environ explicit = env.get("DEVBASE_EDITOR_DOCKER_CONTEXT") @@ -561,16 +563,14 @@ def resolve_docker_context(environ=None, runner: Optional[Callable] = None) -> O # 空文字 ("") は明示的オプトアウト (settings.context を付けない) として扱い、 # `docker context show` を呼ばない。 return explicit.strip() or None - run = runner or subprocess.run - try: - proc = run(["docker", "context", "show"], - capture_output=True, text=True, timeout=10) - except Exception: # noqa: BLE001 - docker 不在等は best-effort - return None - if getattr(proc, "returncode", 1) != 0: - return None - out = (proc.stdout or "").strip() - return out or None + if default: + return default + # 推測は「docker が実際に使う context」に合わせる (環境変数は外さない)。devbase の + # 設定が無く DOCKER_CONTEXT だけで別 daemon へ向けている利用者では、コンテナも + # その context にあるので、attach 先も同じ名前でなければならない。リモート判定用の + # 問い合わせ (環境変数を外す current_context) とは目的が違うので分ける。 + from devbase.utils import docker_context as _dc + return _dc.effective_context(environ=env, runner=runner) _NO_EDITOR_REASON = ( @@ -622,26 +622,16 @@ def _launch(cmd: list, env: dict) -> None: ) -def open_editor(*, project_name: str, dev_service_name: str, workdir: str, - workspace: Optional[str] = None, - index: int = 1, compose_file=None, - environ=None, - isatty: Optional[bool] = None, system: Optional[str] = None, - ipc_alive: Optional[bool] = None, - launcher: Optional[Callable[[list, dict], None]] = None) -> str: - """dev コンテナへ接続した VS Code を開く / コマンド提示 / スキップする。 +def _prepare_ipc_env(env, ctx: EditorContext): + """拾い直した IPC ソケットを env へ反映し、必要な警告ログを出す。 - 戻り値は実行された action ('launch' | 'print_command' | 'skip')。例外は - 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / - ``system`` / ``ipc_alive`` は :func:`detect_context` への差し替え口 (テスト用)。 - ``compose_file`` は実コンテナ名問い合わせ時に起動と同じ override compose を - ``-f`` で渡すため。``workspace`` は複数リポジトリ構成で開く - ``*.code-workspace`` のコンテナ内パス (未指定なら env ``DEVBASE_WORKSPACE``)。 + tmux のセッション環境から拾い直せた場合は、起動する code にもその値を渡す。 + 変数を差し替えないと code 自身が古いソケットへ繋ぎに行って失敗する。変数だけ + 残って接続先が死んでいる IPC ソケットは無言の失敗になりやすいので明示する + (tmux セッション再利用・VS Code ウィンドウのリロード後など)。 + + ``env`` は差し替えが要る場合のみ複製して返す (要らなければそのまま返す)。 """ - env = os.environ if environ is None else environ - ctx = detect_context(env, isatty=isatty, system=system, ipc_alive=ipc_alive) - # tmux のセッション環境から拾い直せた場合は、起動する code にもその値を渡す。 - # 変数を差し替えないと code 自身が古いソケットへ繋ぎに行って失敗する。 stale_ipc = env.get("VSCODE_IPC_HOOK_CLI") if ctx.ipc_socket and ctx.ipc_socket != stale_ipc: env = dict(env) @@ -652,8 +642,6 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, "tmux 設定を参照してください。", stale_ipc or "(未設定)", ctx.ipc_socket, ) - # 変数だけ残って接続先が死んでいる IPC ソケットは無言の失敗になりやすいので - # 明示する (tmux セッション再利用・VS Code ウィンドウのリロード後など)。 if stale_ipc and not ctx.in_vscode: logger.warning( "VSCODE_IPC_HOOK_CLI が指すソケットに接続できません (%s)。VS Code 統合" @@ -663,6 +651,76 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, "おらず接続を拒否します。", stale_ipc, ) + return env + + +def _build_open_uri(ctx: EditorContext, env, container: str, workdir: str, + workspace: Optional[str], docker_context: Optional[str], + display: list) -> tuple[str, str]: + """開く対象の attach URI と URI フラグを組む。 + + ssh_host + docker_context + workspace + uri_flag + uri の組み立てを担う。 + SSH コンテキストでのみネスト authority (@ssh-remote+host) を組む。自動推測は + VS Code Remote-SSH 統合端末 (in_vscode) の時だけ有効にする — plain SSH + (VS Code 外) は既存 ExecServer を前提にできずネスト URI が動かないため、明示 + 設定時のみ採用する。settings.context は「明示 → devbase の解決結果 → (ssh 先の + ときだけ) 現在の context の推測」の順 (PLAN52 決定 11)。解決結果があればローカル + 端末でも付ける。 + + ネスト URI (ssh_host + docker_context) のときは、手元 VS Code に同名 context が + あれば ssh 先を経由せず直接 attach できるフラット URI を info ログで提示する。 + + 戻り値は ``(uri, uri_flag)``。 + """ + ssh_host = (resolve_editor_ssh_host(env, auto_detect=ctx.in_vscode) + if ctx.is_ssh else None) + if ssh_host or docker_context: + docker_context = resolve_docker_context(env, default=docker_context) + else: + docker_context = None + # DEVBASE_WORKSPACE があれば *.code-workspace をワークスペースとして開く。VS Code は + # `--file-uri` に渡したパスが .code-workspace 拡張子なら multi-root ワークスペースとして + # 開くため、フォルダを開く `--folder-uri` と URI ターゲット・フラグの両方を切り替える。 + open_target = workspace or workdir + uri_flag = "--file-uri" if workspace else "--folder-uri" + uri = build_attach_uri(container, open_target, + ssh_host=ssh_host, docker_context=docker_context) + if ssh_host and docker_context: + # Windows VS Code → Remote-SSH(Mac) → 別ホストの docker という一周を避けたい + # 場合、手元の VS Code に同名の context があれば直接 attach できる (PLAN52)。 + flat = build_attach_uri(container, open_target, docker_context=docker_context) + logger.info( + "手元の VS Code に同名の docker context '%s' があれば、ssh 先を経由せず " + "次で直接 attach できます:", docker_context) + logger.info(" %s %s '%s'", + " ".join(shlex.quote(c) for c in display), uri_flag, flat) + return uri, uri_flag + + +def open_editor(*, project_name: str, dev_service_name: str, workdir: str, + workspace: Optional[str] = None, + index: int = 1, compose_file=None, + environ=None, + isatty: Optional[bool] = None, system: Optional[str] = None, + ipc_alive: Optional[bool] = None, + launcher: Optional[Callable[[list, dict], None]] = None, + docker_context: Optional[str] = None) -> str: + """dev コンテナへ接続した VS Code を開く / コマンド提示 / スキップする。 + + 戻り値は実行された action ('launch' | 'print_command' | 'skip')。例外は + 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / + ``system`` / ``ipc_alive`` は :func:`detect_context` への差し替え口 (テスト用)。 + ``compose_file`` は実コンテナ名問い合わせ時に起動と同じ override compose を + ``-f`` で渡すため。``workspace`` は複数リポジトリ構成で開く + ``*.code-workspace`` のコンテナ内パス (未指定なら env ``DEVBASE_WORKSPACE``)。 + ``docker_context`` は devbase が解決した接続先 (PLAN52)。あればローカル端末でも + ``settings.context`` を付け、Dev Containers 拡張にその context で attach させる。 + """ + env = os.environ if environ is None else environ + ctx = detect_context(env, isatty=isatty, system=system, ipc_alive=ipc_alive) + # tmux のセッション環境から拾い直せた場合は env を差し替え、死んだ IPC ソケット + # の警告も出す。 + env = _prepare_ipc_env(env, ctx) editor = resolve_editor_cmd(env) # launch 用 (which 込み・None あり得る) display = resolve_editor_display(env) # print 用 (必ず非 None) plan = decide_action(ctx, editor_available=bool(editor)) @@ -675,20 +733,9 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, container = resolve_container_name( dev_service_name, project_name, index, compose_file=compose_file) - # SSH コンテキストでのみネスト authority (@ssh-remote+host) を組む。自動推測は - # VS Code Remote-SSH 統合端末 (in_vscode) の時だけ有効にする — plain SSH (VS Code 外) - # は既存 ExecServer を前提にできずネスト URI が動かないため、明示設定時のみ採用する。 - ssh_host = (resolve_editor_ssh_host(env, auto_detect=ctx.in_vscode) - if ctx.is_ssh else None) - docker_context = resolve_docker_context(env) if ssh_host else None - # DEVBASE_WORKSPACE があれば *.code-workspace をワークスペースとして開く。VS Code は - # `--file-uri` に渡したパスが .code-workspace 拡張子なら multi-root ワークスペースとして - # 開くため、フォルダを開く `--folder-uri` と URI ターゲット・フラグの両方を切り替える。 workspace = workspace or resolve_workspace(env) - open_target = workspace or workdir - uri_flag = "--file-uri" if workspace else "--folder-uri" - uri = build_attach_uri(container, open_target, - ssh_host=ssh_host, docker_context=docker_context) + uri, uri_flag = _build_open_uri( + ctx, env, container, workdir, workspace, docker_context, display) if plan.action == "print_command": # 提示コマンドは手元 (ローカル) で実行する前提。ローカルに code が無くても diff --git a/lib/devbase/project/config.py b/lib/devbase/project/config.py index 2c4023a7..47a72a8f 100644 --- a/lib/devbase/project/config.py +++ b/lib/devbase/project/config.py @@ -160,6 +160,13 @@ def parse_project_config(data: Mapping[str, Any], source: str) -> ProjectConfig: data: YAML を読み込んだマッピング source: エラーメッセージに出す出所 (ファイルパス等) """ + # 機材依存の docker 節 (context / home / gid) は共有される project.yml ではなく + # gitignore 対象の project.local.yml に置く (PLAN52)。未知キーとして弾くだけだと + # 移す先が分からないので、案内を添える。 + if "docker" in data: + raise ConfigError( + f"{source}: docker 節は共有ファイルには書けません。個人・機材ごとの設定は " + "同じディレクトリの project.local.yml (gitignore 対象) へ移してください。") _reject_unknown_keys(data, _TOP_LEVEL_KEYS, source, "最上位") version = data.get("version") diff --git a/lib/devbase/project/local_config.py b/lib/devbase/project/local_config.py new file mode 100644 index 00000000..8812b5eb --- /dev/null +++ b/lib/devbase/project/local_config.py @@ -0,0 +1,149 @@ +"""``projects//project.local.yml`` (個人・機材ごとの設定) の読み込み (PLAN52)。 + +``project.yml`` はチームで共有される正であり、devbase-samples / devbase-ext の +リポジトリに載る。「このプロジェクトのコンテナは別ホストの Docker に立てる」は +個人の事情で、リモート側の HOME や docker グループの gid に至っては機材そのものに +依存する。共有ファイルに混ぜると同じ ``project.yml`` を使う他の人の ``up`` が壊れる +ため、gitignore 対象の別ファイルへ分ける。 + +スキーマ:: + + docker: # 任意 + context: gpu-wsl # 任意。`docker context ls` に出る名前 + home: /home/takemi # 任意。リモート側の HOME (絶対パス)。bind mount の ~ を展開する + gid: 999 # 任意。リモート側の docker グループ gid + +``project.yml`` へ深くマージはしない (決定 2)。初期スコープの ``docker`` 節は +``project.yml`` に存在しないキーであり、マージする対象が無い。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Optional + +import yaml + +from devbase.errors import ConfigError + +#: 設定ファイル名 (プロジェクトディレクトリ直下) +PROJECT_LOCAL_CONFIG_FILENAME = "project.local.yml" + +_TOP_LEVEL_KEYS = frozenset({"docker"}) +_DOCKER_KEYS = frozenset({"context", "home", "gid"}) + + +@dataclass(frozen=True) +class DockerSettings: + """``docker`` 節 1 つ分。すべて省略可。""" + + context: Optional[str] = None + home: Optional[str] = None + gid: Optional[int] = None + + +@dataclass(frozen=True) +class ProjectLocalConfig: + """``project.local.yml`` 1 ファイル分。いまは ``docker`` だけを持つ。""" + + docker: DockerSettings = DockerSettings() + + +def local_config_path(project_dir: Path) -> Path: + return Path(project_dir) / PROJECT_LOCAL_CONFIG_FILENAME + + +def load_project_local_config(project_dir: Path) -> ProjectLocalConfig: + """``/project.local.yml`` を読む。無い・空なら既定値を返す。 + + Raises: + ConfigError: YAML が壊れている / スキーマ違反。 + """ + path = local_config_path(project_dir) + if not path.is_file(): + return ProjectLocalConfig() + + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except UnicodeDecodeError as e: + raise ConfigError(f"{path} を UTF-8 として読めません ({e})") from e + except OSError as e: + raise ConfigError(f"{path} を読み込めません: {e}") from e + except yaml.YAMLError as e: + raise ConfigError(f"{path} の YAML を解釈できません: {e}") from e + + if raw is None: + return ProjectLocalConfig() + if not isinstance(raw, Mapping): + raise ConfigError(f"{path} の最上位はマッピングである必要があります。") + return parse_project_local_config(raw, source=str(path)) + + +def parse_project_local_config(data: Mapping[str, Any], source: str) -> ProjectLocalConfig: + """読み込み済みのマッピングを検証する (I/O を伴わない)。""" + _reject_unknown_keys(data, _TOP_LEVEL_KEYS, source, "最上位") + + docker = data.get("docker") + if docker is None: + return ProjectLocalConfig() + if not isinstance(docker, Mapping): + raise ConfigError(f"{source}: docker はマッピングである必要があります。") + _reject_unknown_keys(docker, _DOCKER_KEYS, source, "docker") + + return ProjectLocalConfig(docker=DockerSettings( + context=_parse_context(docker.get("context"), source), + home=_parse_home(docker.get("home"), source), + gid=_parse_gid(docker.get("gid"), source), + )) + + +def _reject_unknown_keys(data: Mapping[str, Any], allowed: frozenset, + source: str, where: str) -> None: + unknown = sorted(str(key) for key in data if key not in allowed) + if unknown: + raise ConfigError( + f"{source}: {where}に未知のキーがあります: {', '.join(unknown)} " + f"(使えるキー: {', '.join(sorted(allowed))})") + + +def _parse_context(value: Any, source: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ConfigError( + f"{source}: docker.context は docker context の名前 (空でない文字列) です " + f"({value!r})") + if any(c.isspace() or not c.isprintable() for c in value): + raise ConfigError( + f"{source}: docker.context に空白文字・制御文字は使えません ({value!r})") + return value + + +def _parse_home(value: Any, source: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value.startswith("/"): + raise ConfigError( + f"{source}: docker.home はリモート側の絶対パス (/ で始まる文字列) です " + f"({value!r})") + return value + + +def _parse_gid(value: Any, source: str) -> Optional[int]: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ConfigError( + f"{source}: docker.gid は 0 以上の整数です ({value!r})") + return value + + +__all__ = [ + "PROJECT_LOCAL_CONFIG_FILENAME", + "DockerSettings", + "ProjectLocalConfig", + "load_project_local_config", + "local_config_path", + "parse_project_local_config", +] diff --git a/lib/devbase/utils/docker_context.py b/lib/devbase/utils/docker_context.py new file mode 100644 index 00000000..0334a975 --- /dev/null +++ b/lib/devbase/utils/docker_context.py @@ -0,0 +1,346 @@ +"""docker context の解決と、その接続先を docker 呼び出しへ届ける仕組み (PLAN52)。 + +devbase は ``docker`` / ``docker compose`` を ``subprocess`` で叩くだけなので、接続先の +切り替えは **環境変数 ``DOCKER_CONTEXT``** で全呼び出しへ伝える (決定 1)。``--context`` を +引数へ足す形では、30 か所近い呼び出しの 1 つが漏れただけで「一部だけ手元の daemon を触る」 +事故になる。 + +3 段階で扱う: + +1. :func:`choose_context` — CLI / env / ``project.local.yml`` / 未指定の順で 1 つに決める。 + docker を呼ばない純粋な処理 +2. :func:`resolve_target` — 現在の context (``docker context show``) と比べて**リモート + 扱い**かを決め、``home`` / ``gid`` を添える (決定 3・4) +3. :func:`apply` — ``os.environ`` へ反映する。冪等で、機密の注入が同名キーを上書きした + 後に :func:`reapply` で戻せる (決定 13)。操作の前後で :func:`reset` が元へ戻す + +**問い合わせの環境から ``DOCKER_CONTEXT`` と ``DOCKER_HOST`` を外す**のは、docker が +``DOCKER_CONTEXT`` が残ると設定先自身を、``DOCKER_HOST`` が残ると ``default`` を返すため +である (実測、Docker 29.4.3)。載せた後に呼んでも判定が変わらないようにここで外す。 + +**``DOCKER_HOST`` は context を解決したときに取り除く。** docker は ``DOCKER_HOST`` を +``DOCKER_CONTEXT`` より優先するため、残すと設定した context が効かない。 +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, MutableMapping, Optional, Union + +from devbase.errors import DevbaseError +from devbase.log import get_logger +from devbase.project.local_config import DockerSettings + +logger = get_logger(__name__) + +#: 上書き用の環境変数 (グローバル ``.env`` / プロジェクト ``env`` / shell) +DEVBASE_DOCKER_CONTEXT = "DEVBASE_DOCKER_CONTEXT" +#: docker CLI と compose が読む接続先 +DOCKER_CONTEXT = "DOCKER_CONTEXT" +DOCKER_HOST = "DOCKER_HOST" +DOCKER_GID = "DOCKER_GID" + +#: gid 取得に使う公開イメージ (決定 5)。小さく、``stat -c`` を受け付ける +GID_PROBE_IMAGE = "alpine:3" +#: gid の控えの置き場 (``$DEVBASE_ROOT/.cache/`` 配下) +GID_CACHE_SUBDIR = "docker-gid" + +_PROTECTED = (DOCKER_CONTEXT, DOCKER_HOST, DOCKER_GID) + + +@dataclass(frozen=True) +class ContextChoice: + """優先順位で決めた context と出所 (``cli`` / ``env`` / ``file`` / ``default``)。""" + + context: Optional[str] + source: str + + +@dataclass(frozen=True) +class DockerTarget: + """接続先の確定結果。``remote`` が偽なら ``home`` / ``gid`` は ``None``。""" + + context: Optional[str] + source: str + remote: bool + home: Optional[str] + gid: Optional[int] + + +Applicable = Union[ContextChoice, DockerTarget] +Runner = Callable[..., subprocess.CompletedProcess] + + +# --------------------------------------------------------------------------- +# 1. 解決 +# --------------------------------------------------------------------------- + +def choose_context(settings: DockerSettings, cli_context: Optional[str] = None, + environ: Optional[MutableMapping[str, str]] = None) -> ContextChoice: + """CLI ``--context`` > env ``DEVBASE_DOCKER_CONTEXT`` > ``docker.context`` > 未指定。 + + env の空文字 (空白のみを含む) は「未指定」として扱い、ファイルの値へ落ちる。 + """ + env = os.environ if environ is None else environ + if cli_context is not None and cli_context.strip(): + return ContextChoice(cli_context.strip(), "cli") + from_env = (env.get(DEVBASE_DOCKER_CONTEXT) or "").strip() + if from_env: + return ContextChoice(from_env, "env") + if settings.context: + return ContextChoice(settings.context, "file") + return ContextChoice(None, "default") + + +# --------------------------------------------------------------------------- +# 2. 確定 +# --------------------------------------------------------------------------- + +def current_context(environ: Optional[MutableMapping[str, str]] = None, + runner: Optional[Runner] = None) -> Optional[str]: + """``docker context show`` で現在の context を取る。取れなければ ``None``。 + + ``DOCKER_CONTEXT`` と ``DOCKER_HOST`` を外した環境で実行する (モジュール docstring)。 + """ + env = dict(os.environ if environ is None else environ) + env.pop(DOCKER_CONTEXT, None) + env.pop(DOCKER_HOST, None) + run = runner or subprocess.run + try: + proc = run(["docker", "context", "show"], capture_output=True, text=True, + timeout=10, env=env) + except Exception as e: # noqa: BLE001 - docker 不在等はリモート扱いへ倒す + logger.debug("docker context show を実行できません: %s", e) + return None + if getattr(proc, "returncode", 1) != 0: + return None + return (proc.stdout or "").strip() or None + + +def effective_context(environ: Optional[MutableMapping[str, str]] = None, + runner: Optional[Runner] = None) -> Optional[str]: + """docker が**実際に使う** context 名を ``docker context show`` で取る。 + + :func:`current_context` と違い、環境変数を外さない。``DOCKER_CONTEXT`` が設定されて + いればその名前が、``DOCKER_HOST`` があれば ``default`` が返る。エディタの attach 先の + 推測など「docker と同じ答え」が要る場面に使う。取れなければ ``None``。 + """ + run = runner or subprocess.run + env = dict(os.environ if environ is None else environ) + try: + proc = run(["docker", "context", "show"], capture_output=True, text=True, + timeout=10, env=env) + except Exception as e: # noqa: BLE001 - docker 不在等は best-effort + logger.debug("docker context show を実行できません: %s", e) + return None + if getattr(proc, "returncode", 1) != 0: + return None + return (proc.stdout or "").strip() or None + + +def resolve_target(choice: ContextChoice, settings: DockerSettings, + environ: Optional[MutableMapping[str, str]] = None, + runner: Optional[Runner] = None) -> DockerTarget: + """解決した context を現在の context と比べ、リモート扱いかを決める。 + + - 未指定 (``None``) は docker を呼ばずローカル扱い + - 現在の context と同じならローカル扱い。取得できなければリモート扱い (決定 3) + - リモート扱いでも、CLI / env でファイルと**別の名前**へ向けたときはファイルの + ``home`` / ``gid`` を使わない (前提 4・決定 4) + """ + if choice.context is None: + return DockerTarget(None, choice.source, False, None, None) + + current = current_context(environ, runner) + remote = current is None or current != choice.context + if not remote: + return DockerTarget(choice.context, choice.source, False, None, None) + + home, gid = settings.home, settings.gid + if choice.source in ("cli", "env") and settings.context and settings.context != choice.context: + if home is not None or gid is not None: + logger.warning( + "docker context を %s で '%s' に上書きしたため、project.local.yml の " + "docker.home / docker.gid ('%s' 向けの値) は使いません。", + choice.source, choice.context, settings.context) + home, gid = None, None + return DockerTarget(choice.context, choice.source, True, home, gid) + + +# --------------------------------------------------------------------------- +# 3. 反映 +# --------------------------------------------------------------------------- + +#: いま有効な接続先と、最初の適用時に控えた元の値。lifecycle 操作の単位で生き、 +#: :func:`reset` が捨てる。TUI は 1 プロセスで操作を続けるため、前の操作の接続先を +#: 次へ持ち越さないためにある。 +_active: Optional[Applicable] = None +_originals: Optional[Dict[str, Optional[str]]] = None + + +def apply(target: Applicable, environ: Optional[MutableMapping[str, str]] = None, + *, track: bool = True) -> None: + """接続先を環境へ反映する。冪等。 + + - context が ``None`` なら何も触らない (従来どおり CLI に委ねる) + - ``DOCKER_CONTEXT`` を載せ、``DOCKER_HOST`` があれば警告して取り除く + - :class:`DockerTarget` でリモート扱いかつ gid が決まっていれば ``DOCKER_GID`` も載せる + + ``track=False`` は子プロセス用の辞書へ当てるだけで、モジュールの控えを持たない + (``env exec``)。控えを持つと、その後の :func:`reset` が別の辞書の元の値を + ``os.environ`` へ書き戻す。 + """ + global _active, _originals + env = os.environ if environ is None else environ + if target.context is None: + return + if track: + if _originals is None: + _originals = {name: env.get(name) for name in _PROTECTED} + _active = target + + env[DOCKER_CONTEXT] = target.context + if DOCKER_HOST in env: + logger.warning( + "DOCKER_HOST (%s) が設定されていますが、docker は DOCKER_HOST を DOCKER_CONTEXT " + "より優先するため、context '%s' を使う間は取り除きます。", + env[DOCKER_HOST], target.context) + del env[DOCKER_HOST] + if isinstance(target, DockerTarget) and target.remote and target.gid is not None: + env[DOCKER_GID] = str(target.gid) + + +def reapply(environ: Optional[MutableMapping[str, str]] = None) -> None: + """控えた接続先があれば :func:`apply` を呼び直す (機密注入の後に使う)。""" + if _active is not None: + env = os.environ if environ is None else environ + # DOCKER_HOST の警告は最初の適用で出しているので、再適用では黙って外す + env.pop(DOCKER_HOST, None) + apply(_active, env) + + +def reset(environ: Optional[MutableMapping[str, str]] = None) -> None: + """控えた接続先を捨て、3 変数を最初の適用時の値へ戻す。控えが無ければ何もしない。""" + global _active, _originals + if _originals is None: + _active = None + return + env = os.environ if environ is None else environ + for name, value in _originals.items(): + if value is None: + env.pop(name, None) + else: + env[name] = value + _active = None + _originals = None + + +def active_target() -> Optional[Applicable]: + return _active + + +# --------------------------------------------------------------------------- +# gid +# --------------------------------------------------------------------------- + +def ensure_remote_gid(target: DockerTarget, cache_dir: Path, + environ: Optional[MutableMapping[str, str]] = None, + runner: Optional[Runner] = None) -> int: + """リモート側の docker グループ gid を決める (決定 5)。 + + ``docker.gid`` 明示があればそれ。無ければ ``/docker-gid/`` の + 控えを読み、無ければ ``DOCKER_CONTEXT`` 付きの ``docker run`` で docker.sock の gid を + 取って控える。 + + Raises: + DevbaseError: docker が非ゼロ、または出力が整数でない。 + """ + if target.gid is not None: + return target.gid + + cache_file = Path(cache_dir) / GID_CACHE_SUBDIR / str(target.context) + cached = _read_cached_gid(cache_file) + if cached is not None: + return cached + + gid = _probe_remote_gid(target, environ, runner) + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(f"{gid}\n", encoding="utf-8") + logger.info("context '%s' の docker gid を取得しました: %d (控え: %s)", + target.context, gid, cache_file) + return gid + + +def _probe_remote_gid(target: DockerTarget, + environ: Optional[MutableMapping[str, str]] = None, + runner: Optional[Runner] = None) -> int: + """``DOCKER_CONTEXT`` 付きの ``docker run`` で docker.sock の gid を取る (決定 5)。 + + Docker 用の環境を組み、subprocess を実行し、失敗を DevbaseError へ変換し、標準 + 出力を整数化する。gid が 0 のときは socket が root 所有 / rootless の可能性を + 警告する (値はそのまま返す)。 + + Raises: + DevbaseError: docker が非ゼロ、実行例外、または出力が整数でない。 + """ + env = dict(os.environ if environ is None else environ) + env[DOCKER_CONTEXT] = str(target.context) + env.pop(DOCKER_HOST, None) + cmd = ["docker", "run", "--rm", "-v", "/var/run/docker.sock:/s", + GID_PROBE_IMAGE, "stat", "-c", "%g", "/s"] + run = runner or subprocess.run + try: + proc = run(cmd, capture_output=True, text=True, timeout=120, env=env) + except Exception as e: # noqa: BLE001 - docker 不在・タイムアウトも同じ案内へ + raise DevbaseError(_gid_failure_message(target, str(e))) from e + if proc.returncode != 0: + raise DevbaseError(_gid_failure_message(target, (proc.stderr or "").strip())) + out = (proc.stdout or "").strip() + try: + gid = int(out) + except ValueError: + raise DevbaseError(_gid_failure_message(target, f"出力が整数ではありません: {out!r}")) + if gid == 0: + logger.warning( + "context '%s' の docker.sock の gid は 0 でした。socket が root 所有か rootless " + "Docker の可能性があります。コンテナから docker を使えない場合は " + "project.local.yml の docker.gid を明示してください。", target.context) + return gid + + +def _read_cached_gid(cache_file: Path) -> Optional[int]: + try: + return int(cache_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None + + +def _gid_failure_message(target: DockerTarget, detail: str) -> str: + return ( + f"context '{target.context}' の docker グループ gid を取得できません: {detail}\n" + f" リモート側の gid を確かめて project.local.yml の docker.gid に書いてください:\n" + f" docker:\n context: {target.context}\n gid: " + ) + + +__all__ = [ + "DEVBASE_DOCKER_CONTEXT", + "DOCKER_CONTEXT", + "DOCKER_GID", + "DOCKER_HOST", + "GID_PROBE_IMAGE", + "ContextChoice", + "DockerTarget", + "active_target", + "apply", + "choose_context", + "current_context", + "effective_context", + "ensure_remote_gid", + "reapply", + "reset", + "resolve_target", +] diff --git a/lib/devbase/volume/bind_mounts.py b/lib/devbase/volume/bind_mounts.py new file mode 100644 index 00000000..5ac0b451 --- /dev/null +++ b/lib/devbase/volume/bind_mounts.py @@ -0,0 +1,111 @@ +"""生成物の bind mount の ``~`` をリモート側の HOME で展開する (PLAN52)。 + +compose クライアントは bind mount の ``~`` を**手元の** HOME へ展開してから daemon へ +渡す。daemon が別ホストにあると、そのパスはリモートに無く、Linux の daemon は存在しない +パスを空ディレクトリとして作ってしまう (黙って空になる)。起動時に ``-f`` で渡すのは +生成物 ``.docker-compose.scale.yml`` だけなので、生成の段階で絶対パスへ書き換えれば +展開は起きず、そのままリモートへ渡る (決定 7)。 + +書き換えるのは ``~`` と ``~/...`` だけである。``~user/...`` は別のユーザの HOME を指し、 +``./`` / ``../`` の相対パスは compose が手元の絶対パスへ解決するもので、どちらも +devbase が「正しい値」を推測できないため、黙って書き換えるより一覧で示す (決定 8)。 +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + + +def expand_home(services: Dict[str, Any], home: str) -> List[str]: + """各サービスの bind mount の ``~`` を ``home`` に置き換える。 + + Returns: + 置き換えられなかった mount (``~user`` / 相対パス) の一覧 (``": "``)。 + """ + home = home.rstrip('/') or '/' + warnings: List[str] = [] + for name, service in services.items(): + volumes = _volumes(service) + for i, vol in enumerate(volumes): + source, rest = _split(vol) + if source is None: + continue + if source == '~' or source.startswith('~/'): + new_source = home + source[1:] + volumes[i] = _join(vol, new_source, rest) + elif _needs_remote_path(source): + warnings.append(f"{name}: {_display(vol)}") + return warnings + + +def collect_remote_warnings(services: Dict[str, Any]) -> List[str]: + """``home`` が無いリモート扱いで、リモートに存在しないパスを指す mount を集める。 + + 書き換えは行わない。``~`` 系と相対パスの両方を載せる。 + """ + warnings: List[str] = [] + for name, service in services.items(): + for vol in _volumes(service): + source, _ = _split(vol) + if source is None: + continue + if source == '~' or source.startswith('~') or _needs_remote_path(source): + warnings.append(f"{name}: {_display(vol)}") + return warnings + + +def _volumes(service: Any) -> list: + if not isinstance(service, dict): + return [] + volumes = service.get('volumes') + return volumes if isinstance(volumes, list) else [] + + +def _split(vol: Any) -> Tuple[Optional[str], Any]: + """bind mount なら (source, 残り) を返す。named volume や不明な形は (None, None)。""" + if isinstance(vol, str): + parts = vol.split(':') + if len(parts) < 2: + return None, None + source = parts[0] + if not _looks_like_path(source): + return None, None + return source, parts[1:] + if isinstance(vol, dict): + source = vol.get('source') + if not isinstance(source, str) or not source: + return None, None + # 長い書式で type: bind と明示されていれば、`data` のような素の相対パスも + # bind mount (compose がファイル基準の絶対パスへ解決する)。type が無いときは + # 短い書式と同じ規則で判定する。 + if vol.get('type') == 'bind' or (vol.get('type') is None and _looks_like_path(source)): + return source, None + return None, None + + +def _join(vol: Any, new_source: str, rest: Any) -> Any: + if isinstance(vol, str): + return ':'.join([new_source, *rest]) + vol['source'] = new_source + return vol + + +def _looks_like_path(source: str) -> bool: + """compose が bind mount の source と解釈する形 (``/`` ``.`` ``~`` 始まり)。""" + return source.startswith(('/', '.', '~')) + + +def _needs_remote_path(source: str) -> bool: + """手元でしか解決できないパス: ``~user/...`` と相対パス (``/`` でも ``~`` でも始まらない)。""" + if source.startswith('~'): + return source != '~' and not source.startswith('~/') + return not source.startswith('/') + + +def _display(vol: Any) -> str: + if isinstance(vol, str): + return vol + return f"{vol.get('source')}:{vol.get('target')}" + + +__all__ = ["collect_remote_warnings", "expand_home"] diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index f0dcc999..ee30ad77 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -5,13 +5,14 @@ import yaml from pathlib import Path from typing import ( - Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, + Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Set, ) from devbase.env import compose_migrate, gcp_auth, keys from devbase.errors import DockerError from devbase.log import get_logger +from . import bind_mounts from .manager import ( get_ai_volume_for_index, get_group_volume, @@ -190,6 +191,37 @@ def _load_compose_config(compose_file: Path) -> dict: raise DockerError(f"Failed to parse compose file: {e}") +def _env_shape(existing: Any) -> str: + """environment の表現形式 ('none' / 'dict' / 'list' / 'other') を判定する。""" + if existing is None: + return 'none' + if isinstance(existing, dict): + return 'dict' + if isinstance(existing, list): + return 'list' + return 'other' + + +def _env_item_name(item: Any) -> Optional[str]: + """list 形式の environment 項目からキー名を取り出す (非文字列なら None)。""" + if isinstance(item, str): + return item.split('=', 1)[0].strip() + return None + + +def _iter_env_names(existing: Any) -> Iterator[str]: + """environment (dict または list 形式) に定義されているキー名を列挙する。""" + shape = _env_shape(existing) + if shape == 'dict': + for name in existing: + yield str(name) + elif shape == 'list': + for item in existing: + name = _env_item_name(item) + if name is not None: + yield name + + def _mask_secret_environment( service: dict, secret_env_names: Sequence[str], ) -> None: @@ -209,14 +241,15 @@ def _mask_secret_environment( secrets = list(dict.fromkeys(secret_env_names)) secret_set = set(secrets) existing = service.get('environment') + shape = _env_shape(existing) - if existing is None: + if shape == 'none': # 元から environment が無ければ、機密が無い限り作らない if secrets: service['environment'] = list(secrets) return - if isinstance(existing, dict): + if shape == 'dict': masked = { key: (None if key in secret_set else value) for key, value in existing.items() @@ -226,14 +259,14 @@ def _mask_secret_environment( service['environment'] = masked return - if isinstance(existing, list): + if shape == 'list': masked_list = [] listed = set() for item in existing: - if not isinstance(item, str): + name = _env_item_name(item) + if name is None: masked_list.append(item) continue - name = item.split('=', 1)[0].strip() listed.add(name) # 機密キーは `KEY=value` でも `KEY` でも、値なし参照に揃える masked_list.append(name if name in secret_set else item) @@ -257,12 +290,7 @@ def _service_env_names(service: dict) -> List[str]: 含めるために要る。env や機密の列挙だけを見ていると、直書きされた別 プロファイルの鍵を外し損ねる (issue #134)。 """ - existing = service.get('environment') - if isinstance(existing, dict): - return [str(name) for name in existing] - if isinstance(existing, list): - return [str(item).split('=', 1)[0].strip() for item in existing] - return [] + return list(_iter_env_names(service.get('environment'))) def _drop_env_names(service: dict, names: Iterable[str]) -> None: @@ -283,14 +311,14 @@ def _drop_env_names(service: dict, names: Iterable[str]) -> None: if not drop: return existing = service.get('environment') + shape = _env_shape(existing) - if isinstance(existing, dict): + if shape == 'dict': kept = {k: v for k, v in existing.items() if k not in drop} - elif isinstance(existing, list): + elif shape == 'list': kept = [ item for item in existing - if not (isinstance(item, str) - and item.split('=', 1)[0].strip() in drop) + if _env_item_name(item) not in drop ] else: # None や解釈できない形式には触らない (警告は mask 側で出している) @@ -377,17 +405,20 @@ def _apply_dev_environment(service: dict, extra: Mapping[str, str]) -> None: return existing = service.get('environment') - if isinstance(existing, dict): + shape = _env_shape(existing) + + if shape == 'dict': existing.update(extra) return - if isinstance(existing, list): + if shape == 'list': names = set(extra) - kept = [entry for entry in existing - if not (isinstance(entry, str) - and entry.split('=', 1)[0] in names)] + kept = [ + entry for entry in existing + if _env_item_name(entry) not in names + ] service['environment'] = kept + [f"{k}={v}" for k, v in extra.items()] return - if existing is None: + if shape == 'none': service['environment'] = dict(extra) return @@ -582,6 +613,34 @@ def _services_receiving_secrets( return receivers +def _prepare_remote_mounts( + scaled_services: dict, docker_home: Optional[str], remote: bool, +) -> None: + """リモート扱いでの bind mount の ~ 展開と警告を行う (PLAN52 決定 7・8)。 + + ``docker_home`` があれば全サービスの bind mount の ~ をリモート側の HOME で + 展開する。生成物だけが -f で渡るので、ここで絶対パスにしておけば compose の + 手元 HOME への展開は起きない。展開できない mount (~user / 相対パス) は一覧で + 警告する。``docker_home`` が無くリモート扱いのときは、手元のパスを指す mount + を警告する (docker.home の指定を促す)。``scaled_services`` を破壊的に更新する。 + """ + if docker_home: + unresolved = bind_mounts.expand_home(scaled_services, docker_home) + if unresolved: + logger.warning( + "次の bind mount はリモートには無いパスを指すため書き換えていません " + "(docker.home では代替できない ~user / 相対パス):\n %s", + "\n ".join(unresolved)) + elif remote: + unresolved = bind_mounts.collect_remote_warnings(scaled_services) + if unresolved: + logger.warning( + "リモートの docker context で起動しますが、次の bind mount は手元のパスを" + "指しています (リモートでは空ディレクトリになります)。~ を展開するには " + "project.local.yml に docker.home を書いてください:\n %s", + "\n ".join(unresolved)) + + def generate_scaled_compose( scale: int, compose_file: Path = None, @@ -590,6 +649,8 @@ def generate_scaled_compose( global_env_names: Optional[Sequence[str]] = None, project_env_names: Optional[Sequence[str]] = None, dev_environment: Optional[Mapping[str, str]] = None, + docker_home: Optional[str] = None, + remote: bool = False, ) -> Path: """ Generate scaled docker-compose file with per-instance volumes @@ -603,6 +664,11 @@ def generate_scaled_compose( project_env_names: そのうちプロジェクト機密由来のキー dev_environment: dev サービスへ載せる devbase 由来の環境変数 (PLAN32 の clone プラン ``DEVBASE_REPOS`` 等。機密ではない) + docker_home: リモート側の HOME (PLAN52)。与えると全サービスの bind mount の + ``~`` をこの値で展開する。ローカル扱いでは ``None`` のまま (compose の + 展開に委ねる) + remote: リモート扱いか。``docker_home`` が無いときに、リモートに存在しない + パスを指す mount を警告する 非 dev サービスへは、そのサービスが元々 ``env_file`` で参照していた由来の キーだけを列挙する。由来の内訳が渡されない場合 (両方 ``None``) は全キーを @@ -709,6 +775,8 @@ def generate_scaled_compose( if isinstance(service, dict): _drop_env_names(service, dev_excluded) + _prepare_remote_mounts(scaled_services, docker_home, remote) + scaled_config = { 'services': scaled_services, 'volumes': _build_volumes_section( diff --git a/tests/cli/test_wrapper_build_context.py b/tests/cli/test_wrapper_build_context.py new file mode 100644 index 00000000..e4d50451 --- /dev/null +++ b/tests/cli/test_wrapper_build_context.py @@ -0,0 +1,117 @@ +"""shell の `devbase build --context NAME` が context を引数のまま Python へ渡す (PLAN52 Task 5)。 + +wrapper テストは実際の `uv run` を避けるため、`uv` と `cmd_build` / `run_python` を +シェル関数で差し替えて dispatch と `compose_with_secrets` だけを実行する。 +""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / 'bin' / 'devbase' + + +def _run_wrapper(args, devbase_root, extra_env=None): + """run_python / cmd_build を出力するだけの関数に差し替え、`uv` も関数で受ける。 + + `compose_with_secrets` は**実物のまま**残す (context を引数で渡す当事者のため)。 + """ + harness = ( + 'run_python() { echo "PYTHON:$*"; exit 0; }\n' + 'cmd_build() { echo "BUILD:$*"; echo "CTX:$_BUILD_CONTEXT"; ' + ' compose_with_secrets docker image inspect x; exit 0; }\n' + 'ensure_uv() { :; }\n' + 'uv() { echo "UV:$*"; }\n' + 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' + ' -e \'/^ensure_uv()/,/^}/d\' ' + ' -e \'/^cmd_build()/,/^}/d\' ' + ' -e \'/^DEVBASE_ROOT=/d\' "$WRAPPER_PATH")"\n' + ) + env = {**os.environ, "DEVBASE_ROOT": str(devbase_root), "WRAPPER_PATH": str(WRAPPER), + **(extra_env or {})} + return subprocess.run(["bash", "-c", harness, "devbase", *args], + capture_output=True, text=True, env=env, cwd=str(devbase_root)) + + +def _line(result, prefix): + for line in result.stdout.splitlines(): + if line.startswith(prefix): + return line[len(prefix):] + return None + + +@pytest.fixture +def wrapper_root(tmp_path): + (tmp_path / "containers" / "base").mkdir(parents=True) + (tmp_path / "projects").mkdir() + return tmp_path + + +def test_context_is_extracted_before_image_scan(wrapper_root): + """`build --context NAME` の NAME を単体イメージ名として拾わない。""" + result = _run_wrapper(["build", "--context", "gpu-wsl"], wrapper_root) + assert _line(result, "PYTHON:") is None + assert _line(result, "BUILD:") == "" + assert _line(result, "CTX:") == "gpu-wsl" + + +def test_context_equals_form(wrapper_root): + result = _run_wrapper(["build", "--context=gpu-wsl", "--no-cache"], wrapper_root) + assert _line(result, "BUILD:") == "--no-cache" + assert _line(result, "CTX:") == "gpu-wsl" + + +def test_context_reaches_env_exec_as_argument(wrapper_root): + result = _run_wrapper(["build", "--context", "gpu-wsl"], wrapper_root) + uv = _line(result, "UV:") + assert uv is not None + assert re.search(r"env exec --context gpu-wsl -- docker image inspect x$", uv), uv + + +def test_context_argument_beats_env_file(wrapper_root): + """env に DEVBASE_DOCKER_CONTEXT=a があっても --context b が引数として届く。""" + (wrapper_root / "env").write_text("DEVBASE_DOCKER_CONTEXT=a\n") + result = _run_wrapper(["build", "--context", "b"], wrapper_root) + assert "env exec --context b --" in (_line(result, "UV:") or "") + + +def test_without_context_env_exec_has_no_flag(wrapper_root): + result = _run_wrapper(["build"], wrapper_root) + assert _line(result, "CTX:") == "" + assert "env exec -- docker image inspect x" in (_line(result, "UV:") or "") + + +def test_context_is_forwarded_to_python_single_build(wrapper_root): + result = _run_wrapper(["build", "base", "--context", "gpu-wsl"], wrapper_root) + assert _line(result, "PYTHON:") == "project build base --context gpu-wsl" + + +def test_missing_context_value_is_an_error(wrapper_root): + result = _run_wrapper(["build", "--context"], wrapper_root) + assert result.returncode == 2 + assert "--context" in result.stderr + + +def test_shell_docker_calls_go_through_env_exec(): + """cmd_build の docker 直接呼び出し (buildx build / image inspect) が残っていない。""" + lines = [line for line in WRAPPER.read_text(encoding='utf-8').splitlines() + if line.strip() and not line.lstrip().startswith('#')] + direct = [line for line in lines + if re.search(r'\bdocker (buildx build|image inspect)\b', line) + and 'compose_with_secrets' not in line] + assert direct == [], direct + + +@pytest.mark.parametrize("args", [["build", "--context", ""], ["build", "--context="], + ["build", "--context", " "]]) +def test_empty_context_value_is_an_error(wrapper_root, args): + result = _run_wrapper(args, wrapper_root) + assert result.returncode == 2 + assert "--context" in result.stderr + assert _line(result, "BUILD:") is None diff --git a/tests/commands/test_container_context.py b/tests/commands/test_container_context.py new file mode 100644 index 00000000..dbd54c19 --- /dev/null +++ b/tests/commands/test_container_context.py @@ -0,0 +1,418 @@ +"""lifecycle コマンドが docker context を子プロセスへ届けること (PLAN52)。 + +``docker`` / ``docker compose`` は ``os.environ`` を継承して起動されるため、各偽関数は +呼ばれた時点の ``os.environ`` の値を記録する。 +""" + +from __future__ import annotations + +import os +import subprocess +import types +from pathlib import Path + +import pytest + +from devbase.commands import container +from devbase.env import runtime as secret_runtime +from devbase.errors import DevbaseError +from devbase.utils import docker_context as dc + +PROJECT_YML = "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + + +def _proc(stdout="", returncode=0, stderr=""): + return subprocess.CompletedProcess(args=[], returncode=returncode, + stdout=stdout, stderr=stderr) + + +def _snapshot(): + return {k: os.environ.get(k) for k in ('DOCKER_CONTEXT', 'DOCKER_GID', 'DOCKER_HOST')} + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT', 'DEVBASE_ROOT'): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv('DOCKER_GID', '0') + dc.reset() + # _resolve_project_name / _load_project_env は os.environ を直接書くので、 + # テストごとに丸ごと戻す + saved = dict(os.environ) + yield + dc.reset() + os.environ.clear() + os.environ.update(saved) + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / 'project.yml').write_text(PROJECT_YML) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + return tmp_path + + +@pytest.fixture +def docker_calls(monkeypatch): + """docker への呼び出し (context show / run alpine) を記録し、現在の context を返す。""" + calls = [] + + def runner(cmd, **kw): + calls.append((list(cmd), dict(kw.get('env') or {}), _snapshot())) + if cmd[:3] == ['docker', 'context', 'show']: + return _proc('desktop-linux\n') + if cmd[:2] == ['docker', 'run']: + return _proc('999\n') + return _proc() + + monkeypatch.setattr(dc.subprocess, 'run', runner) + return calls + + +@pytest.fixture +def up_harness(project, monkeypatch): + """cmd_up の外部作用をスタブ化し、それぞれが見た環境変数を記録する。""" + seen = {} + + def record(name): + def _f(*a, **k): + seen[name] = _snapshot() + return _f + + monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + monkeypatch.setattr(container, '_ensure_env_files', lambda: True) + monkeypatch.setattr(container, '_run_pre_up_hook', lambda config=None: True) + monkeypatch.setattr(container, '_ensure_images', lambda: True) + seen['_real_snapshot'] = container._auto_snapshot + monkeypatch.setattr(container, '_auto_snapshot', record('snapshot')) + monkeypatch.setattr(container, 'ensure_volumes', record('volumes')) + monkeypatch.setattr(container, 'ensure_network', record('network')) + monkeypatch.setattr(container, 'docker_compose_down', record('down')) + monkeypatch.setattr(container, 'docker_compose_up', record('up')) + monkeypatch.setattr(container, 'wait_for_containers_ready', record('wait')) + monkeypatch.setattr(container, '_apply_window_titles', record('titles')) + monkeypatch.setattr(container, '_report_missing_repos', lambda *a, **k: None) + monkeypatch.setattr(container, '_maybe_open_editor', + lambda *a, **k: seen.__setitem__('editor', k)) + seen['_real_inject'] = container._inject_secrets + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: secret_runtime.SecretEnv()) + + def fake_generate(scale, secrets, dev_environment=None, **kw): + seen['generate'] = {**_snapshot(), 'kwargs': kw} + container._SCALE_COMPOSE_FILE.write_text("services:\n dev-1: {}\n") + return container._SCALE_COMPOSE_FILE + + monkeypatch.setattr(container, '_generate_compose_for', fake_generate) + return seen + + +# --------------------------------------------------------------------------- +# up +# --------------------------------------------------------------------------- + +def test_up_without_config_does_not_touch_env_or_probe(up_harness, docker_calls): + assert container.cmd_up() == 0 + for step in ('volumes', 'network', 'down', 'up', 'generate', 'snapshot'): + assert up_harness[step]['DOCKER_CONTEXT'] is None + assert up_harness[step]['DOCKER_GID'] == '0' + assert docker_calls == [] + assert up_harness['generate']['kwargs'] == {} + assert up_harness['editor']['docker_context_name'] is None + + +def test_up_with_local_yml_propagates_context_and_gid(up_harness, docker_calls, project): + (project / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n gid: 42\n") + assert container.cmd_up() == 0 + for step in ('volumes', 'network', 'down', 'up', 'generate'): + assert up_harness[step]['DOCKER_CONTEXT'] == 'gpu-wsl' + assert up_harness[step]['DOCKER_GID'] == '42' + # docker context show は 1 回だけ、DOCKER_CONTEXT 抜きの環境で + shows = [c for c in docker_calls if c[0][:3] == ['docker', 'context', 'show']] + assert len(shows) == 1 and 'DOCKER_CONTEXT' not in shows[0][1] + assert not any(c[0][:2] == ['docker', 'run'] for c in docker_calls) + assert up_harness['editor']['docker_context_name'] == 'gpu-wsl' + + +def test_up_fetches_gid_once_when_not_configured(up_harness, docker_calls, project): + (project / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n") + assert container.cmd_up() == 0 + assert up_harness['up']['DOCKER_GID'] == '999' + runs = [c for c in docker_calls if c[0][:2] == ['docker', 'run']] + assert len(runs) == 1 and runs[0][1]['DOCKER_CONTEXT'] == 'gpu-wsl' + assert (project / '.cache' / 'docker-gid' / 'gpu-wsl').read_text().strip() == '999' + + docker_calls.clear() + assert container.cmd_up() == 0 + assert not any(c[0][:2] == ['docker', 'run'] for c in docker_calls) + + +def test_up_same_as_current_context_is_local(up_harness, docker_calls, project): + (project / 'project.local.yml').write_text( + "docker:\n context: desktop-linux\n gid: 42\n home: /home/x\n") + assert container.cmd_up() == 0 + assert up_harness['up']['DOCKER_CONTEXT'] == 'desktop-linux' + assert up_harness['up']['DOCKER_GID'] == '0' # bin/devbase の値のまま + assert up_harness['generate']['kwargs'] == {} # home は渡さない + assert len(docker_calls) == 1 # context show のみ + assert 'snapshot' in up_harness # 自動スナップショットは走る + + +def test_up_remote_skips_auto_snapshot_and_passes_home(up_harness, docker_calls, project, + monkeypatch, caplog): + (project / 'project.local.yml').write_text( + "docker:\n context: gpu-wsl\n gid: 42\n home: /home/takemi\n") + # 実物の _auto_snapshot に戻す。リモート扱いなら SnapshotManager に触る前に抜ける + monkeypatch.setattr(container, '_auto_snapshot', up_harness['_real_snapshot']) + from devbase.snapshot import manager as snapshot_manager + monkeypatch.setattr(snapshot_manager, 'SnapshotManager', + lambda *a, **k: pytest.fail('リモート扱いでスナップショットに触った')) + with caplog.at_level('WARNING'): + assert container.cmd_up() == 0 + assert 'スナップショット' in caplog.text + assert up_harness['generate']['kwargs'] == {'docker_home': '/home/takemi', 'remote': True} + + +def test_up_gid_probe_failure_stops_before_touching_containers(up_harness, docker_calls, + project, monkeypatch, caplog): + (project / 'project.local.yml').write_text("docker:\n context: nope\n") + + def runner(cmd, **kw): + if cmd[:3] == ['docker', 'context', 'show']: + return _proc('desktop-linux\n') + return _proc('', returncode=1, stderr='context "nope" does not exist') + + monkeypatch.setattr(dc.subprocess, 'run', runner) + with caplog.at_level('ERROR'): + assert container.cmd_up() == 1 + assert 'does not exist' in caplog.text and 'docker.gid' in caplog.text + assert 'down' not in up_harness and 'up' not in up_harness + + +def test_up_cli_context_beats_env_and_file(up_harness, docker_calls, project, monkeypatch): + (project / 'project.local.yml').write_text("docker:\n context: a\n gid: 1\n") + monkeypatch.setenv('DEVBASE_DOCKER_CONTEXT', 'b') + assert container.cmd_up(context='c') == 0 + assert up_harness['up']['DOCKER_CONTEXT'] == 'c' + assert up_harness['up']['DOCKER_GID'] == '999' # ファイルの gid は使わない + + +def test_up_env_context_empty_falls_back_to_file(up_harness, docker_calls, project, monkeypatch): + (project / 'project.local.yml').write_text("docker:\n context: a\n gid: 1\n") + monkeypatch.setenv('DEVBASE_DOCKER_CONTEXT', '') + assert container.cmd_up() == 0 + assert up_harness['up']['DOCKER_CONTEXT'] == 'a' + + +def test_up_removes_docker_host_when_context_resolved(up_harness, docker_calls, project, + monkeypatch, caplog): + (project / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n gid: 1\n") + monkeypatch.setenv('DOCKER_HOST', 'tcp://127.0.0.1:1') + with caplog.at_level('WARNING'): + assert container.cmd_up() == 0 + assert up_harness['up']['DOCKER_HOST'] is None + assert 'DOCKER_HOST' in caplog.text + + +def test_up_keeps_docker_host_without_context(up_harness, docker_calls, project, monkeypatch): + monkeypatch.setenv('DOCKER_HOST', 'tcp://127.0.0.1:1') + assert container.cmd_up() == 0 + assert up_harness['up']['DOCKER_HOST'] == 'tcp://127.0.0.1:1' + + +def test_up_reapplies_after_secret_injection(up_harness, docker_calls, project, monkeypatch): + """機密ストアに同名キーがあっても、注入の後に確定済みの接続先へ戻る。""" + (project / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n gid: 42\n") + + def fake_inject(root, project_name): + os.environ.update({'DOCKER_CONTEXT': 'x', 'DOCKER_GID': '1', 'DOCKER_HOST': 'tcp://x'}) + return secret_runtime.SecretEnv() + + monkeypatch.setattr(secret_runtime, 'inject', fake_inject) + monkeypatch.setattr(secret_runtime, 'clear_injected', lambda: None) + # up_harness が差し替えた _inject_secrets を実物へ戻す (再適用は実物の中にある) + monkeypatch.setattr(container, '_inject_secrets', up_harness['_real_inject']) + + assert container.cmd_up() == 0 + assert up_harness['down']['DOCKER_CONTEXT'] == 'gpu-wsl' + assert up_harness['up']['DOCKER_GID'] == '42' + assert up_harness['up']['DOCKER_HOST'] is None + + +# --------------------------------------------------------------------------- +# scale +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("context", [None, 'gpu-wsl']) +def test_scale_propagates_remote_settings(up_harness, docker_calls, project, context): + """現状固定: scale もリモート接続先と home を構成生成へ届ける。""" + (project / 'project.local.yml').write_text( + "docker:\n context: gpu-wsl\n home: /home/remote\n gid: 42\n") + + assert container.cmd_scale(2, context=context) == 0 + + for step in ('volumes', 'network', 'generate', 'wait'): + assert up_harness[step]['DOCKER_CONTEXT'] == 'gpu-wsl' + assert up_harness[step]['DOCKER_GID'] == '42' + assert up_harness['generate']['kwargs'] == {'docker_home': '/home/remote', 'remote': True} + compose_envs = [env for cmd, _, env in docker_calls if cmd[:2] == ['docker', 'compose']] + assert compose_envs + for env in compose_envs: + assert env['DOCKER_CONTEXT'] == 'gpu-wsl' + assert env['DOCKER_GID'] == '42' + + +# --------------------------------------------------------------------------- +# down / ps / logs / login / build +# --------------------------------------------------------------------------- + +@pytest.fixture +def compose_seen(project, monkeypatch): + """down / ps / logs / login が起動する docker の呼び出しと、その時点の環境を記録する。""" + seen = [] + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: secret_runtime.SecretEnv()) + monkeypatch.setattr(container, 'docker_compose_down', + lambda **k: seen.append((['docker', 'compose', 'down'], _snapshot()))) + monkeypatch.setattr(subprocess, 'run', + lambda cmd, **k: seen.append((list(cmd), _snapshot())) or _proc()) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + return seen + + +@pytest.mark.parametrize("call", [ + lambda: container.cmd_down(), + lambda: container.cmd_ps(), + lambda: container.cmd_logs(), + lambda: container.cmd_login('1'), +]) +def test_other_commands_propagate_context(compose_seen, project, call): + (project / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n") + call() + assert compose_seen and all(env['DOCKER_CONTEXT'] == 'gpu-wsl' for _, env in compose_seen) + assert not any(cmd[:3] == ['docker', 'context', 'show'] for cmd, _ in compose_seen) + + +def test_other_commands_without_config_leave_env(compose_seen, project): + container.cmd_down() + assert compose_seen and all(env['DOCKER_CONTEXT'] is None for _, env in compose_seen) + + +def test_run_build_passes_context_argument(project, monkeypatch): + seen = [] + (project / 'bin').mkdir() + (project / 'bin' / 'devbase').write_text('') + monkeypatch.setattr(container.subprocess, 'run', lambda cmd, **k: seen.append(cmd) or _proc()) + dc.apply(dc.DockerTarget('gpu-wsl', 'cli', True, None, 42)) + assert container._run_build(no_cache=True) + assert seen[0][2:] == ['build', '--context', 'gpu-wsl', '--no-cache'] + + dc.reset() + seen.clear() + assert container._run_build() + assert seen[0][2:] == ['build'] + + +# --------------------------------------------------------------------------- +# dispatch: 操作の間で漏れない / 切替後に解決する +# --------------------------------------------------------------------------- + +def test_dispatch_resets_between_operations(compose_seen, project, monkeypatch): + """TUI: context 付きの A の操作の後に、設定の無い B の操作が A を引きずらない。""" + (project / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n") + assert container._dispatch_lifecycle(types.SimpleNamespace(subcommand='down')) == 0 + assert compose_seen[-1][1]['DOCKER_CONTEXT'] == 'gpu-wsl' + assert os.environ.get('DOCKER_CONTEXT') is None # finally で reset + + (project / 'project.local.yml').unlink() + assert container._dispatch_lifecycle(types.SimpleNamespace(subcommand='down')) == 0 + assert compose_seen[-1][1]['DOCKER_CONTEXT'] is None + assert os.environ.get('DOCKER_GID') == '0' + + +def test_dispatch_reinjects_secrets_after_project_switch(compose_seen, project, monkeypatch): + """A の .env の DEVBASE_DOCKER_CONTEXT が残ったまま B の context を解決しない。""" + b = project / 'projects' / 'B' + b.mkdir(parents=True) + (b / 'project.yml').write_text(PROJECT_YML) + (b / 'project.local.yml').write_text("docker:\n context: b\n") + os.environ['DEVBASE_DOCKER_CONTEXT'] = 'a' # A の機密として注入されていた値 + order = [] + + def fake_inject(*, required): + order.append('inject') + os.environ.pop('DEVBASE_DOCKER_CONTEXT', None) # clear_injected 相当 + return secret_runtime.SecretEnv() + + monkeypatch.setattr(container, '_inject_secrets', fake_inject) + monkeypatch.setattr(container, '_resolve_project_name', + lambda name: order.append('switch') or os.chdir(b) or True) + monkeypatch.setattr(container, 'docker_compose_down', + lambda **k: order.append(('down', _snapshot()['DOCKER_CONTEXT']))) + + assert container._dispatch_lifecycle(types.SimpleNamespace(subcommand='down', name='B')) == 0 + assert order[:2] == ['switch', 'inject'] + assert ('down', 'b') in order + + +def test_dispatch_passes_cli_context_only_when_given(monkeypatch): + calls = [] + monkeypatch.setattr(container, 'cmd_down', lambda **k: calls.append(k) or 0) + container._dispatch_lifecycle(types.SimpleNamespace(subcommand='down')) + container._dispatch_lifecycle(types.SimpleNamespace(subcommand='down', context='x')) + assert calls == [{}, {'context': 'x'}] + + +# --------------------------------------------------------------------------- +# parser +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("argv", [ + ['up', '--context', 'x'], ['down', '--context', 'x'], ['ps', '--context', 'x'], + ['login', '--context', 'x'], ['scale', '3', '--context', 'x'], ['rebuild', '--context', 'x'], + ['project', 'up', '--context', 'x'], ['project', 'logs', '--context', 'x'], + ['project', 'build', '--context', 'x'], ['container', 'down', '--context', 'x'], + ['env', 'exec', '--context', 'x', '--', 'true'], +]) +def test_parser_accepts_context(argv): + from devbase.cli import _create_parser + args = _create_parser().parse_args(argv) + assert args.context == 'x' + + +def test_parser_rejects_empty_context(): + from devbase.cli import _create_parser + with pytest.raises(SystemExit): + _create_parser().parse_args(['up', '--context', '']) + + +def test_parser_has_no_top_level_logs(): + from devbase.cli import _create_parser + with pytest.raises(SystemExit): + _create_parser().parse_args(['logs']) + + +def test_dispatch_clears_source_secrets_before_loading_target_env(project, monkeypatch): + """A の機密 (DEVBASE_DOCKER_CONTEXT=a) を、B の env が載せた b を消さずに落とす。""" + b = project / 'projects' / 'B' + b.mkdir(parents=True) + (b / 'project.yml').write_text(PROJECT_YML) + (b / 'env').write_text("DEVBASE_DOCKER_CONTEXT=b\n") + # cli.main() 相当: A の機密として注入 (注入前は未設定) + secret_runtime.clear_injected() + monkeypatch.setattr(secret_runtime, 'resolve', + lambda root, project, store=None: types.SimpleNamespace( + values={'DEVBASE_DOCKER_CONTEXT': 'a'}, names=['DEVBASE_DOCKER_CONTEXT'])) + secret_runtime.inject(project, None) + assert os.environ['DEVBASE_DOCKER_CONTEXT'] == 'a' + + # 切替先 B の機密は空。_inject_secrets は実物のまま (clear_injected を通る) + monkeypatch.setattr(secret_runtime, 'resolve', + lambda root, project, store=None: types.SimpleNamespace(values={}, names=[])) + seen = [] + monkeypatch.setattr(container, 'docker_compose_down', + lambda **k: seen.append(_snapshot())) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + assert container._dispatch_lifecycle(types.SimpleNamespace(subcommand='down', name='B')) == 0 + assert seen[-1]['DOCKER_CONTEXT'] == 'b' + secret_runtime.clear_injected() diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py index f5df914b..8b325dbe 100644 --- a/tests/commands/test_container_up_order.py +++ b/tests/commands/test_container_up_order.py @@ -23,10 +23,36 @@ NEW_COMPOSE = "services:\n dev-1: {}\n" +@pytest.mark.parametrize("open_index, expected", [(0, 1), (3, 3), (4, 1)]) +def test_resolve_explicit_open_index_boundaries(open_index, expected): + """現状固定: 範囲外は 1 に戻し、scale と同じ番号は保持する。""" + assert container._resolve_open_index(open_index, scale=3) == expected + + +@pytest.mark.parametrize("env_val, expected", [ + (None, 1), + ("2", 2), + ("abc", 1), +]) +def test_resolve_open_index_env_fallback(monkeypatch, env_val, expected): + """現状固定: open_index=None のとき DEVBASE_OPEN_INDEX の未設定・整数・非整数を解決する。""" + if env_val is None: + monkeypatch.delenv("DEVBASE_OPEN_INDEX", raising=False) + else: + monkeypatch.setenv("DEVBASE_OPEN_INDEX", env_val) + assert container._resolve_open_index(None, scale=5) == expected + + @pytest.fixture def up_harness(tmp_path, monkeypatch): """cmd_up の外部作用をすべてスタブ化し、呼び出し順を記録する。""" monkeypatch.chdir(tmp_path) + # PLAN52: cmd_up は docker context を解決する。外の環境変数や前のテストの残りを + # 拾わないよう、context 関連の env とモジュール状態を空にしてから始める + from devbase.utils import docker_context as dc + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT'): + monkeypatch.delenv(name, raising=False) + dc.reset() # PLAN32: cmd_up は project.yml を唯一の正として読む (tmp_path / 'project.yml').write_text( "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") diff --git a/tests/commands/test_env_exec_context.py b/tests/commands/test_env_exec_context.py new file mode 100644 index 00000000..2bdb26e7 --- /dev/null +++ b/tests/commands/test_env_exec_context.py @@ -0,0 +1,68 @@ +"""`devbase env exec [--context NAME]` が子プロセスへ DOCKER_CONTEXT を載せる (PLAN52 Task 5)。""" + +from __future__ import annotations + +import subprocess + +import pytest + +from devbase.commands import env as env_cmd +from devbase.env import runtime as secret_runtime +from devbase.utils import docker_context as dc + + +@pytest.fixture +def harness(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT'): + monkeypatch.delenv(name, raising=False) + seen = {} + + def fake_child_env(root, project, **kw): + # 機密ストアの値が載った後の辞書 (同名キーを含む) + return {'PATH': '/x', **seen.get('secrets', {})} + + monkeypatch.setattr(secret_runtime, 'child_env', fake_child_env) + monkeypatch.setattr(secret_runtime, 'current_project_name', lambda root: None) + monkeypatch.setattr(env_cmd.subprocess, 'run', + lambda argv, env=None, **k: seen.__setitem__('env', dict(env)) + or subprocess.CompletedProcess(argv, 0)) + seen['root'] = tmp_path + return seen + + +def test_no_config_leaves_env(harness): + assert env_cmd.cmd_env_exec(harness['root'], ['--', 'true']) == 0 + assert 'DOCKER_CONTEXT' not in harness['env'] + + +def test_local_yml_context_reaches_child(harness): + (harness['root'] / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n") + assert env_cmd.cmd_env_exec(harness['root'], ['--', 'true']) == 0 + assert harness['env']['DOCKER_CONTEXT'] == 'gpu-wsl' + + +def test_cli_context_beats_secret_store(harness): + """.env (機密) に DEVBASE_DOCKER_CONTEXT=a があっても --context b が勝つ。""" + harness['secrets'] = {'DEVBASE_DOCKER_CONTEXT': 'a', 'DOCKER_HOST': 'tcp://x'} + assert env_cmd.cmd_env_exec(harness['root'], ['--', 'true'], context='b') == 0 + assert harness['env']['DOCKER_CONTEXT'] == 'b' + assert 'DOCKER_HOST' not in harness['env'] + + +def test_env_exec_does_not_keep_module_state(harness): + (harness['root'] / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n") + env_cmd.cmd_env_exec(harness['root'], ['--', 'true']) + assert dc.active_target() is None + + +def test_local_yml_is_read_from_project_root_when_run_in_subdir(harness, monkeypatch): + """projects//sub から実行しても、機密と同じくプロジェクト直下の設定を読む。""" + root = harness['root'] + proj = root / 'projects' / 'A' + (proj / 'sub').mkdir(parents=True) + (proj / 'project.local.yml').write_text("docker:\n context: gpu-wsl\n") + monkeypatch.chdir(proj / 'sub') + monkeypatch.setattr(secret_runtime, 'current_project_name', lambda r: 'A') + assert env_cmd.cmd_env_exec(root, ['--', 'true']) == 0 + assert harness['env']['DOCKER_CONTEXT'] == 'gpu-wsl' diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 772274c2..b7cd0e6f 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -906,3 +906,95 @@ def boom(cmd, env): environ={}, isatty=True, launcher=boom, ) assert result == "launch" + + +# --------------------------------------------------------------------------- +# PLAN52: devbase が解決した docker context を settings.context に載せる +# --------------------------------------------------------------------------- + +def _decode(uri: str) -> dict: + hexpart = uri.split("attached-container+")[1].split("@")[0].split("/")[0] + return json.loads(bytes.fromhex(hexpart).decode()) + + +def test_open_editor_local_terminal_with_docker_context(monkeypatch): + """ローカル端末 + 解決した context → settings.context 付きのフラット URI。""" + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + calls = [] + opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={}, isatty=True, launcher=lambda cmd, env: calls.append(cmd), + docker_context="gpu-wsl", + ) + uri = calls[0][2] + assert "@ssh-remote" not in uri + assert _decode(uri) == {"containerName": "/carmo-dev-1", "settings": {"context": "gpu-wsl"}} + + +def test_open_editor_local_terminal_without_docker_context_has_no_settings(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + calls = [] + opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={}, isatty=True, launcher=lambda cmd, env: calls.append(cmd), + ) + assert "settings" not in _decode(calls[0][2]) + + +def test_open_editor_remote_ssh_with_docker_context_nested_and_flat_hint(monkeypatch, caplog): + """Remote-SSH + 解決した context → ネスト URI に settings.context、フラット URI も提示。""" + import logging + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + + def boom(*a, **kw): + raise AssertionError("docker context show should not run") + + monkeypatch.setattr(opener.subprocess, "run", boom) + monkeypatch.setattr(opener, "_query_container_name", lambda *a, **kw: None) + calls = [] + with caplog.at_level(logging.INFO): + opener.open_editor( + project_name="adminer", dev_service_name="dev", workdir="/work/adminer", + environ={"VSCODE_IPC_HOOK_CLI": "/run/x.sock", + "SSH_CONNECTION": "192.168.1.16 5 192.168.1.201 22", + "DEVBASE_EDITOR_SSH_HOST": "mac2"}, + isatty=True, ipc_alive=True, launcher=lambda cmd, env: calls.append(cmd), + docker_context="gpu-wsl", + ) + uri = calls[0][2] + assert "@ssh-remote+mac2/work/adminer" in uri + assert _decode(uri)["settings"]["context"] == "gpu-wsl" + text = "\n".join(r.getMessage() for r in caplog.records) + flat = uri.replace("@ssh-remote+mac2", "") + assert flat in text and "同名" in text + + +def test_open_editor_explicit_editor_context_beats_resolved(monkeypatch): + monkeypatch.setattr(opener.shutil, "which", lambda c: "/usr/bin/code") + calls = [] + opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={"DEVBASE_EDITOR_DOCKER_CONTEXT": "manual"}, isatty=True, + launcher=lambda cmd, env: calls.append(cmd), docker_context="gpu-wsl", + ) + assert _decode(calls[0][2])["settings"]["context"] == "manual" + + +def test_resolve_docker_context_default_beats_docker_show(): + def boom(cmd, **kw): + raise AssertionError("docker context show should not run") + + assert opener.resolve_docker_context({}, runner=boom, default="gpu-wsl") == "gpu-wsl" + + +def test_resolve_docker_context_probe_uses_the_effective_environment(): + """attach 先の推測は docker が実際に使う context に合わせる (環境変数を外さない)。""" + seen = {} + + def runner(cmd, **kw): + seen.update(kw.get("env") or {}) + return _Proc(returncode=0, stdout="existing-remote\n") + + env = {"DOCKER_CONTEXT": "existing-remote", "PATH": "/p"} + assert opener.resolve_docker_context(env, runner=runner) == "existing-remote" + assert seen["DOCKER_CONTEXT"] == "existing-remote" and seen["PATH"] == "/p" diff --git a/tests/project/test_config.py b/tests/project/test_config.py index 7d99ff69..a4537630 100644 --- a/tests/project/test_config.py +++ b/tests/project/test_config.py @@ -514,3 +514,18 @@ def test_encoded_plan_has_no_shell_or_compose_hazards(): assert encoded.strip() == encoded assert all(c.isalnum() or c in "+/=" for c in encoded) + + +# --------------------------------------------------------------------------- +# PLAN52: docker 節は project.local.yml へ +# --------------------------------------------------------------------------- + +def test_docker_key_in_project_yml_points_to_local_file(): + """共有される project.yml に機材依存の docker 節を書いた事故を、移す案内付きで弾く。""" + with pytest.raises(ConfigError) as e: + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + "docker": {"context": "gpu-wsl"}, + }, source="project.yml") + assert "project.local.yml" in str(e.value) diff --git a/tests/project/test_local_config.py b/tests/project/test_local_config.py new file mode 100644 index 00000000..0dcf81f7 --- /dev/null +++ b/tests/project/test_local_config.py @@ -0,0 +1,106 @@ +"""``project.local.yml`` (個人・機材ごとの設定) の読み込みと検証 (PLAN52)。""" + +from __future__ import annotations + +import pytest + +from devbase.errors import ConfigError +from devbase.project.local_config import ( + PROJECT_LOCAL_CONFIG_FILENAME, + DockerSettings, + load_project_local_config, + parse_project_local_config, +) + + +def write_local(tmp_path, text: str): + (tmp_path / PROJECT_LOCAL_CONFIG_FILENAME).write_text(text, encoding="utf-8") + return tmp_path + + +# --------------------------------------------------------------------------- +# 無い・空 +# --------------------------------------------------------------------------- + +def test_missing_file_returns_defaults(tmp_path): + """ファイルが無ければ既定値 (docker の 3 項目とも None) で、例外にしない。""" + config = load_project_local_config(tmp_path) + assert config.docker == DockerSettings(context=None, home=None, gid=None) + + +def test_empty_file_is_same_as_missing(tmp_path): + write_local(tmp_path, "") + config = load_project_local_config(tmp_path) + assert config.docker == DockerSettings(context=None, home=None, gid=None) + + +# --------------------------------------------------------------------------- +# 正常系 +# --------------------------------------------------------------------------- + +def test_reads_docker_section(tmp_path): + write_local(tmp_path, "docker:\n context: gpu-wsl\n home: /home/takemi\n gid: 999\n") + config = load_project_local_config(tmp_path) + assert config.docker == DockerSettings(context="gpu-wsl", home="/home/takemi", gid=999) + + +def test_docker_section_all_optional(): + config = parse_project_local_config({"docker": {"context": "ec2"}}, source="x") + assert config.docker == DockerSettings(context="ec2", home=None, gid=None) + + +# --------------------------------------------------------------------------- +# 検証 +# --------------------------------------------------------------------------- + +def test_unknown_top_level_key_is_rejected(): + with pytest.raises(ConfigError) as e: + parse_project_local_config({"scale": 3}, source="local") + assert "scale" in str(e.value) + assert "docker" in str(e.value) # 使えるキーを添える + + +def test_unknown_docker_key_is_rejected(): + with pytest.raises(ConfigError) as e: + parse_project_local_config({"docker": {"host": "x"}}, source="local") + assert "host" in str(e.value) + assert "context" in str(e.value) and "home" in str(e.value) and "gid" in str(e.value) + + +@pytest.mark.parametrize("value", ["", " ", "a b", 12, "tab\there"]) +def test_invalid_context_is_rejected(value): + with pytest.raises(ConfigError) as e: + parse_project_local_config({"docker": {"context": value}}, source="local") + assert "context" in str(e.value) + + +@pytest.mark.parametrize("value", [-1, True, "999", 1.5]) +def test_invalid_gid_is_rejected(value): + with pytest.raises(ConfigError) as e: + parse_project_local_config({"docker": {"gid": value}}, source="local") + assert "gid" in str(e.value) + + +@pytest.mark.parametrize("value", ["home/takemi", "~", "", 3]) +def test_home_must_be_absolute(value): + with pytest.raises(ConfigError) as e: + parse_project_local_config({"docker": {"home": value}}, source="local") + assert "home" in str(e.value) + + +def test_docker_must_be_mapping(): + with pytest.raises(ConfigError): + parse_project_local_config({"docker": "gpu-wsl"}, source="local") + + +def test_broken_yaml_mentions_file(tmp_path): + write_local(tmp_path, "docker: [\n") + with pytest.raises(ConfigError) as e: + load_project_local_config(tmp_path) + assert PROJECT_LOCAL_CONFIG_FILENAME in str(e.value) + + +def test_top_level_must_be_mapping(tmp_path): + write_local(tmp_path, "- a\n") + with pytest.raises(ConfigError): + load_project_local_config(tmp_path) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/test_docker_context.py b/tests/utils/test_docker_context.py new file mode 100644 index 00000000..ee37c8bf --- /dev/null +++ b/tests/utils/test_docker_context.py @@ -0,0 +1,297 @@ +"""docker context の解決・接続先の確定・環境変数への反映・gid の取得 (PLAN52)。""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from devbase.errors import DevbaseError +from devbase.project.local_config import DockerSettings +from devbase.utils import docker_context as dc + + +@pytest.fixture(autouse=True) +def _reset_state(): + dc.reset({}) + yield + dc.reset({}) + + +def _proc(stdout="", returncode=0, stderr=""): + return subprocess.CompletedProcess(args=[], returncode=returncode, + stdout=stdout, stderr=stderr) + + +# --------------------------------------------------------------------------- +# choose_context: 優先順位 CLI > env > ファイル > 未指定 +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("cli, env, file, expected", [ + ("c", "b", "a", ("c", "cli")), + (None, "b", "a", ("b", "env")), + (None, None, "a", ("a", "file")), + (None, None, None, (None, "default")), + (None, "", "a", ("a", "file")), # env の空文字は未指定 + (None, " ", "a", ("a", "file")), +]) +def test_choose_context_priority(cli, env, file, expected): + environ = {} if env is None else {"DEVBASE_DOCKER_CONTEXT": env} + choice = dc.choose_context(DockerSettings(context=file), cli_context=cli, environ=environ) + assert (choice.context, choice.source) == expected + + +# --------------------------------------------------------------------------- +# resolve_target: リモート判定と home / gid +# --------------------------------------------------------------------------- + +def test_unset_context_is_local_and_does_not_probe(): + calls = [] + + def runner(cmd, **kw): + calls.append(cmd) + return _proc("desktop-linux\n") + + target = dc.resolve_target(dc.ContextChoice(None, "default"), DockerSettings(), + environ={}, runner=runner) + assert target.remote is False and target.context is None + assert target.home is None and target.gid is None + assert calls == [] + + +def test_same_as_current_context_is_local(): + runner = lambda cmd, **kw: _proc("desktop-linux\n") # noqa: E731 + target = dc.resolve_target(dc.ContextChoice("desktop-linux", "file"), + DockerSettings(context="desktop-linux", home="/h", gid=1), + environ={}, runner=runner) + assert target.remote is False + assert target.home is None and target.gid is None + + +def test_different_context_is_remote_with_file_values(): + runner = lambda cmd, **kw: _proc("desktop-linux\n") # noqa: E731 + target = dc.resolve_target(dc.ContextChoice("gpu-wsl", "file"), + DockerSettings(context="gpu-wsl", home="/home/t", gid=999), + environ={}, runner=runner) + assert target.remote is True + assert (target.home, target.gid) == ("/home/t", 999) + + +def test_probe_failure_is_treated_as_remote(): + runner = lambda cmd, **kw: _proc("", returncode=1) # noqa: E731 + target = dc.resolve_target(dc.ContextChoice("gpu-wsl", "cli"), DockerSettings(), + environ={}, runner=runner) + assert target.remote is True + + +def test_probe_runs_without_docker_context_and_docker_host(): + """DOCKER_CONTEXT / DOCKER_HOST が残ると docker context show の答えが変わる。""" + seen = {} + + def runner(cmd, **kw): + seen.update(kw.get("env") or {}) + seen["cmd"] = cmd + return _proc("desktop-linux\n") + + environ = {"DOCKER_CONTEXT": "gpu-wsl", "DOCKER_HOST": "tcp://127.0.0.1:1", "PATH": "/x"} + target = dc.resolve_target(dc.ContextChoice("gpu-wsl", "file"), + DockerSettings(context="gpu-wsl"), environ=environ, runner=runner) + assert seen["cmd"][:3] == ["docker", "context", "show"] + assert "DOCKER_CONTEXT" not in seen and "DOCKER_HOST" not in seen + assert seen["PATH"] == "/x" + assert target.remote is True + + +def test_docker_host_only_with_same_context_is_local(): + """DOCKER_HOST だけがある環境で同名の context を指定してもローカル扱い。""" + runner = lambda cmd, **kw: _proc("desktop-linux\n") # noqa: E731 + target = dc.resolve_target(dc.ContextChoice("desktop-linux", "cli"), DockerSettings(), + environ={"DOCKER_HOST": "tcp://127.0.0.1:1"}, runner=runner) + assert target.remote is False + + +def test_override_to_other_context_drops_file_home_and_gid(caplog): + """前提 4: CLI / env で別の context へ向けたらファイルの home / gid は使わない。""" + runner = lambda cmd, **kw: _proc("desktop-linux\n") # noqa: E731 + with caplog.at_level("WARNING"): + target = dc.resolve_target(dc.ContextChoice("ec2", "cli"), + DockerSettings(context="gpu-wsl", home="/home/t", gid=999), + environ={}, runner=runner) + assert target.remote is True + assert target.home is None and target.gid is None + assert "gpu-wsl" in caplog.text and "ec2" in caplog.text + + +# --------------------------------------------------------------------------- +# apply / reapply / reset +# --------------------------------------------------------------------------- + +def test_apply_none_touches_nothing(): + env = {"DOCKER_HOST": "tcp://x", "DOCKER_GID": "0"} + dc.apply(dc.ContextChoice(None, "default"), env) + assert env == {"DOCKER_HOST": "tcp://x", "DOCKER_GID": "0"} + + +def test_apply_choice_sets_context_and_removes_docker_host(caplog): + env = {"DOCKER_HOST": "tcp://x", "DOCKER_GID": "0"} + with caplog.at_level("WARNING"): + dc.apply(dc.ContextChoice("gpu-wsl", "file"), env) + assert env["DOCKER_CONTEXT"] == "gpu-wsl" + assert "DOCKER_HOST" not in env + assert env["DOCKER_GID"] == "0" # ContextChoice は gid を触らない + assert "DOCKER_HOST" in caplog.text + + +def test_apply_target_sets_gid_when_remote(): + env = {"DOCKER_GID": "0"} + target = dc.DockerTarget(context="gpu-wsl", source="file", remote=True, home=None, gid=999) + dc.apply(target, env) + assert env["DOCKER_CONTEXT"] == "gpu-wsl" and env["DOCKER_GID"] == "999" + + +def test_apply_local_target_keeps_gid(): + env = {"DOCKER_GID": "0"} + target = dc.DockerTarget(context="desktop-linux", source="file", remote=False, + home=None, gid=None) + dc.apply(target, env) + assert env["DOCKER_GID"] == "0" + + +def test_reapply_restores_after_secret_injection_overwrote(): + env = {"DOCKER_GID": "0"} + dc.apply(dc.DockerTarget("gpu-wsl", "cli", True, None, 999), env) + # 機密注入が同名キーを上書きした + env.update({"DOCKER_CONTEXT": "x", "DOCKER_GID": "1", "DOCKER_HOST": "tcp://x"}) + dc.reapply(env) + assert env["DOCKER_CONTEXT"] == "gpu-wsl" and env["DOCKER_GID"] == "999" + assert "DOCKER_HOST" not in env + + +def test_reapply_without_active_target_does_nothing(): + env = {"DOCKER_CONTEXT": "x"} + dc.reapply(env) + assert env == {"DOCKER_CONTEXT": "x"} + + +def test_reset_restores_original_values(): + env = {"DOCKER_HOST": "tcp://x", "DOCKER_GID": "0"} + dc.apply(dc.DockerTarget("gpu-wsl", "cli", True, None, 999), env) + dc.reset(env) + assert env == {"DOCKER_HOST": "tcp://x", "DOCKER_GID": "0"} + dc.reapply(env) # 控えは消えている + assert env == {"DOCKER_HOST": "tcp://x", "DOCKER_GID": "0"} + + +def test_untracked_apply_preserves_parent_target_and_originals(): + """現状固定: 子辞書への適用は親の再適用・復元に影響しない。""" + original = {"DOCKER_CONTEXT": "parent-original", "DOCKER_HOST": "tcp://parent", + "DOCKER_GID": "10", "KEEP": "parent"} + parent = dict(original) + child = {"DOCKER_CONTEXT": "child-original", "DOCKER_HOST": "tcp://child", + "DOCKER_GID": "20", "KEEP": "child"} + try: + dc.apply(dc.DockerTarget("remote-a", "cli", True, None, 42), parent) + dc.apply(dc.DockerTarget("remote-b", "cli", True, None, 99), child, track=False) + expected_child = {"DOCKER_CONTEXT": "remote-b", "DOCKER_GID": "99", "KEEP": "child"} + assert child == expected_child + + parent.update({"DOCKER_CONTEXT": "overwritten", "DOCKER_HOST": "tcp://overwritten", + "DOCKER_GID": "30"}) + dc.reapply(parent) + assert parent == {"DOCKER_CONTEXT": "remote-a", "DOCKER_GID": "42", "KEEP": "parent"} + + dc.reset(parent) + assert parent == original + assert child == expected_child + finally: + dc.reset(parent) + + +def test_apply_is_idempotent_and_keeps_first_originals(): + env = {"DOCKER_GID": "0"} + dc.apply(dc.DockerTarget("gpu-wsl", "cli", True, None, 999), env) + dc.apply(dc.DockerTarget("gpu-wsl", "cli", True, None, 999), env) + dc.reset(env) + assert env == {"DOCKER_GID": "0"} + + +# --------------------------------------------------------------------------- +# ensure_remote_gid +# --------------------------------------------------------------------------- + +def test_explicit_gid_is_used_without_docker_run(tmp_path): + calls = [] + target = dc.DockerTarget("gpu-wsl", "file", True, None, 999) + gid = dc.ensure_remote_gid(target, cache_dir=tmp_path, runner=lambda c, **k: calls.append(c)) + assert gid == 999 and calls == [] + + +def test_gid_is_fetched_once_and_cached(tmp_path): + calls = [] + + def runner(cmd, **kw): + calls.append(cmd) + return _proc("999\n") + + target = dc.DockerTarget("gpu-wsl", "file", True, None, None) + assert dc.ensure_remote_gid(target, cache_dir=tmp_path, runner=runner) == 999 + assert len(calls) == 1 + assert calls[0][:3] == ["docker", "run", "--rm"] + assert "alpine:3" in calls[0] and "stat" in calls[0] + assert (tmp_path / "docker-gid" / "gpu-wsl").read_text().strip() == "999" + + assert dc.ensure_remote_gid(target, cache_dir=tmp_path, runner=runner) == 999 + assert len(calls) == 1 + + +def test_gid_probe_failure_raises_with_stderr_and_hint(tmp_path): + runner = lambda c, **k: _proc("", returncode=1, stderr='context "gpu-wsl" does not exist') # noqa: E731 + target = dc.DockerTarget("gpu-wsl", "file", True, None, None) + with pytest.raises(DevbaseError) as e: + dc.ensure_remote_gid(target, cache_dir=tmp_path, runner=runner) + assert "does not exist" in str(e.value) and "docker.gid" in str(e.value) + assert not (tmp_path / "docker-gid" / "gpu-wsl").exists() + + +def test_gid_probe_non_integer_raises(tmp_path): + runner = lambda c, **k: _proc("abc\n") # noqa: E731 + target = dc.DockerTarget("gpu-wsl", "file", True, None, None) + with pytest.raises(DevbaseError): + dc.ensure_remote_gid(target, cache_dir=tmp_path, runner=runner) + + +@pytest.mark.parametrize("exc", [ + FileNotFoundError("docker not found"), + subprocess.TimeoutExpired(cmd=["docker"], timeout=120), +]) +def test_gid_probe_runner_exception_raises_devbase_error(tmp_path, exc): + """現状固定: runner が送出した例外は DevbaseError へ包まれ、案内を含む。""" + def runner(cmd, **kw): + raise exc + + target = dc.DockerTarget("gpu-wsl", "file", True, None, None) + with pytest.raises(DevbaseError) as excinfo: + dc.ensure_remote_gid(target, cache_dir=tmp_path, runner=runner) + + err_msg = str(excinfo.value) + assert "gpu-wsl" in err_msg + assert "docker.gid" in err_msg + assert not (tmp_path / "docker-gid" / "gpu-wsl").exists() + + +def test_gid_zero_warns_but_is_used(tmp_path, caplog): + runner = lambda c, **k: _proc("0\n") # noqa: E731 + target = dc.DockerTarget("gpu-wsl", "file", True, None, None) + with caplog.at_level("WARNING"): + assert dc.ensure_remote_gid(target, cache_dir=tmp_path, runner=runner) == 0 + assert "docker.gid" in caplog.text + + +def test_gid_cache_ignores_garbage(tmp_path): + (tmp_path / "docker-gid").mkdir() + (tmp_path / "docker-gid" / "gpu-wsl").write_text("garbage") + runner = lambda c, **k: _proc("999\n") # noqa: E731 + target = dc.DockerTarget("gpu-wsl", "file", True, None, None) + assert dc.ensure_remote_gid(target, cache_dir=tmp_path, runner=runner) == 999 + assert Path(tmp_path / "docker-gid" / "gpu-wsl").read_text().strip() == "999" diff --git a/tests/volume/test_bind_mounts.py b/tests/volume/test_bind_mounts.py new file mode 100644 index 00000000..6ad22bb4 --- /dev/null +++ b/tests/volume/test_bind_mounts.py @@ -0,0 +1,79 @@ +"""生成物の bind mount の ``~`` をリモート側の HOME で展開する (PLAN52)。""" + +from __future__ import annotations + +import copy + +from devbase.volume import bind_mounts + + +def _services(): + return { + 'dev-1': {'volumes': [ + '~/.aws:/home/ubuntu/.aws', + '~:/mnt/home:ro', + '/var/run/docker.sock:/var/run/docker.sock', + 'devbase_work_1:/work', + {'type': 'bind', 'source': '~/devbase', 'target': '/work/devbase'}, + {'type': 'volume', 'source': 'named', 'target': '/data'}, + ]}, + 'db': {'volumes': ['./init.sql:/docker-entrypoint-initdb.d/init.sql', + '~alice/x:/x']}, + 'nothing': {}, + } + + +def test_expand_home_rewrites_tilde_forms(): + services = _services() + warnings = bind_mounts.expand_home(services, '/home/takemi') + assert services['dev-1']['volumes'][0] == '/home/takemi/.aws:/home/ubuntu/.aws' + assert services['dev-1']['volumes'][1] == '/home/takemi:/mnt/home:ro' + assert services['dev-1']['volumes'][4]['source'] == '/home/takemi/devbase' + # 触らないもの + assert services['dev-1']['volumes'][2] == '/var/run/docker.sock:/var/run/docker.sock' + assert services['dev-1']['volumes'][3] == 'devbase_work_1:/work' + assert services['dev-1']['volumes'][5]['source'] == 'named' + # 書き換えず警告に載せるもの + assert warnings == ['db: ./init.sql:/docker-entrypoint-initdb.d/init.sql', 'db: ~alice/x:/x'] + + +def test_expand_home_strips_trailing_slash(): + services = {'s': {'volumes': ['~/x:/x']}} + bind_mounts.expand_home(services, '/home/t/') + assert services['s']['volumes'][0] == '/home/t/x:/x' + + +def test_collect_remote_warnings_without_home_lists_tilde_and_relative(): + services = _services() + before = copy.deepcopy(services) + warnings = bind_mounts.collect_remote_warnings(services) + assert services == before # 書き換えない + assert warnings == [ + 'dev-1: ~/.aws:/home/ubuntu/.aws', + 'dev-1: ~:/mnt/home:ro', + 'dev-1: ~/devbase:/work/devbase', + 'db: ./init.sql:/docker-entrypoint-initdb.d/init.sql', + 'db: ~alice/x:/x', + ] + + +def test_no_warnings_when_nothing_to_report(): + services = {'s': {'volumes': ['/abs:/x', 'named:/y']}} + assert bind_mounts.expand_home(services, '/h') == [] + assert bind_mounts.collect_remote_warnings(services) == [] + + +def test_long_form_bind_with_bare_relative_source_is_reported(): + """長い書式の type: bind は `data` のような素の相対 source も bind mount。""" + services = {'s': {'volumes': [{'type': 'bind', 'source': 'data', 'target': '/data'}, + {'type': 'bind', 'source': '~/x', 'target': '/x'}]}} + assert bind_mounts.collect_remote_warnings(services) == ['s: data:/data', 's: ~/x:/x'] + warnings = bind_mounts.expand_home(services, '/home/t') + assert warnings == ['s: data:/data'] + assert services['s']['volumes'][1]['source'] == '/home/t/x' + assert services['s']['volumes'][0]['source'] == 'data' # 書き換えない + + +def test_long_form_named_volume_without_type_is_not_a_bind(): + services = {'s': {'volumes': [{'source': 'named', 'target': '/data'}]}} + assert bind_mounts.collect_remote_warnings(services) == [] diff --git a/tests/volume/test_compose_dev_environment.py b/tests/volume/test_compose_dev_environment.py index cef01a07..a5767e2c 100644 --- a/tests/volume/test_compose_dev_environment.py +++ b/tests/volume/test_compose_dev_environment.py @@ -154,3 +154,46 @@ def test_devbase_managed_environment_is_always_present(project): env = env_of(generated(project)["services"]["dev-1"]) assert {k: env[k] for k in DEVBASE_MANAGED} == DEVBASE_MANAGED + + +def test_list_form_environment_with_whitespace_key_is_overwritten(project): + """list 形式でキー名に空白が含まれていても devbase 側の値で正しく上書きされる。""" + (project / "compose.yml").write_text("""services: + dev: + image: alpine + environment: + - " DEVBASE_PRIMARY_DIR = old " + volumes: + - x:/work +volumes: + x: {} +""") + + generate_scaled_compose(1, dev_environment=REPO_ENV) + + dev = generated(project)["services"]["dev-1"] + assert user_env(dev) == { + "DEVBASE_REPOS": "cGxhbg==", + "DEVBASE_PRIMARY_DIR": "carmo", + } + + +def test_env_helpers(): + """環境変数走査ヘルパ (_env_shape, _env_item_name, _iter_env_names) の動作検証。""" + from devbase.volume.compose import _env_item_name, _env_shape, _iter_env_names + + assert _env_shape(None) == "none" + assert _env_shape({}) == "dict" + assert _env_shape([]) == "list" + assert _env_shape("invalid") == "other" + + assert _env_item_name("KEY=VAL") == "KEY" + assert _env_item_name(" KEY =VAL ") == "KEY" + assert _env_item_name("KEY") == "KEY" + assert _env_item_name(" KEY ") == "KEY" + assert _env_item_name(123) is None + + assert list(_iter_env_names(None)) == [] + assert list(_iter_env_names({"A": "1", "B": "2"})) == ["A", "B"] + assert list(_iter_env_names(["A=1", " B = 2 ", 123, "C"])) == ["A", "B", "C"] + diff --git a/tests/volume/test_compose_remote_home.py b/tests/volume/test_compose_remote_home.py new file mode 100644 index 00000000..79230951 --- /dev/null +++ b/tests/volume/test_compose_remote_home.py @@ -0,0 +1,50 @@ +"""リモート扱いの生成で bind mount の ``~`` を docker.home で展開する (PLAN52 Task 4)。""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.volume import compose + + +@pytest.fixture +def in_tmp_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "carmo-ai") + (tmp_path / "compose.yml").write_text(yaml.safe_dump({"services": { + "dev": {"image": "dev:latest", "volumes": ["~/.aws:/home/ubuntu/.aws", + "/var/run/docker.sock:/var/run/docker.sock"]}, + "db": {"image": "db", "volumes": ["./init.sql:/init.sql"]}, + }}, sort_keys=False), encoding="utf-8") + return tmp_path + + +def _sources(tmp_path, service): + doc = yaml.safe_load((tmp_path / ".docker-compose.scale.yml").read_text()) + return [v if isinstance(v, str) else v["source"] for v in doc["services"][service]["volumes"]] + + +def test_local_generation_keeps_tilde(in_tmp_cwd): + compose.generate_scaled_compose(1) + assert "~/.aws:/home/ubuntu/.aws" in _sources(in_tmp_cwd, "dev-1") + + +def test_remote_with_home_rewrites_and_warns_for_relative(in_tmp_cwd, caplog): + with caplog.at_level("WARNING"): + compose.generate_scaled_compose(1, docker_home="/home/takemi", remote=True) + assert "/home/takemi/.aws:/home/ubuntu/.aws" in _sources(in_tmp_cwd, "dev-1") + assert "./init.sql:/init.sql" in _sources(in_tmp_cwd, "db") + assert "db: ./init.sql:/init.sql" in caplog.text + assert "~/.aws" not in caplog.text + + +def test_remote_without_home_warns_and_keeps(in_tmp_cwd, caplog): + with caplog.at_level("WARNING"): + compose.generate_scaled_compose(1, remote=True) + assert "~/.aws:/home/ubuntu/.aws" in _sources(in_tmp_cwd, "dev-1") + assert "dev-1: ~/.aws:/home/ubuntu/.aws" in caplog.text + assert "docker.home" in caplog.text + assert "docker.sock" not in caplog.text