diff --git a/CHANGELOG.md b/CHANGELOG.md index f576cd7c..b3b23b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,26 @@ 実体の無いパスが `env` に残っていると ADC がユーザー認証へフォールバックできません。 ### Fixed +- **`devbase build ` が必ず失敗する問題**を修正しました (#139)。`` の位置引数が + 剥がされないまま `docker buildx build` へ渡り、PATH が 2 つになって + `docker: 'docker buildx build' requires 1 argument` で落ちていました。CLI リファレンスに + 正式な構文として載っているにもかかわらず、**ベースイメージだけを再ビルドする手段が + 無い**状態でした。 + + `bin/devbase` の dispatch が位置引数を検出し、`--expires` と同じく Python 側へ振り分けます。 + `devbase build` / `--no-cache` / `--project-no-cache` は従来どおり shell の 2 段ビルドです。 + + あわせて `devbase project build ` / `devbase container build ` が作るタグを + `:latest` から **`devbase-:latest`** へ直しました。旧タグは他の Dockerfile の + `FROM devbase-base:latest` から解決できず、ビルドしても使われませんでした。ビルドコマンドも + shell 側と同じ `docker buildx build --load` に揃えています。旧タグはリポジトリ内のどこからも + 参照されていないため、移行の手当ては要りません。 + + `` にはディレクトリ名を渡してください (`devbase build base`)。`devbase-base` のように + 接頭辞込みで渡すと `containers/devbase-base` を探して見つからず、終了コード 1 で終わります。 + `` が `projects/` に実在する名前と一致する場合は、そのプロジェクトへの操作として + 解釈されます (#142)。この場合は `devbase project build ` を使ってください。 + - **使われない GCP サービスアカウント鍵をコンテナへ渡さない**ようにしました (#134)。 `GCP_AUTH_MODE=adc` が止めるのは「鍵をファイルへ書き出すこと」だけで、鍵を運ぶ `GCP_CREDENTIALS_BASE64__*` と `GOOGLE_APPLICATION_CREDENTIALS_BASE64` は生成 compose の diff --git a/bin/devbase b/bin/devbase index 272a391c..dbaf716b 100755 --- a/bin/devbase +++ b/bin/devbase @@ -421,18 +421,27 @@ case "$_resolved_cmd" in run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands # - # build: 既定 / --no-cache / は shell の cmd_build (devbase-base の - # 2 段ビルド) で処理する。--expires はイメージ作成日の判定が必要で、shell では - # RFC3339 日付パースが非可搬なため Python (project build) へ委譲する - # (i07: build --expires=N / rebuild / up が共通の期限リゾルバを使う)。 + # build: 既定 / --no-cache / --project-no-cache は shell の cmd_build + # (devbase-base の 2 段ビルド) で処理する。次の 2 つは Python (project build) + # へ委譲する (PLAN49 / i07): + # - 指定の単体ビルド: `devbase project build ` / + # `devbase container build ` と同じ実装へ届ける。逆向き (Python から + # shell を呼ぶ) にすると、この wrapper 冒頭の name 解決を通ってしまい、 + # containers/ と projects/ に同名がある場合 (bi-tools) に別のものを + # ビルドしてしまう。 + # - --expires: イメージ作成日の判定が必要で、shell では RFC3339 日付パースが + # 非可搬なため (build --expires=N / rebuild / up が共通の期限リゾルバを使う)。 build) _has_expires=0 + _build_image="" for _ba in "${_DEVBASE_ARGS[@]}"; do case "$_ba" in --expires|--expires=*) _has_expires=1 ;; + -*) ;; + *) _build_image="$_ba" ;; esac done - if [ "$_has_expires" = 1 ]; then + if [ "$_has_expires" = 1 ] || [ -n "$_build_image" ]; then run_python project build "${_DEVBASE_ARGS[@]}" else cmd_build "${_DEVBASE_ARGS[@]}" diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 5bb5f273..77a3bba9 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -13,7 +13,8 @@ flowchart TB BinDevbase --> ResolveCmd{"resolve_command
プレフィックスマッチ"} - ResolveCmd -->|"build"| CmdBuild["cmd_build()
Bash で直接実行"] + ResolveCmd -->|"build (フラグのみ)"| CmdBuild["cmd_build()
Bash で直接実行"] + ResolveCmd -->|"build <image> / --expires"| RunPython ResolveCmd -->|"その他すべて"| RunPython["run_python()
uv run → python -m devbase.cli"] RunPython --> CliPy["cli.py
_expand_argv → _create_parser → _dispatch"] @@ -29,16 +30,35 @@ flowchart TB Project -->|"list (TTY)"| Tui["tui/
(階層メニュー TUI)"] CmdBuild --> Docker["docker buildx build / docker compose build"] + Container --> DockerSingle["docker buildx build --load
(単体ビルド)"] ``` ### なぜ二層構成なのか | 層 | 担当 | 利点 | |----|------|------| -| **Bash** (`bin/devbase`) | PATH 設定、シェル補完登録、環境変数エクスポート、`build` コマンド | シェル環境へのネイティブ統合。`source` で `.env` を読み込み、`DEVBASE_ROOT` を確定してから Python に渡せる | -| **Python** (`lib/devbase/`) | init, status, project, env, plugin, snapshot, tui | 複雑なロジック(YAML パース、Git 操作、差分バックアップ等)を安全かつ保守的に実装できる | +| **Bash** (`bin/devbase`) | PATH 設定、シェル補完登録、環境変数エクスポート、`build` の compose ビルド | シェル環境へのネイティブ統合。`source` で `.env` を読み込み、`DEVBASE_ROOT` を確定してから Python に渡せる | +| **Python** (`lib/devbase/`) | init, status, project, env, plugin, snapshot, tui、`build` の単体ビルドと期限判定 | 複雑なロジック(YAML パース、Git 操作、差分バックアップ等)を安全かつ保守的に実装できる | -`build` コマンドだけが Bash 側に残っている理由は、Docker buildx の制御と compose.yml のパース処理がシェルスクリプトで完結するためである。 +### `build` の振り分け + +`build` だけは引数によって層が分かれる。`bin/devbase` の dispatch が判定する。 + +| 引数 | 実行する層 | 実体 | 理由 | +|------|-----------|------|------| +| なし / `--no-cache` / `--project-no-cache` | Bash | `cmd_build()` | compose.yml のパースと `FROM devbase-*` の依存検出、2 段ビルドの制御がシェルで完結する | +| `` | Python | `container._build_single_image()` | `devbase project build ` / `devbase container build ` と同じ実装へ届ける。逆向きに Python から `bin/devbase build ` を呼ぶと、wrapper 冒頭の name 解決を通ってしまい、`containers/` と `projects/` に同名がある場合に別のものをビルドする | +| `--expires[=DAYS]` | Python | `container.cmd_build()` → `_build_resolved()` | イメージ作成日の判定に RFC3339 の日付パースが要り、シェルでは非可搬 | + +単体ビルド(`` 指定)は `$DEVBASE_ROOT/containers/` を +`docker buildx build --load -t devbase-:latest` で作る。タグはディレクトリ名から +一意に決まり、接頭辞は剥がさない。剥がすと `containers/xxx` と `containers/devbase-xxx` が +同じタグを取り合うためである。`image` はディレクトリ名 1 つとして妥当な文字だけを +受け付ける(先頭は英数字、以降は英数字・`.`・`-`・`_`)。 + +期限判定の経路(`--expires`)は Python から `_run_build()` で `bin/devbase build` を +呼び戻すが、そこでは位置引数を渡さないため Bash の `cmd_build()` へ入る。単体ビルドの +振り分けと再帰しない。 ## モジュール構成 @@ -55,7 +75,7 @@ Python 側のエントリーポイント。以下の責務を持つ。 | 定数 | 役割 | |------|------| -| `SHORTCUTS` | トップレベルショートカット → サブコマンドのマッピング。`up`, `down`, `login`, `ps`, `scale`, `rebuild` が `project` グループへ転送される(`build` は shell 実装へ委譲するため除外、`list` は lifecycle ではないため `_dispatch` で個別 routing) | +| `SHORTCUTS` | トップレベルショートカット → サブコマンドのマッピング。`up`, `down`, `login`, `ps`, `scale`, `rebuild` が `project` グループへ転送される(`build` は引数によって shell / Python へ分かれるため除外、`list` は lifecycle ではないため `_dispatch` で個別 routing) | | `GROUP_ALIASES` | グループのエイリアス。`ct` → `container`, `pl` → `plugin`, `ss` → `snapshot` | | `SUBCMD_MAP` | 各グループが受け付けるサブコマンド一覧。プレフィックスマッチの候補として使用される | @@ -203,7 +223,7 @@ sequenceDiagram User->>Bash: devbase con u Bash->>Bash: resolve_command("con") → "container" - alt build コマンド + alt build コマンド (フラグのみ) Bash->>Bash: cmd_build() を直接実行 else その他のコマンド Bash->>Python: uv run python -m devbase.cli container u diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 505edf85..7314eb3b 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -214,6 +214,30 @@ devbase build [image] [--no-cache | --expires[=DAYS]] > 単体ビルドでは `--no-cache` のみ反映され、`--expires` は対象外です。`--expires` 付きビルドは > 作成日判定のため Python 経路(`project build`)で処理されます。 +### 単体ビルドが作るイメージ + +`image` を指定すると、`$DEVBASE_ROOT/containers/` を次のコマンドでビルドします。 + +``` +docker buildx build --load -t devbase-:latest $DEVBASE_ROOT/containers/ +``` + +タグは必ず `devbase-` を前置します。`containers/` 配下のイメージは他の Dockerfile から +`FROM devbase-base:latest` の形で参照されるため、前置しないタグではビルドしても解決できません。 +タグは `containers/` 配下のディレクトリ名から一意に決まります。`` にはディレクトリ名を +渡してください。`devbase build devbase-base` のように接頭辞込みで渡すと `containers/devbase-base` +を探して見つからず、終了コード 1 で終わります。 + +ビルドは 1 回だけで、compose イメージは巻き込みません。`containers/` または +その `Dockerfile` が無い場合は、探したパスを表示して終了コード 1 で終わります。 + +> **`` が `$DEVBASE_ROOT/projects/` に実在する名前と一致する場合、トップレベルの +> `devbase build ` はそのプロジェクトへの操作として解釈されます。** これは +> `devbase build <プロジェクト名>` を「そのプロジェクトをビルドする」と読む設計によるもので、 +> イメージ指定は失われます。該当するときは `devbase project build ` を使ってください +> (こちらは常にイメージ名として扱います)。詳細は +> [#142](https://github.com/devbasex/devbase/issues/142) を参照してください。 + ## `devbase project rebuild` `devbase build --expires=7` のシノニムです(既定 7 日)。プロジェクトイメージが 7 日以上古ければ diff --git a/issues/PLAN49_build-image-argument.md b/issues/old/PLAN49_build-image-argument.md similarity index 70% rename from issues/PLAN49_build-image-argument.md rename to issues/old/PLAN49_build-image-argument.md index c282c1e8..8a926d76 100644 --- a/issues/PLAN49_build-image-argument.md +++ b/issues/old/PLAN49_build-image-argument.md @@ -279,6 +279,12 @@ Python の 2 実装が残ったままになり、タグ規約の食い違いを 無い。`base` を `base:latest` としてビルドしても、他の Dockerfile の `FROM devbase-base:latest` からは見えず、ビルドした意味が失われる。 +タグは `containers/` 配下のディレクトリ名から一意に導く。渡された `image` から `devbase-` +接頭辞を剥がすことはしない。剥がすと `containers/xxx` と `containers/devbase-xxx` が +`devbase-xxx:latest` を取り合い、別ディレクトリなのに互いのイメージを上書きしてしまう。 +`devbase build devbase-base` のように接頭辞込みで渡した場合は、存在確認で +`containers/devbase-base` を探して見つからず、探したパスを示して終了コード 1 で終わる。 + コマンドを `docker build` から `docker buildx build --load` へ揃えるのは、shell の `build_base_image` が同じイメージを buildx で作っているためである。ビルダが分かれると、 同じイメージを 2 通りの方法で作ることになり、`--load` を伴わない buildx 既定ビルダでは @@ -322,3 +328,127 @@ Python の 2 実装が残ったままになり、タグ規約の食い違いを → **AC8(改): `image` 指定の単体ビルド経路で `docker` を起動する実装が 1 箇所(Python の `cmd_build`)だけになる。** compose ビルドの 1 段目である shell の `build_base_image` は 対象外とする(2026-09-02、決定 4 の理由による) +- ~~単体ビルドのタグは、`image` が `devbase-` 始まりで渡された場合も二重に付かないよう + 接頭辞を剥がしてから付け直す~~ + → **タグは `containers/` 配下のディレクトリ名から一意に導き、`devbase-` 接頭辞は + 剥がさずそのまま前置する。** 剥がすと `containers/xxx` と `containers/devbase-xxx` が + `devbase-xxx:latest` を取り合い、別ディレクトリなのに互いのイメージを上書きしてしまう。 + 接頭辞込みで渡された場合は `containers/devbase-` が見つからず、探したパスを示して + 終了コード 1 で終わる(2026-09-02、PR [#144](https://github.com/devbasex/devbase/pull/144) + のレビュー指摘による) +- **追加: 単体ビルドの `image` は `containers/` 配下の 1 ディレクトリ名として妥当な文字 + (`[A-Za-z0-9][A-Za-z0-9._-]*`)に限り、それ以外は `docker` を起動せず終了コード 1 で + 終わる。** `/` や `\`、`..` を通すと `$DEVBASE_ROOT` の外を指せてしまい、Docker タグとしても + 不正な名前を渡せてしまうため(2026-09-02、PR [#144](https://github.com/devbasex/devbase/pull/144) + のレビュー指摘による) + +--- + +# 実装計画 + +## 関連リンク + +- issue [#139](https://github.com/devbasex/devbase/issues/139) +- 設計 PR [#143](https://github.com/devbasex/devbase/pull/143)(マージ済み。本ファイルの前半 2 節) +- 範囲外として起票: [#141](https://github.com/devbasex/devbase/issues/141) / [#142](https://github.com/devbasex/devbase/issues/142) + +## モード + +`standard`。ドキュメント記載の `devbase build [image]` が動かない不具合の修正で、本番の振る舞いが変わる。構文・オプションの約束は変えない。 + +## 目的と非目的 + +達成したい状態: + +- `devbase build ` が `containers/` を単体ビルドし、`devbase-:latest` を作る +- 単体ビルドの `docker` 呼び出しが Python の 1 箇所だけになる +- `bin/devbase` の引数解釈に回帰テストがある + +やらないこと: + +- `` と実在プロジェクト名の衝突の解消(#142) +- CI へ pytest を追加すること(#141) +- `--expires` を単体ビルドへ適用すること +- `containers/` 配下の変更 + +## 受け入れ条件 + +本ファイル前半の AC1〜AC11(AC8 は「受け入れ条件の変更」節の改訂版)をそのまま使う。検証手段は同節の「テスト設計」に対応させる。 + +## 代替案と採否 + +設計の「決定の記録」に記載済み。ここでは再掲しない。 + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| `devbase build [image]` | 失敗 → 成功 | 破壊なし。現状 100% 失敗するため依存する呼び出し側が存在しない | +| `devbase project build ` / `devbase container build ` | タグが `:latest` → `devbase-:latest` | 旧タグはリポジトリ内のどこからも参照されておらず `FROM devbase-*` を解決できないため、移行措置を設けない | +| データスキーマ | なし | — | + +## 修正対象 + +| ファイル | 変更 | +| --- | --- | +| `bin/devbase` | dispatch の `build)` ケース(428-440 行)に位置引数の検出を足す | +| `lib/devbase/commands/container.py` | `cmd_build` の単体ビルド(972-998 行)のタグとビルダを直す。`_dispatch_lifecycle` の docstring(435-437 行)を実態へ合わせる | +| `lib/devbase/cli.py` | `SHORTCUTS` と `_add_shortcut_parsers` のルーティング注記を実態へ合わせる | +| `tests/cli/test_build_image_argument.py` | 新規 | +| `docs/user/cli-reference/02-project.md` | `devbase project build` の節にタグ規約と衝突挙動を追記 | + +## タスク分解 + +### Task 1: 単体ビルドの `docker` コマンドを直す + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/cli/test_build_image_argument.py` +- **変更内容:** `cmd_build(image=...)` が組み立てるコマンドを `['docker', 'build', '-t', image, str(image_dir)]` から `['docker', 'buildx', 'build', '--load', '-t', f'devbase-{image}:latest', str(image_dir)]` へ変える。`--no-cache` は末尾に付ける。タグは `containers/` 配下のディレクトリ名から一意に導き、`devbase-` 接頭辞は剥がさずそのまま前置する(剥がすと `containers/xxx` と `containers/devbase-xxx` が同じタグを取り合い、別ディレクトリなのに互いのイメージを上書きしてしまうため)。`devbase build devbase-base` のように接頭辞込みで渡した場合は `containers/devbase-base` が見つからず、探したパスを示して終了コード 1 で終わる。 +- **満たす受け入れ条件:** AC1(docker 引数列)、AC3、AC4、AC5、AC7 +- **進め方:** `subprocess.run` を差し替えて引数列を捕まえる失敗するテストを先に書き、実装で通す。存在しないディレクトリ・`Dockerfile` 不在・`DEVBASE_ROOT` 未設定の 3 つの失敗系も同じ回で固定する。 + +### Task 2: wrapper が位置引数を Python へ振り分ける + +- **対象ファイル:** `bin/devbase`、`tests/cli/test_build_image_argument.py` +- **変更内容:** dispatch の `build)` ケースのループで、`--expires` の検出に加えて `-` で始まらない引数を `_build_image` として拾う。`_has_expires` か `_build_image` のいずれかが立っていれば `run_python project build "${_DEVBASE_ARGS[@]}"`、どちらも無ければ `cmd_build "${_DEVBASE_ARGS[@]}"` を呼ぶ。 +- **満たす受け入れ条件:** AC1(振り分け)、AC2、AC6-1〜AC6-4、AC9 +- **進め方:** `tests/cli/test_project_name_resolution.py` と同じスタブ方式(`run_python` / `cmd_build` / `compose_with_secrets` を関数で上書きし、`bin/devbase` を実プロセス起動)で失敗するテストを先に書く。`build base` が `PYTHON:` 側へ、`build` / `build --no-cache` / `build --project-no-cache` が `BUILD:` 側へ行くことを固定する。 + +### Task 3: ルーティングの注記を実態へ合わせる + +- **対象ファイル:** `lib/devbase/cli.py`、`lib/devbase/commands/container.py` +- **変更内容:** 「build は shell 実装へ委譲する」旨の注記が 3 箇所(`cli.py` の `SHORTCUTS` 前、`cli.py` の `_add_shortcut_parsers` docstring、`container.py` の `_dispatch_lifecycle` docstring)にある。`image` 指定と `--expires` は Python、それ以外が shell という現在の実態を書く。 +- **満たす受け入れ条件:** AC8(実装が 1 箇所であることを読み手が追えるようにする) +- **進め方:** コメントのみ。テスト駆動の対象外。 + +### Task 4: ドキュメントを追従させる + +- **対象ファイル:** `docs/user/cli-reference/02-project.md` +- **変更内容:** `devbase project build` の節に、単体ビルドが作るタグが `devbase-:latest` であること、`` が実在プロジェクト名と一致すると name 解決が優先されること(逃げ道は `devbase project build `、詳細は #142)を書く。 +- **満たす受け入れ条件:** AC11 +- **進め方:** ドキュメントのみ。テスト駆動の対象外。 + +## 影響範囲 + +- `devbase build` の 4 経路(既定 / `--no-cache` / `--project-no-cache` / `--expires`)— 変えない。Task 2 の振り分け条件が誤ると全経路に影響するため、AC6 のテストで固定する +- `devbase rebuild` / `devbase up` — `_run_build` 経由で `bash bin/devbase build [--no-cache|--project-no-cache]` を呼ぶ。いずれもフラグのみで位置引数を持たないため、振り分けの影響を受けない +- `lib/devbase/snapshot/manager.py` — `devbase-snapshot:latest` を独自に build している。今回は触らない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 振り分け条件の誤りで `devbase build --no-cache` が Python 側へ流れ、2 段ビルドが失われる | AC6 のテストで 4 経路すべての行き先を固定する | +| `_run_build`(Python → shell)と新しい振り分け(shell → Python)で無限再帰する | `_run_build` は位置引数を渡さないため shell 側の `cmd_build` へ入る。AC6-2 / AC6-3 のテストがこれを固定する | +| タグ変更に気付かず `base:latest` を参照している箇所が残る | `grep -rn "base:latest"` 等でリポジトリ全体を走査し、`devbase-` 接頭辞なしの参照が無いことを確認する | + +## 切り戻し手順 + +コード変更のみでデータ移行を伴わない。PR の revert で完全に戻る。イメージのタグが変わるが、旧タグ(`:latest`)は誰も参照していないため後始末は不要。 + +## 完了の定義 + +- [ ] AC1〜AC11 をすべて満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run pytest tests/ -q` が通る +- [ ] `shellcheck --severity=error bin/devbase` が通る +- [ ] `uv run ruff check --select=E9,F63,F7,F82 lib` が通る +- [ ] `python -m compileall -q lib bin` が通る diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 747b8d1c..ee7102cc 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -20,10 +20,12 @@ # Shortcuts: top-level command -> project subcommand # 委譲先は共有の cmd_project (PLAN06 で container は非推奨化)。 -# NOTE: `build` はここに含めない。配布入口 bin/devbase が `build` を shell の -# cmd_build (devbase-base 依存検出 + 2 段ビルド + --no-cache 対応) に委譲しており、 -# Python の project build (単純な compose build) とは実装が異なるため。Python 側で -# `build` を project build ショートカットとして広告すると wrapper の実経路と乖離する。 +# NOTE: `build` はここに含めない。配布入口 bin/devbase が `build` の引数を見て +# 振り分けており (PLAN49)、Python 側で単一のショートカットとして広告すると実経路と +# 乖離するため: +# - 既定 / --no-cache / --project-no-cache -> shell の cmd_build +# (devbase-base 依存検出 + 2 段ビルド) +# - 指定 / --expires -> Python の project build # project build / container build サブコマンド自体は引き続き利用可能。 # # 同期注意 (メンテナンス性): SHORTCUTS のキー集合と _add_project_parser の @@ -551,8 +553,9 @@ def _add_shortcuts(subparsers): `login` は project login と同様に単一 positional を `index` として扱い `[name]` は受け付けない (曖昧さ回避)。`build` はショートカットに含めない (SHORTCUTS の - 注記参照): bin/devbase が build を shell 実装 (cmd_build) に委譲するため、 - Python 側でトップレベル build を広告すると実経路と乖離する。 + 注記参照): bin/devbase が build の引数を見て shell (cmd_build) と Python + (project build) へ振り分けるため、Python 側でトップレベル build を単一の + ショートカットとして広告すると実経路と乖離する。 """ _add_login_subparser(subparsers) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 68726c28..92b4e28f 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -433,8 +433,9 @@ def _dispatch_lifecycle(args) -> int: chdir する (PLAN06 方針 A の Python 側フォールバック)。chdir を各 handler に 散らさずここで実施するのは、`cmd_down()` / `cmd_login()` / `cmd_logs()` 等が project_name 引数を取らず、per-handler 実装では down/login/logs で名前解決が - 効かなくなるため。build は wrapper の shell 実装で CWD 実行されるため、この - Python フォールバックの対象外 (name 属性も持たない)。 + 効かなくなるため。build は name positional を持たないため、この Python + フォールバックの対象外である。compose ビルドは wrapper の shell 実装で CWD + 実行され、`` 指定の単体ビルドは CWD に依存しない (PLAN49)。 """ subcmd = getattr(args, 'subcommand', None) project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None) @@ -953,13 +954,75 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: # cmd_build # --------------------------------------------------------------------------- -def cmd_build(image: str = None, no_cache: bool = False, +# 単体ビルドで受け付けるイメージ名。`containers/` 配下の 1 ディレクトリ名であることを +# 保証するため、英数字始まりで英数字・ハイフン・アンダースコア・ピリオドのみを許可する。 +_IMAGE_NAME_RE = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]*') + + +def _build_single_image(image: str, no_cache: bool = False) -> int: + """``$DEVBASE_ROOT/containers/`` を単体ビルドする (PLAN49 / #139)。 + + ``devbase build `` (bin/devbase の dispatch が振り分け) と + ``devbase project build `` / ``devbase container build `` の + 共通の実装。compose ビルドは巻き込まない。 + + Returns: + ``docker`` の終了コード。事前条件を満たさない場合は 1。 + """ + devbase_root = os.environ.get('DEVBASE_ROOT', '') + if not devbase_root: + logger.error("DEVBASE_ROOT not set") + return 1 + + # `image` はパスの一部として連結し、そのままタグにもなる。`/` `\` `..` などを + # 通すと $DEVBASE_ROOT の外を指せてしまい、Docker タグとして不正な名前も作れるため、 + # ディレクトリ名 1 つとして妥当な文字だけを許可し、それ以外はここで弾く。 + if not _IMAGE_NAME_RE.fullmatch(image): + logger.error( + "Invalid image name: %r (must be a single directory name under " + "containers/: alphanumeric start, then letters, digits, '.', '-', '_')", + image) + return 1 + + image_dir = Path(devbase_root) / 'containers' / image + if not image_dir.is_dir(): + logger.error("Image directory not found: %s", image_dir) + return 1 + + dockerfile = image_dir / 'Dockerfile' + if not dockerfile.exists(): + logger.error("Dockerfile not found: %s", dockerfile) + return 1 + + # タグは `devbase-` + ディレクトリ名。`containers/` 配下のイメージはすべてこの規約で + # 参照されており (compose.yml の image: / 他 Dockerfile の `FROM devbase-base:latest` / + # snapshot の SNAPSHOT_IMAGE)、接頭辞なしでは `FROM devbase-*` から解決できない。 + # 接頭辞は剥がさない。剥がすと `containers/xxx` と `containers/devbase-xxx` が同じ + # タグを取り合い、ディレクトリが別なのに互いのイメージを上書きしてしまう。 + # `devbase build devbase-base` のように接頭辞込みで渡した場合は、上の存在確認で + # `containers/devbase-base` を探して見つからず、探したパスを示して終了する。 + tag = f"devbase-{image}:latest" + + # `docker build` ではなく `docker buildx build --load` を使う。shell 側の + # build_base_image が同じイメージを buildx で作っており、ビルダが分かれると + # 同じイメージを 2 通りの方法で作ることになる。`--load` が無いと buildx の + # 既定ビルダでは生成物がローカルのイメージ一覧へ現れない。 + logger.info("Building image '%s' from %s ...", tag, image_dir) + cmd = ['docker', 'buildx', 'build', '--load', '-t', tag, str(image_dir)] + if no_cache: + cmd.append('--no-cache') + result = subprocess.run(cmd, check=False) + return result.returncode + + +def cmd_build(image: Optional[str] = None, no_cache: bool = False, expires: Optional[int] = None) -> int: """Build container images. 引数の意味 (i07 の 3 モード): - - ``image`` 指定: ``$DEVBASE_ROOT/containers/`` を直接 ``docker build`` - する単体ビルド (``--no-cache`` のみ反映、``--expires`` は対象外)。 + - ``image`` 指定: ``$DEVBASE_ROOT/containers/`` を + ``docker buildx build --load -t devbase-:latest`` で作る単体ビルド + (``--no-cache`` のみ反映、``--expires`` は対象外)。 - ``image`` なし + フラグなし: 通常のキャッシュビルド。 - ``image`` なし + ``--no-cache``: base / project とも無条件 no-cache。 - ``image`` なし + ``--expires=N``: project の作成日で期限判定し、N 日以上なら @@ -967,7 +1030,8 @@ def cmd_build(image: str = None, no_cache: bool = False, フラグなしの compose ビルドも、devbase-base の 2 段ビルドを行う shell ``cmd_build`` (``bin/devbase``) 経由 (:func:`_build_resolved` → :func:`_run_build`) - に統一する。``image`` 指定の単体ビルドのみ直接 ``docker build`` する。 + に統一する。``image`` 指定の単体ビルドはここが唯一の実装で、shell 側の dispatch + (``devbase build ``) もここへ振り分けられる (PLAN49)。 """ if image is not None: # 単体ビルド (image 指定) では期限判定を行わないため --expires は無視される。 @@ -975,27 +1039,7 @@ def cmd_build(image: str = None, no_cache: bool = False, if expires is not None: logger.warning( "--expires is ignored when building a single image ('%s')", image) - devbase_root = os.environ.get('DEVBASE_ROOT', '') - if not devbase_root: - logger.error("DEVBASE_ROOT not set") - return 1 - - image_dir = Path(devbase_root) / 'containers' / image - if not image_dir.is_dir(): - logger.error("Image directory not found: %s", image_dir) - return 1 - - dockerfile = image_dir / 'Dockerfile' - if not dockerfile.exists(): - logger.error("Dockerfile not found: %s", dockerfile) - return 1 - - logger.info("Building image '%s' from %s ...", image, image_dir) - cmd = ['docker', 'build', '-t', image, str(image_dir)] - if no_cache: - cmd.append('--no-cache') - result = subprocess.run(cmd, check=False) - return result.returncode + return _build_single_image(image, no_cache=no_cache) # `--expires` 単独 (値なし) は sentinel -1。既定日数へ解決する。 if expires is not None and expires < 0: diff --git a/tests/cli/test_build_image_argument.py b/tests/cli/test_build_image_argument.py new file mode 100644 index 00000000..c2982768 --- /dev/null +++ b/tests/cli/test_build_image_argument.py @@ -0,0 +1,309 @@ +"""PLAN49 (#139): `devbase build ` 単体ビルドのテスト。 + +検証対象: + - wrapper (bin/devbase) の `build)` dispatch: 位置引数 `` と `--expires` は + Python (project build) へ、フラグのみの経路は shell の cmd_build へ振り分ける。 + - Python `container.cmd_build(image=...)`: `containers/` を + `devbase-:latest` として単体ビルドする docker 引数列を組み立てる。 + +修正前は `` が剥がされないまま shell の cmd_build へ流れ、 +`docker buildx build ... ` と PATH が 2 つになって必ず失敗した。 + +wrapper テストは実際の `uv run` を避けるため run_python / cmd_build / compose_with_secrets +をスタブへ差し替え、DEVBASE_ROOT を一時ディレクトリへ向けた薄いハーネスで dispatch だけを +実行する (wrapper 冒頭の DEVBASE_ROOT 自動解決行も sed で除去する)。 +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path + +import pytest + +from devbase.commands import container + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / "bin" / "devbase" + + +# =========================================================================== +# wrapper: build の振り分け (位置引数 / --expires / フラグのみ) +# =========================================================================== + +def _run_wrapper(args, devbase_root, cwd=None): + """run_python / cmd_build / compose_with_secrets をスタブ化して dispatch だけ実行する。 + + - run_python -> "PYTHON:" を出力して終了 + - cmd_build -> "BUILD:" を出力して終了 + - compose_with_secrets -> "COMPOSE:" (cmd_build へ入った場合の保険) + """ + harness = ( + 'run_python() { echo "PYTHON:$*"; exit 0; }\n' + 'cmd_build() { echo "BUILD:$*"; exit 0; }\n' + 'compose_with_secrets() { echo "COMPOSE:$*"; exit 0; }\n' + 'ensure_uv() { :; }\n' + 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' + ' -e \'/^ensure_uv()/,/^}/d\' ' + ' -e \'/^cmd_build()/,/^}/d\' ' + ' -e \'/^compose_with_secrets()/,/^}/d\' ' + ' -e \'/^DEVBASE_ROOT=/d\' "$WRAPPER_PATH")"\n' + ) + env = { + **os.environ, + "DEVBASE_ROOT": str(devbase_root), + "WRAPPER_PATH": str(WRAPPER), + } + return subprocess.run( + ["bash", "-c", harness, "devbase", *args], + capture_output=True, + text=True, + env=env, + cwd=str(cwd or REPO_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): + """`containers/base` を持ち、`projects/` は空の DEVBASE_ROOT。 + + `projects/` を空にするのは、wrapper 冒頭の name 解決 (実在プロジェクト名なら cd して + 引数を除去する) を発火させないためである。`base` が name 解決へ吸われると、この + テストが検証したい dispatch まで引数が届かない。 + """ + (tmp_path / "containers" / "base").mkdir(parents=True) + (tmp_path / "containers" / "base" / "Dockerfile").write_text("FROM ubuntu:26.04\n") + (tmp_path / "projects").mkdir() + return tmp_path + + +def test_wrapper_routes_build_image_to_python(wrapper_root): + """`devbase build base` は Python の project build へ渡り、image が保たれる。""" + result = _run_wrapper(["build", "base"], wrapper_root) + assert _line(result, "PYTHON:") == "project build base" + # shell の compose ビルド経路へ落ちない (AC2) + assert _line(result, "BUILD:") is None + assert _line(result, "COMPOSE:") is None + + +def test_wrapper_routes_build_image_no_cache_to_python(wrapper_root): + """`--no-cache` を伴っても image 指定は Python 経路で、引数の順序が保たれる。""" + result = _run_wrapper(["build", "base", "--no-cache"], wrapper_root) + assert _line(result, "PYTHON:") == "project build base --no-cache" + assert _line(result, "BUILD:") is None + + +def test_wrapper_routes_bare_build_to_shell(wrapper_root): + """image 省略・フラグなしは shell の cmd_build (2 段の compose ビルド)。""" + result = _run_wrapper(["build"], wrapper_root) + assert _line(result, "BUILD:") == "" + assert _line(result, "PYTHON:") is None + + +def test_wrapper_routes_build_no_cache_to_shell(wrapper_root): + """`devbase build --no-cache` は shell 経路のまま (退行防止)。""" + result = _run_wrapper(["build", "--no-cache"], wrapper_root) + assert _line(result, "BUILD:") == "--no-cache" + assert _line(result, "PYTHON:") is None + + +def test_wrapper_routes_build_project_no_cache_to_shell(wrapper_root): + """`--project-no-cache` は shell 経路のまま。 + + Python の `_run_build(project_no_cache=True)` がこの形で wrapper を呼び戻すため、 + ここが Python へ振り分けられると shell と Python の間で再帰する。 + """ + result = _run_wrapper(["build", "--project-no-cache"], wrapper_root) + assert _line(result, "BUILD:") == "--project-no-cache" + assert _line(result, "PYTHON:") is None + + +@pytest.mark.parametrize("flag", ["--expires", "--expires=7"]) +def test_wrapper_routes_build_expires_to_python(wrapper_root, flag): + """`--expires` は作成日判定のため Python 経路 (既存仕様の維持)。""" + result = _run_wrapper(["build", flag], wrapper_root) + assert _line(result, "PYTHON:") == f"project build {flag}" + assert _line(result, "BUILD:") is None + + +def test_wrapper_routes_build_image_with_expires_to_python(wrapper_root): + """image と `--expires` の併用も Python へ渡し、警告は Python 側で出す。""" + result = _run_wrapper(["build", "base", "--expires=7"], wrapper_root) + assert _line(result, "PYTHON:") == "project build base --expires=7" + + +# =========================================================================== +# Python: cmd_build(image=...) が組み立てる docker 引数列 +# =========================================================================== + +@pytest.fixture +def devbase_root(tmp_path, monkeypatch): + (tmp_path / "containers" / "base").mkdir(parents=True) + (tmp_path / "containers" / "base" / "Dockerfile").write_text("FROM ubuntu:26.04\n") + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def captured_run(monkeypatch): + """`container.subprocess.run` を差し替え、渡された引数列を記録する。""" + calls = [] + + class _Result: + returncode = 0 + + def _fake_run(cmd, *args, **kwargs): + calls.append(cmd) + return _Result() + + monkeypatch.setattr(container.subprocess, "run", _fake_run) + return calls + + +def test_single_build_uses_devbase_prefixed_tag(devbase_root, captured_run): + """単体ビルドは `devbase-:latest` を buildx で作る。 + + `:latest` では他の Dockerfile の `FROM devbase-base:latest` から解決できず、 + ビルドしても使われない。 + """ + rc = container.cmd_build(image="base") + + assert rc == 0 + assert captured_run == [[ + "docker", "buildx", "build", "--load", + "-t", "devbase-base:latest", + str(devbase_root / "containers" / "base"), + ]] + + +def test_single_build_appends_no_cache(devbase_root, captured_run): + """`--no-cache` はコンテキストパスの後ろに 1 つだけ足す。""" + container.cmd_build(image="base", no_cache=True) + + assert captured_run[0][-1] == "--no-cache" + assert captured_run[0].count("--no-cache") == 1 + # コンテキストパスは 1 つだけ (issue #139 の PATH 2 つ問題の回帰防止) + assert captured_run[0].count(str(devbase_root / "containers" / "base")) == 1 + + +def test_single_build_tag_maps_one_to_one_to_directory(devbase_root, captured_run): + """タグは `containers/` 配下のディレクトリ名と 1:1 に対応する。 + + 接頭辞を剥がすと `containers/xxx` と `containers/devbase-xxx` が同じタグを取り合い、 + 別ディレクトリなのに互いのイメージを上書きするため、剥がさずそのまま前置する。 + """ + for name in ("xxx", "devbase-xxx"): + (devbase_root / "containers" / name).mkdir() + (devbase_root / "containers" / name / "Dockerfile").write_text("FROM x\n") + + container.cmd_build(image="xxx") + container.cmd_build(image="devbase-xxx") + + tags = [cmd[cmd.index("-t") + 1] for cmd in captured_run] + assert tags == ["devbase-xxx:latest", "devbase-devbase-xxx:latest"] + assert len(set(tags)) == 2 + + +def test_single_build_missing_directory_fails(devbase_root, captured_run, caplog): + """存在しないイメージ名は非 0 で終わり、探したパスを出す。docker は起動しない。""" + with caplog.at_level(logging.ERROR): + rc = container.cmd_build(image="nosuchimage") + + assert rc == 1 + assert captured_run == [] + assert str(devbase_root / "containers" / "nosuchimage") in caplog.text + + +def test_single_build_missing_dockerfile_fails(devbase_root, captured_run, caplog): + """Dockerfile が無い場合も非 0 で終わり、docker は起動しない。""" + (devbase_root / "containers" / "nodockerfile").mkdir() + + with caplog.at_level(logging.ERROR): + rc = container.cmd_build(image="nodockerfile") + + assert rc == 1 + assert captured_run == [] + assert "Dockerfile" in caplog.text + + +def test_single_build_without_devbase_root_fails(monkeypatch, captured_run, caplog): + """DEVBASE_ROOT 未設定は非 0 で終わる。""" + monkeypatch.delenv("DEVBASE_ROOT", raising=False) + + with caplog.at_level(logging.ERROR): + rc = container.cmd_build(image="base") + + assert rc == 1 + assert captured_run == [] + + +def test_single_build_propagates_docker_exit_code(devbase_root, monkeypatch): + """docker の終了コードをそのまま返す。""" + class _Result: + returncode = 42 + + monkeypatch.setattr(container.subprocess, "run", lambda *a, **k: _Result()) + + assert container.cmd_build(image="base") == 42 + + +def test_single_build_ignores_expires_with_warning(devbase_root, captured_run, caplog): + """`--expires` は単体ビルドの対象外。警告を出したうえで単体ビルドする。""" + with caplog.at_level(logging.WARNING): + rc = container.cmd_build(image="base", expires=7) + + assert rc == 0 + assert "--expires" in caplog.text + # 期限判定 (docker image inspect) を挟まず、ビルド 1 回だけ + assert len(captured_run) == 1 + assert captured_run[0][:4] == ["docker", "buildx", "build", "--load"] + + +@pytest.mark.parametrize("bad_image", [ + "../etc", + "base/../../etc", + "a/b", + "..", + ".", + "", + "..\\etc", + "-base", +]) +def test_single_build_rejects_invalid_image_name( + devbase_root, captured_run, caplog, bad_image): + """ディレクトリ名として不正な `image` は docker を起動せず 1 で終わる。 + + `/` や `\\`、`..` を通すと $DEVBASE_ROOT の外を指せてしまい、Docker タグとしても + 不正な名前を渡せてしまうため、パス組み立ての前に弾く (PR #144 のレビュー指摘)。 + """ + with caplog.at_level(logging.ERROR): + rc = container.cmd_build(image=bad_image) + + assert rc == 1 + assert captured_run == [] + assert "Invalid image name" in caplog.text + + +def test_single_build_accepts_real_container_directory_names(devbase_root, captured_run): + """`containers/` 配下の実在ディレクトリ名は検証を通る。""" + names = ["base", "bi-tools", "general", "go", "latex", + "lfm", "php", "php85", "snapshot", "trygroup"] + for name in names: + d = devbase_root / "containers" / name + d.mkdir(exist_ok=True) + (d / "Dockerfile").write_text("FROM x\n") + + for name in names: + assert container.cmd_build(image=name) == 0 + + tags = [cmd[cmd.index("-t") + 1] for cmd in captured_run] + assert tags == [f"devbase-{name}:latest" for name in names]