diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 9ad3734..6ed1477 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -73,9 +73,18 @@ jobs: -t "$FULL_IMAGE:latest" \ -t "$FULL_IMAGE:v$VERSION" \ -t "$FULL_IMAGE:sha-$SHORT_SHA" \ - --load \ + --output "type=docker,dest=$RUNNER_TEMP/tvj-epg-built.tar" \ . + - name: Verify BuildKit export and load image + run: | + set -euo pipefail + python3 scripts/verify_image_healthcheck.py --archive "$RUNNER_TEMP/tvj-epg-built.tar" + docker load --input "$RUNNER_TEMP/tvj-epg-built.tar" + + - name: Report container engine versions + run: docker version + - name: Verify healthcheck and publish env: REGISTRY: ${{ vars.REGISTRY }} @@ -84,7 +93,8 @@ jobs: set -euo pipefail SHORT_SHA="${GITHUB_SHA::7}" FULL_IMAGE="$REGISTRY/david/tvj-epg" - docker image inspect "$FULL_IMAGE:latest" | python3 scripts/verify_image_healthcheck.py + docker image save --output "$RUNNER_TEMP/tvj-epg-loaded.tar" "$FULL_IMAGE:latest" + python3 scripts/verify_image_healthcheck.py --archive "$RUNNER_TEMP/tvj-epg-loaded.tar" docker push "$FULL_IMAGE:latest" docker push "$FULL_IMAGE:v$VERSION" docker push "$FULL_IMAGE:sha-$SHORT_SHA" diff --git a/README.md b/README.md index dd2e1a6..4734e21 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,10 @@ podman inspect --format '{{.State.Health.Status}}' tvj-epg No health environment variable or Quadlet `HealthCmd` is required. New containers inherit the image healthcheck unless deployment settings explicitly disable it. Build with `podman build --format docker` to preserve healthcheck metadata; OCI -builds can omit it. The Gitea publishing workflow verifies the image healthcheck before pushing -tags. It loads and verifies the image before publishing. +builds can omit it. The Gitea publishing workflow verifies the healthcheck in the Docker archive +exported by BuildKit, then loads it through the Podman socket and verifies a +second archive exported from the loaded image before pushing tags. This checks +the actual image configuration rather than the compatibility API inspect view. After an image update, pull the new image and recreate the container through its Quadlet service to acquire the metadata. An existing container does not acquire diff --git a/scripts/verify_image_healthcheck.py b/scripts/verify_image_healthcheck.py index b245c4c..4e71080 100644 --- a/scripts/verify_image_healthcheck.py +++ b/scripts/verify_image_healthcheck.py @@ -1,10 +1,41 @@ -"""Fail publication if the built image lost its automatic healthcheck.""" +"""Verify health metadata in inspect JSON or an exported Docker image archive.""" +import argparse import json import sys +import tarfile -images = json.load(sys.stdin) -healthcheck = images[0].get("Config", {}).get("Healthcheck") or {} -expected = ["CMD", "python", "-m", "epg.healthcheck"] -if healthcheck.get("Test") != expected: - sys.exit("Image healthcheck missing or incorrect; refusing publication") -print("Image healthcheck verified") + +def archive_config(path): + with tarfile.open(path) as archive: + manifest_file = archive.extractfile('manifest.json') + if manifest_file is None: + raise ValueError('Archive has no Docker manifest') + manifest = json.load(manifest_file) + if not manifest: + raise ValueError('Archive manifest is empty') + config_file = archive.extractfile(manifest[0]['Config']) + if config_file is None: + raise ValueError('Archive has no image config') + return json.load(config_file).get('config', {}) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', help='Docker image tar archive to verify') + args = parser.parse_args() + try: + config = archive_config(args.archive) if args.archive else json.load(sys.stdin)[0].get('Config', {}) + healthcheck = config.get('Healthcheck') or {} + print('Image config Healthcheck:', json.dumps(healthcheck), flush=True) + expected = ['CMD', 'python', '-m', 'epg.healthcheck'] + if healthcheck.get('Test') != expected: + raise ValueError('Image config lacks the expected healthcheck; refusing publication') + except (OSError, ValueError, KeyError, IndexError, tarfile.TarError) as error: + print(str(error), file=sys.stderr) + return 1 + print('Image healthcheck verified') + return 0 + + +if __name__ == '__main__': + sys.exit(main())