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/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. プロジェクトの起動 diff --git a/bin/devbase b/bin/devbase index f30261d..2243d7f 100755 --- a/bin/devbase +++ b/bin/devbase @@ -85,19 +85,14 @@ 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..." - if docker compose build dev "$@"; then + echo "[2/2] Building project image..." + if docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then echo "" echo "✓ All images built successfully" else @@ -131,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..89307aa 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -101,6 +101,13 @@ devbase up - 起動時にスナップショットを自動作成(新世代 or 差分追加) - `CONTAINER_SCALE` の値に基づいてコンテナ数を決定 +- イメージの自動準備: + - `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 e31e9a2..4ec166e 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1,9 +1,13 @@ """Container lifecycle commands (up, down, ps, login, logs, scale, build)""" +import hashlib import json import os +import re import subprocess import sys +import time +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -103,7 +107,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(差分世代数ベース世代管理) @@ -405,8 +413,47 @@ def _ensure_env_files() -> bool: return success +_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 if required container images exist. If not, run build command.""" + """Check that required container images exist and are fresh. + + 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 + 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. + """ compose_file = Path('compose.yml') if not compose_file.exists(): logger.warning("compose.yml not found, skipping image check") @@ -431,23 +478,69 @@ 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) + ok = _run_pull(image_name) + if ok: + _mark_pulled(image_name) + return ok + + 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: + pull_age = _pull_age_days(image_name) + 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...", + image_name, pull_age, max_age + ) + ok = _run_pull(image_name) + if ok: + _mark_pulled(image_name) + return ok + + age_days = _get_image_age_days(inspect.stdout) + if age_days is None or age_days < max_age: 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, max_age + ) + logger.info("Rebuilding with --no-cache...") + return _run_build(no_cache=True) except Exception as e: logger.warning("Error checking image: %s", e) @@ -455,8 +548,29 @@ 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 + # Docker's 'Created' is RFC3339 with nanoseconds, e.g. + # '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) + 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,17 +581,79 @@ 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 +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 + 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. + + 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 + 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: + """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')