diff --git a/CHANGELOG.md b/CHANGELOG.md index 779ea24..e8bda73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,24 @@ ローカル / WSL(Windows 側)/ VS Code Remote-SSH 統合ターミナル(手元クライアント側) を自動判別し、素の SSH では手元で実行するコマンドを提示します。エディタは `DEVBASE_EDITOR`(既定 `code`)で変更可能。詳細: `docs/user/environment-variables.md`。 +- **`devbase build` に `--expires[=DAYS]` を追加**しました (i07)。イメージ作成日が + DAYS 日(既定 7、`DEVBASE_IMAGE_MAX_AGE_DAYS` で上書き可)以上のときのみ no-cache で + 再ビルドし、未満なら再ビルドしません(既存イメージを使用)。親イメージ(`FROM devbase-*`)の + 作成日は独立して判定します。`devbase build` の `--no-cache` も明示フラグとして整理しました。 ### Changed +- **`build` / `rebuild` / `up` の再ビルド仕様を統一**しました (i07)。キャッシュの + 扱いを 3 モード(既定=キャッシュビルド / `--no-cache`=無条件 no-cache / `--expires=N`= + 期限切れ時のみ no-cache・期限内は再ビルドしない)に整理し、`devbase rebuild` を + `devbase build --expires=7` のシノニムに、`devbase up` の自動準備をその `rebuild` 相当に + 集約しました。`devbase rebuild` は従来の素の `docker compose build --no-cache` をやめ、 + devbase-base の 2 段ビルドと期限判定(期限内はスキップ)を行うようになりました。`devbase up` + の「7 日未満は再ビルドしない」挙動は従来どおり維持されます。 +- **`devbase up` の自動再ビルドで base イメージの日付判定を分離**しました。 + プロジェクトイメージが閾値(既定 7 日)超過で `--no-cache` 再ビルドされる際、 + ベースの作成日を独立して判定し、ベースが閾値内(新しい)であればベースを no-cache で + 作り直さず、プロジェクトイメージのみ no-cache で再ビルドします。ベースが古い、または + 判定できない場合はベースも含めて no-cache で再ビルドします。 - **シェル有効化を `bin/rc` の source に統一**しました (PLAN31_1)。`devbase init` 後に いま開いているシェルへ devbase(PATH / 補完)を即時適用するには `. ~/devbase/bin/rc`(= `source ~/devbase/bin/rc`)を使います。`bin/rc` は自身の diff --git a/bin/devbase b/bin/devbase index 2cb1b06..8b2db6e 100755 --- a/bin/devbase +++ b/bin/devbase @@ -64,6 +64,22 @@ export DEVBASE_ROOT cmd_build() { echo "=== Building devbase images ===" + # devbase build modes: + # --no-cache : rebuild base and project without cache + # --project-no-cache : prepare base with cache, rebuild project without cache + # The latter is used by the automatic staleness refresh path after it has + # already determined that the base image is still fresh. + local project_no_cache=0 + local -a build_args=() + local _arg + for _arg in "$@"; do + case "$_arg" in + --project-no-cache) project_no_cache=1 ;; + *) build_args+=("$_arg") ;; + esac + done + set -- "${build_args[@]}" + # Helper: Check if Dockerfile uses devbase-* base images check_base_image_dependency() { local dockerfile_path="$1" @@ -118,11 +134,43 @@ cmd_build() { fi fi - # --no-cache specified: always rebuild base first + local base_image_name=$(check_base_image_dependency "$dockerfile_path") + local base_image_to_build="${base_image_name:-devbase-base}" + + if [ "$project_no_cache" = "1" ]; then + echo "" + local -a base_cached_args=() + local _ba + for _ba in "$@"; do + if [ "$_ba" = "--no-cache" ]; then + continue + fi + base_cached_args+=("$_ba") + done + echo "[1/2] Building ${base_image_to_build} (using cache)..." + if ! build_base_image "$base_image_to_build" "${base_cached_args[@]}"; then + exit 1 + fi + + echo "" + echo "[2/2] Building project image without cache..." + if docker compose build "${DEV_SERVICE_NAME:-dev}" --no-cache "$@"; then + echo "" + echo "✓ All images built successfully" + else + echo "" + echo "✗ Failed to build project image" + exit 1 + fi + return + fi + + # --no-cache specified: rebuild base first without cache, then rebuild + # project without cache. if [[ "$*" == *"--no-cache"* ]]; then echo "" - echo "[1/2] Building devbase-base..." - if ! build_base_image "devbase-base" "$@"; then + echo "[1/2] Building ${base_image_to_build}..." + if ! build_base_image "$base_image_to_build" "$@"; then exit 1 fi @@ -140,7 +188,6 @@ cmd_build() { fi # Normal build: check if project uses devbase-* base image - local base_image_name=$(check_base_image_dependency "$dockerfile_path") if [ -n "$base_image_name" ]; then echo "" echo "[1/2] Project uses ${base_image_name}, building base image..." @@ -358,7 +405,24 @@ case "$_resolved_cmd" in init|status|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|rebuild|list) run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;; # Shell-implemented commands - build) cmd_build "${_DEVBASE_ARGS[@]}" ;; + # + # build: 既定 / --no-cache / は shell の cmd_build (devbase-base の + # 2 段ビルド) で処理する。--expires はイメージ作成日の判定が必要で、shell では + # RFC3339 日付パースが非可搬なため Python (project build) へ委譲する + # (i07: build --expires=N / rebuild / up が共通の期限リゾルバを使う)。 + build) + _has_expires=0 + for _ba in "${_DEVBASE_ARGS[@]}"; do + case "$_ba" in + --expires|--expires=*) _has_expires=1 ;; + esac + done + if [ "$_has_expires" = 1 ]; then + run_python project build "${_DEVBASE_ARGS[@]}" + else + cmd_build "${_DEVBASE_ARGS[@]}" + fi + ;; # Help and unknown -h|--help|help|"") run_python "--help" ;; *) echo "Error: unknown command '$1'" >&2; exit 1 ;; diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 842d8f4..43bb562 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -55,10 +55,11 @@ graph TD > **Note:** `logs` はトップレベルシノニムを持ちません。`devbase project logs` を使用してください。 > -> **※ `build` の転送先について:** `devbase build` は他のショートカットのように `project` グループ -> (Python 実装)へ転送されるのではなく、`bin/devbase` のシェル実装 `cmd_build` に直接委譲されます。 -> base イメージの段階ビルド等を CWD で行う必要があるためで、`devbase project build` とは実装経路が -> 異なります(名前指定はラッパーの `cd` で解決)。挙動上の入出力は同等ですが、実装は別物です。 +> **※ `build` の転送先について:** `devbase build`(既定 / `--no-cache` / ``)は他の +> ショートカットのように `project` グループ(Python 実装)へ転送されるのではなく、`bin/devbase` の +> シェル実装 `cmd_build` に直接委譲されます。base イメージの段階ビルド等を CWD で行う必要があるため +> です(名前指定はラッパーの `cd` で解決)。ただし `devbase build --expires[=DAYS]` のみ、作成日の +> 判定が必要なため例外的に Python 経路(`project build`)へ委譲されます。挙動上の入出力は同等です。 ### ユニークプレフィックスマッチング @@ -165,9 +166,13 @@ devbase up [name] - 起動時にスナップショットを自動作成(新世代 or 差分追加) - `CONTAINER_SCALE` の値に基づいてコンテナ数を決定 -- イメージの自動準備: +- イメージの自動準備(`devbase up` は `devbase rebuild`=`devbase build --expires=7` 相当を実行): - `build:` 定義あり、イメージ未存在 → `devbase build` を自動実行 - - `build:` 定義あり、イメージが7日以上古い → `devbase build --no-cache` で再ビルド + - `build:` 定義あり、イメージ存在 → プロジェクトイメージの作成日で再ビルドの要否を判定: + - 7日未満 → 再ビルドしない(既存イメージをそのまま使用) + - 7日以上 + ベースが閾値内=新しい → プロジェクトのみ no-cache(ベースはキャッシュ) + - 7日以上 + ベースが古い/判定不能 → ベースも含めて no-cache + - ベースイメージ `FROM devbase-*` の作成日はプロジェクトと独立して判定します - `image:` のみ(公開イメージ)、未存在 → `docker pull` を自動実行 - `image:` のみ、前回 pull から7日以上経過 → `docker pull` で再取得 (前回 pull 日時は `${DEVBASE_ROOT}/.cache/pulls/` の touch-file mtime で判定) @@ -261,21 +266,34 @@ devbase project scale adminer 3 ### `devbase project build` -コンテナイメージをビルドします。 +コンテナイメージをビルドします。キャッシュの扱いは 3 モードあります。 ``` devbase project build [image] -devbase build [image] +devbase build [image] [--no-cache | --expires[=DAYS]] ``` +| モード | 子イメージ | 親イメージ(`FROM devbase-*`) | +|--------|-----------|-------------------------------| +| `devbase build` | キャッシュがあれば使う | キャッシュがあれば使う | +| `devbase build --no-cache` | 無条件で no-cache | 無条件で no-cache | +| `devbase build --expires[=DAYS]` | DAYS 日以上古ければ no-cache、未満なら再ビルドしない | 親の作成日で独立に同判定 | + | パラメータ | 必須 | 説明 | |-----------|------|------| -| `image` | いいえ | ビルドするイメージ名(省略時は全イメージ) | +| `image` | いいえ | 単体ビルドするイメージ名(`$DEVBASE_ROOT/containers/` を直接ビルド。省略時は compose イメージ) | +| `--no-cache` | いいえ | base / project とも無条件でキャッシュ無視 | +| `--expires[=DAYS]` | いいえ | 作成日が DAYS 日以上のときのみ no-cache 再ビルド、未満なら再ビルドしない(既定 7、`DEVBASE_IMAGE_MAX_AGE_DAYS` で上書き可)。`--no-cache` とは併用しません | + +> **`--no-cache` / `--expires` は compose ビルド(`image` 省略時)に適用されます。** `image` 指定の +> 単体ビルドでは `--no-cache` のみ反映され、`--expires` は対象外です。`--expires` 付きビルドは +> 作成日判定のため Python 経路(`project build`)で処理されます。 ### `devbase project rebuild` -キャッシュを使わずにコンテナイメージを再ビルドします(`docker compose build --no-cache`)。 -`build` と異なり Python 実装で完結するため、トップレベルショートカット `devbase rebuild` を持ちます。 +`devbase build --expires=7` のシノニムです(既定 7 日)。プロジェクトイメージが 7 日以上古ければ +no-cache で再ビルドし、未満なら再ビルドしません(既存イメージを使用)。親イメージ(`FROM devbase-*`)の +作成日は独立して判定します。トップレベルショートカット `devbase rebuild` を持ちます。 ``` devbase project rebuild [name] diff --git a/issues/i07.md b/issues/i07.md new file mode 100644 index 0000000..40b53f0 --- /dev/null +++ b/issues/i07.md @@ -0,0 +1,129 @@ + +# devbase build / devbase up の仕様再考 + +`devbase build` と `devbase up` の build 機構が混乱してきたので、再ビルドの意味を +統一する。中心となるのは「キャッシュをどう扱うか」を以下の 3 モードに整理し、 +`rebuild` / `up` をその上に集約することである。 + +## 用語 +- **親 image (base image)**: `FROM devbase-*` で参照されるベースイメージ + (`devbase-base` など)。 +- **子 image (project image)**: compose の build 対象となるプロジェクトイメージ。 + +## build の 3 モード + +| 指定 | 子 image | 親 image | +|---|---|---| +| `devbase build` | キャッシュビルド(常に実行) | キャッシュビルド(常に実行) | +| `devbase build --no-cache` | 無条件で no-cache | 無条件で no-cache | +| `devbase build --expires=n` | n 日以上古ければ no-cache、未満なら再ビルドしない | 親の作成日で独立に同判定 | + +- `--expires=n` の `n` のデフォルトは 7。 +- `--expires=n` で **project が n 日未満なら再ビルドを実行しない**(既存イメージを + そのまま使う)。n 日以上のときだけ no-cache で作り直す。 +- `--expires=n` における親 image の判定は、**親 image 自身の作成日**で行う + (親が n 日以上古ければ親も no-cache、未満なら親はキャッシュ利用)。 + つまり「子は古いが親は新しい」場合、親はキャッシュを使い子だけ no-cache で + 作り直す。 +- `--no-cache` と `--expires` は併用しない (意味が矛盾するため)。`--no-cache` は + 無条件、`--expires` は条件付き、と明確に分ける。 + +## rebuild / up + +- `devbase rebuild` は `devbase build --expires=7` のシノニム + (デフォルトの 7 日で判定する)。 +- `devbase up` 時は、`devbase rebuild` 相当の build を実行する。 + +## 補足: image-only サービス (公開イメージ) の扱い + +`build:` を持たず `image:` のみのサービスについては、作成日 (`Created`) が +upstream のビルド時刻であってローカルの鮮度を表さないため、`--expires` 判定は +**前回 pull 日時** (touch-file の mtime) を基準とし、n 日以上経過していれば +`docker pull` で再取得する。 + +--- + +# 実装プラン + +## 現状の構造 (混乱の原因) + +再ビルドの概念が 3 つの別実装に散らばっている: + +1. **shell `cmd_build`** (`bin/devbase`): base 検出 + 2 段ビルドの機械的処理。 + 現状フラグは `--no-cache` (両方 no-cache) / `--project-no-cache` (base は + cache・project は no-cache)。 +2. **Python `cmd_rebuild`** (`container.py`): 素の `docker compose build --no-cache`。 + base 処理も期限判定もしない → 仕様から最も乖離。 +3. **Python `_ensure_images` → `_rebuild_if_stale` → `_base_image_is_fresh`** + (`up` 経路): 作成日による期限判定 + base 独立判定を持ち、結果に応じて shell + `cmd_build` を `--no-cache` / `--project-no-cache` で呼ぶ。 + +期限判定 (RFC3339 作成日のパース、base の独立判定) は **③ に既に実装済み**。 +shell では日付パースが困難なため、判定は Python に集約し、shell は機械的 3 モード +に徹する、という方針で統一する。 + +## 設計方針 + +- **shell `cmd_build`** は機械的な 3 モードのみを担う (現状維持): + デフォルト (cache) / `--no-cache` (両方 no-cache) / `--project-no-cache` + (base cache・project no-cache)。`--expires` の日付判定は持たせない。 +- **`--expires=n` の解決は Python に集約**する。project image / base image の + 作成日を inspect し、上表のどのモードで shell を呼ぶかを決める共通リゾルバを + 用意し、`build --expires` / `rebuild` / `up` の 3 経路から再利用する。 + 既存の `_rebuild_if_stale` / `_base_image_is_fresh` / `_get_image_age_days` を + この共通リゾルバに一般化する。 + +## 変更点 + +### 1. 共通リゾルバの導入 (`container.py`) +- `_rebuild_if_stale` を一般化した `_resolve_build_mode(expires, image_name, + inspect_json, dev_service)` を作り、戻り値に応じて `_run_build()` を呼ぶ。 + - project が期限内 → no-op (cache 利用、再ビルド不要) + - project が期限超過 + base 期限内 → `_run_build(project_no_cache=True)` + - project が期限超過 + base 期限超過/判定不能 → `_run_build(no_cache=True)` + +### 2. `cmd_rebuild` を `build --expires=7` のシノニムに (`container.py`) +- 現状の `docker compose build --no-cache` 直呼びを廃止。 +- compose config から dev サービスを解決し、共通リゾルバを `expires=7` + (= `_image_max_age_days()`) で呼ぶ実装に置き換える。 +- これにより base 2 段ビルドと期限判定が rebuild にも効くようになる。 + +### 3. `devbase build` に `--expires` / `--no-cache` を追加 +- **CLI**: `_add_build_subparser` (`cli.py`) に `--no-cache` / `--expires N` + (`nargs='?'`, default なし、`--expires` 単独時は 7) を追加。 +- **shell ルーティング** (`bin/devbase`): top-level `build` は現状 shell `cmd_build` + へ直行している。`--expires` 付き呼び出しは日付判定が必要なため Python 経路へ + 振り分ける (例: `build --expires` を検出したら `run_python build ...` に回す)。 + `--no-cache` / 引数なしは従来どおり shell `cmd_build` で機械処理してよい。 + ※ build の CWD 依存 (PLAN06) は、Python が `bash bin/devbase build` を + 呼び戻す既存の `_run_build` 経路で担保されている (wrapper が既に cd 済み)。 + +### 4. `up` 経路を rebuild に集約 (`container.py`) +- `_ensure_images` の「build 定義あり」分岐 (`_rebuild_if_stale`) を、2 で作る + rebuild 相当 (= 共通リゾルバ) に寄せる。`up` = `rebuild` 相当を実体化する。 +- ただし `up` は rebuild に無い責務 (イメージ未存在時の build/pull、image-only + サービスの期限切れ pull) も持つため、それらは `_ensure_images` 側に残す。 + 「build 定義あり + イメージ存在」のケースのみ共通リゾルバへ委譲する。 + +### 5. ドキュメント / テスト +- `docs/user/cli-reference.md`: build の 3 モード表、`rebuild` = `build + --expires=7`、`up` = `rebuild` 相当を反映。 +- `CHANGELOG.md`: 仕様統一を追記。 +- テスト: + - `test_base_image_staleness.py` の `--base-cache` 否定アサーション 3 箇所 + (存在しない引数名の見張り) を削除し、`--project-no-cache` の肯定検証に統一。 + - `--expires` の境界 (n 日未満=cache / n 日以上=no-cache)、`rebuild` = + `build --expires=7` のシノニム、`up` 経路が共通リゾルバを通ることのテストを追加。 + +## 影響範囲 +- `bin/devbase` (cmd_build ルーティング) +- `lib/devbase/cli.py` (build parser に `--no-cache` / `--expires`) +- `lib/devbase/commands/container.py` (共通リゾルバ・cmd_rebuild・_ensure_images) +- `docs/user/cli-reference.md`, `CHANGELOG.md` +- `tests/cli/test_base_image_staleness.py` + +## 進め方 (推奨ステップ) +1. 共通リゾルバ抽出 + `cmd_rebuild` 置換 (内部のみ、CLI 変更なし) — テスト先行。 +2. `build --expires` / `--no-cache` の CLI + shell ルーティング追加。 +3. `up` 経路 (`_ensure_images`) をリゾルバへ集約。 +4. docs / CHANGELOG / テスト整理。 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 5248bdf..734c7ce 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -35,7 +35,7 @@ 'login': 'login', 'ps': 'ps', 'scale': 'scale', - # `rebuild` は Python 実装 (cmd_rebuild = docker compose build --no-cache) で + # `rebuild` は Python 実装 (cmd_rebuild = build --expires=7 相当の期限判定ビルド) で # 完結するため `build` と異なりトップレベルショートカットに含めてよい # (build は shell 実装に委譲するため除外している。上の NOTE 参照)。 'rebuild': 'rebuild', @@ -137,6 +137,18 @@ def _add_build_subparser(sub): """ p = sub.add_parser('build', help='Build container images') p.add_argument('image', nargs='?', default=None, help='Image name') + # `--no-cache` と `--expires` は仕様上併用しない (無条件 no-cache か期限判定の + # いずれか)。併用すると no-cache が優先され --expires が黙殺されるため、 + # add_mutually_exclusive_group で CLI レベルの排他制御を行い usage error で落とす。 + build_mode = p.add_mutually_exclusive_group() + build_mode.add_argument('--no-cache', action='store_true', + help='Rebuild base and project images without cache') + # `--expires` 単独 (値なし) は const=-1 を渡し、cmd_build 側で既定日数 + # (_image_max_age_days, 環境変数 DEVBASE_IMAGE_MAX_AGE_DAYS 既定 7) に解決する。 + build_mode.add_argument('--expires', nargs='?', type=int, const=-1, default=None, + metavar='DAYS', + help='Rebuild without cache only if the image is older than ' + 'DAYS days (default 7). Base image is judged independently.') def _add_container_parser(subparsers): @@ -162,7 +174,7 @@ def _add_container_parser(subparsers): _add_build_subparser(ct_sub) - ct_sub.add_parser('rebuild', help='Rebuild images without cache (docker compose build --no-cache)') + ct_sub.add_parser('rebuild', help='Rebuild stale images (= build --expires=7)') def _add_project_parser(subparsers): @@ -207,12 +219,12 @@ def _add_project_parser(subparsers): _add_build_subparser(pj_sub) - # `rebuild` は Python 実装 (docker compose build --no-cache)。up/down 同様に + # `rebuild` は Python 実装 (`build --expires=7` 相当の期限判定ビルド)。up/down 同様に # 省略可能な `[name]` を取り、name 指定時は _dispatch_lifecycle が chdir してから # 実行する。wrapper の _PROJECT_NAME_SUBCOMMANDS / _NAME_RESOLVABLE_SHORTCUTS にも # 追加すること。 _add_name_arg(pj_sub.add_parser( - 'rebuild', help='Rebuild images without cache (docker compose build --no-cache)')) + 'rebuild', help='Rebuild stale images (= build --expires=7)')) # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 @@ -472,7 +484,7 @@ def _add_shortcuts(subparsers): # `rebuild` は project rebuild のトップレベルシノニム (Python 実装のため build と # 異なりショートカット可)。up/down と同じく `[name]` を受け付ける。 _add_name_arg(subparsers.add_parser( - 'rebuild', help='Rebuild images without cache (docker compose build --no-cache)')) + 'rebuild', help='Rebuild stale images (= build --expires=7)')) # `list` は `project list` のトップレベルシノニム。lifecycle ではなく一覧表示 # のため SHORTCUTS (project lifecycle へ写像) ではなく _dispatch で個別に @@ -493,7 +505,7 @@ def _create_parser(): " login project login\n" " ps project ps\n" " scale project scale\n" - " rebuild project rebuild (docker compose build --no-cache)\n" + " rebuild project rebuild (= build --expires=7)\n" "\n" "Note: `container` is deprecated; use `project` instead.\n" ) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index f3d7861..8711cdf 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -335,7 +335,9 @@ def _dispatch_lifecycle(args) -> int: 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)), + '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(), } @@ -685,9 +687,28 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: # cmd_build # --------------------------------------------------------------------------- -def cmd_build(image: str = None) -> int: - """Build container images""" +def cmd_build(image: 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`` なし + フラグなし: 通常のキャッシュビルド。 + - ``image`` なし + ``--no-cache``: base / project とも無条件 no-cache。 + - ``image`` なし + ``--expires=N``: project の作成日で期限判定し、N 日以上なら + no-cache (base は独立判定)、N 日未満なら再ビルドしない (既存イメージを使用)。 + + フラグなしの compose ビルドも、devbase-base の 2 段ビルドを行う shell + ``cmd_build`` (``bin/devbase``) 経由 (:func:`_build_resolved` → :func:`_run_build`) + に統一する。``image`` 指定の単体ビルドのみ直接 ``docker build`` する。 + """ if image is not None: + # 単体ビルド (image 指定) では期限判定を行わないため --expires は無視される。 + # 誤併用に気付けるよう警告を出す。 + 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") @@ -704,52 +725,92 @@ def cmd_build(image: str = None) -> int: return 1 logger.info("Building image '%s' from %s ...", image, image_dir) - result = subprocess.run( - ['docker', 'build', '-t', image, str(image_dir)], - check=False - ) + cmd = ['docker', 'build', '-t', image, str(image_dir)] + if no_cache: + cmd.append('--no-cache') + result = subprocess.run(cmd, check=False) return result.returncode - compose_file = Path('compose.yml') - if not compose_file.exists(): - logger.error("compose.yml not found in current directory") - return 1 - - logger.info("Building images from compose.yml ...") - result = subprocess.run( - ['docker', 'compose', 'build'], - check=False - ) - return result.returncode + # `--expires` 単独 (値なし) は sentinel -1。既定日数へ解決する。 + if expires is not None and expires < 0: + expires = _image_max_age_days() + return _build_resolved(expires=expires, no_cache=no_cache) # --------------------------------------------------------------------------- # cmd_rebuild # --------------------------------------------------------------------------- -def cmd_rebuild() -> int: - """Rebuild project images without cache (``docker compose build --no-cache``). +def _resolve_dev_service() -> Optional[dict]: + """compose config から dev サービス定義を取得する。失敗時は None。""" + result = subprocess.run( + ['docker', 'compose', 'config', '--format', 'json'], + capture_output=True, text=True, check=False + ) + if result.returncode != 0: + return None + try: + config = json.loads(result.stdout) + except json.JSONDecodeError: + return None + return config.get('services', {}).get(get_dev_service_name(), {}) + + +def _build_resolved(expires: Optional[int], no_cache: bool) -> int: + """``devbase build [--expires N | --no-cache]`` / ``rebuild`` の共通エントリ。 - cmd_build がレイヤーキャッシュを使うのに対し、こちらはキャッシュを無効化して - プロジェクト (compose) イメージを作り直す。``devbase rebuild`` / ``devbase project - rebuild [name]`` のエントリ。 + - ``no_cache=True`` : 無条件で base / project とも no-cache 再ビルド + - ``expires`` 指定 : project の作成日で期限判定し、:func:`_build_with_expires` + に委譲 (base は独立判定) + - どちらも無し : 通常のキャッシュビルド - 注意: シェルラッパー (``bin/devbase`` の ``build --no-cache``) のような - devbase-base の 2 段ビルドは行わず、compose の build 対象サービスのみを - no-cache で再ビルドする。base まで作り直す場合は ``devbase build --no-cache`` - を使う。 + プロセス互換の終了コードを返す (0=成功)。 """ - compose_file = Path('compose.yml') - if not compose_file.exists(): + if not Path('compose.yml').exists(): logger.error("compose.yml not found in current directory") return 1 - logger.info("Rebuilding images without cache from compose.yml ...") - result = subprocess.run( - ['docker', 'compose', 'build', '--no-cache'], - check=False + if no_cache: + return 0 if _run_build(no_cache=True) else 1 + if expires is None: + return 0 if _run_build() else 1 + + # expires 指定: project イメージの作成日と dev サービス定義 (base 判定用) が必要。 + dev_service = _resolve_dev_service() + if not dev_service: + logger.info("Unable to read compose config; building with cache") + return 0 if _run_build() else 1 + image_name = dev_service.get('image', '') + if not image_name: + return 0 if _run_build() else 1 + inspect = subprocess.run( + ['docker', 'image', 'inspect', image_name], + capture_output=True, text=True, check=False ) - return result.returncode + if inspect.returncode != 0: + # イメージ未存在 → キャッシュビルドで作成する。 + logger.info("Container image '%s' not found; building...", image_name) + return 0 if _run_build() else 1 + return 0 if _build_with_expires(expires, image_name, inspect.stdout, dev_service) else 1 + + +def cmd_rebuild(expires: int = None) -> int: + """Rebuild project images honoring an expiry window (``build --expires=N`` synonym). + + ``devbase rebuild`` は ``devbase build --expires=7`` のシノニム (既定 7 日)。 + shell ラッパー (``bin/devbase`` の ``cmd_build``) を経由して devbase-base の + 2 段ビルドと期限判定を行う: + + - project が期限内 → 再ビルドしない (既存イメージを使用) + - project が期限超過 + base 新しい → project のみ no-cache (base はキャッシュ) + - project が期限超過 + base 古い/判定不能 → base も含めて no-cache + + ``devbase rebuild`` / ``devbase project rebuild [name]`` のエントリ。 + """ + if expires is None: + expires = _image_max_age_days() + logger.info("Rebuilding images (expires=%d days) from compose.yml ...", expires) + return _build_resolved(expires=expires, no_cache=False) # --------------------------------------------------------------------------- @@ -839,8 +900,13 @@ def _ensure_images() -> bool: Behavior (threshold = DEVBASE_IMAGE_MAX_AGE_DAYS or 7): - Image missing + has build: → run `devbase build` - Image missing + image-only (no build:) → run `docker pull` - - Image present and >= threshold days old + has build: - → rebuild with `--no-cache` (uses image 'Created' timestamp) + - Image present + has build: → run the shared expiry resolver + (`devbase up` = `devbase rebuild` 相当, i07). Rebuild is gated by the + project image 'Created' age: + * younger than threshold → no rebuild (existing image kept) + * >= threshold, base fresh → project no-cache, base cached + * >= threshold, base stale/unknown → both no-cache + Base image (`FROM devbase-*`) freshness is judged independently. - Image present + image-only + last-pull >= threshold days old → run `docker pull` (uses local touch-file mtime, since image 'Created' reflects upstream build time and is not a meaningful @@ -890,7 +956,11 @@ def _ensure_images() -> bool: return _fetch_missing_image(image_name, has_build) if not has_build: return _repull_if_stale(image_name) - return _rebuild_if_stale(image_name, inspect.stdout) + # build 定義あり + イメージ存在 → rebuild と同じ期限リゾルバへ委譲する + # (devbase up = devbase rebuild 相当。i07 仕様統一)。 + return _build_with_expires( + _image_max_age_days(), image_name, inspect.stdout, dev_service + ) except Exception as e: logger.warning("Error checking image: %s", e) @@ -936,20 +1006,111 @@ def _repull_if_stale(image_name: str) -> bool: return _pull_and_mark(image_name) -def _rebuild_if_stale(image_name: str, inspect_json: str) -> bool: - """build 定義のあるサービスの鮮度チェック: イメージ作成日が閾値超過なら no-cache 再ビルド。""" - max_age = _image_max_age_days() +def _build_with_expires(expires: int, image_name: str, inspect_json: str, + dev_service: dict) -> bool: + """期限ウィンドウに従って build 定義のあるサービスを再ビルドする共通リゾルバ。 + + ``devbase build --expires=N`` / ``devbase rebuild`` (= ``build --expires=7``) / + ``devbase up`` の自動準備経路が共有する。project イメージの作成日で再ビルドの + 要否とキャッシュの扱いを切り替える: + + - project が ``expires`` 日未満 (または判定不能) → 再ビルドしない (既存 + イメージをそのまま使う) + - project が ``expires`` 日以上 + base が閾値内 (新しい) → project のみ + no-cache、base はキャッシュ利用 (``--project-no-cache``) + - project が ``expires`` 日以上 + base が古い/判定不能 → base も含めて + no-cache (``--no-cache``) + + base イメージ (``FROM devbase-*``) の作成日は project とは独立して判定する。 + """ age_days = _get_image_age_days(inspect_json) - if age_days is None or age_days < max_age: + if age_days is None or age_days < expires: + if age_days is not None: + logger.info( + "Container image '%s' is %d days old (< %d days threshold); " + "skipping rebuild (existing image is fresh)", + image_name, age_days, expires + ) return True logger.info( "Container image '%s' is %d days old (>= %d days threshold)", - image_name, age_days, max_age + image_name, age_days, expires ) - logger.info("Rebuilding with --no-cache...") + if _base_image_is_fresh(dev_service, expires): + logger.info("Rebuilding project without cache (base is fresh)...") + return _run_build(project_no_cache=True) + logger.info("Rebuilding base and project without cache...") return _run_build(no_cache=True) +def _base_image_is_fresh(dev_service: dict, max_age: int) -> bool: + """ベースイメージ (``FROM devbase-*``) が閾値内に作成されたものか判定する。 + + True なら base は新しいため project だけ no-cache で再ビルドする。判定不能 + (ベース未検出 / inspect 失敗 / 日付解析失敗) の場合は False を返し、base も + 含めて no-cache で再ビルドする。 + """ + base_ref = _get_base_image_ref(dev_service) + if not base_ref: + return False + inspect = subprocess.run( + ['docker', 'image', 'inspect', base_ref], + capture_output=True, + text=True, + check=False + ) + if inspect.returncode != 0: + return False + age_days = _get_image_age_days(inspect.stdout) + if age_days is None: + return False + if age_days < max_age: + logger.info( + "Base image '%s' is %d days old (< %d days threshold)", + base_ref, age_days, max_age + ) + return True + logger.info( + "Base image '%s' is %d days old (>= %d days threshold)", + base_ref, age_days, max_age + ) + return False + + +def _get_base_image_ref(dev_service: dict) -> Optional[str]: + """dev サービスの Dockerfile の ``FROM devbase-*`` からベースイメージ参照を得る。 + + 例: ``FROM devbase-base:latest`` -> ``devbase-base:latest`` + ``FROM devbase-base`` -> ``devbase-base:latest`` (tag 補完) + 見つからない / 読めない場合は None。 + """ + build = dev_service.get('build') + if not build: + return None + if isinstance(build, str): + context, dockerfile = build, 'Dockerfile' + else: + context = build.get('context', '.') + dockerfile = build.get('dockerfile', 'Dockerfile') + df_path = Path(dockerfile) + if not df_path.is_absolute(): + df_path = Path(context) / dockerfile + try: + text = df_path.read_text(encoding='utf-8', errors='replace') + except OSError: + return None + for line in text.splitlines(): + # FROM は小文字 (`from`) も許容され、`--platform=...` が前置されることがある。 + m = re.match(r'\s*FROM\s+(?:--platform=\S+\s+)?(devbase-\S+)', + line, re.IGNORECASE) + if m: + ref = m.group(1) + if ':' not in ref: + ref += ':latest' + return ref + return None + + def _pull_and_mark(image_name: str) -> bool: """docker pull を実行し、成功時は pull マーカーを更新する。""" ok = _run_pull(image_name) @@ -979,8 +1140,12 @@ def _get_image_age_days(inspect_json: str) -> Optional[int]: return None -def _run_build(no_cache: bool = False) -> bool: - """Run the build command (optionally with --no-cache).""" +def _run_build(no_cache: bool = False, project_no_cache: bool = False) -> bool: + """Run the build command. + + no_cache=True rebuilds base and project without cache. + project_no_cache=True rebuilds only the project image without cache. + """ devbase_root = Path(os.environ.get('DEVBASE_ROOT', '')) if not devbase_root.exists(): logger.error("DEVBASE_ROOT not set") @@ -992,7 +1157,9 @@ def _run_build(no_cache: bool = False) -> bool: return False cmd = ['bash', str(devbase_bin), 'build'] - if no_cache: + if project_no_cache: + cmd.append('--project-no-cache') + elif no_cache: cmd.append('--no-cache') try: diff --git a/tests/cli/test_base_image_staleness.py b/tests/cli/test_base_image_staleness.py new file mode 100644 index 0000000..c99a941 --- /dev/null +++ b/tests/cli/test_base_image_staleness.py @@ -0,0 +1,201 @@ +"""devbase up の自動再ビルドにおける base イメージ日付判定のテスト。 + +project イメージが閾値超過で再ビルド対象になっても、base イメージが閾値内なら +base を no-cache build しない。base が古い/判定不能な場合だけ、base も含めて +no-cache build する。 +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from devbase.commands import container + + +def _inspect_json(days_old: int) -> str: + created = (datetime.now(timezone.utc) - timedelta(days=days_old)).isoformat() + return json.dumps([{"Created": created}]) + + +def test_get_base_image_ref_parses_from(tmp_path): + (tmp_path / "Dockerfile").write_text("FROM devbase-base:latest\nRUN echo hi\n") + dev_service = {"build": {"context": str(tmp_path), "dockerfile": "Dockerfile"}} + assert container._get_base_image_ref(dev_service) == "devbase-base:latest" + + +def test_get_base_image_ref_appends_latest_tag(tmp_path): + (tmp_path / "Dockerfile").write_text("FROM devbase-general\n") + dev_service = {"build": {"context": str(tmp_path), "dockerfile": "Dockerfile"}} + assert container._get_base_image_ref(dev_service) == "devbase-general:latest" + + +def test_get_base_image_ref_none_when_not_devbase(tmp_path): + (tmp_path / "Dockerfile").write_text("FROM ubuntu:noble\n") + dev_service = {"build": {"context": str(tmp_path), "dockerfile": "Dockerfile"}} + assert container._get_base_image_ref(dev_service) is None + + +def test_get_base_image_ref_string_build(tmp_path): + (tmp_path / "Dockerfile").write_text("FROM devbase-base\n") + dev_service = {"build": str(tmp_path)} + assert container._get_base_image_ref(dev_service) == "devbase-base:latest" + + +def test_base_image_is_fresh_true(monkeypatch): + monkeypatch.setattr(container, "_get_base_image_ref", lambda _s: "devbase-base:latest") + + class _R: + returncode = 0 + stdout = _inspect_json(2) + + monkeypatch.setattr(container.subprocess, "run", lambda *a, **k: _R()) + assert container._base_image_is_fresh({}, 7) is True + + +def test_base_image_is_fresh_false_when_stale(monkeypatch): + monkeypatch.setattr(container, "_get_base_image_ref", lambda _s: "devbase-base:latest") + + class _R: + returncode = 0 + stdout = _inspect_json(10) + + monkeypatch.setattr(container.subprocess, "run", lambda *a, **k: _R()) + assert container._base_image_is_fresh({}, 7) is False + + +def test_build_with_expires_skips_when_project_fresh(monkeypatch): + """project が期限内なら再ビルドせず (ビルドを一切呼ばず) True を返す。""" + called = [] + monkeypatch.setattr(container, "_run_build", lambda **k: called.append(k) or True) + assert container._build_with_expires(7, "dev:latest", _inspect_json(1), {}) is True + assert called == [] + + +def test_build_with_expires_project_only_when_base_fresh(monkeypatch): + captured = {} + monkeypatch.setattr(container, "_base_image_is_fresh", lambda _s, _m: True) + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + assert container._build_with_expires(7, "dev:latest", _inspect_json(10), {}) is True + assert captured == {"project_no_cache": True} + + +def test_build_with_expires_full_no_cache_when_base_stale(monkeypatch): + captured = {} + monkeypatch.setattr(container, "_base_image_is_fresh", lambda _s, _m: False) + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + assert container._build_with_expires(7, "dev:latest", _inspect_json(10), {}) is True + assert captured == {"no_cache": True} + + +def _setup_devbase_root(tmp_path, monkeypatch): + (tmp_path / "bin").mkdir() + (tmp_path / "bin" / "devbase").write_text("#!/bin/bash\n") + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + + +def test_run_build_project_no_cache_flag(tmp_path, monkeypatch): + _setup_devbase_root(tmp_path, monkeypatch) + captured = {} + + class _R: + returncode = 0 + + monkeypatch.setattr( + container.subprocess, + "run", + lambda cmd, **k: captured.update(cmd=cmd) or _R(), + ) + assert container._run_build(project_no_cache=True) is True + assert captured["cmd"][-2:] == ["build", "--project-no-cache"] + + +def test_run_build_no_cache_flag(tmp_path, monkeypatch): + _setup_devbase_root(tmp_path, monkeypatch) + captured = {} + + class _R: + returncode = 0 + + monkeypatch.setattr( + container.subprocess, + "run", + lambda cmd, **k: captured.update(cmd=cmd) or _R(), + ) + assert container._run_build(no_cache=True) is True + assert captured["cmd"][-2:] == ["build", "--no-cache"] + + +def test_wrapper_has_project_no_cache_mode(): + wrapper = (Path(__file__).resolve().parents[2] / "bin" / "devbase").read_text() + assert "--project-no-cache) project_no_cache=1" in wrapper + assert 'docker compose build "${DEV_SERVICE_NAME:-dev}" --no-cache "$@"' in wrapper + + +# --------------------------------------------------------------------------- +# _build_resolved: build --expires / rebuild の共通エントリ +# --------------------------------------------------------------------------- + +def _make_compose(tmp_path, monkeypatch): + (tmp_path / "compose.yml").write_text("services: {}\n") + monkeypatch.chdir(tmp_path) + + +def test_build_resolved_missing_compose_returns_1(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert container._build_resolved(expires=7, no_cache=False) == 1 + + +def test_build_resolved_no_cache_skips_inspection(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + captured = {} + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + + def _boom(): + raise AssertionError("no_cache path must not inspect compose") + + monkeypatch.setattr(container, "_resolve_dev_service", _boom) + assert container._build_resolved(expires=None, no_cache=True) == 0 + assert captured == {"no_cache": True} + + +def test_build_resolved_plain_when_no_expires(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + captured = {} + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + assert container._build_resolved(expires=None, no_cache=False) == 0 + assert captured == {} + + +def test_build_resolved_expires_missing_image_builds_cached(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + monkeypatch.setattr(container, "_resolve_dev_service", lambda: {"image": "dev:latest"}) + + class _R: + returncode = 1 # docker image inspect → 未存在 + stdout = "" + + monkeypatch.setattr(container.subprocess, "run", lambda *a, **k: _R()) + captured = {} + monkeypatch.setattr(container, "_run_build", lambda **k: captured.update(k) or True) + assert container._build_resolved(expires=7, no_cache=False) == 0 + assert captured == {} + + +def test_build_resolved_expires_present_delegates(tmp_path, monkeypatch): + _make_compose(tmp_path, monkeypatch) + monkeypatch.setattr(container, "_resolve_dev_service", lambda: {"image": "dev:latest"}) + + class _R: + returncode = 0 + stdout = _inspect_json(10) + + monkeypatch.setattr(container.subprocess, "run", lambda *a, **k: _R()) + seen = {} + monkeypatch.setattr( + container, "_build_with_expires", + lambda expires, image, inspect_json, dev: seen.update(expires=expires, image=image) or True, + ) + assert container._build_resolved(expires=7, no_cache=False) == 0 + assert seen == {"expires": 7, "image": "dev:latest"} diff --git a/tests/cli/test_build_shortcut_consistency.py b/tests/cli/test_build_shortcut_consistency.py index d49182e..122cfd3 100644 --- a/tests/cli/test_build_shortcut_consistency.py +++ b/tests/cli/test_build_shortcut_consistency.py @@ -52,14 +52,21 @@ def test_project_build_subcommand_still_available(): assert ns.image == "myimage" -def test_wrapper_routes_build_to_shell_not_python(): - # bin/devbase の dispatch で build は shell の cmd_build に委譲され、 - # Python 用 run_python の case には含まれないことを確認する。 +def test_wrapper_routes_build_default_to_shell(): + # bin/devbase の dispatch で build の既定経路 (--expires なし) は shell の + # cmd_build に委譲される (i07: --expires のみ Python へ委譲)。 wrapper = (Path(__file__).resolve().parents[2] / "bin" / "devbase").read_text() - # build は専用の shell ケースへ (PLAN06 Task 2 で name strip 後の _DEVBASE_ARGS - # を渡す形に変更。引数は wrapper 側の name 解決で既にコマンド/名を除去済み)。 - assert "build) cmd_build" in wrapper or "build) cmd_build" in wrapper - # run_python に委譲する case 行に build が紛れ込んでいないこと + # build) ケース内に既定経路の cmd_build 委譲が存在する。 + assert 'cmd_build "${_DEVBASE_ARGS[@]}"' in wrapper + # 既定の run_python 委譲 (`run_python "${_resolved_cmd}"`) の case 行には + # build が含まれない (build は専用ケースで処理する)。 for line in wrapper.splitlines(): if "run_python" in line and "${_resolved_cmd}" in line: assert "build" not in line + + +def test_wrapper_routes_build_expires_to_python(): + # build --expires は作成日判定のため Python (project build) へ委譲する。 + wrapper = (Path(__file__).resolve().parents[2] / "bin" / "devbase").read_text() + assert "--expires|--expires=*) _has_expires=1" in wrapper + assert 'run_python project build "${_DEVBASE_ARGS[@]}"' in wrapper diff --git a/tests/cli/test_rebuild.py b/tests/cli/test_rebuild.py index 8efcfd1..f2c879c 100644 --- a/tests/cli/test_rebuild.py +++ b/tests/cli/test_rebuild.py @@ -1,9 +1,9 @@ -"""i30: `devbase rebuild` (docker compose build --no-cache 相当) のテスト。 +"""i30/i07: `devbase rebuild` (= `devbase build --expires=7` のシノニム) のテスト。 - parser: `project rebuild [name]` / `container rebuild` / top-level `rebuild [name]` - SHORTCUTS / SUBCMD_MAP への登録 - `_dispatch_lifecycle` が rebuild を cmd_rebuild へ振り分ける -- cmd_rebuild の振る舞い (compose.yml 不在=1 / 存在時に docker compose build --no-cache) +- cmd_rebuild の振る舞い (compose.yml 不在=1 / 既定 expires で _build_resolved へ委譲) - wrapper (bin/devbase) が rebuild を Python 経路へ流す (shell build 経路ではない) """ @@ -101,28 +101,21 @@ def test_cmd_rebuild_missing_compose(tmp_path, monkeypatch): assert container.cmd_rebuild() == 1 -def test_cmd_rebuild_runs_no_cache_build(tmp_path, monkeypatch): +def test_cmd_rebuild_delegates_to_build_resolved_with_default_expires(monkeypatch): + """rebuild は build --expires= のシノニム: 既定日数で _build_resolved に委譲する。""" from devbase.commands import container - (tmp_path / 'compose.yml').write_text('services: {}\n') - monkeypatch.chdir(tmp_path) - captured = {} - - def fake_run(cmd, check=False): - captured['cmd'] = cmd - return types.SimpleNamespace(returncode=0) - - monkeypatch.setattr(container.subprocess, 'run', fake_run) + monkeypatch.setattr(container, '_image_max_age_days', lambda: 7) + monkeypatch.setattr(container, '_build_resolved', + lambda expires, no_cache: captured.update(expires=expires, + no_cache=no_cache) or 0) assert container.cmd_rebuild() == 0 - assert captured['cmd'] == ['docker', 'compose', 'build', '--no-cache'] + assert captured == {'expires': 7, 'no_cache': False} -def test_cmd_rebuild_propagates_returncode(tmp_path, monkeypatch): +def test_cmd_rebuild_propagates_returncode(monkeypatch): from devbase.commands import container - (tmp_path / 'compose.yml').write_text('services: {}\n') - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(container.subprocess, 'run', - lambda cmd, check=False: types.SimpleNamespace(returncode=2)) + monkeypatch.setattr(container, '_build_resolved', lambda expires, no_cache: 2) assert container.cmd_rebuild() == 2