|
5 | 5 | import re |
6 | 6 | import subprocess |
7 | 7 | import sys |
| 8 | +import time |
8 | 9 | from datetime import datetime, timezone |
9 | 10 | from pathlib import Path |
10 | 11 | from typing import Optional |
@@ -443,11 +444,11 @@ def _ensure_images() -> bool: |
443 | 444 | - Image missing + has build: → run `devbase build` |
444 | 445 | - Image missing + image-only (no build:) → run `docker pull` |
445 | 446 | - Image present and >= threshold days old + has build: |
446 | | - → rebuild with `--no-cache` |
447 | | - - Image present + image-only → nothing to do |
448 | | - (Created reflects upstream build time, not local pull time, so we |
449 | | - cannot derive a meaningful freshness signal here. Users who want |
450 | | - public images refreshed should run `docker pull` explicitly.) |
| 447 | + → rebuild with `--no-cache` (uses image 'Created' timestamp) |
| 448 | + - Image present + image-only + last-pull >= threshold days old |
| 449 | + → run `docker pull` (uses local touch-file mtime, since image |
| 450 | + 'Created' reflects upstream build time and is not a meaningful |
| 451 | + local-freshness signal) |
451 | 452 | - Otherwise: nothing to do |
452 | 453 |
|
453 | 454 | Returns True on success or no-op, False on failure. |
@@ -495,14 +496,28 @@ def _ensure_images() -> bool: |
495 | 496 | logger.info("Running 'devbase container build' to create it...") |
496 | 497 | return _run_build() |
497 | 498 | logger.info("Container image '%s' not found, pulling...", image_name) |
498 | | - return _run_pull(image_name) |
| 499 | + ok = _run_pull(image_name) |
| 500 | + if ok: |
| 501 | + _mark_pulled(image_name) |
| 502 | + return ok |
499 | 503 |
|
500 | | - # Image-only services: 'Created' reflects upstream build time, not |
501 | | - # local pull time, so age-based re-pull is not meaningful. Skip. |
| 504 | + max_age = _image_max_age_days() |
| 505 | + |
| 506 | + # Image-only services: use local touch-file mtime, since image |
| 507 | + # 'Created' reflects upstream build time, not local pull time. |
502 | 508 | if not has_build: |
503 | | - return True |
| 509 | + pull_age = _pull_age_days(image_name) |
| 510 | + if pull_age is None or pull_age < max_age: |
| 511 | + return True |
| 512 | + logger.info( |
| 513 | + "Image '%s' last pulled %d days ago (>= %d days threshold), re-pulling...", |
| 514 | + image_name, pull_age, max_age |
| 515 | + ) |
| 516 | + ok = _run_pull(image_name) |
| 517 | + if ok: |
| 518 | + _mark_pulled(image_name) |
| 519 | + return ok |
504 | 520 |
|
505 | | - max_age = _image_max_age_days() |
506 | 521 | age_days = _get_image_age_days(inspect.stdout) |
507 | 522 | if age_days is None or age_days < max_age: |
508 | 523 | return True |
@@ -578,6 +593,38 @@ def _run_pull(image_name: str) -> bool: |
578 | 593 | return False |
579 | 594 |
|
580 | 595 |
|
| 596 | +def _pull_marker_path(image_name: str) -> Optional[Path]: |
| 597 | + """Path of the touch-file recording the last pull time of `image_name`. |
| 598 | +
|
| 599 | + Returns None when DEVBASE_ROOT is not set so callers can no-op safely. |
| 600 | + """ |
| 601 | + devbase_root = os.environ.get('DEVBASE_ROOT') |
| 602 | + if not devbase_root: |
| 603 | + return None |
| 604 | + safe = re.sub(r'[^A-Za-z0-9._-]', '_', image_name) |
| 605 | + return Path(devbase_root) / '.cache' / 'pulls' / safe |
| 606 | + |
| 607 | + |
| 608 | +def _pull_age_days(image_name: str) -> Optional[int]: |
| 609 | + """Days since the last successful pull of `image_name`. None if never.""" |
| 610 | + marker = _pull_marker_path(image_name) |
| 611 | + if marker is None or not marker.exists(): |
| 612 | + return None |
| 613 | + return int((time.time() - marker.stat().st_mtime) / 86400) |
| 614 | + |
| 615 | + |
| 616 | +def _mark_pulled(image_name: str) -> None: |
| 617 | + """Touch the marker file to record a successful pull.""" |
| 618 | + marker = _pull_marker_path(image_name) |
| 619 | + if marker is None: |
| 620 | + return |
| 621 | + try: |
| 622 | + marker.parent.mkdir(parents=True, exist_ok=True) |
| 623 | + marker.touch() |
| 624 | + except OSError as e: |
| 625 | + logger.warning("Could not write pull marker for '%s': %s", image_name, e) |
| 626 | + |
| 627 | + |
581 | 628 | def _update_scale_in_env(new_scale: int) -> bool: |
582 | 629 | """Update CONTAINER_SCALE value in env file""" |
583 | 630 | env_file = Path('./env') |
|
0 commit comments