diff --git a/.github/workflows/release_docker.yml b/.github/workflows/release_docker.yml index f7406653ab..f4a9638b51 100644 --- a/.github/workflows/release_docker.yml +++ b/.github/workflows/release_docker.yml @@ -2,16 +2,68 @@ name: Build Docker on: workflow_call: + workflow_dispatch: + inputs: + version: + description: 'Version for image tags, e.g. 1.4.1 (defaults to the short commit SHA when empty)' + required: false + default: '' + platform: + description: 'Target CPU architecture (amd64 or arm64)' + required: false + default: 'amd64' + tag_suffix: + description: 'Suffix appended to every image tag, e.g. arm64 (leave empty for the default arch)' + required: false + default: '' + runner: + description: 'Runner label; only ubuntu-24.04 / ubuntu-24.04-arm are honored, anything else falls back to ubuntu-latest' + required: false + default: 'ubuntu-latest' + assemble_manifests: + description: "Skip image builds and only assemble multi-arch manifests from already-pushed '-amd64'/'-arm64' tags" + required: false + default: 'false' + +# The workflow only checks out code and pushes images to Docker Hub via the +# DOCKERHUB_* secrets; it never needs more than read access to the repository. +permissions: + contents: read jobs: build-docker-images: - runs-on: ubuntu-latest + # workflow_dispatch builds may push shared mutable tags ('', + # 'v', 'latest') with the repo's Docker Hub credentials, so they + # are restricted to protected refs; feature-branch dispatches cannot push. + # Tag refs are allowed at the same trust level as the release pipeline. + if: github.event_name == 'workflow_call' || (inputs.assemble_manifests != 'true' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-') || startsWith(github.ref, 'refs/tags/v'))) + permissions: + contents: read + # Explicit bound below the 360-minute GitHub default, with headroom for + # slow native arm64 builds; a stuck build releases the runner in 4h. + timeout-minutes: 240 + # Serialize ALL registry mutations (builds and assemblies mutate the same + # tag space) behind one group. GitHub keeps at most one in-progress and + # one pending run per group; a newer run cancels an older PENDING run, so + # an overlapping dispatch can drop a queued run — including a release + # build from release.yml, and the per-arch flow itself (with amd64 + # running, queueing arm64 makes it PENDING, and queueing the assembly + # then cancels it). Wait for each queued run to finish before dispatching + # the next, re-dispatch anything canceled, and re-run release.yml if a + # release run fails this way; in-progress runs are never canceled. + concurrency: + group: docker-release + cancel-in-progress: false + # Allowlist the runner label: a free-form `runs-on:` would let anyone with + # workflow-dispatch rights route this build (and its Docker Hub secrets) + # to an arbitrary, possibly untrusted runner. + runs-on: ${{ (inputs.runner == 'ubuntu-24.04' || inputs.runner == 'ubuntu-24.04-arm') && inputs.runner || 'ubuntu-latest' }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version-file: './build-tools/.python-version' @@ -22,31 +74,47 @@ jobs: # # Install any other dependencies your script might need - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Hub if: github.event_name != 'pull_request' - uses: docker/login-action@v2 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Determine version id: version + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ $GITHUB_REF == refs/tags/* ]]; then - VERSION=${GITHUB_REF#refs/tags/} + validate() { # Validate the grammar up front (the version flows into docker build/push - # command lines inside build.py), so a non-semver tag fails here with a - # clear message. The leading 'v' is allowed here; build.py strips it. - if ! echo "$VERSION" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then - echo "derived version '$VERSION' is not [v]X.Y.Z[-suffix]; tag a semver release" >&2 + # command lines inside build.py), so a bad version fails here with a + # clear message. Whole-value match: grep would compare line by line + # and pass a multi-line value, which would later expand into several + # tag arguments. The leading 'v' is allowed here; build.py strips it. + if ! [[ "$1" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "derived version '$1' is not [v]X.Y.Z[-suffix]; tag a semver release or pass a valid version input" >&2 exit 1 fi - echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + } + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + validate "$VERSION" + STAMP_LATEST=true + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + validate "$VERSION" + STAMP_LATEST=true else - echo "VERSION=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + # Ad-hoc dispatch default: the short commit SHA. These builds keep + # their own tags and must not repoint the production 'latest' tag. + VERSION=$(git rev-parse --short HEAD) + STAMP_LATEST=false fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "stamp_latest=$STAMP_LATEST" >> $GITHUB_OUTPUT # Note: the version (and the Docker Hub username) are passed through environment # variables instead of direct ${{ }} interpolation into the run block, so that a @@ -54,10 +122,151 @@ jobs: - name: Build and Push Docker images env: VERSION: ${{ steps.version.outputs.VERSION }} + STAMP_LATEST: ${{ steps.version.outputs.stamp_latest }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + PLATFORM: ${{ inputs.platform || 'amd64' }} + TAG_SUFFIX: ${{ inputs.tag_suffix }} run: | - python build-tools/docker/build.py --push --docker-hub-user "$DOCKERHUB_USERNAME" \ - --version "$VERSION" --latest + # Validate the suffix up front: it flows into image tags on docker + # command lines, so anything outside the Docker tag grammar must fail + # here with a clear message instead of producing a broken (or + # hostile) tag. The bash regex matches the whole value (grep would + # match line by line and pass a multi-line suffix). build.py validates + # it again for standalone use. + if [[ -n "$TAG_SUFFIX" ]] && ! [[ "$TAG_SUFFIX" =~ ^[a-zA-Z0-9]([a-zA-Z0-9_.-]{0,127})?$ ]]; then + echo "invalid tag_suffix '$TAG_SUFFIX'; expected [a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}" >&2 + exit 1 + fi + args=(--push --docker-hub-user "$DOCKERHUB_USERNAME" \ + --version "$VERSION" --platform "$PLATFORM") + # 'latest' is stamped only for release versions; the classification + # (semver vs commit SHA) happened in 'Determine version', so this + # step re-derives no grammar. + if [[ "$STAMP_LATEST" == 'true' ]]; then + args+=(--latest) + fi + if [[ -n "$TAG_SUFFIX" ]]; then + args+=(--tag_suffix "$TAG_SUFFIX") + fi + python build-tools/docker/build.py "${args[@]}" env: DOCKER_BUILDKIT: 1 + + # Registry-side assembly of multi-arch manifest lists from images that were + # pushed earlier by separate per-arch dispatches of the build job above + # ("--platform arm64 --tag_suffix arm64"). Nothing is rebuilt: "docker + # buildx imagetools create" only references already-pushed manifests. Runs + # from the default branch via "gh workflow run ... --ref ": GitHub + # validates the workflow registration against the default branch but reads + # the file content from the dispatched ref. + assemble-manifests: + if: github.event_name == 'workflow_dispatch' && inputs.assemble_manifests == 'true' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-') || startsWith(github.ref, 'refs/tags/v')) + permissions: + contents: read + timeout-minutes: 60 + # Builds and assemblies mutate the same tag space, so both share the + # 'docker-release' group (see the build job for the pending-run caveat). + concurrency: + group: docker-release + cancel-in-progress: false + runs-on: ubuntu-latest + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Login to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Assemble and push multi-arch manifests + env: + INPUT_VERSION: ${{ inputs.version }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + set -euo pipefail + + VERSION="${INPUT_VERSION#v}" + # Whole-value match: grep would compare line by line and pass a + # multi-line value, which would later expand into several tag + # arguments. + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "invalid version '$INPUT_VERSION'; expected [v]X.Y.Z[-suffix]" >&2 + exit 1 + fi + + assemble() { + local repo="$1" tag="$2" + local amd64_ready=0 + # Pre-flight: confirm every source manifest before mutating + # anything, so a missing image fails the job here instead of + # leaving this tag half-assembled (part of the per-arch tags + # created, the base tag already repointed). + docker buildx imagetools inspect "$repo:$tag-amd64" >/dev/null 2>&1 && amd64_ready=1 + if [ "$amd64_ready" -eq 0 ] && ! docker buildx imagetools inspect "$repo:$tag" >/dev/null 2>&1; then + echo "neither $repo:$tag-amd64 nor $repo:$tag found; push the amd64 images first" >&2 + exit 1 + fi + if ! docker buildx imagetools inspect "$repo:$tag-arm64" >/dev/null 2>&1; then + echo "$repo:$tag-arm64 not found; push the arm64 images first" >&2 + exit 1 + fi + # Snapshot the existing (historically amd64-only) manifest under an + # explicit arch tag, unless it's already there (idempotent re-runs). + if [ "$amd64_ready" -eq 0 ]; then + docker buildx imagetools create -t "$repo:$tag-amd64" "$repo:$tag" + fi + # Provenance: the two sources were pushed by independent dispatches + # and can originate from different commits. Full cross-arch + # integrity needs image-level attestation; until then, log both + # digests so the pairing is auditable after the fact. + echo "amd64 digest of $repo:$tag-amd64:" + docker buildx imagetools inspect "$repo:$tag-amd64" | grep '^Digest:' + echo "arm64 digest of $repo:$tag-arm64:" + docker buildx imagetools inspect "$repo:$tag-arm64" | grep '^Digest:' + docker buildx imagetools create -t "$repo:$tag" "$repo:$tag-amd64" "$repo:$tag-arm64" + } + + # Only the app images are maintained on Docker Hub (build.py's + # push_instances pushes these six); the builder/runner-base images + # stay local to the CI runner, so there is nothing to assemble for + # them. + for repo in "$DOCKERHUB_USERNAME/node-daemon" \ + "$DOCKERHUB_USERNAME/api-blockchain-scanner-daemon" \ + "$DOCKERHUB_USERNAME/api-web-server" \ + "$DOCKERHUB_USERNAME/wallet-cli" \ + "$DOCKERHUB_USERNAME/wallet-rpc-daemon" \ + "$DOCKERHUB_USERNAME/dns-server"; do + for tag in "$VERSION" "v$VERSION" latest; do + assemble "$repo" "$tag" + done + done + + - name: Verify manifests + env: + INPUT_VERSION: ${{ inputs.version }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + VERSION="${INPUT_VERSION#v}" + for repo in node-daemon api-blockchain-scanner-daemon api-web-server wallet-cli wallet-rpc-daemon dns-server; do + echo "=== $DOCKERHUB_USERNAME/$repo:v$VERSION ===" + # A missing manifest must fail the job: reporting success here would + # defeat the purpose of the check. + if ! output="$(docker buildx imagetools inspect "$DOCKERHUB_USERNAME/$repo:v$VERSION")"; then + echo "manifest for $DOCKERHUB_USERNAME/$repo:v$VERSION is missing" >&2 + exit 1 + fi + echo "$output" | grep -E '^Name:|Platform:' + # Both architectures must be present in the manifest list; a stale + # single-arch tag must fail the job instead of shipping silently. + for platform in linux/amd64 linux/arm64; do + # imagetools output is tabwriter-aligned ("Platform: linux/amd64"), + # so tolerate any run of whitespace after the colon. + if ! grep -Eq "Platform:[[:space:]]+$platform" <<<"$output"; then + echo "manifest for $DOCKERHUB_USERNAME/$repo:v$VERSION lacks $platform" >&2 + exit 1 + fi + done + done diff --git a/build-tools/docker/README.md b/build-tools/docker/README.md index 2a45ccd215..c0bf70361c 100644 --- a/build-tools/docker/README.md +++ b/build-tools/docker/README.md @@ -61,3 +61,12 @@ docker run -it --network=mintlayer-net -v ~/.mintlayer:/root/.mintlayer mintlaye replace `` with `mainnet` or `testnet` depending on what network you're running and `` with the result of the command above. This command mounts the same `~/.mintlayer` directory as a volume in the `wallet-cli` container and uses the `--rpc-cookie-file` option to specify the path to the cookie file. + +## Multi-arch (amd64 / arm64) images + +By default the build produces `linux/amd64` images, exactly as it always has. Two extra knobs exist for building other architectures: + +- `build.py --platform arm64` builds `linux/arm64` images (must run on an arm64 machine, e.g. the `ubuntu-24.04-arm` GitHub runner, since the compilation happens inside the container). +- `build.py --tag_suffix arm64` appends the suffix to every built and pushed tag (e.g. `node-daemon:1.4.1-arm64`), so a non-default architecture never overwrites the default-arch tags. + +To assemble the suffixed images into a proper multi-arch manifest list under the unsuffixed tags (`docker pull mintlayer/node-daemon` then auto-selects the architecture), dispatch the **"Assemble multi-arch Docker manifests"** workflow (`release_docker_manifest.yml`) with the release version after both architectures have been pushed. diff --git a/build-tools/docker/build.py b/build-tools/docker/build.py index 1c2351af48..417cccce64 100644 --- a/build-tools/docker/build.py +++ b/build-tools/docker/build.py @@ -1,6 +1,7 @@ import argparse import os import pathlib +import re import subprocess import tomllib @@ -26,44 +27,74 @@ def get_cargo_version(cargo_toml_path): return version -def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None): - # Docker build command - command = f"docker build" +def apply_tag_suffix(tags, tag_suffix): + # Append the suffix (e.g. "arm64") to every tag, so that images built for a + # non-default architecture never overwrite the tags of the default-arch images. + if not tag_suffix: + return list(tags) + return [f"{tag}-{tag_suffix}" for tag in tags] + + +def build_docker_image(dockerfile_path, image_name, tags, num_jobs=None, platform="amd64", tag_suffix=None): + raw_tags = list(tags) + tags = apply_tag_suffix(raw_tags, tag_suffix) + + command = ["docker", "build"] full_tags = [image_name] if len(tags) == 0 else [f"{image_name}:{tag}" for tag in tags]; for full_tag in full_tags: - command += f" -t {full_tag}" + command += ["-t", full_tag] - command += f" -f {dockerfile_path}" + command += ["-f", str(dockerfile_path)] if num_jobs: - command += f" --build-arg NUM_JOBS={num_jobs}" + command += ["--build-arg", f"NUM_JOBS={num_jobs}"] - # Force the amd64 platform in case we're building on an arm-based one. - command += " --platform linux/amd64" + command += ["--platform", f"linux/{platform}"] # Note: "plain" output is more verbose, but it makes it easier to understand what went wrong # when a problem occurs. - command += " --progress=plain" - command += " ." + command += ["--progress=plain"] + command += ["."] try: - # Run the command - subprocess.check_call(command, shell=True, cwd=ROOT_DIR) + # Run the command. An argument list (no shell) keeps every tag and path + # literal, so a crafted version/suffix cannot inject shell commands. + subprocess.check_call(command, cwd=ROOT_DIR) print(f"Built {image_name} successfully (the tags are: {full_tags}).") except subprocess.CalledProcessError as error: print(f"Failed to build {image_name}: {error}") exit(1) # stop the build + if tag_suffix and raw_tags: + # The app images reference "mintlayer-builder:latest" / "mintlayer-runner-base" + # without a suffix in their FROM lines. When building for a non-default + # architecture, the suffixed base image exists locally but the unsuffixed tag + # would be resolved from the registry (where only the amd64 variant exists). + # Add a local unsuffixed alias pointing to the image we've just built, so the + # subsequent app image builds resolve it locally. These local aliases are never + # pushed (push_instances only pushes the suffixed tags), so they cannot + # overwrite the registry's default-arch tags. Skipped when the caller + # passed no tags: there is no versioned tag to derive an alias from. + local_alias = f"{image_name}:{raw_tags[0]}" + alias_command = ["docker", "tag", full_tags[0], local_alias] + try: + subprocess.check_call(alias_command) + print(f"Tagged {full_tags[0]} locally as {local_alias}.") + except subprocess.CalledProcessError as error: + print(f"Failed to tag {image_name}: {error}") + exit(1) # stop the build + def push_docker_image(image_name, tags): for tag in tags: full_image_name = f"{image_name}:{tag}" - push_command = f"docker push {full_image_name}" + + push_command = ["docker", "push", full_image_name] try: - subprocess.check_call(push_command, shell=True) + subprocess.check_call(push_command) print(f"Pushed {full_image_name} successfully.") except subprocess.CalledProcessError as error: print(f"Failed to push {full_image_name}: {error}") @@ -75,35 +106,35 @@ def delete_docker_image(image_name, version): full_image_name = f"{image_name}:{version}" # Docker rmi command - command = f"docker rmi {full_image_name}" + command = ["docker", "rmi", full_image_name] # Run the command try: - subprocess.check_call(command, shell=True) + subprocess.check_call(command) print(f"Deleted {full_image_name} successfully.") except subprocess.CalledProcessError as error: print(f"Failed to delete {full_image_name}: {error}") # No need to fail the build here -def build_instances(tags, docker_hub_user, num_jobs): +def build_instances(tags, docker_hub_user, num_jobs, platform="amd64", tag_suffix=None): build_docker_image("build-tools/docker/Dockerfile.builder", - "mintlayer-builder", ["latest"], num_jobs) + "mintlayer-builder", ["latest"], num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.runner-base", - "mintlayer-runner-base", ["latest"], num_jobs) + "mintlayer-runner-base", ["latest"], num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.node-daemon", - f"{docker_hub_user}/node-daemon", tags) + f"{docker_hub_user}/node-daemon", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.api-blockchain-scanner-daemon", - f"{docker_hub_user}/api-blockchain-scanner-daemon", tags) + f"{docker_hub_user}/api-blockchain-scanner-daemon", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.api-web-server", - f"{docker_hub_user}/api-web-server", tags) + f"{docker_hub_user}/api-web-server", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.wallet-cli", - f"{docker_hub_user}/wallet-cli", tags) + f"{docker_hub_user}/wallet-cli", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.wallet-rpc-daemon", - f"{docker_hub_user}/wallet-rpc-daemon", tags) + f"{docker_hub_user}/wallet-rpc-daemon", tags, num_jobs, platform, tag_suffix) build_docker_image("build-tools/docker/Dockerfile.dns-server", - f"{docker_hub_user}/dns-server", tags) + f"{docker_hub_user}/dns-server", tags, num_jobs, platform, tag_suffix) # delete_docker_image("mintlayer-builder", "latest") @@ -125,8 +156,25 @@ def main(): parser.add_argument('--version', help='Override version number', default=None) parser.add_argument('--num_jobs', help='Number of parallel jobs', default=(os.cpu_count() or 1)) parser.add_argument('--local_tags', nargs='*', help='Additional tags to apply (these won\'t be pushed)', default=[]) + parser.add_argument('--platform', choices=['amd64', 'arm64'], default='amd64', + help='Target CPU architecture for the images (the default keeps the historical behavior)') + parser.add_argument('--tag_suffix', help='Suffix appended to every built and pushed image tag (e.g. "arm64")', + default=None) args = parser.parse_args() + # The suffix lands in image tags on docker command lines; anything outside + # the Docker tag grammar must fail here rather than produce a broken or + # hostile tag. (The release workflow validates the same input before + # invoking this script; this check covers standalone use.) + if args.tag_suffix and not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}", args.tag_suffix): + parser.error("--tag_suffix must match [a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}") + + # An unsuffixed non-amd64 push would silently overwrite the registry's + # default-arch (amd64) tags, which is exactly what the suffix exists to + # prevent. + if args.push and args.platform != "amd64" and not args.tag_suffix: + parser.error("--tag_suffix is required when --push is used with a --platform other than amd64") + version = args.version if args.version else get_cargo_version(ROOT_CARGO_TOML) # Note: the CI currently takes the version from the release tag, so it always starts with "v", # but the version from Cargo.toml doesn't have this prefix. @@ -140,11 +188,14 @@ def main(): all_tags = args.local_tags + tags_to_push if args.build: - build_instances(all_tags, args.docker_hub_user, args.num_jobs) + # Note: the suffix is applied inside build_docker_image, so it must NOT be + # pre-applied to these tags (that would produce doubled suffixes). + build_instances(all_tags, args.docker_hub_user, args.num_jobs, + args.platform, args.tag_suffix) # Only push the image if the --push flag is provided if args.push: - push_instances(args.docker_hub_user, tags_to_push) + push_instances(args.docker_hub_user, apply_tag_suffix(tags_to_push, args.tag_suffix)) if __name__ == "__main__":