mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(app-proxy): share one asset tree across architectures (#2325)
This commit is contained in:
@@ -15,6 +15,13 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-fluxer-app-proxy-self-hosted
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
@@ -26,13 +33,208 @@ jobs:
|
||||
- name: approved
|
||||
run: echo "Build release approved."
|
||||
|
||||
build:
|
||||
meta:
|
||||
name: resolve metadata
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-app-proxy-self-hosted
|
||||
dockerfile: fluxer_app_proxy/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
extra-build-args: |
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: set variables
|
||||
id: vars
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step set_metadata
|
||||
--build-version "${{ inputs['build-version'] }}"
|
||||
|
||||
dist:
|
||||
name: build the canonical asset tree
|
||||
needs: meta
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/fluxer-app-proxy-self-hosted
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL: ""
|
||||
BUNDLE_LOCAL_ASSETS: "true"
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: prepare docker config
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step prepare_docker_config
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- name: configure ghcr auth
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step configure_ghcr_auth
|
||||
|
||||
- name: build the dist once and publish it as the canonical asset image
|
||||
env:
|
||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist
|
||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step build_dist
|
||||
|
||||
- name: generate asset manifest
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step generate_asset_manifest
|
||||
|
||||
- name: verify every manifest asset ships in the image
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step verify_published_assets
|
||||
|
||||
build:
|
||||
name: build ${{ matrix.platform }}
|
||||
needs: [meta, dist]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: amd64
|
||||
runner: ubuntu-24.04
|
||||
- platform: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
with:
|
||||
context: .
|
||||
file: fluxer_app_proxy/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
platforms: linux/${{ matrix.platform }}
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
merge:
|
||||
name: merge multi-arch manifest
|
||||
needs: [meta, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: verify cross-architecture asset parity
|
||||
env:
|
||||
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-amd64
|
||||
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-arm64
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step verify_asset_parity
|
||||
|
||||
- name: create and push multi-arch manifest
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: write
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: >-
|
||||
tools/ci/run.sh release
|
||||
publish
|
||||
--component fluxer-app-proxy-self-hosted
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
docker buildx imagetools create
|
||||
-t "${IMAGE}:v1"
|
||||
-t "${IMAGE}:latest"
|
||||
"${IMAGE}:${VERSION}"
|
||||
|
||||
@@ -57,11 +57,11 @@ jobs:
|
||||
--step set_metadata
|
||||
--build-version "${{ inputs['build-version'] }}"
|
||||
|
||||
build:
|
||||
name: build app-proxy (amd64)
|
||||
dist:
|
||||
name: build and publish the canonical asset tree
|
||||
needs: meta
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -87,17 +87,17 @@ jobs:
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step configure_ghcr_auth
|
||||
|
||||
- name: build and push image + extract assets
|
||||
- name: build the dist once and publish it as the canonical asset image
|
||||
env:
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
|
||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist
|
||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step build_and_extract
|
||||
--step build_dist
|
||||
|
||||
- name: generate asset manifest
|
||||
run: >-
|
||||
@@ -114,9 +114,63 @@ jobs:
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step upload_assets
|
||||
|
||||
- name: verify every uploaded asset is readable
|
||||
env:
|
||||
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step verify_published_assets
|
||||
|
||||
build:
|
||||
name: build app-proxy (amd64)
|
||||
needs: [meta, dist]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: prepare docker config
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step prepare_docker_config
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- name: configure ghcr auth
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step configure_ghcr_auth
|
||||
|
||||
- name: build and push image
|
||||
env:
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
SOURCE_DATE: ${{ steps.source.outputs.date }}
|
||||
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
|
||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step build_image
|
||||
|
||||
build-arm64:
|
||||
name: build app-proxy (arm64)
|
||||
needs: meta
|
||||
needs: [meta, dist]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
@@ -127,6 +181,9 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
@@ -143,8 +200,10 @@ jobs:
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
|
||||
BUNDLE_LOCAL_ASSETS=false
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
env:
|
||||
@@ -155,7 +214,7 @@ jobs:
|
||||
name: merge multi-arch manifest
|
||||
needs: [meta, build, build-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -173,6 +232,15 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: verify cross-architecture asset parity
|
||||
env:
|
||||
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
|
||||
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step verify_asset_parity
|
||||
|
||||
- name: fuse amd64 + arm64 into a multi-arch manifest
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
|
||||
|
||||
+16
-11
@@ -4,6 +4,8 @@ ARG BUILD_VERSION=""
|
||||
ARG PUBLIC_ASSET_BASE_URL=""
|
||||
ARG FLUXER_APP_PROXY_TIME_FREEZE_ENABLED="true"
|
||||
ARG BUNDLE_LOCAL_ASSETS="true"
|
||||
ARG APP_ASSETS_REF=app-assets
|
||||
ARG APP_ASSETS_PLATFORM=$BUILDPLATFORM
|
||||
|
||||
# ---------- Stage 1: Build the SPA (fluxer_app) ----------
|
||||
FROM node:24-bookworm-slim AS app-build
|
||||
@@ -79,13 +81,19 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
cd fluxer_app && pnpm build
|
||||
|
||||
# Extract the full SPA asset tree for local serving by the app proxy.
|
||||
# ---------- Stage 2: The one canonical asset tree every architecture serves ----------
|
||||
# Stage for CI to extract the full dist (docker bake app-dist target)
|
||||
FROM alpine:3.21 AS app-dist
|
||||
COPY --from=app-build /usr/src/app/fluxer_app/dist /dist
|
||||
|
||||
FROM alpine:3.21 AS app-assets
|
||||
ARG BUNDLE_LOCAL_ASSETS=true
|
||||
ARG PUBLIC_ASSET_BASE_URL=""
|
||||
COPY --from=app-build /usr/src/app/fluxer_app/dist /assets
|
||||
COPY fluxer_app_proxy/scripts/precompress_assets.sh /usr/local/bin/precompress_assets.sh
|
||||
RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
|
||||
RUN apk add --no-cache brotli
|
||||
RUN --mount=type=bind,from=app-dist,source=/dist,target=/dist \
|
||||
cp -a /dist /assets \
|
||||
&& if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
|
||||
case "${PUBLIC_ASSET_BASE_URL}" in \
|
||||
http://* | https://*) ;; \
|
||||
*) \
|
||||
@@ -95,15 +103,12 @@ RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
|
||||
;; \
|
||||
esac; \
|
||||
find /assets/assets -type f ! -name 'fonts-*.txt' -delete; \
|
||||
fi
|
||||
RUN apk add --no-cache brotli \
|
||||
fi \
|
||||
&& /usr/local/bin/precompress_assets.sh /assets
|
||||
|
||||
# Stage for CI to extract the full dist (docker bake app-dist target)
|
||||
FROM alpine:3.21 AS app-dist
|
||||
COPY --from=app-build /usr/src/app/fluxer_app/dist /dist
|
||||
FROM --platform=${APP_ASSETS_PLATFORM} ${APP_ASSETS_REF} AS app-static
|
||||
|
||||
# ---------- Stage 2: Build the Rust proxy binary ----------
|
||||
# ---------- Stage 3: Build the Rust proxy binary ----------
|
||||
FROM rust:1-bookworm AS rust-builder
|
||||
|
||||
ARG FLUXER_APP_PROXY_TIME_FREEZE_ENABLED="true"
|
||||
@@ -141,7 +146,7 @@ RUN if [ "${FLUXER_APP_PROXY_TIME_FREEZE_ENABLED}" = "false" ]; then \
|
||||
fi \
|
||||
&& cp target/release/fluxer_app_proxy /usr/local/bin/fluxer-app-proxy
|
||||
|
||||
# ---------- Stage 3: Runtime ----------
|
||||
# ---------- Stage 4: Runtime ----------
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG BUILD_VERSION=""
|
||||
@@ -167,7 +172,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=rust-builder /usr/local/bin/fluxer-app-proxy /usr/local/bin/fluxer-app-proxy
|
||||
COPY --from=app-assets /assets ./static/
|
||||
COPY --from=app-static /assets ./static/
|
||||
|
||||
ENV BUILD_VERSION="${BUILD_VERSION}"
|
||||
ENV FLUXER_APP_PROXY_HOST="0.0.0.0"
|
||||
|
||||
@@ -6,6 +6,10 @@ variable "BUNDLE_LOCAL_ASSETS" { default = "true" }
|
||||
variable "IMAGE_REPO" { default = "" }
|
||||
variable "CACHE_FROM" { default = "" }
|
||||
variable "CACHE_TO" { default = "" }
|
||||
variable "APP_ASSETS_REF" { default = "app-assets" }
|
||||
variable "APP_ASSETS_PLATFORM" { default = "linux/amd64" }
|
||||
variable "SOURCE_SHA" { default = "" }
|
||||
variable "SOURCE_DATE" { default = "" }
|
||||
|
||||
group "default" {
|
||||
targets = ["app-proxy", "app-dist"]
|
||||
@@ -24,6 +28,10 @@ target "app-proxy" {
|
||||
PUBLIC_ASSET_BASE_URL = PUBLIC_ASSET_BASE_URL
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED = FLUXER_APP_PROXY_TIME_FREEZE_ENABLED
|
||||
BUNDLE_LOCAL_ASSETS = BUNDLE_LOCAL_ASSETS
|
||||
APP_ASSETS_REF = APP_ASSETS_REF
|
||||
APP_ASSETS_PLATFORM = APP_ASSETS_PLATFORM
|
||||
SOURCE_SHA = SOURCE_SHA
|
||||
SOURCE_DATE = SOURCE_DATE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +41,10 @@ target "app-dist" {
|
||||
tags = []
|
||||
output = ["type=local,dest=app-dist-output"]
|
||||
}
|
||||
|
||||
target "app-assets-image" {
|
||||
inherits = ["app-proxy"]
|
||||
target = "app-assets"
|
||||
tags = IMAGE_REPO != "" ? ["${IMAGE_REPO}:${BUILD_VERSION}-assets"] : []
|
||||
output = ["type=registry"]
|
||||
}
|
||||
|
||||
+591
-13
@@ -2,18 +2,28 @@
|
||||
|
||||
use crate::common::{
|
||||
CALVER_SCHEME, CalverEnv, CommandSpec, S3UploadPlanItem, append_github_env,
|
||||
append_github_output, collect_files, path_to_s3_key, require_env, resolve_calver, run_command,
|
||||
runner_temp, s3_client, trim_option, upload_s3_plan_append_only,
|
||||
append_github_output, collect_files, env_string, output_text, path_to_s3_key,
|
||||
remove_dir_if_exists, require_env, resolve_calver, run_command, runner_temp, s3_client,
|
||||
trim_option, upload_s3_plan_append_only,
|
||||
};
|
||||
use anyhow::{Context, Result, anyhow, ensure};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use chrono::Utc;
|
||||
use clap::{Args, ValueEnum};
|
||||
use reqwest::Client;
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const DEFAULT_PUBLIC_ASSET_BASE_URL: &str = "https://fluxerstatic.com";
|
||||
const DEFAULT_APP_PROXY_TIME_FREEZE_ENABLED: &str = "true";
|
||||
@@ -21,6 +31,15 @@ const DEFAULT_APP_PROXY_BUNDLE_LOCAL_ASSETS: &str = "false";
|
||||
const DEFAULT_STATIC_BUCKET: &str = "fluxer-static";
|
||||
const DEFAULT_S3_ENDPOINT: &str = "https://ewr1.vultrobjects.com";
|
||||
const IMMUTABLE_ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
||||
const RUNTIME_STATIC_DIR: &str = "/srv/app/static";
|
||||
const CANONICAL_ASSETS_DIR: &str = "/assets";
|
||||
const AMD64_PLATFORM: &str = "linux/amd64";
|
||||
const ARM64_PLATFORM: &str = "linux/arm64";
|
||||
const PARITY_DIFF_LIMIT: usize = 20;
|
||||
const ASSET_READ_CONCURRENCY: usize = 16;
|
||||
const ASSET_READ_ATTEMPTS: u32 = 3;
|
||||
const ASSET_READ_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||
const ASSET_READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub struct BuildAppProxyArgs {
|
||||
@@ -36,9 +55,12 @@ enum AppProxyStep {
|
||||
SetMetadata,
|
||||
PrepareDockerConfig,
|
||||
ConfigureGhcrAuth,
|
||||
BuildAndExtract,
|
||||
BuildDist,
|
||||
BuildImage,
|
||||
GenerateAssetManifest,
|
||||
UploadAssets,
|
||||
VerifyPublishedAssets,
|
||||
VerifyAssetParity,
|
||||
}
|
||||
|
||||
pub async fn run(args: BuildAppProxyArgs) -> Result<()> {
|
||||
@@ -46,9 +68,12 @@ pub async fn run(args: BuildAppProxyArgs) -> Result<()> {
|
||||
AppProxyStep::SetMetadata => set_metadata_step(args.build_version.as_deref()),
|
||||
AppProxyStep::PrepareDockerConfig => prepare_docker_config_step(),
|
||||
AppProxyStep::ConfigureGhcrAuth => configure_ghcr_auth_step(),
|
||||
AppProxyStep::BuildAndExtract => build_and_extract_step(),
|
||||
AppProxyStep::BuildDist => build_dist_step(),
|
||||
AppProxyStep::BuildImage => build_image_step(),
|
||||
AppProxyStep::GenerateAssetManifest => generate_asset_manifest_step(),
|
||||
AppProxyStep::UploadAssets => upload_assets_step().await,
|
||||
AppProxyStep::VerifyPublishedAssets => verify_published_assets_step().await,
|
||||
AppProxyStep::VerifyAssetParity => verify_asset_parity_step(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,21 +143,50 @@ fn ghcr_auth_value(username: &str, token: &str) -> String {
|
||||
BASE64.encode(format!("{username}:{token}"))
|
||||
}
|
||||
|
||||
fn build_and_extract_step() -> Result<()> {
|
||||
run_command(build_and_extract_command()?)
|
||||
fn build_dist_step() -> Result<()> {
|
||||
run_command(bake_command(&["app-dist", "app-assets-image"])?)
|
||||
}
|
||||
|
||||
fn build_and_extract_command() -> Result<CommandSpec> {
|
||||
fn build_image_step() -> Result<()> {
|
||||
let command = bake_command(&["app-proxy"])?
|
||||
.env("APP_ASSETS_REF", assets_ref()?)
|
||||
.env("APP_ASSETS_PLATFORM", canonical_assets_platform());
|
||||
run_command(command)
|
||||
}
|
||||
|
||||
fn canonical_assets_platform() -> String {
|
||||
env_string("APP_ASSETS_PLATFORM").unwrap_or_else(|| AMD64_PLATFORM.to_string())
|
||||
}
|
||||
|
||||
fn assets_ref() -> Result<String> {
|
||||
match env_string("APP_ASSETS_REF") {
|
||||
Some(reference) => Ok(reference),
|
||||
None => Ok(assets_image_ref(
|
||||
&image_repo()?,
|
||||
&require_env("BUILD_VERSION")?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn assets_image_ref(image_repo: &str, build_version: &str) -> String {
|
||||
format!("{image_repo}:{build_version}-assets")
|
||||
}
|
||||
|
||||
fn image_repo() -> Result<String> {
|
||||
match env::var("IMAGE_REPO") {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => Ok(format!("ghcr.io/{}/fluxer-app-proxy", ghcr_owner()?)),
|
||||
}
|
||||
}
|
||||
|
||||
fn bake_command(targets: &[&str]) -> Result<CommandSpec> {
|
||||
let build_version = require_env("BUILD_VERSION")?;
|
||||
let public_asset_base_url = env::var("PUBLIC_ASSET_BASE_URL")
|
||||
.unwrap_or_else(|_| DEFAULT_PUBLIC_ASSET_BASE_URL.to_string());
|
||||
let image_repo = match env::var("IMAGE_REPO") {
|
||||
Ok(value) => value,
|
||||
Err(_) => format!("ghcr.io/{}/fluxer-app-proxy", ghcr_owner()?),
|
||||
};
|
||||
Ok(CommandSpec::new("docker")
|
||||
.args(["buildx", "bake", "-f", "fluxer_app_proxy/docker-bake.hcl"])
|
||||
.env("IMAGE_REPO", image_repo)
|
||||
.args(targets.iter().copied())
|
||||
.env("IMAGE_REPO", image_repo()?)
|
||||
.env("BUILD_VERSION", build_version)
|
||||
.env("PUBLIC_ASSET_BASE_URL", public_asset_base_url)
|
||||
.env(
|
||||
@@ -303,6 +357,285 @@ fn validate_manifest_asset(asset: &str) -> Result<String> {
|
||||
Ok(asset.to_string())
|
||||
}
|
||||
|
||||
async fn verify_published_assets_step() -> Result<()> {
|
||||
let dist = app_dist_dir();
|
||||
let manifest_path = dist.join("assets-manifest.txt");
|
||||
let assets = read_asset_manifest(&manifest_path)?;
|
||||
ensure!(!assets.is_empty(), "{} is empty", manifest_path.display());
|
||||
|
||||
let index_path = dist.join("index.html");
|
||||
let index = fs::read_to_string(&index_path)
|
||||
.with_context(|| format!("Failed to read {}", index_path.display()))?;
|
||||
let unproduced = unproduced_references(&referenced_assets(&index), &assets);
|
||||
ensure!(
|
||||
unproduced.is_empty(),
|
||||
"index.html references assets this build did not produce: {}",
|
||||
unproduced.join(", ")
|
||||
);
|
||||
|
||||
match env_string("PUBLIC_ASSET_BASE_URL") {
|
||||
Some(base) => verify_remote_assets(&base, &assets).await,
|
||||
None => verify_local_assets(&dist, &assets),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_local_assets(dist: &Path, assets: &[String]) -> Result<()> {
|
||||
let missing = assets
|
||||
.iter()
|
||||
.filter(|asset| !dist.join(asset).is_file())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
ensure!(
|
||||
missing.is_empty(),
|
||||
"Manifest assets are missing from {}: {}",
|
||||
dist.display(),
|
||||
missing.join(", ")
|
||||
);
|
||||
println!(
|
||||
"published asset verification passed - {} assets present in {}",
|
||||
assets.len(),
|
||||
dist.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_remote_assets(base: &str, assets: &[String]) -> Result<()> {
|
||||
let client = Client::builder()
|
||||
.timeout(ASSET_READ_TIMEOUT)
|
||||
.build()
|
||||
.context("Failed to build the asset verification HTTP client")?;
|
||||
let semaphore = Arc::new(Semaphore::new(ASSET_READ_CONCURRENCY));
|
||||
let mut tasks = JoinSet::new();
|
||||
for asset in assets {
|
||||
let permit = semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.context("Asset verification semaphore closed")?;
|
||||
let client = client.clone();
|
||||
let url = asset_url(base, asset);
|
||||
let asset = asset.clone();
|
||||
tasks.spawn(async move {
|
||||
let _permit = permit;
|
||||
read_published_asset(&client, &url)
|
||||
.await
|
||||
.err()
|
||||
.map(|error| format!("{asset}: {error}"))
|
||||
});
|
||||
}
|
||||
|
||||
let mut failures = Vec::new();
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if let Some(failure) = result.context("Asset verification task failed")? {
|
||||
failures.push(failure);
|
||||
}
|
||||
}
|
||||
failures.sort();
|
||||
ensure!(
|
||||
failures.is_empty(),
|
||||
"{} of {} published assets are not readable at {base}:\n{}",
|
||||
failures.len(),
|
||||
assets.len(),
|
||||
failures.join("\n")
|
||||
);
|
||||
|
||||
println!(
|
||||
"published asset verification passed - {} assets readable at {base}",
|
||||
assets.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_published_asset(client: &Client, url: &str) -> Result<()> {
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=ASSET_READ_ATTEMPTS {
|
||||
match client.get(url).header("range", "bytes=0-0").send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => {
|
||||
last_error = Some(anyhow!("{url} responded {}", response.status()));
|
||||
}
|
||||
Err(error) => last_error = Some(anyhow!("{url} request failed: {error}")),
|
||||
}
|
||||
if attempt < ASSET_READ_ATTEMPTS {
|
||||
sleep(ASSET_READ_RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| anyhow!("{url} could not be read")))
|
||||
}
|
||||
|
||||
fn asset_url(base: &str, key: &str) -> String {
|
||||
format!("{}/{}", base.trim_end_matches('/'), key)
|
||||
}
|
||||
|
||||
fn referenced_assets(source: &str) -> Vec<String> {
|
||||
const PREFIX: &str = "assets/";
|
||||
let bytes = source.as_bytes();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut cursor = 0;
|
||||
while let Some(offset) = source[cursor..].find(PREFIX) {
|
||||
let start = cursor + offset;
|
||||
cursor = start + PREFIX.len();
|
||||
if start > 0
|
||||
&& !matches!(
|
||||
bytes[start - 1],
|
||||
b'/' | b'"' | b'\'' | b'=' | b'(' | b' ' | b'>'
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = source[cursor..]
|
||||
.chars()
|
||||
.take_while(|value| {
|
||||
value.is_ascii_alphanumeric() || matches!(value, '.' | '_' | '-' | '/')
|
||||
})
|
||||
.collect::<String>();
|
||||
if !name.is_empty() && !name.ends_with('/') {
|
||||
names.insert(format!("{PREFIX}{name}"));
|
||||
}
|
||||
}
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
fn unproduced_references(referenced: &[String], produced: &[String]) -> Vec<String> {
|
||||
let produced = produced.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||
referenced
|
||||
.iter()
|
||||
.filter(|asset| !produced.contains(asset.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn verify_asset_parity_step() -> Result<()> {
|
||||
let root = runner_temp().join("app-proxy-asset-parity");
|
||||
remove_dir_if_exists(&root)?;
|
||||
|
||||
let canonical_dir = extract_image_dir(
|
||||
&require_env("APP_PROXY_ASSETS_REF")?,
|
||||
&canonical_assets_platform(),
|
||||
CANONICAL_ASSETS_DIR,
|
||||
&root.join("canonical"),
|
||||
)?;
|
||||
let canonical = asset_tree_digests(&canonical_dir)?;
|
||||
ensure!(
|
||||
!canonical.is_empty(),
|
||||
"Canonical asset tree is empty: {}",
|
||||
canonical_dir.display()
|
||||
);
|
||||
|
||||
for (label, reference_env, platform) in [
|
||||
("amd64", "APP_PROXY_AMD64_REF", AMD64_PLATFORM),
|
||||
("arm64", "APP_PROXY_ARM64_REF", ARM64_PLATFORM),
|
||||
] {
|
||||
let runtime_dir = extract_image_dir(
|
||||
&require_env(reference_env)?,
|
||||
platform,
|
||||
RUNTIME_STATIC_DIR,
|
||||
&root.join(label),
|
||||
)?;
|
||||
let differences = tree_differences(&canonical, &asset_tree_digests(&runtime_dir)?);
|
||||
ensure!(
|
||||
differences.is_empty(),
|
||||
"{label} app-proxy asset tree does not match the canonical dist:\n{}",
|
||||
differences.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"asset parity verified - {} files in every architecture",
|
||||
canonical.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_image_dir(reference: &str, platform: &str, path: &str, dest: &Path) -> Result<PathBuf> {
|
||||
run_command(pull_command(reference, platform))?;
|
||||
let container = output_text(create_command(reference, platform))?;
|
||||
ensure!(
|
||||
!container.is_empty(),
|
||||
"docker create returned no container id for {reference}"
|
||||
);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
let copied = run_command(copy_command(&container, path, dest));
|
||||
let removed = run_command(CommandSpec::new("docker").args(["rm", "-f", container.as_str()]));
|
||||
copied?;
|
||||
removed?;
|
||||
Ok(dest.to_path_buf())
|
||||
}
|
||||
|
||||
fn pull_command(reference: &str, platform: &str) -> CommandSpec {
|
||||
CommandSpec::new("docker").args(["pull", "--platform", platform, reference])
|
||||
}
|
||||
|
||||
fn create_command(reference: &str, platform: &str) -> CommandSpec {
|
||||
CommandSpec::new("docker").args(["create", "--platform", platform, reference])
|
||||
}
|
||||
|
||||
fn copy_command(container: &str, path: &str, dest: &Path) -> CommandSpec {
|
||||
CommandSpec::new("docker")
|
||||
.arg("cp")
|
||||
.arg(format!("{container}:{path}"))
|
||||
.arg(dest.as_os_str())
|
||||
}
|
||||
|
||||
fn asset_tree_digests(root: &Path) -> Result<BTreeMap<String, String>> {
|
||||
collect_files(root)?
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.with_context(|| format!("Failed to relativize {}", path.display()))?;
|
||||
Ok((path_to_s3_key(relative), sha256_file(&path)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String> {
|
||||
let mut file =
|
||||
File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
fn tree_differences(
|
||||
canonical: &BTreeMap<String, String>,
|
||||
other: &BTreeMap<String, String>,
|
||||
) -> Vec<String> {
|
||||
let mut differences = Vec::new();
|
||||
for (path, digest) in canonical {
|
||||
match other.get(path) {
|
||||
None => differences.push(format!("missing: {path}")),
|
||||
Some(actual) if actual != digest => {
|
||||
differences.push(format!("content differs: {path}"))
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for path in other.keys() {
|
||||
if !canonical.contains_key(path) {
|
||||
differences.push(format!("unexpected: {path}"));
|
||||
}
|
||||
}
|
||||
if differences.len() > PARITY_DIFF_LIMIT {
|
||||
let remaining = differences.len() - PARITY_DIFF_LIMIT;
|
||||
differences.truncate(PARITY_DIFF_LIMIT);
|
||||
differences.push(format!("...and {remaining} more differences"));
|
||||
}
|
||||
differences
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -589,6 +922,251 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referenced_assets_collects_absolute_and_root_relative_urls() {
|
||||
let index = concat!(
|
||||
"<script src=\"https://fluxerstatic.com/assets/a.js\"></script>",
|
||||
"<link rel=\"stylesheet\" href=\"/assets/b.css\">",
|
||||
"<a href=\"assets/fonts-NOTICE.txt\">notice</a>",
|
||||
"<script src=\"https://fluxerstatic.com/assets/a.js\"></script>",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
referenced_assets(index),
|
||||
vec!["assets/a.js", "assets/b.css", "assets/fonts-NOTICE.txt"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referenced_assets_ignores_lookalike_paths() {
|
||||
assert!(
|
||||
referenced_assets(
|
||||
"<script src=\"/myassets/x.js\"></script><img src=\"/other/notassets/y.js\">"
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referenced_assets_keeps_nested_asset_paths() {
|
||||
assert_eq!(
|
||||
referenced_assets("<script src=\"/assets/chunks/a.js\"></script>"),
|
||||
vec!["assets/chunks/a.js"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unproduced_references_flags_assets_the_build_did_not_produce() {
|
||||
let referenced = vec!["assets/a.js".to_string(), "assets/gone.js".to_string()];
|
||||
let produced = vec!["assets/a.js".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
unproduced_references(&referenced, &produced),
|
||||
vec!["assets/gone.js"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_differences_is_empty_for_identical_trees() {
|
||||
let tree = BTreeMap::from([
|
||||
("index.html".to_string(), "aa".to_string()),
|
||||
("assets/a.js".to_string(), "bb".to_string()),
|
||||
]);
|
||||
|
||||
assert!(tree_differences(&tree, &tree.clone()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_differences_reports_missing_unexpected_and_changed() {
|
||||
let canonical = BTreeMap::from([
|
||||
("assets/a.js".to_string(), "aa".to_string()),
|
||||
("assets/b.js".to_string(), "bb".to_string()),
|
||||
]);
|
||||
let other = BTreeMap::from([
|
||||
("assets/a.js".to_string(), "changed".to_string()),
|
||||
("assets/c.js".to_string(), "cc".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
tree_differences(&canonical, &other),
|
||||
vec![
|
||||
"content differs: assets/a.js",
|
||||
"missing: assets/b.js",
|
||||
"unexpected: assets/c.js",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_differences_caps_the_reported_lines() {
|
||||
let canonical = (0..50)
|
||||
.map(|index| (format!("assets/{index}.js"), "aa".to_string()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let other = BTreeMap::new();
|
||||
|
||||
let differences = tree_differences(&canonical, &other);
|
||||
|
||||
assert_eq!(differences.len(), PARITY_DIFF_LIMIT + 1);
|
||||
assert_eq!(
|
||||
differences.last().unwrap(),
|
||||
&format!("...and {} more differences", 50 - PARITY_DIFF_LIMIT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_tree_digests_hashes_every_file_relative_to_the_root() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("static");
|
||||
write_file(&root.join("index.html"), "index");
|
||||
write_file(&root.join("assets/chunks/a.js"), "a");
|
||||
|
||||
let digests = asset_tree_digests(&root).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
digests.keys().cloned().collect::<Vec<_>>(),
|
||||
vec!["assets/chunks/a.js", "index.html"]
|
||||
);
|
||||
|
||||
write_file(&root.join("assets/chunks/a.js"), "b");
|
||||
let changed = asset_tree_digests(&root).unwrap();
|
||||
assert_ne!(digests["assets/chunks/a.js"], changed["assets/chunks/a.js"]);
|
||||
assert_eq!(digests["index.html"], changed["index.html"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_url_joins_the_base_and_key_once() {
|
||||
assert_eq!(
|
||||
asset_url("https://fluxerstatic.com", "assets/a.js"),
|
||||
"https://fluxerstatic.com/assets/a.js"
|
||||
);
|
||||
assert_eq!(
|
||||
asset_url("https://fluxerstatic.com/", "assets/a.js"),
|
||||
"https://fluxerstatic.com/assets/a.js"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assets_image_ref_names_the_canonical_image() {
|
||||
assert_eq!(
|
||||
assets_image_ref("ghcr.io/example/fluxer-app-proxy", "2026.520.1"),
|
||||
"ghcr.io/example/fluxer-app-proxy:2026.520.1-assets"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_commands_pin_the_image_platform() {
|
||||
let pull = pull_command("ghcr.io/example/fluxer-app-proxy:1-assets", AMD64_PLATFORM);
|
||||
assert_eq!(pull.program, OsString::from("docker"));
|
||||
assert_eq!(
|
||||
pull.args,
|
||||
vec![
|
||||
OsString::from("pull"),
|
||||
OsString::from("--platform"),
|
||||
OsString::from(AMD64_PLATFORM),
|
||||
OsString::from("ghcr.io/example/fluxer-app-proxy:1-assets"),
|
||||
]
|
||||
);
|
||||
|
||||
let create = create_command("ghcr.io/example/fluxer-app-proxy:1-arm64", ARM64_PLATFORM);
|
||||
assert_eq!(
|
||||
create.args,
|
||||
vec![
|
||||
OsString::from("create"),
|
||||
OsString::from("--platform"),
|
||||
OsString::from(ARM64_PLATFORM),
|
||||
OsString::from("ghcr.io/example/fluxer-app-proxy:1-arm64"),
|
||||
]
|
||||
);
|
||||
|
||||
let copy = copy_command("cafe1234", RUNTIME_STATIC_DIR, Path::new("/tmp/arm64"));
|
||||
assert_eq!(
|
||||
copy.args,
|
||||
vec![
|
||||
OsString::from("cp"),
|
||||
OsString::from("cafe1234:/srv/app/static"),
|
||||
OsString::from("/tmp/arm64"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dockerfile_injects_one_canonical_asset_tree_into_every_architecture() {
|
||||
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
||||
for entry in [
|
||||
"ARG APP_ASSETS_REF=app-assets",
|
||||
"ARG APP_ASSETS_PLATFORM=$BUILDPLATFORM",
|
||||
"FROM --platform=${APP_ASSETS_PLATFORM} ${APP_ASSETS_REF} AS app-static",
|
||||
"COPY --from=app-static /assets ./static/",
|
||||
] {
|
||||
assert!(
|
||||
dockerfile.contains(entry),
|
||||
"every architecture must serve the injected canonical tree, so the Dockerfile must carry {entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dockerfile_prepares_the_asset_tree_once_before_the_architecture_stages() {
|
||||
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
||||
let canonical = dockerfile
|
||||
.split("FROM alpine:3.21 AS app-assets")
|
||||
.nth(1)
|
||||
.expect("app-assets stage");
|
||||
let (canonical, per_architecture) = canonical
|
||||
.split_once("AS app-static")
|
||||
.expect("app-static stage");
|
||||
|
||||
for entry in ["apk add --no-cache brotli", "precompress_assets.sh"] {
|
||||
assert!(
|
||||
canonical.contains(entry),
|
||||
"the canonical asset tree is prepared once, so {entry} must run in the app-assets stage"
|
||||
);
|
||||
assert!(
|
||||
!per_architecture.contains(entry),
|
||||
"{entry} must not run again per architecture or the trees stop being byte-identical"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bake_publishes_the_canonical_asset_image() {
|
||||
let bake = include_str!("../../../fluxer_app_proxy/docker-bake.hcl");
|
||||
for entry in [
|
||||
"target \"app-assets-image\"",
|
||||
"${BUILD_VERSION}-assets",
|
||||
"type=registry",
|
||||
"APP_ASSETS_REF = APP_ASSETS_REF",
|
||||
"APP_ASSETS_PLATFORM = APP_ASSETS_PLATFORM",
|
||||
] {
|
||||
assert!(bake.contains(entry), "docker-bake.hcl must carry {entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_assets_platform_falls_back_to_the_bake_default() {
|
||||
let bake = include_str!("../../../fluxer_app_proxy/docker-bake.hcl");
|
||||
let declared =
|
||||
format!("variable \"APP_ASSETS_PLATFORM\" {{ default = \"{AMD64_PLATFORM}\" }}");
|
||||
assert!(
|
||||
bake.contains(&declared),
|
||||
"the parity gate reads APP_ASSETS_PLATFORM and falls back to {AMD64_PLATFORM}, so docker-bake.hcl must declare the same default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_hosted_workflow_keeps_assets_same_origin() {
|
||||
let workflow = include_str!("../../../.github/workflows/build-app-proxy-self-hosted.yaml");
|
||||
for entry in [
|
||||
"PUBLIC_ASSET_BASE_URL: \"\"",
|
||||
"BUNDLE_LOCAL_ASSETS: \"true\"",
|
||||
] {
|
||||
assert!(
|
||||
workflow.contains(entry),
|
||||
"a self-hosted dist that inherits the hosted CDN default would ship index.html pointing at fluxerstatic.com, so the workflow must set {entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_to_s3_key_uses_forward_slashes() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user