diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f05d601..bface19 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -99,6 +99,14 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + strategy: + fail-fast: false + matrix: + # The Containerfile cross-compiles, so a non-native architecture still + # builds on this amd64 runner without emulation. + arch: [amd64, arm64] + env: + ARCH: ${{ matrix.arch }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -106,16 +114,16 @@ jobs: persist-credentials: false - name: Build operator image - run: make buildimg + run: make buildimg IMG="bootc-operator:dev-${ARCH}" PLATFORM="linux/${ARCH}" - name: Save operator image - run: podman save -o operator-image.tar bootc-operator:dev + run: podman save -o "operator-image-${ARCH}.tar" "bootc-operator:dev-${ARCH}" - name: Upload operator image uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: operator-image - path: operator-image.tar + name: operator-image-${{ matrix.arch }} + path: operator-image-${{ matrix.arch }}.tar e2e: runs-on: ubuntu-latest @@ -180,10 +188,10 @@ jobs: - name: Download operator image uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: operator-image + name: operator-image-amd64 - name: Load operator image - run: podman load -i operator-image.tar + run: podman load -i operator-image-amd64.tar - name: Fix registries configuration run: | @@ -195,7 +203,7 @@ jobs: run: make start-bink - name: Deploy to bink cluster - run: make deploy-bink + run: make deploy-bink IMG=bootc-operator:dev-amd64 - name: Gather deploy logs if: failure() @@ -222,13 +230,29 @@ jobs: env: IMAGE: ghcr.io/${{ github.repository }} steps: - - name: Download operator image + - name: Download operator images uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: operator-image + pattern: operator-image-* + merge-multiple: true - - name: Load operator image - run: podman load -i operator-image.tar + - name: Assemble manifest list + run: | + shopt -s failglob + refs=() + for tar in operator-image-*.tar; do + arch="${tar#operator-image-}" + arch="${arch%.tar}" + podman load -i "$tar" + refs+=("containers-storage:localhost/bootc-operator:dev-$arch") + done + # Guard against a missing artifact silently publishing a partial + # manifest list, which would leave some nodes unable to pull. + if [[ "${#refs[@]}" -lt 2 ]]; then + echo "expected at least 2 architectures, got ${#refs[@]}" >&2 + exit 1 + fi + podman manifest create bootc-operator:dev "${refs[@]}" - name: Push to GHCR env: @@ -239,13 +263,20 @@ jobs: REF_NAME: ${{ github.ref_name }} run: | podman login -u "${ACTOR}" -p "${GH_TOKEN}" ghcr.io - podman push bootc-operator:dev "${IMAGE}":dev - podman push bootc-operator:dev "${IMAGE}":"${SHA}" + tags=(dev "${SHA}") if [[ "${REF}" == refs/tags/v* ]]; then - podman push bootc-operator:dev "${IMAGE}":"${REF_NAME}" + tags+=("${REF_NAME}") fi if [[ "${REF}" == refs/heads/main ]]; then - podman push bootc-operator:dev "${IMAGE}":latest + tags+=(latest) fi + + # v2s2 keeps the per-architecture manifests in the same format as + # the images published before this became a manifest list, so the + # only change downstream sees is the added list layer. + for tag in "${tags[@]}"; do + podman manifest push --all --format v2s2 \ + bootc-operator:dev "docker://${IMAGE}:${tag}" + done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 998d9a1..e92bada 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,11 +78,26 @@ make deploy-bink BINK_CLUSTER_NAME=dev ```shell make build # Build all binaries (manager + daemon) to ./bin/ make buildimg # Build the container image (default: bootc-operator:dev) +make buildimg-all # Build a multi-arch manifest list (linux/amd64, linux/arm64) ``` The `bootc-operator` container image contains both the controller and daemon binaries. +Released images are manifest lists covering `linux/amd64` and `linux/arm64`. +Both binaries are pure Go, so the Containerfile cross-compiles them for the +target architecture instead of emulating the toolchain; building for a +non-native architecture therefore costs about the same as a native build and +needs no qemu setup. To build a single non-native image, pass `PLATFORM`: + +```shell +make buildimg PLATFORM=linux/arm64 +``` + +Note that the e2e suite remains amd64-only, because the +[bink](https://github.com/bootc-dev/bink) node images it boots are published +for amd64 alone. + After modifying API types in `api/`, regenerate CRDs and code: ```shell diff --git a/Containerfile b/Containerfile index e6a92ad..05bbf64 100644 --- a/Containerfile +++ b/Containerfile @@ -1,4 +1,4 @@ -FROM quay.io/fedora/fedora-minimal:44 AS buildroot +FROM --platform=$BUILDPLATFORM quay.io/fedora/fedora-minimal:44 AS buildroot ARG DNF_FLAGS="-y --setopt=install_weak_deps=False" RUN --mount=type=cache,id=dnf,target=/var/cache/libdnf5 \ dnf install ${DNF_FLAGS} golang @@ -9,10 +9,15 @@ COPY go.mod go.sum ./ RUN --mount=type=cache,id=gomod,target=/root/go/pkg/mod \ go mod download COPY . . +# The buildroot stage always runs on the build host's architecture, so cross +# compile via TARGETARCH rather than emulating the Go toolchain. Both binaries +# are pure Go, hence CGO_ENABLED=0. The Go build cache is content addressed and +# keyed on the target, so sharing it across architectures is safe. +ARG TARGETARCH RUN --mount=type=cache,id=gomod,target=/root/go/pkg/mod \ --mount=type=cache,id=gobuild,target=/root/.cache/go-build \ - go build -o manager ./cmd/controller/ && \ - go build -o daemon ./cmd/daemon/ + CGO_ENABLED=0 GOARCH=${TARGETARCH} go build -o manager ./cmd/controller/ && \ + CGO_ENABLED=0 GOARCH=${TARGETARCH} go build -o daemon ./cmd/daemon/ FROM quay.io/fedora/fedora-minimal:44 COPY --from=builder /workspace/manager /workspace/daemon /usr/local/bin/ diff --git a/Makefile b/Makefile index b3beaad..18a3070 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ # Image URL to use all building/pushing image targets IMG ?= bootc-operator:dev CONTAINER_TOOL ?= podman +# Architectures published by buildimg-all. Both binaries are pure Go, so the +# Containerfile cross-compiles rather than emulating the toolchain. +PLATFORMS ?= linux/amd64,linux/arm64 # Bink cluster settings. deploy-bink and e2e share the same cluster by default. # To use a separate dev cluster: make deploy-bink BINK_CLUSTER_NAME=dev BINK_CLUSTER_NAME ?= e2e @@ -89,8 +92,12 @@ build-daemon: ## Build daemon binary. go build -o bin/daemon ./cmd/daemon/ .PHONY: buildimg -buildimg: ## Build container image. - $(CONTAINER_TOOL) build -t $(IMG) . +buildimg: ## Build container image. PLATFORM=linux/arm64 cross-builds for another arch. + $(CONTAINER_TOOL) build $(if $(PLATFORM),--platform $(PLATFORM)) -t $(IMG) . + +.PHONY: buildimg-all +buildimg-all: ## Build a multi-arch manifest list image (override PLATFORMS to change). + $(CONTAINER_TOOL) build --platform $(PLATFORMS) --manifest $(IMG) . .PHONY: build-update-image build-update-image: ## Build a derived node image for update testing and push to bink registry.