From ebbc0ff76ed3b6a205bec139a7e1405502235c12 Mon Sep 17 00:00:00 2001
From: Gilles Filippini <pini@debian.org>
Date: Fri, 7 Apr 2023 16:41:51 +0200
Subject: [PATCH] ci: enable multiplatform builds with docker buildx

* Build from the Dockerfile and copy the artifacts from the resulting
  image
* Use cross-compilation at build stage when building for a foreign architecture
* Use 'docker buildx build ...' in CI to enable multiplatform builds
  If buildx is not installed use:
    DOCKER_BUILDKIT=1 docker build ...
* Gilab runner must expose tag 'multiplatform' which means it has native
  amd64 arch + emulated arm64 support:
    https://github.com/tonistiigi/binfmt/tree/deploy/v7.0.0-28#installing-emulators
    $ docker run --privileged --rm tonistiigi/binfmt --install arm64
---
 .dockerignore     |   1 +
 .gitlab-ci.yml    | 268 +++++++++++++++++++---------------------------
 docker/Dockerfile |  87 ++++++++++-----
 3 files changed, 168 insertions(+), 188 deletions(-)

diff --git a/.dockerignore b/.dockerignore
index c602282b3..1c3094162 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -6,3 +6,4 @@ docker/Dockerfile
 docker-compose.yml
 arm-build/
 **/target/
+build/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 6352c8a28..d85ac2e87 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,3 +1,9 @@
+# Runner tags:
+# - dind: Docker in Docker
+# - multiplatform: support amd64 and arm64 architectures
+#     https://github.com/tonistiigi/binfmt/tree/deploy/v7.0.0-28#installing-emulators
+#     $ docker run --privileged --rm tonistiigi/binfmt --install arm64
+
 stages:
   - schedule
   - labels
@@ -58,95 +64,59 @@ fmt_and_clippy:
     - cargo clippy -- -V
     - cargo clippy --all --tests -- -D warnings
 
-build_debug:
-  extends: .env
-  rules:
-    - if: $CI_COMMIT_TAG
-      when: never
-    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_BRANCH == "master"'
-      changes:
-      - Cargo.lock
-    - when: never
+.docker_build:
+  image: docker:20.10
+  script:
+    - mkdir -p build
+    # A builder name and node name must be specified so that the command doesn't fail when the builder exists already
+    - docker buildx create --name gitlab-runner-buildx --node gitlab-runner-buildx0 --use
+    - docker buildx ls
+    - echo docker buildx build --tag "$IMAGE_NAME:$IMAGE_TAG" -f docker/Dockerfile $DOCKER_BUILD_OPTIONS .
+    - docker buildx build --tag "$IMAGE_NAME:$IMAGE_TAG" -f docker/Dockerfile $DOCKER_BUILD_OPTIONS .
+  tags:
+    - dind
+
+.docker_build_save:
+  extends: .docker_build
   stage: build
   script:
-    - cargo clean -p duniter
-    - cargo build --locked
-    - mkdir build
-    - mv target/debug/duniter build/duniter
+    - !reference [.docker_build, script]
+    - docker run -d --rm --name "$CONTAINER" --entrypoint "" --user "$(id -u)" "$IMAGE_NAME:$IMAGE_TAG" sleep infinity
+    - docker cp "$CONTAINER:/usr/local/bin/duniter" build/
+    - docker stop "$CONTAINER"
   artifacts:
     paths:
       - build/
     expire_in: 3 day
-  cache:
-    - key:
-        files:
-          - Cargo.lock
-      paths:
-        - target/debug
-      policy: push
 
-build_debug_with_cache:
-  extends: .env
+build_debug:
+  extends: .docker_build_save
   rules:
-    - changes:
-      - Cargo.lock
-      when: never
     - if: $CI_COMMIT_TAG
       when: never
     - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_BRANCH == "master"'
     - when: never
