diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 27e1ae0..c9f8f8d 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -1,6 +1,7 @@ -# Reusable: builds an image repo's Dockerfile for every required platform and pushes it -# to GHCR (always) and Docker Hub (when credentials exist). Repo-specific values come from -# the caller's caddy-image.json; image names come from the caller's repository name. +# Reusable: builds an image repo's Dockerfile for every platform it ships and pushes it +# to GHCR (always) and Docker Hub (when credentials exist). Repo-specific values, the +# platform list included, come from the caller's caddy-image.json; image names come from +# the caller's repository name. # # Caller: # jobs: @@ -40,6 +41,11 @@ on: required: false type: string default: Dockerfile + base_repository: + description: 'Repository that hosts this workflow and the config loader (override in forks of caddy-base)' + required: false + type: string + default: CaddyBuilds/caddy-base secrets: DOCKERHUB_USERNAME: required: false @@ -52,9 +58,6 @@ permissions: contents: write packages: write -env: - PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le,linux/s390x - jobs: build-and-push: runs-on: ubuntu-latest @@ -63,6 +66,16 @@ jobs: - name: Checkout uses: actions/checkout@v7 + # The config schema (keys, defaults, allowed platforms) lives in the Python package + # next to this workflow, so the loader is checked out at this same commit and the + # checker and the build can never disagree. Removed again to keep the build context clean. + - name: Checkout caddy-base + uses: actions/checkout@v7 + with: + repository: ${{ inputs.base_repository }} + ref: ${{ job.workflow_sha || github.job_workflow_sha }} + path: .caddy-base + - name: Read image config id: cfg env: @@ -71,24 +84,20 @@ jobs: run: | test -f "$CONFIG" || { echo "::error::Image config '$CONFIG' not found."; exit 1; } test -f "$DOCKERFILE" || { echo "::error::Dockerfile '$DOCKERFILE' not found."; exit 1; } - for key in title description verify_module; do - value=$(jq -er --arg k "$key" '.[$k] // empty' "$CONFIG") || { echo "::error::'$key' missing in $CONFIG"; exit 1; } - echo "$key=$value" >> "$GITHUB_OUTPUT" - done - for key in alpine_alias:true slim:false create_release:true; do - name=${key%%:*}; default=${key##*:} - echo "$name=$(jq -r --arg k "$name" --argjson d "$default" 'if has($k) then .[$k] else $d end' "$CONFIG")" >> "$GITHUB_OUTPUT" - done + PYTHONPATH=.caddy-base/scripts python3 -m check_caddy_status.image_config "$CONFIG" > "$RUNNER_TEMP/image-config.env" + cat "$RUNNER_TEMP/image-config.env" >> "$GITHUB_OUTPUT" + slim=$(sed -n 's/^slim=//p' "$RUNNER_TEMP/image-config.env") # Build the named "default" stage when the Dockerfile has one, else the last stage. if grep -qiE '^\s*FROM\s.*\sAS\s+default\s*$' "$DOCKERFILE"; then echo "default_target=default" >> "$GITHUB_OUTPUT" else echo "default_target=" >> "$GITHUB_OUTPUT" - if [ "$(jq -r '.slim // false' "$CONFIG")" = "true" ]; then + if [ "$slim" = "true" ]; then echo "::error::slim is enabled but '$DOCKERFILE' has no 'AS default' stage."; exit 1 fi fi echo "base_ref=${{ job.workflow_sha || github.job_workflow_sha }}" >> "$GITHUB_OUTPUT" + rm -rf .caddy-base cat "$GITHUB_OUTPUT" - name: Set up QEMU @@ -184,7 +193,7 @@ jobs: push: true build-args: | CADDY_VERSION=${{ env.VERSION }} - platforms: ${{ env.PLATFORMS }} + platforms: ${{ steps.cfg.outputs.platforms }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} @@ -233,7 +242,7 @@ jobs: push: true build-args: | CADDY_VERSION=${{ env.VERSION }} - platforms: ${{ env.PLATFORMS }} + platforms: ${{ steps.cfg.outputs.platforms }} tags: ${{ steps.meta_slim.outputs.tags }} labels: ${{ steps.meta_slim.outputs.labels }} annotations: ${{ steps.meta_slim.outputs.annotations }} @@ -244,6 +253,7 @@ jobs: env: MODULE: ${{ steps.cfg.outputs.verify_module }} SLIM: ${{ steps.cfg.outputs.slim }} + PLATFORMS: ${{ steps.cfg.outputs.platforms }} run: | images="${GHCR_IMAGE}:${VERSION}" [ "$SLIM" = "true" ] && images="$images ${GHCR_IMAGE}:${VERSION}-slim" diff --git a/README.md b/README.md index 793a8bb..745b7d0 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ emulated; only the final `COPY` stage runs per platform. See [`examples/Dockerfi "verify_module": "dns.providers.cloudflare", "alpine_alias": true, "slim": false, - "create_release": true + "create_release": true, + "platforms": ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/ppc64le", "linux/s390x"] } ``` @@ -63,6 +64,10 @@ emulated; only the final `COPY` stage runs per platform. See [`examples/Dockerfi | `alpine_alias` | no | `true` | Also push `X.Y.Z-alpine`, `X.Y-alpine`, `X-alpine` and `alpine` as aliases of the default image | | `slim` | no | `false` | Also build the Dockerfile's `slim` stage and push `X.Y.Z-slim`, `X.Y-slim`, `X-slim`, `slim` | | `create_release` | no | `true` | Create a GitHub Release per Caddy version and append rebuild notes to it | +| `platforms` | no | the five above | Platforms to build, verify and require upstream. Any non-empty subset of what the official image publishes: `linux/amd64`, `linux/arm64`, `linux/arm/v6`, `linux/arm/v7`, `linux/ppc64le`, `linux/riscv64`, `linux/s390x` | + +The schema lives in `scripts/check_caddy_status/image_config.py`; the build workflow runs +that module to read the file, so the checker and the build always agree on it. **Two caller workflows**, copied from [`examples/.github/workflows/`](examples/.github/workflows/): @@ -91,10 +96,10 @@ Docker Hub is used when `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets exist ## How the check decides to build -1. Fetch the latest Caddy release and confirm the official image has every required platform. +1. Fetch the latest Caddy release and confirm the official image has every platform we ship. 2. If our image for that version is missing a platform on GHCR, build (`new_caddy_version`). 3. Compare each module's latest commit with `module-versions.json`; on change, build (`module_update`) and commit the new state. -4. Compare the official image's push time with our build time; if upstream republished, build (`base_image_update`). +4. Compare the official image's push time (for the platforms we ship) with our build time; if upstream republished, build (`base_image_update`). The reason and details are passed to the build and recorded in the image labels (`caddy-build.reason`, `caddy-build.details`, `caddy-build.base-workflow`) and the release notes. diff --git a/examples/caddy-image.json b/examples/caddy-image.json index 4c3e09d..221232d 100644 --- a/examples/caddy-image.json +++ b/examples/caddy-image.json @@ -4,5 +4,12 @@ "verify_module": "dns.providers.cloudflare", "alpine_alias": true, "slim": false, - "create_release": true + "create_release": true, + "platforms": [ + "linux/amd64", + "linux/arm64", + "linux/arm/v7", + "linux/ppc64le", + "linux/s390x" + ] } diff --git a/scripts/check_caddy_status/__main__.py b/scripts/check_caddy_status/__main__.py index ec0dbe6..86a2080 100644 --- a/scripts/check_caddy_status/__main__.py +++ b/scripts/check_caddy_status/__main__.py @@ -13,7 +13,7 @@ from pathlib import Path from .base_image import check_base_image_updates -from .config import CUSTOM_IMAGE, GHCR_IMAGE, OFFICIAL_CADDY_IMAGE, REQUIRED_PLATFORMS +from .config import CUSTOM_IMAGE, GHCR_IMAGE, OFFICIAL_CADDY_IMAGE from .docker_hub import check_docker_hub_tag, get_platforms_from_tag_data from .dockerfile import DockerfileError, parse_xcaddy_modules from .ghcr import check_ghcr_tag @@ -39,9 +39,6 @@ def main(): if dry_run: log.info("*** DRY-RUN MODE — no outputs will be set, no files written ***") - if not REQUIRED_PLATFORMS: - log.error("Configuration error: REQUIRED_PLATFORMS set is empty.") - sys.exit(1) if not GHCR_IMAGE and not CUSTOM_IMAGE: log.error("Configuration error: set GITHUB_REPOSITORY, GHCR_IMAGE, or DOCKERHUB_REPOSITORY_NAME.") sys.exit(1) @@ -54,10 +51,11 @@ def main(): log.error(f"Configuration error: {e}") sys.exit(1) versions_file = root / args.versions_file + platforms = set(image.platforms) log.info(f"Image: {image.title} — {image.description}") log.info(f"Modules: {', '.join(m['module'] for m in modules)}") - log.info(f"Required platforms: {REQUIRED_PLATFORMS}") + log.info(f"Platforms: {', '.join(image.platforms)}") log.info(f"GHCR image: {GHCR_IMAGE or '(not set)'}") log.info(f"Docker Hub image: {CUSTOM_IMAGE or '(not set)'}") @@ -68,7 +66,7 @@ def main(): # Step 1: Check official Caddy image on Docker Hub log.info(f"\nStep 1: Checking official image '{OFFICIAL_CADDY_IMAGE}:{docker_tag}'...") - official_image_ready = _check_official_image(docker_tag) + official_image_ready = _check_official_image(docker_tag, platforms) if not official_image_ready: log.info("Result: Official image is not ready. No build triggered.") @@ -79,7 +77,7 @@ def main(): # Step 2: Check custom image on GHCR + Docker Hub log.info(f"\nStep 2: Checking custom image status...") - custom_image_complete = _check_custom_image(docker_tag) + custom_image_complete = _check_custom_image(docker_tag, platforms) # Step 3: Check module updates log.info(f"\nStep 3: Checking module updates...") @@ -96,7 +94,7 @@ def main(): base_details = [] if custom_image_complete and not modules_changed: log.info(f"\nStep 4: Checking official base image updates...") - base_details = check_base_image_updates(docker_tag, image.official_variant_suffixes) + base_details = check_base_image_updates(docker_tag, image.official_variant_suffixes, platforms) # Final decision needs_build = False @@ -123,14 +121,14 @@ def main(): log.info(f"\n--- Check finished at {end_time.isoformat()} (Duration: {end_time - start_time}) ---") -def _check_official_image(docker_tag): - """Returns True if the official Caddy image has all required platforms.""" +def _check_official_image(docker_tag, platforms): + """Returns True if the official Caddy image has every platform we ship.""" result, data = check_docker_hub_tag(OFFICIAL_CADDY_IMAGE, docker_tag) if result == TagCheckResult.FOUND: - found = get_platforms_from_tag_data(data) + found = get_platforms_from_tag_data(data, platforms) log.info(f" Found platforms: {found or '{}'}") - missing = REQUIRED_PLATFORMS - found + missing = platforms - found if not missing: log.info(" Official image has all required platforms.") log.set_summary("Official Image", "Ready (all platforms)") @@ -147,18 +145,18 @@ def _check_official_image(docker_tag): return False -def _check_custom_image(docker_tag): - """Returns True if the custom image exists with all platforms on GHCR or Docker Hub.""" +def _check_custom_image(docker_tag, platforms): + """Returns True if the custom image exists with every platform we ship on GHCR or Docker Hub.""" custom_complete = False ghcr_result = None # GHCR (primary) if GHCR_IMAGE: log.info(f" Checking GHCR: ghcr.io/{GHCR_IMAGE}:{docker_tag}") - ghcr_result, ghcr_data = check_ghcr_tag(GHCR_IMAGE, docker_tag) + ghcr_result, ghcr_data = check_ghcr_tag(GHCR_IMAGE, docker_tag, platforms) if ghcr_result == TagCheckResult.FOUND: - missing = REQUIRED_PLATFORMS - ghcr_data + missing = platforms - ghcr_data if not missing: custom_complete = True log.info(" GHCR image complete.") @@ -182,8 +180,8 @@ def _check_custom_image(docker_tag): dh_result, dh_data = check_docker_hub_tag(CUSTOM_IMAGE, docker_tag) if dh_result == TagCheckResult.FOUND: - dh_platforms = get_platforms_from_tag_data(dh_data) - dh_missing = REQUIRED_PLATFORMS - dh_platforms + dh_platforms = get_platforms_from_tag_data(dh_data, platforms) + dh_missing = platforms - dh_platforms if not dh_missing: log.info(" Docker Hub image complete.") log.set_summary("Custom (Docker Hub)", "Complete") diff --git a/scripts/check_caddy_status/base_image.py b/scripts/check_caddy_status/base_image.py index e401356..ee4010d 100644 --- a/scripts/check_caddy_status/base_image.py +++ b/scripts/check_caddy_status/base_image.py @@ -5,27 +5,26 @@ """ from .config import CUSTOM_IMAGE, GHCR_IMAGE, OFFICIAL_CADDY_IMAGE -from .docker_hub import check_docker_hub_tag, get_images_by_platform +from .docker_hub import get_docker_hub_pushed from .ghcr import get_ghcr_created -from .http_client import TagCheckResult from .logger import log -from .timeutil import parse_timestamp -def check_base_image_updates(docker_tag, variant_suffixes): +def check_base_image_updates(docker_tag, variant_suffixes, platforms): """Returns descriptions of variants whose official image is newer than our build (empty if none). Args: docker_tag: Caddy version without the ``v`` prefix. variant_suffixes: our tag suffixes that are built FROM ``library/caddy`` (e.g. ``["", "-alpine"]``). + platforms: the platforms we ship; pushes of other platforms are ignored. """ stale = [] unknown = [] for suffix in variant_suffixes: tag = f"{docker_tag}{suffix}" - official = _docker_hub_pushed(OFFICIAL_CADDY_IMAGE, tag) - ours = _custom_build_time(tag) + official = get_docker_hub_pushed(OFFICIAL_CADDY_IMAGE, tag, platforms) + ours = _custom_build_time(tag, platforms) if official is None or ours is None: log.warn(f" Could not compare '{tag}' (official: {official}, ours: {ours}), skipping.") @@ -46,7 +45,7 @@ def check_base_image_updates(docker_tag, variant_suffixes): return stale -def _custom_build_time(tag): +def _custom_build_time(tag, platforms): """Returns when our image was built. GHCR is preferred because the image config's ``created`` is the build time. Docker Hub @@ -58,19 +57,5 @@ def _custom_build_time(tag): if created: return created if CUSTOM_IMAGE: - return _docker_hub_pushed(CUSTOM_IMAGE, tag) + return get_docker_hub_pushed(CUSTOM_IMAGE, tag, platforms) return None - - -def _docker_hub_pushed(image_name, tag): - """Returns the most recent push among the tag's required platforms, or None. - - The tag-level ``last_updated`` also moves when platforms we don't ship (Windows, - riscv64) are re-pushed, which would trigger needless rebuilds. - """ - result, data = check_docker_hub_tag(image_name, tag) - if result != TagCheckResult.FOUND: - return None - pushed = [parse_timestamp(img.get("last_pushed")) for img in get_images_by_platform(data).values()] - pushed = [p for p in pushed if p] - return max(pushed) if pushed else None diff --git a/scripts/check_caddy_status/config.py b/scripts/check_caddy_status/config.py index 899c6a3..8c8edce 100644 --- a/scripts/check_caddy_status/config.py +++ b/scripts/check_caddy_status/config.py @@ -1,7 +1,7 @@ """Environment-derived settings shared by every image repo. -Repo-specific values (title, modules, variants) come from the image config file and the -Dockerfile, loaded at runtime — see ``image_config`` and ``dockerfile``. +Repo-specific values (title, modules, variants, platforms) come from the image config file +and the Dockerfile, loaded at runtime — see ``image_config`` and ``dockerfile``. """ import os @@ -21,15 +21,6 @@ # GHCR — always matches the GitHub repo GHCR_IMAGE = os.environ.get("GHCR_IMAGE", "") or _github_repository -# Platforms required in both official and custom images -REQUIRED_PLATFORMS = { - "linux/amd64", - "linux/arm64", - "linux/arm/v7", - "linux/ppc64le", - "linux/s390x", -} - # HTTP retry settings MAX_RETRIES = 3 BACKOFF_BASE = 2 diff --git a/scripts/check_caddy_status/docker_hub.py b/scripts/check_caddy_status/docker_hub.py index 0cbba39..27dde2c 100644 --- a/scripts/check_caddy_status/docker_hub.py +++ b/scripts/check_caddy_status/docker_hub.py @@ -2,9 +2,10 @@ import requests -from .config import REQUIRED_PLATFORMS from .http_client import TagCheckResult, request_with_retry from .logger import log +from .platforms import platform_string +from .timeutil import parse_timestamp def check_docker_hub_tag(image_name, tag): @@ -34,8 +35,8 @@ def check_docker_hub_tag(image_name, tag): return TagCheckResult.ERROR, msg -def get_images_by_platform(tag_data): - """Maps required linux platform strings to their image entries from Docker Hub tag data.""" +def get_images_by_platform(tag_data, platforms): + """Maps each wanted platform to its image entry from Docker Hub tag data.""" images = {} if not tag_data or "images" not in tag_data or not isinstance(tag_data["images"], list): log.info(" Could not find valid 'images' list in Docker Hub tag data.") @@ -44,26 +45,27 @@ def get_images_by_platform(tag_data): for img in tag_data["images"]: if not isinstance(img, dict): continue - os_name = img.get("os") - arch = img.get("architecture") - variant = img.get("variant") + platform = platform_string(img.get("os"), img.get("architecture"), img.get("variant")) + if platform in platforms: + images[platform] = img - if os_name != "linux" or not arch: - continue + return images - if arch == "arm" and variant == "v7": - platform_str = f"{os_name}/{arch}/{variant}" - elif f"{os_name}/{arch}" in REQUIRED_PLATFORMS: - platform_str = f"{os_name}/{arch}" - else: - continue - if platform_str in REQUIRED_PLATFORMS: - images[platform_str] = img +def get_platforms_from_tag_data(tag_data, platforms): + """The wanted platforms that a Docker Hub tag API response actually has.""" + return set(get_images_by_platform(tag_data, platforms)) - return images +def get_docker_hub_pushed(image_name, tag, platforms): + """Returns the most recent push among the tag's wanted platforms, or None. -def get_platforms_from_tag_data(tag_data): - """Extracts required linux platform strings from Docker Hub tag API response.""" - return set(get_images_by_platform(tag_data)) + The tag-level ``last_updated`` also moves when platforms we don't ship (Windows, + riscv64) are re-pushed, which would trigger needless rebuilds. + """ + result, data = check_docker_hub_tag(image_name, tag) + if result != TagCheckResult.FOUND: + return None + pushed = [parse_timestamp(img.get("last_pushed")) for img in get_images_by_platform(data, platforms).values()] + pushed = [p for p in pushed if p] + return max(pushed) if pushed else None diff --git a/scripts/check_caddy_status/ghcr.py b/scripts/check_caddy_status/ghcr.py index b2b7ac5..78c5df9 100644 --- a/scripts/check_caddy_status/ghcr.py +++ b/scripts/check_caddy_status/ghcr.py @@ -2,9 +2,9 @@ import requests -from .config import REQUIRED_PLATFORMS from .http_client import TagCheckResult, request_with_retry from .logger import log +from .platforms import platform_string from .timeutil import parse_timestamp INDEX_ACCEPT = ( @@ -45,8 +45,8 @@ def _get_pull_token(image_name): return None, f"GHCR token request failed: {e}" -def check_ghcr_tag(image_name, tag): - """Checks if a tag exists on GHCR and returns its platforms. +def check_ghcr_tag(image_name, tag, platforms): + """Checks if a tag exists on GHCR and returns which of the wanted platforms it has. Returns: tuple: (TagCheckResult, platforms_set_or_error_msg) @@ -65,23 +65,14 @@ def check_ghcr_tag(image_name, tag): if resp.status_code != 200: return TagCheckResult.ERROR, f"GHCR manifest status {resp.status_code}" - data = resp.json() - platforms = set() - for m in data.get("manifests", []): + found = set() + for m in resp.json().get("manifests", []): p = m.get("platform", {}) - os_name = p.get("os") - arch = p.get("architecture") - variant = p.get("variant") - if os_name != "linux" or not arch: - continue - if arch == "arm" and variant == "v7": - platform_str = f"{os_name}/{arch}/{variant}" - else: - platform_str = f"{os_name}/{arch}" - if platform_str in REQUIRED_PLATFORMS: - platforms.add(platform_str) - - return TagCheckResult.FOUND, platforms + platform = platform_string(p.get("os"), p.get("architecture"), p.get("variant")) + if platform in platforms: + found.add(platform) + + return TagCheckResult.FOUND, found except (requests.exceptions.RequestException, json.JSONDecodeError) as e: return TagCheckResult.ERROR, f"GHCR manifest request failed: {e}" diff --git a/scripts/check_caddy_status/image_config.py b/scripts/check_caddy_status/image_config.py index 0832e4d..fe0edfa 100644 --- a/scripts/check_caddy_status/image_config.py +++ b/scripts/check_caddy_status/image_config.py @@ -1,14 +1,22 @@ """Loads the per-repo image config (``caddy-image.json``). The file holds what differs between image repos and is read by both the check script -and the build workflow, so there is one place to change it. +and the build workflow, so there is one place to change it. The build workflow runs this +module as a script to turn the file into ``key=value`` lines for ``$GITHUB_OUTPUT``:: + + python3 -m check_caddy_status.image_config caddy-image.json """ +import argparse import json +import sys from dataclasses import dataclass +from .platforms import DEFAULT_PLATFORMS, OFFICIAL_PLATFORMS + REQUIRED_KEYS = ("title", "description", "verify_module") -DEFAULTS = {"alpine_alias": True, "slim": False, "create_release": True} +FLAG_DEFAULTS = {"alpine_alias": True, "slim": False, "create_release": True} +OPTIONAL_KEYS = tuple(FLAG_DEFAULTS) + ("platforms",) class ImageConfigError(ValueError): @@ -23,6 +31,7 @@ class ImageConfig: alpine_alias: bool = True slim: bool = False create_release: bool = True + platforms: tuple = DEFAULT_PLATFORMS @property def official_variant_suffixes(self): @@ -33,6 +42,16 @@ def official_variant_suffixes(self): """ return [""] + (["-alpine"] if self.alpine_alias else []) + def as_outputs(self): + """The config as ``key=value`` lines in the shape ``$GITHUB_OUTPUT`` expects.""" + lines = [] + for key in REQUIRED_KEYS: + lines.append(f"{key}={' '.join(getattr(self, key).split())}") + for key in FLAG_DEFAULTS: + lines.append(f"{key}={'true' if getattr(self, key) else 'false'}") + lines.append(f"platforms={','.join(self.platforms)}") + return lines + def load_image_config(path): """Reads and validates the config file. Raises ImageConfigError on problems.""" @@ -51,13 +70,47 @@ def load_image_config(path): if missing: raise ImageConfigError(f"Image config '{path}' is missing required keys: {', '.join(missing)}") - unknown = set(data) - set(REQUIRED_KEYS) - set(DEFAULTS) + unknown = set(data) - set(REQUIRED_KEYS) - set(OPTIONAL_KEYS) if unknown: raise ImageConfigError(f"Image config '{path}' has unknown keys: {', '.join(sorted(unknown))}") - values = {**DEFAULTS, **data} - for key in DEFAULTS: + values = {**FLAG_DEFAULTS, **data} + for key in FLAG_DEFAULTS: if not isinstance(values[key], bool): raise ImageConfigError(f"Image config '{path}': '{key}' must be true or false") - return ImageConfig(**{k: values[k] for k in REQUIRED_KEYS + tuple(DEFAULTS)}) + platforms = _validate_platforms(data.get("platforms", list(DEFAULT_PLATFORMS)), path) + + return ImageConfig(**{k: values[k] for k in REQUIRED_KEYS + tuple(FLAG_DEFAULTS)}, platforms=platforms) + + +def _validate_platforms(value, path): + """Returns the platforms as a tuple in config order; they must be a non-empty subset of the official set.""" + if not isinstance(value, list) or not value or not all(isinstance(p, str) for p in value): + raise ImageConfigError(f"Image config '{path}': 'platforms' must be a non-empty list of strings") + unknown = [p for p in value if p not in OFFICIAL_PLATFORMS] + if unknown: + raise ImageConfigError( + f"Image config '{path}': the official caddy image does not publish {', '.join(unknown)}. " + f"Allowed: {', '.join(OFFICIAL_PLATFORMS)}" + ) + if len(set(value)) != len(value): + raise ImageConfigError(f"Image config '{path}': 'platforms' has duplicates") + return tuple(value) + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Validate an image config and print it as key=value lines") + parser.add_argument("config", help="Path of caddy-image.json") + args = parser.parse_args(argv) + try: + config = load_image_config(args.config) + except ImageConfigError as e: + print(f"::error::{e}", file=sys.stderr) + return 1 + print("\n".join(config.as_outputs())) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_caddy_status/platforms.py b/scripts/check_caddy_status/platforms.py new file mode 100644 index 0000000..a1b3b8c --- /dev/null +++ b/scripts/check_caddy_status/platforms.py @@ -0,0 +1,36 @@ +"""Platform lists shared by the checker, the build workflow and the caddy-base image. + +Platform strings use Docker's canonical form: ``linux/arm/v7`` keeps its variant, +``linux/arm64`` drops the implied ``v8``. +""" + +# Every linux platform the official caddy image publishes. An image repo may ship any +# non-empty subset; the caddy-base image ships all of them so a slim stage has a base +# on every platform a fork can pick. +OFFICIAL_PLATFORMS = ( + "linux/amd64", + "linux/arm64", + "linux/arm/v6", + "linux/arm/v7", + "linux/ppc64le", + "linux/riscv64", + "linux/s390x", +) + +# What an image repo ships unless its config lists ``platforms``. +DEFAULT_PLATFORMS = ( + "linux/amd64", + "linux/arm64", + "linux/arm/v7", + "linux/ppc64le", + "linux/s390x", +) + + +def platform_string(os_name, arch, variant=None): + """Canonical ``os/arch[/variant]`` for a registry platform descriptor, or None when not linux.""" + if os_name != "linux" or not arch: + return None + if arch == "arm" and variant: + return f"{os_name}/{arch}/{variant}" + return f"{os_name}/{arch}" diff --git a/scripts/tests/test_image_config.py b/scripts/tests/test_image_config.py index ba3027e..c453682 100644 --- a/scripts/tests/test_image_config.py +++ b/scripts/tests/test_image_config.py @@ -3,7 +3,8 @@ import unittest from pathlib import Path -from check_caddy_status.image_config import ImageConfigError, load_image_config +from check_caddy_status.image_config import ImageConfigError, load_image_config, main +from check_caddy_status.platforms import DEFAULT_PLATFORMS, OFFICIAL_PLATFORMS def _write(data): @@ -24,6 +25,57 @@ def test_defaults(self): self.assertFalse(cfg.slim) self.assertTrue(cfg.create_release) self.assertEqual(cfg.official_variant_suffixes, ["", "-alpine"]) + self.assertEqual(cfg.platforms, DEFAULT_PLATFORMS) + + def test_platforms_subset_keeps_config_order(self): + cfg = load_image_config(_write({**MINIMAL, "platforms": ["linux/arm64", "linux/amd64"]})) + self.assertEqual(cfg.platforms, ("linux/arm64", "linux/amd64")) + + def test_platforms_may_be_the_whole_official_set(self): + cfg = load_image_config(_write({**MINIMAL, "platforms": list(OFFICIAL_PLATFORMS)})) + self.assertEqual(cfg.platforms, OFFICIAL_PLATFORMS) + + def test_platforms_empty(self): + with self.assertRaisesRegex(ImageConfigError, "non-empty"): + load_image_config(_write({**MINIMAL, "platforms": []})) + + def test_platforms_not_a_list(self): + with self.assertRaises(ImageConfigError): + load_image_config(_write({**MINIMAL, "platforms": "linux/amd64"})) + + def test_platforms_unknown(self): + with self.assertRaisesRegex(ImageConfigError, "windows/amd64"): + load_image_config(_write({**MINIMAL, "platforms": ["linux/amd64", "windows/amd64"]})) + + def test_platforms_duplicate(self): + with self.assertRaisesRegex(ImageConfigError, "duplicates"): + load_image_config(_write({**MINIMAL, "platforms": ["linux/amd64", "linux/amd64"]})) + + def test_outputs(self): + cfg = load_image_config(_write({**MINIMAL, "title": "Caddy Bunny ", "slim": True, "platforms": ["linux/amd64", "linux/arm64"]})) + self.assertEqual( + cfg.as_outputs(), + [ + "title=Caddy Bunny", + "description=Caddy with Bunny DNS module", + "verify_module=dns.providers.bunny", + "alpine_alias=true", + "slim=true", + "create_release=true", + "platforms=linux/amd64,linux/arm64", + ], + ) + + def test_cli(self): + import contextlib + import io + + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + self.assertEqual(main([str(_write(MINIMAL))]), 0) + self.assertEqual(main([str(_write({**MINIMAL, "platforms": []}))]), 1) + self.assertIn("platforms=" + ",".join(DEFAULT_PLATFORMS), out.getvalue()) + self.assertTrue(err.getvalue().startswith("::error::")) def test_overrides(self): cfg = load_image_config(_write({**MINIMAL, "alpine_alias": False, "slim": True})) diff --git a/scripts/tests/test_platforms.py b/scripts/tests/test_platforms.py new file mode 100644 index 0000000..00e177d --- /dev/null +++ b/scripts/tests/test_platforms.py @@ -0,0 +1,52 @@ +import unittest + +from check_caddy_status.docker_hub import get_platforms_from_tag_data +from check_caddy_status.platforms import DEFAULT_PLATFORMS, OFFICIAL_PLATFORMS, platform_string + + +class PlatformStringTest(unittest.TestCase): + def test_canonical_forms(self): + self.assertEqual(platform_string("linux", "amd64"), "linux/amd64") + self.assertEqual(platform_string("linux", "arm64", "v8"), "linux/arm64") + self.assertEqual(platform_string("linux", "arm", "v7"), "linux/arm/v7") + self.assertEqual(platform_string("linux", "arm", "v6"), "linux/arm/v6") + self.assertEqual(platform_string("linux", "riscv64", None), "linux/riscv64") + + def test_non_linux_is_ignored(self): + self.assertIsNone(platform_string("windows", "amd64")) + self.assertIsNone(platform_string("unknown", "unknown")) + self.assertIsNone(platform_string("linux", None)) + + def test_defaults_are_official(self): + self.assertTrue(set(DEFAULT_PLATFORMS) <= set(OFFICIAL_PLATFORMS)) + self.assertEqual(len(set(OFFICIAL_PLATFORMS)), len(OFFICIAL_PLATFORMS)) + + +class DockerHubPlatformsTest(unittest.TestCase): + TAG_DATA = { + "images": [ + {"os": "linux", "architecture": "amd64", "variant": None}, + {"os": "linux", "architecture": "arm64", "variant": "v8"}, + {"os": "linux", "architecture": "arm", "variant": "v7"}, + {"os": "linux", "architecture": "arm", "variant": "v6"}, + {"os": "linux", "architecture": "riscv64", "variant": None}, + {"os": "unknown", "architecture": "unknown", "variant": None}, + "garbage", + ] + } + + def test_only_wanted_platforms_are_reported(self): + wanted = {"linux/amd64", "linux/arm64", "linux/arm/v7", "linux/s390x"} + self.assertEqual(get_platforms_from_tag_data(self.TAG_DATA, wanted), {"linux/amd64", "linux/arm64", "linux/arm/v7"}) + + def test_arm_v6_and_riscv64(self): + wanted = {"linux/arm/v6", "linux/riscv64"} + self.assertEqual(get_platforms_from_tag_data(self.TAG_DATA, wanted), wanted) + + def test_bad_data(self): + self.assertEqual(get_platforms_from_tag_data(None, {"linux/amd64"}), set()) + self.assertEqual(get_platforms_from_tag_data({"images": "x"}, {"linux/amd64"}), set()) + + +if __name__ == "__main__": + unittest.main()