diff --git a/.github/workflows/build-app-proxy-self-hosted.yaml b/.github/workflows/build-app-proxy-self-hosted.yaml index 30b395130..4360e16da 100644 --- a/.github/workflows/build-app-proxy-self-hosted.yaml +++ b/.github/workflows/build-app-proxy-self-hosted.yaml @@ -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}" diff --git a/.github/workflows/build-app-proxy.yaml b/.github/workflows/build-app-proxy.yaml index 0e002cb0b..3696d427f 100644 --- a/.github/workflows/build-app-proxy.yaml +++ b/.github/workflows/build-app-proxy.yaml @@ -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 diff --git a/fluxer_app_proxy/Dockerfile b/fluxer_app_proxy/Dockerfile index d04151384..b1f174e1e 100644 --- a/fluxer_app_proxy/Dockerfile +++ b/fluxer_app_proxy/Dockerfile @@ -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" diff --git a/fluxer_app_proxy/docker-bake.hcl b/fluxer_app_proxy/docker-bake.hcl index 85748ce29..44d16f891 100644 --- a/fluxer_app_proxy/docker-bake.hcl +++ b/fluxer_app_proxy/docker-bake.hcl @@ -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"] +} diff --git a/tools/ci/src/app_proxy.rs b/tools/ci/src/app_proxy.rs index ba1cd24e7..f19f26f1b 100644 --- a/tools/ci/src/app_proxy.rs +++ b/tools/ci/src/app_proxy.rs @@ -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 { +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 { + 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 { + 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 { 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 { 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::>(); + 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 { + 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::(); + if !name.is_empty() && !name.ends_with('/') { + names.insert(format!("{PREFIX}{name}")); + } + } + names.into_iter().collect() +} + +fn unproduced_references(referenced: &[String], produced: &[String]) -> Vec { + let produced = produced.iter().map(String::as_str).collect::>(); + 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 { + 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> { + 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 { + 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, + other: &BTreeMap, +) -> Vec { + 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!( + "", + "", + "notice", + "", + ); + + 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( + "" + ) + .is_empty() + ); + } + + #[test] + fn referenced_assets_keeps_nested_asset_paths() { + assert_eq!( + referenced_assets(""), + 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::>(); + 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!["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!(