From feae205400326fa5afa664de0221082bc9490b02 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 11 Aug 2026 20:40:22 +0300 Subject: [PATCH] add image publishing job and size report --- .github/workflows/docker.yml | 2 + .gitignore | 1 + Jenkinsfile.image | 55 ++++++++++++++++++ ci/publish-image.sh | 102 +++++++++++++++++++++++++++++++++ ci/tests/fake-docker | 53 +++++++++++++++++ ci/tests/publish-image-test.sh | 90 +++++++++++++++++++++++++++++ 6 files changed, 303 insertions(+) create mode 100644 Jenkinsfile.image create mode 100755 ci/publish-image.sh create mode 100755 ci/tests/fake-docker create mode 100755 ci/tests/publish-image-test.sh diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f9bf718..4a2992f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,6 +18,8 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Test image publishing command + run: ./ci/tests/publish-image-test.sh - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build Docker Image diff --git a/.gitignore b/.gitignore index 99ca89b..c56ebbc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ test_env* .pids /test-env-compose/grafana/* /test-env-compose/prometheus/* +/image-size-report.md \ No newline at end of file diff --git a/Jenkinsfile.image b/Jenkinsfile.image new file mode 100644 index 0000000..c9b9012 --- /dev/null +++ b/Jenkinsfile.image @@ -0,0 +1,55 @@ +pipeline { + agent any + + options { + disableConcurrentBuilds() + timestamps() + } + + parameters{ + string( + name: 'RELEASE_REF', + defaultValue: '', + description: 'Release ref such as refs/tags/1.2.3; defaults to GIT_BRANCH' + ) + } + + environment { + IMAGE_REPOSITORY = 'registry.onlinedi.vision:5000/od-official-server' + IMAGE_REPORT = 'image-size-report.md' + } + + stages { + stage('Test publishing command') { + steps { + sh './ci/tests/publish-image-test.sh' + } + } + + stage('Build and push image') { + steps{ + withDockerRegistry( + url: 'https://registry.onlinedi.vision:5000', + credentialsId: 'docker-registry' + ){ + sh './ci/publish-image.sh' + } + } + } + stage('Show image report') { + steps { + sh 'sed -n \'1,120p\' "$IMAGE_REPORT"' + } + } + } + + post { + always { + archiveArtifacts( + artifacts: 'image-size-report.md', + allowEmptyArchive: true + ) + } + } + +} \ No newline at end of file diff --git a/ci/publish-image.sh b/ci/publish-image.sh new file mode 100755 index 0000000..2b9ea46 --- /dev/null +++ b/ci/publish-image.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +# Build, push, and report metadata for one tagged release image. + +set -euo pipefail + +# Prefer Jenkins' manual parameter, falling back to the checked-out Git ref. + +git_ref="${RELEASE_REF:-${GIT_BRANCH:-}}" +image_repository="${IMAGE_REPOSITORY:-registry.onlinedi.vision:5000/od-official-server}" +report_file="${IMAGE_REPORT:-image-size-report.md}" + +# Prevent branches and malformed refs from publishing release images. + +if [[ ! "$git_ref" =~ ^refs/tags/([^/]+)$ ]]; then + printf 'Ref must have the form refs/tags/VERSION; received: %s\n' \ + "${git_ref:-}" >&2 + exit 2 +fi + +release_version="${BASH_REMATCH[1]}" +image_tag="v${release_version}" + +# Ensure the generated value is a valid Docker image tag. + +if [[ ! "$image_tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + printf 'Invalid container tag: %s\n' "$image_tag" >&2 + exit 2 +fi + +image_ref="${image_repository}:${image_tag}" + +# docker-bake.hcl expects this variable. +export GIT_BRANCH="$git_ref" + +# Build and load one image into Docker. +docker buildx bake release \ + --set 'release.output=type=docker' \ + --set "release.tags=${image_ref}" + +# Push the exact image that was just built. +docker push "$image_ref" + +size_bytes="$(docker image inspect --format '{{.Size}}' "$image_ref")" + +# Read local image metadata after the image has been pushed. + +if [[ ! "$size_bytes" =~ ^[0-9]+$ ]]; then + printf 'Docker returned an invalid image size: %s\n' "$size_bytes" >&2 + exit 1 +fi + +size_human="$(awk -v bytes="$size_bytes" 'BEGIN { + split("B KiB MiB GiB TiB", units, " "); + size = bytes; + unit = 1; + + while (size >= 1024 && unit < 5) { + size /= 1024; + unit++; + } + + if (unit == 1) + printf "%d %s", size, units[unit]; + else + printf "%.2f %s", size, units[unit]; +}')" + +image_id="$(docker image inspect --format '{{.Id}}' "$image_ref")" +repo_digests="$( + docker image inspect \ + --format '{{range .RepoDigests}}{{println .}}{{end}}' \ + "$image_ref" +)" + +repo_digest="unavailable" + +while IFS= read -r candidate; do + if [[ -n "$candidate" ]]; then + repo_digest="$candidate" + break + fi +done <<< "$repo_digests" + +git_commit="${GIT_COMMIT:-unknown}" + +{ + printf '# Container image report\n\n' + printf '| Field | Value |\n' + printf '| --- | --- |\n' + printf '| Image | `%s` |\n' "$image_ref" + printf '| Repository digest | `%s` |\n' "$repo_digest" + printf '| Image ID | `%s` |\n' "$image_id" + printf '| Local image size | %s (%s bytes) |\n' \ + "$size_human" "$size_bytes" + printf '| Git commit | `%s` |\n' "$git_commit" + printf '| Generated at | %s |\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +} > "$report_file" + +printf 'Published %s\n' "$image_ref" +printf 'Image size: %s (%s bytes)\n' "$size_human" "$size_bytes" \ No newline at end of file diff --git a/ci/tests/fake-docker b/ci/tests/fake-docker new file mode 100755 index 0000000..1671d39 --- /dev/null +++ b/ci/tests/fake-docker @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +# Minimal Docker replacement used by tests; it never contacts a real daemon. + +set -euo pipefail + +# Record every simulated Docker command so the test can count and inspect them. + +printf '%s\n' "$*" >> "${FAKE_DOCKER_LOG:?FAKE_DOCKER_LOG must be set}" + +# Simulate either a successful build or a requested build failure. + +if [[ "$1" == "buildx" && "${2:-}" == "bake" ]]; then + if [[ "${FAKE_DOCKER_FAIL_BUILD:-0}" == "1" ]]; then + exit 41 + fi + + exit 0 +fi + +# Accept the push command without contacting the registry. + +if [[ "$1" == "push" ]]; then + exit 0 +fi + +# Return deterministic metadata for report-generation tests. + +if [[ "$1" == "image" && "${2:-}" == "inspect" ]]; then + case "${4:-}" in + '{{.Size}}') + printf '12582912\n' + ;; + '{{.Id}}') + printf 'sha256:image-id\n' + ;; + '{{range .RepoDigests}}{{println .}}{{end}}') + printf '%s\n' \ + 'registry.onlinedi.vision:5000/od-official-server@sha256:digest' + ;; + *) + printf 'Unexpected inspect format: %s\n' "${4:-}" >&2 + exit 3 + ;; + esac + + exit 0 +fi + +# Fail when the production script starts using an unhandled Docker command. + +printf 'Unexpected Docker command: %s\n' "$*" >&2 +exit 3 \ No newline at end of file diff --git a/ci/tests/publish-image-test.sh b/ci/tests/publish-image-test.sh new file mode 100755 index 0000000..fe0bf54 --- /dev/null +++ b/ci/tests/publish-image-test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +# Verify publishing behavior safely by replacing Docker with the local fake. + +set -euo pipefail + +# Keep fake commands, logs, and reports isolated from the repository. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +test_directory="$(mktemp -d)" +trap 'rm -rf "$test_directory"' EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +# Place the fake first in PATH under the name expected by the production script. + +cp "$repo_root/ci/tests/fake-docker" "$test_directory/docker" +chmod +x "$test_directory/docker" + +docker_log="$test_directory/docker.log" +report_file="$test_directory/image-size-report.md" + +# Happy path: a valid release must build once, push once, and create a report. + +FAKE_DOCKER_LOG="$docker_log" \ +PATH="$test_directory:$PATH" \ +RELEASE_REF='refs/tags/1.2.3' \ +GIT_COMMIT='0123456789abcdef' \ +IMAGE_REPORT="$report_file" \ + "$repo_root/ci/publish-image.sh" + +# Confirm that publishing did not rebuild or push the image more than once. + +build_count="$(grep -c '^buildx bake release ' "$docker_log")" +push_count="$( + grep -c \ + '^push registry.onlinedi.vision:5000/od-official-server:v1.2.3$' \ + "$docker_log" +)" + +[[ "$build_count" == "1" ]] || + fail 'expected exactly one image build' + +[[ "$push_count" == "1" ]] || + fail 'expected exactly one image push' + +# Confirm that the archived report contains the expected size and digest. + +grep -q '12.00 MiB (12582912 bytes)' "$report_file" || + fail 'report is missing the image size' + +grep -q 'sha256:digest' "$report_file" || + fail 'report is missing the pushed digest' + + +# Safety case: branch refs must be rejected before Docker is called. + +: > "$docker_log" + +if FAKE_DOCKER_LOG="$docker_log" \ + PATH="$test_directory:$PATH" \ + RELEASE_REF='refs/heads/main' \ + "$repo_root/ci/publish-image.sh"; then + fail 'branch references must not be publishable' +fi + +[[ ! -s "$docker_log" ]] || + fail 'invalid refs must be rejected before calling Docker' + +# Failure case: a failed build must stop execution before the push. + +: > "$docker_log" + +if FAKE_DOCKER_LOG="$docker_log" \ + FAKE_DOCKER_FAIL_BUILD=1 \ + PATH="$test_directory:$PATH" \ + RELEASE_REF='refs/tags/2.0.0' \ + "$repo_root/ci/publish-image.sh"; then + fail 'a failed build must fail the publishing command' +fi + +failed_pushes="$(grep -c '^push ' "$docker_log" || true)" + +[[ "$failed_pushes" == "0" ]] || + fail 'the script pushed an image after a failed build' + +printf 'PASS: image publishing and report generation\n' \ No newline at end of file