fix(app-proxy): share one asset tree across architectures (#2325)

This commit is contained in:
Hampus
2026-09-01 20:47:17 +02:00
committed by GitHub
parent cef600277c
commit 53a9fdc4b6
5 changed files with 912 additions and 44 deletions
@@ -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
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:
image: fluxer-app-proxy-self-hosted
dockerfile: fluxer_app_proxy/Dockerfile
build-version: ${{ inputs['build-version'] }}
extra-build-args: |
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}"
+79 -11
View File
@@ -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
View File
@@ -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"
+15
View File
@@ -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
View File
@@ -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!(