From 95e67c759854d7725a58be32abe29fb1d6871a68 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 2 Sep 2026 13:53:47 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(build):=20devbase=20build=20=20?= =?UTF-8?q?=E3=81=AE=E5=8D=98=E4=BD=93=E3=83=93=E3=83=AB=E3=83=89=E3=82=92?= =?UTF-8?q?=E5=8B=95=E3=81=8F=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devbase build ` は位置引数が剥がされないまま shell の cmd_build へ 流れ、`docker buildx build ... ` と PATH が 2 つになって 必ず失敗していた (#139)。 - bin/devbase の build dispatch で位置引数を検出し、`--expires` と同じく Python の project build へ振り分ける。既定 / --no-cache / --project-no-cache は従来どおり shell の cmd_build のまま - 単体ビルドを container._build_single_image() として抽出し、タグを devbase-:latest に、コマンドを docker buildx build --load に揃える。 接頭辞なしのタグは他 Dockerfile の FROM devbase-base:latest から解決できず、 ビルドしても使われなかった - 振り分けを Python 側へ寄せたのは、逆向きにすると wrapper 冒頭の name 解決を 通ってしまい、containers/ と projects/ に同名がある場合 (bi-tools) に devbase project build が別のものをビルドするため - bin/devbase の引数解釈に回帰テストを追加 (実プロセス起動 + スタブ) - ルーティングの注記とドキュメントを実態へ追従 範囲外として起票: #141 (CI が pytest を実行していない)、 #142 ( と実在プロジェクト名の衝突) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9tinzTAbF1LVEYvfUgzKx --- bin/devbase | 19 +- docs/user/cli-reference/02-project.md | 22 +++ issues/PLAN49_build-image-argument.md | 111 +++++++++++ lib/devbase/cli.py | 15 +- lib/devbase/commands/container.py | 81 +++++--- tests/cli/test_build_image_argument.py | 262 +++++++++++++++++++++++++ 6 files changed, 472 insertions(+), 38 deletions(-) create mode 100644 tests/cli/test_build_image_argument.py 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/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 505edf85..0b4ff61b 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -214,6 +214,28 @@ 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` の形で参照されるため、前置しないタグではビルドしても解決できません。 +`devbase build devbase-base` のように前置込みで渡しても二重にはなりません。 + +ビルドは 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/PLAN49_build-image-argument.md index c282c1e8..0db1563c 100644 --- a/issues/PLAN49_build-image-argument.md +++ b/issues/PLAN49_build-image-argument.md @@ -322,3 +322,114 @@ Python の 2 実装が残ったままになり、タグ規約の食い違いを → **AC8(改): `image` 指定の単体ビルド経路で `docker` を起動する実装が 1 箇所(Python の `cmd_build`)だけになる。** compose ビルドの 1 段目である shell の `build_base_image` は 対象外とする(2026-09-02、決定 4 の理由による) + +--- + +# 実装計画 + +## 関連リンク + +- 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` は末尾に付ける。`image` が `devbase-` 始まりで渡された場合も二重に付かないよう接頭辞を剥がしてから付け直す。 +- **満たす受け入れ条件:** 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..352109b7 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,58 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: # cmd_build # --------------------------------------------------------------------------- -def cmd_build(image: str = None, no_cache: bool = False, +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_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-:latest` に揃える。`containers/` 配下のイメージは + # すべてこの規約で参照されており (compose.yml の image: / 他 Dockerfile の + # `FROM devbase-base:latest` / snapshot の SNAPSHOT_IMAGE)、接頭辞なしで + # ビルドすると `FROM devbase-*` から解決できず、ビルドした意味が失われる。 + # 利用者が `devbase-base` と接頭辞込みで渡しても二重に付けない。 + tag = f"devbase-{image.removeprefix('devbase-')}: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 +1013,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 +1022,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..b6eab41a --- /dev/null +++ b/tests/cli/test_build_image_argument.py @@ -0,0 +1,262 @@ +"""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_does_not_double_prefix(devbase_root, captured_run): + """`devbase-base` と渡されても `devbase-devbase-base` にしない。""" + (devbase_root / "containers" / "devbase-base").mkdir() + (devbase_root / "containers" / "devbase-base" / "Dockerfile").write_text("FROM x\n") + + container.cmd_build(image="devbase-base") + + assert "-t" in captured_run[0] + tag = captured_run[0][captured_run[0].index("-t") + 1] + assert tag == "devbase-base:latest" + + +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"] From 6096f9e01f944915177b8ca85a03fb8f8c87a4b8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 2 Sep 2026 14:03:00 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(build):=20=E5=8D=98=E4=BD=93=E3=83=93?= =?UTF-8?q?=E3=83=AB=E3=83=89=E3=81=AE=E3=82=BF=E3=82=B0=E3=82=92=E3=83=87?= =?UTF-8?q?=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA=E5=90=8D=E3=81=A8?= =?UTF-8?q?=201:1=20=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `removeprefix('devbase-')` を外し、タグを `containers/` 配下のディレクトリ名から 一意に導くようにした。接頭辞を剥がすと `containers/xxx` と `containers/devbase-xxx` が同じ `devbase-xxx:latest` を取り合い、別ディレクトリなのに互いのイメージを 上書きする衝突リスクがあったため。 `devbase build devbase-base` のように接頭辞込みで渡した場合は、既存の存在確認で `containers/devbase-base` を探して見つからず、探したパスを示して終了コード 1 で 終わる。黙って別のイメージを作るより明確な挙動になる。 テストは接頭辞の二重付与を禁じるものから、ディレクトリ名とタグの 1:1 対応 (`xxx` と `devbase-xxx` でタグが衝突しない)を固定するものへ書き換えた。 ドキュメントも同じ趣旨へ追従させた。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9tinzTAbF1LVEYvfUgzKx --- docs/user/cli-reference/02-project.md | 4 +++- lib/devbase/commands/container.py | 8 ++++++-- tests/cli/test_build_image_argument.py | 22 ++++++++++++++-------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 0b4ff61b..7314eb3b 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -224,7 +224,9 @@ docker buildx build --load -t devbase-:latest $DEVBASE_ROOT/containers/` にはディレクトリ名を +渡してください。`devbase build devbase-base` のように接頭辞込みで渡すと `containers/devbase-base` +を探して見つからず、終了コード 1 で終わります。 ビルドは 1 回だけで、compose イメージは巻き込みません。`containers/` または その `Dockerfile` が無い場合は、探したパスを表示して終了コード 1 で終わります。 diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 352109b7..0cc36dc2 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -983,8 +983,12 @@ def _build_single_image(image: str, no_cache: bool = False) -> int: # すべてこの規約で参照されており (compose.yml の image: / 他 Dockerfile の # `FROM devbase-base:latest` / snapshot の SNAPSHOT_IMAGE)、接頭辞なしで # ビルドすると `FROM devbase-*` から解決できず、ビルドした意味が失われる。 - # 利用者が `devbase-base` と接頭辞込みで渡しても二重に付けない。 - tag = f"devbase-{image.removeprefix('devbase-')}:latest" + # タグは `containers/` 配下のディレクトリ名から一意に導く。接頭辞を剥がすと + # `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 で作っており、ビルダが分かれると diff --git a/tests/cli/test_build_image_argument.py b/tests/cli/test_build_image_argument.py index b6eab41a..b1bf69b9 100644 --- a/tests/cli/test_build_image_argument.py +++ b/tests/cli/test_build_image_argument.py @@ -195,16 +195,22 @@ def test_single_build_appends_no_cache(devbase_root, captured_run): assert captured_run[0].count(str(devbase_root / "containers" / "base")) == 1 -def test_single_build_does_not_double_prefix(devbase_root, captured_run): - """`devbase-base` と渡されても `devbase-devbase-base` にしない。""" - (devbase_root / "containers" / "devbase-base").mkdir() - (devbase_root / "containers" / "devbase-base" / "Dockerfile").write_text("FROM x\n") +def test_single_build_tag_maps_one_to_one_to_directory(devbase_root, captured_run): + """タグは `containers/` 配下のディレクトリ名と 1:1 に対応する。 - container.cmd_build(image="devbase-base") + 接頭辞を剥がすと `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") - assert "-t" in captured_run[0] - tag = captured_run[0][captured_run[0].index("-t") + 1] - assert tag == "devbase-base:latest" + 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): From 3c5ec49ac78b5c5174c63362d317bc4aa8190644 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 2 Sep 2026 14:12:23 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(build):=20=E5=8D=98=E4=BD=93=E3=83=93?= =?UTF-8?q?=E3=83=AB=E3=83=89=E3=81=AE=20image=20=E5=90=8D=E3=82=92?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=81=97=E5=AE=9F=E8=A3=85=E8=A8=88=E7=94=BB?= =?UTF-8?q?=E3=82=92=E7=8F=BE=E4=BB=95=E6=A7=98=E3=81=B8=E6=8F=83=E3=81=88?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #144 のレビュー指摘 2 件に対応する。 - `_build_single_image` の先頭で `image` が `containers/` 配下の 1 ディレクトリ名として妥当か検証する。`/` `\` `..` 空文字などを弾き、 `DEVBASE_ROOT` の外を指すパスや Docker タグとして不正な名前を 渡させない。弾いた場合は理由を logger.error に出して 1 を返し、 docker は起動しない。 - `issues/PLAN49_build-image-argument.md` の Task 1 と決定 3 を、 round 2 で確定した「タグはディレクトリ名から一意に導き `devbase-` 接頭辞は剥がさない」仕様へ更新し、「受け入れ条件の変更」節へ 旧記述の取り消し線付きで理由と日付を追記する。 - 不正な image 名の拒否と実在ディレクトリ名の受理をテストで固定する。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9tinzTAbF1LVEYvfUgzKx --- issues/PLAN49_build-image-argument.md | 21 ++++++++++++- lib/devbase/commands/container.py | 15 ++++++++++ tests/cli/test_build_image_argument.py | 41 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/issues/PLAN49_build-image-argument.md b/issues/PLAN49_build-image-argument.md index 0db1563c..8a926d76 100644 --- a/issues/PLAN49_build-image-argument.md +++ b/issues/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,6 +328,19 @@ 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) + のレビュー指摘による) --- @@ -383,7 +402,7 @@ Python の 2 実装が残ったままになり、タグ規約の食い違いを ### 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` は末尾に付ける。`image` が `devbase-` 始まりで渡された場合も二重に付かないよう接頭辞を剥がしてから付け直す。 +- **変更内容:** `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 つの失敗系も同じ回で固定する。 diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 0cc36dc2..85fa513a 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -954,6 +954,11 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: # cmd_build # --------------------------------------------------------------------------- +# 単体ビルドで受け付けるイメージ名。`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)。 @@ -969,6 +974,16 @@ def _build_single_image(image: str, no_cache: bool = False) -> int: 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) diff --git a/tests/cli/test_build_image_argument.py b/tests/cli/test_build_image_argument.py index b1bf69b9..c2982768 100644 --- a/tests/cli/test_build_image_argument.py +++ b/tests/cli/test_build_image_argument.py @@ -266,3 +266,44 @@ def test_single_build_ignores_expires_with_warning(devbase_root, captured_run, c # 期限判定 (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] From 1430627c2a5fbcafa90c1f89e7db7a334965bfcd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 2 Sep 2026 14:15:55 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor(build):=20=E5=8D=98=E4=BD=93?= =?UTF-8?q?=E3=83=93=E3=83=AB=E3=83=89=E3=81=AE=E3=82=BF=E3=82=B0=E3=81=AE?= =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=81=8B=E3=82=89=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E3=81=97=E3=81=9F=E8=AA=AC=E6=98=8E=E3=82=92=E9=99=A4?= =?UTF-8?q?=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビューで方針が変わった際に、改訂前の説明 (接頭辞なしでは解決できない) と 改訂後の説明 (接頭辞を剥がさない) が並んで残っていた。1 つにまとめる。 振る舞いは変えない。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9tinzTAbF1LVEYvfUgzKx --- lib/devbase/commands/container.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 85fa513a..92b4e28f 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -994,13 +994,11 @@ def _build_single_image(image: str, no_cache: bool = False) -> int: logger.error("Dockerfile not found: %s", dockerfile) return 1 - # タグは `devbase-:latest` に揃える。`containers/` 配下のイメージは - # すべてこの規約で参照されており (compose.yml の image: / 他 Dockerfile の - # `FROM devbase-base:latest` / snapshot の SNAPSHOT_IMAGE)、接頭辞なしで - # ビルドすると `FROM devbase-*` から解決できず、ビルドした意味が失われる。 - # タグは `containers/` 配下のディレクトリ名から一意に導く。接頭辞を剥がすと - # `containers/xxx` と `containers/devbase-xxx` が同じタグを取り合い、ディレクトリが - # 別なのに互いのイメージを上書きしてしまうため、剥がさない。 + # タグは `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" From a52ced0106c9cfc36fd90a6915c386ba9e6c5815 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 2 Sep 2026 14:18:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs(issue139):=20=E7=A2=BA=E5=AE=9A?= =?UTF-8?q?=E4=BB=95=E6=A7=98=E3=82=92=20docs=20=E3=81=B8=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=E3=81=97=20PLAN49=20=E3=82=92=E3=82=A2=E3=83=BC?= =?UTF-8?q?=E3=82=AB=E3=82=A4=E3=83=96=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/developer/architecture.md: build の振り分けを現在の実装へ更新した。 「build だけが Bash 側に残っている」という記述は、 と --expires が Python 経路になった今は誤り。引数ごとの層と実体、単体ビルドのタグ規約、 期限判定の経路が再帰しない理由を表と本文で示す - CHANGELOG.md: Unreleased の Fixed へ #139 を追加 - issues/PLAN49_build-image-argument.md を issues/old/ へ移動 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9tinzTAbF1LVEYvfUgzKx --- CHANGELOG.md | 20 ++++++++++++ docs/developer/architecture.md | 32 +++++++++++++++---- .../{ => old}/PLAN49_build-image-argument.md | 0 3 files changed, 46 insertions(+), 6 deletions(-) rename issues/{ => old}/PLAN49_build-image-argument.md (100%) 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/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/issues/PLAN49_build-image-argument.md b/issues/old/PLAN49_build-image-argument.md similarity index 100% rename from issues/PLAN49_build-image-argument.md rename to issues/old/PLAN49_build-image-argument.md