-  stage: build
-  script:
-    - cargo clean -p duniter
-    - cargo build --locked
-    - mkdir build
-    - mv target/debug/duniter build/duniter
-  artifacts:
-    paths:
-      - build/
-    expire_in: 3 day
-  cache:
-    - key:
-        files:
-          - Cargo.lock
-      paths:
-        - target/debug
-      policy: pull
+  variables:
+    IMAGE_NAME: "duniter/duniter-v2s"
+    IMAGE_TAG: "debug-sha-$CI_COMMIT_SHORT_SHA"
+    DOCKER_BUILD_OPTIONS: "--load --build-arg debug=1"
+    CONTAINER: "duniter-v2s_$IMAGE_TAG"
 
 build_release:
-  extends: .env
+  extends: .docker_build_save
   rules:
     - if: "$CI_COMMIT_TAG && $CI_COMMIT_TAG =~ /^v*/"
-    - when: never
-  stage: build
-  script:
-    - cargo build --locked --release
-    - mkdir build
-    - mv target/release/duniter build/duniter
-  artifacts:
-    paths:
-      - build/
-    expire_in: 3 day
-
-build_release_manual:
-  extends: .env
-  rules:
-    - if: $CI_COMMIT_TAG
-      when: never
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_BRANCH == "master"'
     - when: manual
-  stage: build
-  allow_failure: true
-  script:
-    - cargo build --locked --release
-    - mkdir build
-    - mv target/release/duniter build/duniter
-  artifacts:
-    paths:
-      - build/
-    expire_in: 3 day
+  variables:
+    IMAGE_NAME: "duniter/duniter-v2s"
+    IMAGE_TAG: "sha-$CI_COMMIT_SHORT_SHA"
+    DOCKER_BUILD_OPTIONS: "--load --platform linux/amd64"
+    CONTAINER: "duniter-v2s_$IMAGE_TAG"
 
-tests_debug:
-  extends: .env
+test_debug:
+  stage: tests
+  extends: .docker_build
   rules:
     - if: $CI_COMMIT_REF_NAME =~ /^wip*$/
       when: manual
@@ -154,128 +124,106 @@ tests_debug:
       when: never
     - if: '$CI_MERGE_REQUEST_ID || $CI_COMMIT_BRANCH == "master"'
     - when: manual
-  stage: tests
   variables:
-    DUNITER_BINARY_PATH: "../build/duniter"
-    DUNITER_END2END_TESTS_SPAWN_NODE_TIMEOUT: "20"
-  script:
-    - cargo test --workspace --exclude duniter-end2end-tests --exclude duniter-live-tests
-    - cargo cucumber -i account_creation*
-    - cargo cucumber -i certification*
-    - cargo cucumber -i identity_creation*
-    - cargo cucumber -i monetary_mass*
-    - cargo cucumber -i oneshot_account*
-    - cargo cucumber -i transfer_all*
-  after_script:
-    - cd target/debug/deps/
-    - rm cucumber_tests-*.d
-    - mv cucumber_tests* ../../../build/duniter-cucumber
-  artifacts:
-    paths:
-      - build/
-    expire_in: 3 day
+    IMAGE_NAME: "duniter/duniter-v2s-test"
+    IMAGE_TAG: "debug-sha-$CI_COMMIT_SHORT_SHA"
+    DOCKER_BUILD_OPTIONS: "--target build --build-arg debug=1 --build-arg cucumber=1"
 
-tests_release:
-  extends: .env
+test_release:
+  stage: tests
+  extends: .docker_build
   rules:
     - if: "$CI_COMMIT_TAG && $CI_COMMIT_TAG =~ /^v*/"
     - when: never
-  stage: tests
   variables:
-    DUNITER_BINARY_PATH: "../build/duniter"
-    DUNITER_END2END_TESTS_SPAWN_NODE_TIMEOUT: "20"
-  script:
-    - cargo test --workspace --exclude duniter-end2end-tests --exclude duniter-live-tests
-    - cargo cucumber -i account_creation*
-    - cargo cucumber -i certification*
-    - cargo cucumber -i identity_creation*
-    - cargo cucumber -i monetary_mass*
-    - cargo cucumber -i oneshot_account*
-    - cargo cucumber -i transfer_all*
-  after_script:
-    - cd target/debug/deps/
-    - rm cucumber_tests-*.d
-    - mv cucumber_tests* ../../../build/duniter-cucumber
-  artifacts:
-    paths:
-      - build/
-    expire_in: 3 day
-  dependencies:
-    - build_release
+    IMAGE_NAME: "duniter/duniter-v2s-test"
+    IMAGE_TAG: "sha-$CI_COMMIT_SHORT_SHA"
+    DOCKER_BUILD_OPTIONS: "--target build --build-arg cucumber=1"
 
-.docker-build-app-image:
+deploy_docker_debug_sha:
   stage: deploy
-  image: docker:18.06
-  tags:
-    - docker
-  services:
-    - docker:dind
-  before_script:
-    - docker info
-  script:
-    - docker pull $CI_REGISTRY_IMAGE:$IMAGE_TAG || true
-    - docker build --cache-from $CI_REGISTRY_IMAGE:$IMAGE_TAG --pull -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" -f $DOCKERFILE_PATH .
-    - docker login -u "duniterteam" -p "$DUNITERTEAM_PASSWD"
-    - docker tag "$CI_REGISTRY_IMAGE:$IMAGE_TAG" "duniter/duniter-v2s:$IMAGE_TAG"
-    - docker push "duniter/duniter-v2s:$IMAGE_TAG"
-
-deploy_docker_test_image:
-  extends: .docker-build-app-image
+  extends: .docker_build
   rules:
-    - if: $CI_COMMIT_REF_NAME =~ /^wip*$/
-      when: manual
-    - if: '$CI_COMMIT_TAG || $CI_COMMIT_BRANCH == "master"'
+    - if: $CI_COMMIT_TAG
       when: never
-    - when: manual
-  allow_failure: true
+    - if: $CI_COMMIT_BRANCH == "master"
+  script:
+    - docker login -u "duniterteam" -p "$DUNITERTEAM_PASSWD"
+    - !reference [.docker_build, script]
   variables:
-    DOCKERFILE_PATH: "docker/Dockerfile"
-    IMAGE_TAG: "test-image-$CI_COMMIT_SHORT_SHA"
+    IMAGE_NAME: "duniter/duniter-v2s"
+    IMAGE_TAG: "debug-sha-$CI_COMMIT_SHORT_SHA"
+    DOCKER_BUILD_OPTIONS: "--push --build-arg debug=1"
 
-deploy_docker_debug_sha:
-  extends: .docker-build-app-image
+deploy_docker_release_sha:
+  stage: deploy
+  extends: .docker_build
   rules:
     - if: $CI_COMMIT_TAG
       when: never
-    - if: $CI_COMMIT_BRANCH == "master"
-  variables:
-    DOCKERFILE_PATH: "docker/Dockerfile"
-    IMAGE_TAG: "debug-sha-$CI_COMMIT_SHORT_SHA"
-  after_script:
+    - when: manual
+  script:
     - docker login -u "duniterteam" -p "$DUNITERTEAM_PASSWD"
-    - docker tag "duniter/duniter-v2s:$IMAGE_TAG" "duniter/duniter-v2s:debug-latest"
-    - docker push "duniter/duniter-v2s:debug-latest"
+    - !reference [.docker_build, script]
+  variables:
+    IMAGE_NAME: "duniter/duniter-v2s"
+    IMAGE_TAG: "sha-$CI_COMMIT_SHORT_SHA"
+    DOCKER_BUILD_OPTIONS: "--push --platform linux/amd64"
 
-deploy_docker_release_sha:
-  extends: .docker-build-app-image
+deploy_docker_release_sha_multiplatform:
+  stage: deploy
+  needs: ["deploy_docker_release_sha"]
+  extends: .docker_build
   rules:
     - if: $CI_COMMIT_TAG
       when: never
     - when: manual
-  allow_failure: true
+  script:
+    - docker login -u "duniterteam" -p "$DUNITERTEAM_PASSWD"
+    - !reference [.docker_build, script]
   variables:
-    DOCKERFILE_PATH: "docker/Dockerfile"
+    IMAGE_NAME: "duniter/duniter-v2s"
     IMAGE_TAG: "sha-$CI_COMMIT_SHORT_SHA"
-  dependencies:
-    - build_release_manual
+    DOCKER_BUILD_OPTIONS: "--push --platform linux/amd64,linux/arm64"
+  tags:
+    - dind
+    - multiplatform
 
 deploy_docker_release_tag:
-  extends: .docker-build-app-image
+  stage: deploy
+  extends: .docker_build
   rules:
     - if: "$CI_COMMIT_TAG && $CI_COMMIT_TAG =~ /^v*/"
     - when: never
+  script:
+    - docker login -u "duniterteam" -p "$DUNITERTEAM_PASSWD"
+    - !reference [.docker_build, script]
   variables:
-    DOCKERFILE_PATH: "docker/Dockerfile"
+    IMAGE_NAME: "duniter/duniter-v2s"
     IMAGE_TAG: "$CI_COMMIT_TAG"
-  after_script:
-    - docker login -u "duniterteam" -p "$DUNITERTEAM_PASSWD"
-    - docker tag "duniter/duniter-v2s:$IMAGE_TAG" "duniter/duniter-v2s:latest"
-    - docker push "duniter/duniter-v2s:latest"
-  dependencies:
-    - build_release
+    DOCKER_BUILD_OPTIONS: "--push --platform linux/amd64 --tag $IMAGE_NAME:latest"
+
+deploy_docker_release_tag_multiplatform:
+  stage: deploy
+  needs: ["deploy_docker_release_tag"]
+  extends: .docker_build
+  rules:
+    - if: "$CI_COMMIT_TAG && $CI_COMMIT_TAG =~ /^v*/"
+    - when: never
+  script:
+    - docker login -u "$REPO_DOCKER_USER" -p "$REPO_DOCKER_PASS"
+    - !reference [.docker_build, script]
+  variables:
+    IMAGE_NAME: "pinidh/duniter-v2s"
+    IMAGE_TAG: "$CI_COMMIT_TAG"
+    DOCKER_BUILD_OPTIONS: "--push --platform linux/amd64,linux/arm64 --tag $IMAGE_NAME:latest"
+  tags:
+    - dind
+    - multiplatform
 
 readme_docker_release_tag:
   stage: deploy_readme
+  needs: ["deploy_docker_release_tag"]
   rules:
     - if: "$CI_COMMIT_TAG && $CI_COMMIT_TAG =~ /^v*/"
     - when: never
diff --git a/docker/Dockerfile b/docker/Dockerfile
index bb2f489c8..3683f585c 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,45 +1,77 @@
+# Build with `docker buildx build ...`
+# If buildx is not installed use `DOCKER_BUILDKIT=1 docker build ...`
+
 # ------------------------------------------------------------------------------
 # Build Stage
 # ------------------------------------------------------------------------------
 
-# Building for Debian buster because we need the binary to be compatible
-# with the image paritytech/ci-linux:production (currently based on
-# debian:buster-slim) used by the gitlab CI
-FROM rust:1-buster as build
+# When building for a foreign arch, use cross-compilation
+# https://www.docker.com/blog/faster-multi-platform-builds-dockerfile-cross-compilation-guide/
+FROM --platform=$BUILDPLATFORM rust:1-bullseye as build
+ARG BUILDPLATFORM
+ARG TARGETPLATFORM
+
+# We need the target arch triplet in both Debian and rust flavor
+RUN echo "DEBIAN_ARCH_TRIPLET='$(dpkg-architecture -A${TARGETPLATFORM#linux/} -qDEB_TARGET_MULTIARCH)'" >>/root/dynenv
+RUN . /root/dynenv && \
+    echo "RUST_ARCH_TRIPLET='$(echo "$DEBIAN_ARCH_TRIPLET" | sed -E 's/-linux-/-unknown&/')'" >>/root/dynenv
+
 WORKDIR /root
 
 # Copy source tree
 COPY . .
 
