From 8aba99504b3cfdd7b7f185e289a2d702822e77ca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 2 May 2026 08:26:23 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20README=E3=81=8B=E3=82=89=E3=80=8C?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E3=80=8D=E3=83=AC=E3=82=B8=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=83=AA=E8=A1=A8=E8=A8=98=E3=82=92=E9=99=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2.2.0 で公式レジストリ固定の概念は廃止されているため、 クイックスタートのコメントを「サンプルレジストリ devbasex/devbase-samples は自動登録済み」に修正。 Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39fc9c7..f405835 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ cd devbase source ~/.bashrc # または ~/.zshrc # 2. Pluginのインストール -devbase plugin repo add user/repo # リポジトリ登録(initで公式は自動登録済み) +devbase plugin repo add user/repo # リポジトリ登録(init でサンプルレジストリ devbasex/devbase-samples は自動登録済み) devbase plugin install # Plugin名でインストール # 3. プロジェクトの起動 From da96a195bf164824a9f5a8b3c128249142414800 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 2 May 2026 08:26:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(container):=20=E5=8F=A4=E3=81=84?= =?UTF-8?q?=E3=82=A4=E3=83=A1=E3=83=BC=E3=82=B8=E3=81=AE=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E3=83=AA=E3=83=95=E3=83=AC=E3=83=83=E3=82=B7=E3=83=A5=E3=81=A8?= =?UTF-8?q?=E5=85=AC=E9=96=8B=E3=82=A4=E3=83=A1=E3=83=BC=E3=82=B8=E3=81=AE?= =?UTF-8?q?pull=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devbase up 時のイメージ事前チェック (_ensure_images) を強化: 1. build: 定義あり / イメージが7日以上古い場合 → devbase build --no-cache で再ビルド 2. image: のみ (build: なし) / イメージが存在しない場合 → docker pull で取得 3. image: のみ / イメージが7日以上古い場合 → docker pull で再取得 実装: - _IMAGE_MAX_AGE_DAYS = 7 を定数化 - _get_image_age_days(): docker image inspect の Created を解析 Docker のナノ秒精度 RFC3339 を Python 3.10 の fromisoformat 互換形式に正規化 - _run_pull(image_name): docker pull の実行 - _run_build(no_cache=False): bash の cmd_build に --no-cache を透過 Bash 側 cmd_build は既に --no-cache をサポート済みのため変更不要。 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/commands/container.py | 94 +++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index e31e9a2..bffaabb 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -405,8 +405,21 @@ def _ensure_env_files() -> bool: return success +_IMAGE_MAX_AGE_DAYS = 7 + + def _ensure_images() -> bool: - """Check if required container images exist. If not, run build command.""" + """Check that required container images exist and are fresh. + + Behavior: + - Image missing + has build: → run `devbase build` + - Image missing + image-only (no build:) → run `docker pull` + - Image present and >= _IMAGE_MAX_AGE_DAYS old + has build: + → rebuild with `--no-cache` + - Image present and >= _IMAGE_MAX_AGE_DAYS old + image-only + → re-pull + - Otherwise: nothing to do + """ compose_file = Path('compose.yml') if not compose_file.exists(): logger.warning("compose.yml not found, skipping image check") @@ -431,23 +444,40 @@ def _ensure_images() -> bool: services = config.get('services', {}) dev_service = services.get(dev_service_name, {}) image_name = dev_service.get('image', '') + has_build = bool(dev_service.get('build')) if not image_name: logger.warning("No image specified for %s service", dev_service_name) return True - result = subprocess.run( + inspect = subprocess.run( ['docker', 'image', 'inspect', image_name], capture_output=True, + text=True, check=False ) - if result.returncode == 0: + if inspect.returncode != 0: + if has_build: + logger.info("Container image '%s' not found", image_name) + logger.info("Running 'devbase container build' to create it...") + return _run_build() + logger.info("Container image '%s' not found, pulling...", image_name) + return _run_pull(image_name) + + age_days = _get_image_age_days(inspect.stdout) + if age_days is None or age_days < _IMAGE_MAX_AGE_DAYS: return True - logger.info("Container image '%s' not found", image_name) - logger.info("Running 'devbase container build' to create it...") - return _run_build() + logger.info( + "Container image '%s' is %d days old (>= %d days threshold)", + image_name, age_days, _IMAGE_MAX_AGE_DAYS + ) + if has_build: + logger.info("Rebuilding with --no-cache...") + return _run_build(no_cache=True) + logger.info("Re-pulling latest image...") + return _run_pull(image_name) except Exception as e: logger.warning("Error checking image: %s", e) @@ -455,8 +485,38 @@ def _ensure_images() -> bool: return _run_build() -def _run_build() -> bool: - """Run the build command.""" +def _get_image_age_days(inspect_json: str) -> Optional[int]: + """Return age of the inspected image in days, or None on failure.""" + try: + data = json.loads(inspect_json) + if not data: + return None + created = data[0].get('Created', '') + if not created: + return None + from datetime import datetime, timezone + # Docker's 'Created' is RFC3339 with nanoseconds, e.g. + # '2024-01-15T10:30:00.123456789Z'. fromisoformat (Python 3.10) does not + # accept nanoseconds, so trim to microseconds and convert 'Z' to +00:00. + ts = created.replace('Z', '+00:00') + if '.' in ts: + head, frac = ts.split('.', 1) + tz_idx = max(frac.rfind('+'), frac.rfind('-')) + if tz_idx >= 0: + frac, tz = frac[:tz_idx], frac[tz_idx:] + else: + tz = '' + ts = f"{head}.{frac[:6]}{tz}" + dt = datetime.fromisoformat(ts) + delta = datetime.now(timezone.utc) - dt + return delta.days + except Exception as e: + logger.warning("Could not parse image creation date: %s", e) + return None + + +def _run_build(no_cache: bool = False) -> bool: + """Run the build command (optionally with --no-cache).""" devbase_root = Path(os.environ.get('DEVBASE_ROOT', '')) if not devbase_root.exists(): logger.error("DEVBASE_ROOT not set") @@ -467,14 +527,28 @@ def _run_build() -> bool: logger.error("devbase command not found at %s", devbase_bin) return False + cmd = ['bash', str(devbase_bin), 'build'] + if no_cache: + cmd.append('--no-cache') + + try: + result = subprocess.run(cmd, check=False) + return result.returncode == 0 + except Exception as e: + logger.error("Running build: %s", e) + return False + + +def _run_pull(image_name: str) -> bool: + """docker pull the specified public image.""" try: result = subprocess.run( - ['bash', str(devbase_bin), 'build'], + ['docker', 'pull', image_name], check=False ) return result.returncode == 0 except Exception as e: - logger.error("Running build: %s", e) + logger.error("Pulling image '%s': %s", image_name, e) return False From 6859672ba4d3908673c868b78f633da69c482580 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 2 May 2026 08:36:07 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(build):=20cmd=5Fbuild=20--no-cache=20?= =?UTF-8?q?=E3=81=8B=E3=82=89=20docker=20builder=20prune=20-af=20=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --no-cache フラグだけでビルド時にキャッシュは使われないため、 docker builder prune -af は冗長。さらに、prune は - 未使用キャッシュ (-a) を含むホスト全体の buildx キャッシュを削除 - 他プロジェクトの build キャッシュも巻き添えに する副作用があり、特に PR #7 で導入した自動 --no-cache 再ビルド (7日経過時) では意図せぬ全消去を引き起こす。 最小驚きの原則に従い、--no-cache はその build のみに作用するよう 修正。ステップ番号を [1/3]-[3/3] から [1/2]-[2/2] に整合化。 ディスク容量整理が必要な場合は別コマンド (devbase clean 等) で 明示的に行うべき。 Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/devbase | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/bin/devbase b/bin/devbase index f30261d..ec85ac2 100755 --- a/bin/devbase +++ b/bin/devbase @@ -85,18 +85,13 @@ cmd_build() { # --no-cache specified: always rebuild base first if [[ "$*" == *"--no-cache"* ]]; then echo "" - echo "[1/3] Clearing buildx cache..." - docker builder prune -af >/dev/null 2>&1 || true - echo "✓ Buildx cache cleared" - - echo "" - echo "[2/3] Building devbase-base..." + echo "[1/2] Building devbase-base..." if ! build_base_image "devbase-base" "$@"; then exit 1 fi echo "" - echo "[3/3] Building project image..." + echo "[2/2] Building project image..." if docker compose build dev "$@"; then echo "" echo "✓ All images built successfully" From 9010cbd334815a431e527288d75a0b46bb3df720 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 2 May 2026 08:38:40 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor(container):=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E7=B4=B0=E9=83=A8?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=20(datetime=20import,=20env=20var,=20regex)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7 の self-review で挙げた MED/LOW 指摘を反映: - [MED] datetime / timezone / re を関数内 import からモジュール先頭へ移動 Python 標準ライブラリの軽量モジュールを関数内 import するのは非慣用。 - [MED] 閾値を環境変数 DEVBASE_IMAGE_MAX_AGE_DAYS で上書き可能に _image_max_age_days() ヘルパを追加。不正値 (非数値・負数) は 警告ログを出してデフォルト 7 にフォールバック。 - [LOW] RFC3339 タイムスタンプ整形を re.sub で簡潔化 split('.') + rfind('+/-') の手作業ロジックを 1 行の正規表現に。 ナノ秒/マイクロ秒/ミリ秒/小数なし/オフセット付き全パターン動作確認済み。 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/commands/container.py | 56 ++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index bffaabb..9e576fb 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -2,8 +2,10 @@ import json import os +import re import subprocess import sys +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -405,18 +407,40 @@ def _ensure_env_files() -> bool: return success -_IMAGE_MAX_AGE_DAYS = 7 +_IMAGE_MAX_AGE_DAYS_DEFAULT = 7 + + +def _image_max_age_days() -> int: + """Threshold for triggering an image rebuild/pull. + + Override via the DEVBASE_IMAGE_MAX_AGE_DAYS environment variable. + Falls back to the default on missing or malformed values. + """ + raw = os.environ.get('DEVBASE_IMAGE_MAX_AGE_DAYS') + if not raw: + return _IMAGE_MAX_AGE_DAYS_DEFAULT + try: + value = int(raw) + if value < 0: + raise ValueError + return value + except ValueError: + logger.warning( + "Invalid DEVBASE_IMAGE_MAX_AGE_DAYS=%r, using default %d", + raw, _IMAGE_MAX_AGE_DAYS_DEFAULT + ) + return _IMAGE_MAX_AGE_DAYS_DEFAULT def _ensure_images() -> bool: """Check that required container images exist and are fresh. - Behavior: + 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 >= _IMAGE_MAX_AGE_DAYS old + has build: + - Image present and >= threshold days old + has build: → rebuild with `--no-cache` - - Image present and >= _IMAGE_MAX_AGE_DAYS old + image-only + - Image present and >= threshold days old + image-only → re-pull - Otherwise: nothing to do """ @@ -465,13 +489,14 @@ def _ensure_images() -> bool: logger.info("Container image '%s' not found, pulling...", image_name) return _run_pull(image_name) + max_age = _image_max_age_days() age_days = _get_image_age_days(inspect.stdout) - if age_days is None or age_days < _IMAGE_MAX_AGE_DAYS: + if age_days is None or age_days < max_age: return True logger.info( "Container image '%s' is %d days old (>= %d days threshold)", - image_name, age_days, _IMAGE_MAX_AGE_DAYS + image_name, age_days, max_age ) if has_build: logger.info("Rebuilding with --no-cache...") @@ -494,21 +519,12 @@ def _get_image_age_days(inspect_json: str) -> Optional[int]: created = data[0].get('Created', '') if not created: return None - from datetime import datetime, timezone # Docker's 'Created' is RFC3339 with nanoseconds, e.g. - # '2024-01-15T10:30:00.123456789Z'. fromisoformat (Python 3.10) does not - # accept nanoseconds, so trim to microseconds and convert 'Z' to +00:00. - ts = created.replace('Z', '+00:00') - if '.' in ts: - head, frac = ts.split('.', 1) - tz_idx = max(frac.rfind('+'), frac.rfind('-')) - if tz_idx >= 0: - frac, tz = frac[:tz_idx], frac[tz_idx:] - else: - tz = '' - ts = f"{head}.{frac[:6]}{tz}" - dt = datetime.fromisoformat(ts) - delta = datetime.now(timezone.utc) - dt + # '2024-01-15T10:30:00.123456789Z'. Python 3.10's fromisoformat does + # not accept nanoseconds, so trim fractional seconds to 6 digits and + # normalize 'Z' to '+00:00'. + ts = re.sub(r'(\.\d{6})\d+', r'\1', created.replace('Z', '+00:00')) + delta = datetime.now(timezone.utc) - datetime.fromisoformat(ts) return delta.days except Exception as e: logger.warning("Could not parse image creation date: %s", e) From 23e4e9ec1e22fde44730ddf3615069f77a214341 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 2 May 2026 09:09:31 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(container):=20Codex=20review=20?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AE=20HIGH/MED=20=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 第二意見レビューで指摘された 4 件に対応: [1.1 HIGH] 公開イメージの定期 re-pull を廃止 docker image inspect の Created はイメージのビルド日時で ローカル pull 日時ではないため、age 比較で再 pull すると 毎回 pull がループする欠陥があった。image-only サービス (build: なし) は不在時のみ pull し、定期再 pull は行わない 方針に変更。「image: のみで定期 pull が必要」というユース ケースが出てきたら別 PR で digest 比較等を実装する。 [1.2 HIGH] DEV_SERVICE_NAME 連動の build に修正 Python 側 _ensure_images() は get_dev_service_name() で対象 サービスを解決していたが、Bash 側 cmd_build は 'docker compose build dev' を固定実行していた。 DEV_SERVICE_NAME != dev なプロジェクトで自動再ビルド経路に 入ると存在しないサービスをビルドしようとして失敗する回帰。 Bash 側を 'docker compose build "${DEV_SERVICE_NAME:-dev}"' に変更してチェックと実行のサービス名解決を整合化。 [1.4 MED] エラーメッセージを build/pull 双方を案内する文面に _ensure_images() 失敗時に常に "devbase container build" を 案内していたが、image-only サービスでは build は不適切。 build と docker pull の両方を案内する文面に変更。 [2.1 MED] cli-reference.md に自動 build/pull 動作を明記 devbase up が条件次第で build/pull を自動実行する旨と、 DEVBASE_IMAGE_MAX_AGE_DAYS 環境変数の存在を追記。 Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/devbase | 4 ++-- docs/user/cli-reference.md | 5 +++++ lib/devbase/commands/container.py | 26 ++++++++++++++++++-------- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/bin/devbase b/bin/devbase index ec85ac2..2243d7f 100755 --- a/bin/devbase +++ b/bin/devbase @@ -92,7 +92,7 @@ cmd_build() { echo "" echo "[2/2] Building project image..." - if docker compose build dev "$@"; then + if docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then echo "" echo "✓ All images built successfully" else @@ -126,7 +126,7 @@ cmd_build() { echo "" echo "[2/2] Building project image..." - if docker compose build dev "$@"; then + if docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then echo "" echo "✓ All images built successfully" else diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 1dd5cf3..d68a00a 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -101,6 +101,11 @@ devbase up - 起動時にスナップショットを自動作成(新世代 or 差分追加) - `CONTAINER_SCALE` の値に基づいてコンテナ数を決定 +- イメージの自動準備: + - `build:` 定義あり、イメージ未存在 → `devbase build` を自動実行 + - `build:` 定義あり、イメージが7日以上古い → `devbase build --no-cache` で再ビルド + - `image:` のみ(公開イメージ)、未存在 → `docker pull` を自動実行 + - 閾値は `DEVBASE_IMAGE_MAX_AGE_DAYS` 環境変数で上書き可能(既定 7、不正値は警告して既定値) ### `devbase container down` diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 9e576fb..d5f254d 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -105,7 +105,11 @@ def cmd_up(project_name: str = None, scale: int = None) -> int: # Pre-check 2: Ensure container images exist if not _ensure_images(): - logger.error("Failed to build container images. Please run 'devbase container build' manually.") + logger.error( + "Failed to ensure container images. " + "Run 'devbase container build' for build-based services, " + "or 'docker pull ' for image-only services." + ) return 1 # Pre-step: Auto snapshot(差分世代数ベース世代管理) @@ -440,9 +444,13 @@ def _ensure_images() -> bool: - Image missing + image-only (no build:) → run `docker pull` - Image present and >= threshold days old + has build: → rebuild with `--no-cache` - - Image present and >= threshold days old + image-only - → re-pull + - Image present + image-only → nothing to do + (Created reflects upstream build time, not local pull time, so we + cannot derive a meaningful freshness signal here. Users who want + public images refreshed should run `docker pull` explicitly.) - Otherwise: nothing to do + + Returns True on success or no-op, False on failure. """ compose_file = Path('compose.yml') if not compose_file.exists(): @@ -489,6 +497,11 @@ def _ensure_images() -> bool: logger.info("Container image '%s' not found, pulling...", image_name) return _run_pull(image_name) + # Image-only services: 'Created' reflects upstream build time, not + # local pull time, so age-based re-pull is not meaningful. Skip. + if not has_build: + return True + max_age = _image_max_age_days() age_days = _get_image_age_days(inspect.stdout) if age_days is None or age_days < max_age: @@ -498,11 +511,8 @@ def _ensure_images() -> bool: "Container image '%s' is %d days old (>= %d days threshold)", image_name, age_days, max_age ) - if has_build: - logger.info("Rebuilding with --no-cache...") - return _run_build(no_cache=True) - logger.info("Re-pulling latest image...") - return _run_pull(image_name) + logger.info("Rebuilding with --no-cache...") + return _run_build(no_cache=True) except Exception as e: logger.warning("Error checking image: %s", e) From decf80f6a1adae183917d3644e98de64b3b49f22 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 2 May 2026 09:14:51 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat(container):=20=E5=85=AC=E9=96=8B?= =?UTF-8?q?=E3=82=A4=E3=83=A1=E3=83=BC=E3=82=B8=E3=81=AE=20touch-file=20?= =?UTF-8?q?=E3=83=99=E3=83=BC=E3=82=B9=E5=AE=9A=E6=9C=9F=20pull=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review [1.1] 対応で削除した「公開イメージの定期再 pull」を、 touch-file 方式で再実装。 実装: - _pull_marker_path(image): \${DEVBASE_ROOT}/.cache/pulls/ image 名の '/' や ':' を '_' に置換してファイル名安全化 - _pull_age_days(image): touch-file mtime からの経過日数 (None=未存在) - _mark_pulled(image): pull 成功時に marker を touch - _ensure_images() の image-only 分岐: - 不在 → pull → mark - 前回 pull から N 日経過 → pull → mark - それ以外 → no-op メリット: - JSON parse 不要 (pathlib + time だけ) - 中断耐性 (壊れた JSON が残らない) - 手動リセット容易 (rm .cache/pulls/foo で次回再 pull) - 約 30 行で完結 副次: - .gitignore に .cache/ を追加 - cli-reference.md に touch-file 仕様を追記 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + docs/user/cli-reference.md | 2 + lib/devbase/commands/container.py | 67 ++++++++++++++++++++++++++----- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 390388b..d945644 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ projects/* !projects/.gitkeep .env.sources.yml issues/ +.cache/ diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index d68a00a..89307aa 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -105,6 +105,8 @@ devbase up - `build:` 定義あり、イメージ未存在 → `devbase build` を自動実行 - `build:` 定義あり、イメージが7日以上古い → `devbase build --no-cache` で再ビルド - `image:` のみ(公開イメージ)、未存在 → `docker pull` を自動実行 + - `image:` のみ、前回 pull から7日以上経過 → `docker pull` で再取得 + (前回 pull 日時は `${DEVBASE_ROOT}/.cache/pulls/` の touch-file mtime で判定) - 閾値は `DEVBASE_IMAGE_MAX_AGE_DAYS` 環境変数で上書き可能(既定 7、不正値は警告して既定値) ### `devbase container down` diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index d5f254d..5a694f8 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -5,6 +5,7 @@ import re import subprocess import sys +import time from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -443,11 +444,11 @@ def _ensure_images() -> bool: - 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` - - Image present + image-only → nothing to do - (Created reflects upstream build time, not local pull time, so we - cannot derive a meaningful freshness signal here. Users who want - public images refreshed should run `docker pull` explicitly.) + → rebuild with `--no-cache` (uses image 'Created' timestamp) + - 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 + local-freshness signal) - Otherwise: nothing to do Returns True on success or no-op, False on failure. @@ -495,14 +496,28 @@ def _ensure_images() -> bool: logger.info("Running 'devbase container build' to create it...") return _run_build() logger.info("Container image '%s' not found, pulling...", image_name) - return _run_pull(image_name) + ok = _run_pull(image_name) + if ok: + _mark_pulled(image_name) + return ok - # Image-only services: 'Created' reflects upstream build time, not - # local pull time, so age-based re-pull is not meaningful. Skip. + max_age = _image_max_age_days() + + # Image-only services: use local touch-file mtime, since image + # 'Created' reflects upstream build time, not local pull time. if not has_build: - return True + pull_age = _pull_age_days(image_name) + if pull_age is None or pull_age < max_age: + return True + logger.info( + "Image '%s' last pulled %d days ago (>= %d days threshold), re-pulling...", + image_name, pull_age, max_age + ) + ok = _run_pull(image_name) + if ok: + _mark_pulled(image_name) + return ok - max_age = _image_max_age_days() age_days = _get_image_age_days(inspect.stdout) if age_days is None or age_days < max_age: return True @@ -578,6 +593,38 @@ def _run_pull(image_name: str) -> bool: return False +def _pull_marker_path(image_name: str) -> Optional[Path]: + """Path of the touch-file recording the last pull time of `image_name`. + + Returns None when DEVBASE_ROOT is not set so callers can no-op safely. + """ + devbase_root = os.environ.get('DEVBASE_ROOT') + if not devbase_root: + return None + safe = re.sub(r'[^A-Za-z0-9._-]', '_', image_name) + return Path(devbase_root) / '.cache' / 'pulls' / safe + + +def _pull_age_days(image_name: str) -> Optional[int]: + """Days since the last successful pull of `image_name`. None if never.""" + marker = _pull_marker_path(image_name) + if marker is None or not marker.exists(): + return None + return int((time.time() - marker.stat().st_mtime) / 86400) + + +def _mark_pulled(image_name: str) -> None: + """Touch the marker file to record a successful pull.""" + marker = _pull_marker_path(image_name) + if marker is None: + return + try: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() + except OSError as e: + logger.warning("Could not write pull marker for '%s': %s", image_name, e) + + def _update_scale_in_env(new_scale: int) -> bool: """Update CONTAINER_SCALE value in env file""" env_file = Path('./env') From eed868db181cb37da9ca4994afe695288236d05e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 2 May 2026 09:29:21 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(container):=20Codex=20=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E5=9B=9E=20review=20=E6=8C=87=E6=91=98=20(HIGH/MED)?= =?UTF-8?q?=20=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [HIGH] 既存 image-only サービスで marker bootstrap PR 適用前から手動 docker pull 済みの公開 image は marker が無く、 _pull_age_days() が永遠に None を返して定期 pull が発火しない不具合。 image-only かつ marker 不在のケースで marker を「now」で bootstrap し、 次回以降 threshold-based pull が正しく作動するよう修正。 option (a) (即 pull) ではなく option (b) (現時点を起点に記録) を選択。 理由: 初回 up での予期せぬネットワーク呼び出しを避け、 最大 7 日の更新遅延 (SLA 厳密ではない) を受容する方が UX として穏便。 [MED] marker filename の衝突を hash で防止 re.sub による単純置換は lossy で、 'a/b:c' と 'a_b/c' がともに 'a_b_c' に衝突する問題があった。 filename を `--` 形式に変更し、 人間可読性と衝突耐性を両立。 [LOW] 未来 mtime のクランプ 時刻巻き戻しや手動 mtime 操作で marker mtime が未来を指すと、 時刻差が負値になり pull_age < max_age が常に成立して refresh が永続抑止される問題があった。 負値検出時は warning ログを出して 0 を返すよう修正。 動作確認: - collision: a/b:c, a_b/c, a:b/c が全て異なる hash で識別される - bootstrap: marker 無し既存 image で初回 up 時に marker 作成、age=0 - future mtime: 5 日先の mtime → warning + age=0 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/devbase/commands/container.py | 39 +++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 5a694f8..4ec166e 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1,5 +1,6 @@ """Container lifecycle commands (up, down, ps, login, logs, scale, build)""" +import hashlib import json import os import re @@ -507,7 +508,19 @@ def _ensure_images() -> bool: # 'Created' reflects upstream build time, not local pull time. if not has_build: pull_age = _pull_age_days(image_name) - if pull_age is None or pull_age < max_age: + if pull_age is None: + # Pre-existing image with no marker (e.g., upgrade from a + # devbase version without touch-file tracking). Bootstrap a + # marker now so future runs can apply the threshold. We do + # not auto-pull here to avoid surprising network calls on + # the first `up` after upgrade. + logger.info( + "First time tracking image '%s'; recording marker (no pull this run)", + image_name + ) + _mark_pulled(image_name) + return True + if pull_age < max_age: return True logger.info( "Image '%s' last pulled %d days ago (>= %d days threshold), re-pulling...", @@ -596,21 +609,37 @@ def _run_pull(image_name: str) -> bool: def _pull_marker_path(image_name: str) -> Optional[Path]: """Path of the touch-file recording the last pull time of `image_name`. + Filename format: ``--`` to keep the human-readable part + while preventing collisions between distinct image references that + sanitize to the same string (e.g., ``a/b:c`` vs ``a_b/c``). + Returns None when DEVBASE_ROOT is not set so callers can no-op safely. """ devbase_root = os.environ.get('DEVBASE_ROOT') if not devbase_root: return None - safe = re.sub(r'[^A-Za-z0-9._-]', '_', image_name) - return Path(devbase_root) / '.cache' / 'pulls' / safe + sanitized = re.sub(r'[^A-Za-z0-9._-]', '_', image_name)[:60] + digest = hashlib.sha256(image_name.encode('utf-8')).hexdigest()[:12] + return Path(devbase_root) / '.cache' / 'pulls' / f'{sanitized}--{digest}' def _pull_age_days(image_name: str) -> Optional[int]: - """Days since the last successful pull of `image_name`. None if never.""" + """Days since the last successful pull of `image_name`. None if never. + + Negative ages (clock skew or future-dated marker) are clamped to 0 with + a warning so they do not silently suppress refresh forever. + """ marker = _pull_marker_path(image_name) if marker is None or not marker.exists(): return None - return int((time.time() - marker.stat().st_mtime) / 86400) + delta = time.time() - marker.stat().st_mtime + if delta < 0: + logger.warning( + "Pull marker for '%s' has a future mtime (clock skew?); treating as 0 days", + image_name + ) + return 0 + return int(delta / 86400) def _mark_pulled(image_name: str) -> None: