Repository navigation
ci(docker): support arm64 builds and multi-arch manifest assembly #2132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
10473d0
430d64c
c7e9a81
e71a589
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 '<tag>-amd64'/'<tag>-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 ('<version>', | ||||||||||||||||||||||
| # 'v<version>', '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' }} | ||||||||||||||||||||||
|
nullPointerEnjoyer marked this conversation as resolved.
|
||||||||||||||||||||||
| 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,42 +74,199 @@ 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 | ||||||||||||||||||||||
|
Comment on lines
+106
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||
| 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 | ||||||||||||||||||||||
| # crafted tag/ref/input cannot inject shell into the step. | ||||||||||||||||||||||
| - 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 | ||||||||||||||||||||||
|
nullPointerEnjoyer marked this conversation as resolved.
|
||||||||||||||||||||||
| 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 <branch>": 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 | ||||||||||||||||||||||
|
nullPointerEnjoyer marked this conversation as resolved.
|
||||||||||||||||||||||
| runs-on: ubuntu-latest | ||||||||||||||||||||||
|
nullPointerEnjoyer marked this conversation as resolved.
|
||||||||||||||||||||||
| 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 | ||||||||||||||||||||||
|
nullPointerEnjoyer marked this conversation as resolved.
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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" | ||||||||||||||||||||||
|
nullPointerEnjoyer marked this conversation as resolved.
Comment on lines
+225
to
+229
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion:
Suggested change
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # 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 | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.