-RUN test -x build/duniter || \
-    ( \
-        apt-get update && \
-        DEBIAN_FRONTEND=noninteractive apt-get install -y clang cmake protobuf-compiler \
-    )
+RUN apt-get update && \
+    DEBIAN_FRONTEND=noninteractive apt-get install -y clang cmake protobuf-compiler
 
 # build duniter
-ARG threads=1
-RUN test -x build/duniter || \
-    ( \
-        CARGO_PROFILE_RELEASE_LTO="true" \
-            cargo build --release -j $threads && \
-        mkdir -p build && \
-        mv target/release/duniter build/ \
-    )
-
-# Create fake duniter-cucumber if is not exist
-# The goal is to avoid error later, this binary is optional
-RUN test -x build/duniter-cucumber || \
-    ( \
-        mkdir -p build && \
-        touch build/duniter-cucumber \
-    )
+ARG debug=0
+RUN if [ "$debug" = 0 ]; then \
+        echo "CARGO_PROFILE_RELEASE_LTO=true; export CARGO_PROFILE_RELEASE_LTO" >>/root/dynenv && \
+        echo "CARGO_OPTIONS=--release" >>/root/dynenv && \
+        echo "TARGET_FOLDER=release" >>/root/dynenv; \
+    else \
+        echo "TARGET_FOLDER=debug" >>/root/dynenv; \
+    fi
+
+# Configure cross-build environment if need be
+RUN set -x && \
+    if [ "$TARGETPLATFORM" != "$BUILDPLATFORM" ]; then \
+        . /root/dynenv && \
+        apt install -y gcc-$DEBIAN_ARCH_TRIPLET binutils-$DEBIAN_ARCH_TRIPLET && \
+        rustup target add "$RUST_ARCH_TRIPLET" && \
+        : https://github.com/rust-lang/cargo/issues/4133 && \
+        echo "RUSTFLAGS='-C linker=$DEBIAN_ARCH_TRIPLET-gcc'; export RUSTFLAGS" >>/root/dynenv; \
+    fi
+
+# Build
+RUN set -x && \
+    cat /root/dynenv && \
+    . /root/dynenv && \
+    cargo build --locked $CARGO_OPTIONS --target "$RUST_ARCH_TRIPLET" && \
+    mkdir -p build && \
+    mv target/$RUST_ARCH_TRIPLET/$TARGET_FOLDER/duniter build/
+
+# Run tests if requested, expted when cross-building
+ARG cucumber=0
+RUN if [ "$cucumber" != 0 ] && [ "$TARGETPLATFORM" = "$BUILDPLATFORM" ]; then \
+        cargo test --workspace --exclude duniter-end2end-tests --exclude duniter-live-tests && \
+        cargo cucumber -i account_creation* && \
+        cargo cucumber -i certification* && \
+        cargo cucumber -i identity_creation* && \
+        cargo cucumber -i monetary_mass* && \
+        cargo cucumber -i oneshot_account* && \
+        cargo cucumber -i transfer_all* && \
+        cd target/debug/deps/ && \
+        rm cucumber_tests-*.d && \
+        mv cucumber_tests* ../../../build/duniter-cucumber; \
+    fi
 
 # ------------------------------------------------------------------------------
 # Final Stage
 # ------------------------------------------------------------------------------
 
-FROM debian:buster-slim
+FROM debian:bullseye-slim
 
 LABEL maintainer="Gilles Filippini <gilles.filippini@pini.fr>"
 LABEL version="0.0.0"
@@ -55,6 +87,5 @@ ENTRYPOINT ["docker-entrypoint"]
 USER duniter
 
 # Intall
-COPY --from=build /root/build/duniter /usr/local/bin/duniter
-COPY --from=build /root/build/duniter-cucumber /usr/local/bin/duniter-cucumber
+COPY --from=build /root/build /usr/local/bin/
 COPY docker/docker-entrypoint /usr/local/bin/
-- 
GitLab