mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
1163 lines
39 KiB
Rust
1163 lines
39 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::common::{
|
|
CALVER_SCHEME, CalverEnv, CommandSpec, S3UploadPlanItem, append_github_env,
|
|
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 crate::functions::sha256_reader;
|
|
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 std::collections::{BTreeMap, BTreeSet};
|
|
use std::env;
|
|
use std::fs::{self, File};
|
|
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";
|
|
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 {
|
|
#[arg(long, value_enum)]
|
|
step: AppProxyStep,
|
|
#[arg(long)]
|
|
build_version: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
#[clap(rename_all = "snake_case")]
|
|
enum AppProxyStep {
|
|
SetMetadata,
|
|
PrepareDockerConfig,
|
|
ConfigureGhcrAuth,
|
|
BuildDist,
|
|
BuildImage,
|
|
GenerateAssetManifest,
|
|
UploadAssets,
|
|
VerifyPublishedAssets,
|
|
VerifyAssetParity,
|
|
}
|
|
|
|
pub async fn run(args: BuildAppProxyArgs) -> Result<()> {
|
|
match args.step {
|
|
AppProxyStep::SetMetadata => set_metadata_step(args.build_version.as_deref()),
|
|
AppProxyStep::PrepareDockerConfig => prepare_docker_config_step(),
|
|
AppProxyStep::ConfigureGhcrAuth => configure_ghcr_auth_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(),
|
|
}
|
|
}
|
|
|
|
fn set_metadata_step(build_version_arg: Option<&str>) -> Result<()> {
|
|
let calver_env = CalverEnv {
|
|
build_version: trim_option(build_version_arg.map(ToOwned::to_owned))
|
|
.or_else(|| trim_option(env::var("BUILD_VERSION").ok())),
|
|
fluxer_build_version: trim_option(env::var("FLUXER_BUILD_VERSION").ok()),
|
|
fluxer_build_date: trim_option(env::var("FLUXER_BUILD_DATE").ok()),
|
|
};
|
|
let version = resolve_calver(&calver_env, Utc::now())?;
|
|
append_github_output(&[
|
|
("build_version", version.as_str()),
|
|
("version", version.as_str()),
|
|
("calver_scheme", CALVER_SCHEME),
|
|
])
|
|
}
|
|
|
|
fn prepare_docker_config_step() -> Result<()> {
|
|
let docker_config = runner_temp().join("docker-config");
|
|
fs::create_dir_all(&docker_config)
|
|
.with_context(|| format!("Failed to create {}", docker_config.display()))?;
|
|
append_github_env(&[("DOCKER_CONFIG", docker_config.to_string_lossy().as_ref())])
|
|
}
|
|
|
|
fn configure_ghcr_auth_step() -> Result<()> {
|
|
let docker_config = require_env("DOCKER_CONFIG")?;
|
|
let username = require_env("GHCR_USERNAME")?;
|
|
let token = require_env("GHCR_TOKEN")?;
|
|
let path = PathBuf::from(docker_config).join("config.json");
|
|
write_ghcr_auth_config(&path, &username, &token)
|
|
}
|
|
|
|
fn write_ghcr_auth_config(path: &Path, username: &str, token: &str) -> Result<()> {
|
|
let mut config = if path.exists() {
|
|
serde_json::from_str::<Value>(
|
|
&fs::read_to_string(path)
|
|
.with_context(|| format!("Failed to read {}", path.display()))?,
|
|
)
|
|
.with_context(|| format!("Failed to parse {}", path.display()))?
|
|
} else {
|
|
Value::Object(Map::new())
|
|
};
|
|
|
|
let root = config
|
|
.as_object_mut()
|
|
.ok_or_else(|| anyhow!("Docker config root must be a JSON object"))?;
|
|
let auths = root
|
|
.entry("auths")
|
|
.or_insert_with(|| Value::Object(Map::new()))
|
|
.as_object_mut()
|
|
.ok_or_else(|| anyhow!("Docker config auths must be a JSON object"))?;
|
|
auths.insert(
|
|
"ghcr.io".to_string(),
|
|
json!({ "auth": ghcr_auth_value(username, token) }),
|
|
);
|
|
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
}
|
|
fs::write(path, format!("{}\n", serde_json::to_string(&config)?))
|
|
.with_context(|| format!("Failed to write {}", path.display()))
|
|
}
|
|
|
|
fn ghcr_auth_value(username: &str, token: &str) -> String {
|
|
BASE64.encode(format!("{username}:{token}"))
|
|
}
|
|
|
|
fn build_dist_step() -> Result<()> {
|
|
run_command(bake_command(&["app-dist", "app-assets-image"])?)
|
|
}
|
|
|
|
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());
|
|
Ok(CommandSpec::new("docker")
|
|
.args(["buildx", "bake", "-f", "fluxer_app_proxy/docker-bake.hcl"])
|
|
.args(targets.iter().copied())
|
|
.env("IMAGE_REPO", image_repo()?)
|
|
.env("BUILD_VERSION", build_version)
|
|
.env("PUBLIC_ASSET_BASE_URL", public_asset_base_url)
|
|
.env(
|
|
"FLUXER_APP_PROXY_TIME_FREEZE_ENABLED",
|
|
env::var("FLUXER_APP_PROXY_TIME_FREEZE_ENABLED")
|
|
.unwrap_or_else(|_| DEFAULT_APP_PROXY_TIME_FREEZE_ENABLED.to_string()),
|
|
)
|
|
.env(
|
|
"BUNDLE_LOCAL_ASSETS",
|
|
env::var("BUNDLE_LOCAL_ASSETS")
|
|
.unwrap_or_else(|_| DEFAULT_APP_PROXY_BUNDLE_LOCAL_ASSETS.to_string()),
|
|
)
|
|
.env(
|
|
"CACHE_FROM",
|
|
env::var("CACHE_FROM").unwrap_or_else(|_| default_app_proxy_cache_ref()),
|
|
)
|
|
.env(
|
|
"CACHE_TO",
|
|
env::var("CACHE_TO").unwrap_or_else(|_| {
|
|
format!(
|
|
"{},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true",
|
|
default_app_proxy_cache_ref()
|
|
)
|
|
}),
|
|
)
|
|
.env(
|
|
"DOCKER_BUILD_SUMMARY",
|
|
env::var("DOCKER_BUILD_SUMMARY").unwrap_or_else(|_| "false".to_string()),
|
|
)
|
|
.env(
|
|
"DOCKER_BUILD_RECORD_UPLOAD",
|
|
env::var("DOCKER_BUILD_RECORD_UPLOAD").unwrap_or_else(|_| "false".to_string()),
|
|
))
|
|
}
|
|
|
|
fn default_app_proxy_cache_ref() -> String {
|
|
let owner = ghcr_owner().unwrap_or_else(|_| "fluxerapp".to_string());
|
|
format!("type=registry,ref=ghcr.io/{owner}/fluxer-app-proxy:buildcache-amd64")
|
|
}
|
|
|
|
fn ghcr_owner() -> Result<String> {
|
|
for key in ["GHCR_OWNER", "GITHUB_REPOSITORY_OWNER", "OWNER"] {
|
|
if let Ok(value) = env::var(key) {
|
|
let value = value.trim();
|
|
if !value.is_empty() {
|
|
return Ok(value.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Ok(repository) = env::var("GITHUB_REPOSITORY")
|
|
&& let Some((owner, _)) = repository.split_once('/')
|
|
{
|
|
let owner = owner.trim();
|
|
if !owner.is_empty() {
|
|
return Ok(owner.to_string());
|
|
}
|
|
}
|
|
|
|
Err(anyhow!(
|
|
"GHCR owner must be set with GHCR_OWNER, GITHUB_REPOSITORY_OWNER, OWNER, or GITHUB_REPOSITORY"
|
|
))
|
|
}
|
|
|
|
fn generate_asset_manifest_step() -> Result<()> {
|
|
let dist = app_dist_dir();
|
|
let manifest_path = dist.join("assets-manifest.txt");
|
|
let assets = asset_manifest_entries(&dist)?;
|
|
fs::write(&manifest_path, format!("{}\n", assets.join("\n")))
|
|
.with_context(|| format!("Failed to write {}", manifest_path.display()))?;
|
|
println!("=== asset manifest ===");
|
|
for asset in &assets {
|
|
println!("{asset}");
|
|
}
|
|
println!("total assets: {}", assets.len());
|
|
Ok(())
|
|
}
|
|
|
|
fn app_dist_dir() -> PathBuf {
|
|
env::var("APP_DIST_DIR")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| PathBuf::from("app-dist-output/dist"))
|
|
}
|
|
|
|
fn asset_manifest_entries(dist: &Path) -> Result<Vec<String>> {
|
|
let assets_dir = dist.join("assets");
|
|
ensure!(
|
|
assets_dir.exists(),
|
|
"App proxy assets directory is missing: {}",
|
|
assets_dir.display()
|
|
);
|
|
let mut entries = collect_files(&assets_dir)?
|
|
.into_iter()
|
|
.map(|path| {
|
|
path.strip_prefix(dist)
|
|
.with_context(|| format!("Failed to relativize {}", path.display()))
|
|
.map(path_to_s3_key)
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
entries.sort();
|
|
Ok(entries)
|
|
}
|
|
|
|
async fn upload_assets_step() -> Result<()> {
|
|
let client = s3_client(Some(DEFAULT_S3_ENDPOINT)).await?;
|
|
let bucket = env::var("STATIC_BUCKET").unwrap_or_else(|_| DEFAULT_STATIC_BUCKET.to_string());
|
|
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 plan = asset_upload_plan(&dist, &assets)?;
|
|
let stats = upload_s3_plan_append_only(&client, &bucket, plan).await?;
|
|
|
|
println!("upload complete - {} assets", assets.len());
|
|
println!(
|
|
"append-only result - uploaded {}, skipped existing {}, repaired metadata {}",
|
|
stats.uploaded, stats.skipped_existing, stats.metadata_repaired
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn asset_upload_plan(dist: &Path, assets: &[String]) -> Result<Vec<S3UploadPlanItem>> {
|
|
assets
|
|
.iter()
|
|
.map(|asset| {
|
|
let path = dist.join(asset);
|
|
ensure!(
|
|
path.is_file(),
|
|
"Manifest asset is missing: {}",
|
|
path.display()
|
|
);
|
|
Ok(S3UploadPlanItem::new(path, asset.clone())
|
|
.with_detected_content_type()
|
|
.with_cache_control(IMMUTABLE_ASSET_CACHE_CONTROL)
|
|
.repair_existing_metadata())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn read_asset_manifest(path: &Path) -> Result<Vec<String>> {
|
|
let manifest =
|
|
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
|
|
manifest
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.map(validate_manifest_asset)
|
|
.collect()
|
|
}
|
|
|
|
fn validate_manifest_asset(asset: &str) -> Result<String> {
|
|
ensure!(
|
|
asset.starts_with("assets/"),
|
|
"Asset manifest entry must be under assets/: {asset}"
|
|
);
|
|
ensure!(
|
|
!asset.contains("..") && !asset.starts_with('/') && !asset.contains('\\'),
|
|
"Invalid asset manifest path: {asset}"
|
|
);
|
|
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 file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
|
sha256_reader(file).with_context(|| format!("Failed to read {}", path.display()))
|
|
}
|
|
|
|
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::*;
|
|
use crate::common::parse_version_instant;
|
|
use chrono::{DateTime, TimeZone, Utc};
|
|
use std::ffi::OsString;
|
|
|
|
fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> DateTime<Utc> {
|
|
Utc.with_ymd_and_hms(year, month, day, hour, minute, second)
|
|
.single()
|
|
.unwrap()
|
|
}
|
|
|
|
fn write_file(path: &Path, contents: &str) {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).unwrap();
|
|
}
|
|
fs::write(path, contents).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_calver_from_explicit_or_date_override() {
|
|
let explicit = CalverEnv {
|
|
build_version: Some("2026.520.1".to_string()),
|
|
fluxer_build_version: Some("2026.521.2".to_string()),
|
|
fluxer_build_date: Some("2026-05-22T03:04:05Z".to_string()),
|
|
};
|
|
assert_eq!(
|
|
resolve_calver(&explicit, dt(2026, 1, 1, 0, 0, 0)).unwrap(),
|
|
"2026.520.1"
|
|
);
|
|
|
|
let generated = CalverEnv {
|
|
fluxer_build_date: Some("2026-05-20T01:02:03Z".to_string()),
|
|
..CalverEnv::default()
|
|
};
|
|
assert_eq!(
|
|
resolve_calver(&generated, dt(2026, 1, 1, 0, 0, 0)).unwrap(),
|
|
"2026.520.10203"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_calver_time() {
|
|
assert_eq!(
|
|
parse_version_instant("2026.520.246000")
|
|
.unwrap_err()
|
|
.to_string(),
|
|
"Invalid build version date/time: 2026.520.246000"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ghcr_auth_config_merges_existing_auths() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("config.json");
|
|
fs::write(
|
|
&config_path,
|
|
r#"{"auths":{"example.com":{"auth":"old"}},"currentContext":"builder"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
write_ghcr_auth_config(&config_path, "octo", "secret").unwrap();
|
|
|
|
let config: Value =
|
|
serde_json::from_str(&fs::read_to_string(config_path).unwrap()).unwrap();
|
|
assert_eq!(config["auths"]["example.com"]["auth"], "old");
|
|
assert_eq!(
|
|
config["auths"]["ghcr.io"]["auth"],
|
|
BASE64.encode("octo:secret")
|
|
);
|
|
assert_eq!(config["currentContext"], "builder");
|
|
}
|
|
|
|
#[test]
|
|
fn ghcr_auth_config_rejects_non_object_roots_and_auths() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("config.json");
|
|
fs::write(&config_path, "[]").unwrap();
|
|
assert_eq!(
|
|
write_ghcr_auth_config(&config_path, "octo", "secret")
|
|
.unwrap_err()
|
|
.to_string(),
|
|
"Docker config root must be a JSON object"
|
|
);
|
|
|
|
fs::write(&config_path, r#"{"auths":[]}"#).unwrap();
|
|
assert_eq!(
|
|
write_ghcr_auth_config(&config_path, "octo", "secret")
|
|
.unwrap_err()
|
|
.to_string(),
|
|
"Docker config auths must be a JSON object"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_command_sets_bake_environment() {
|
|
let command = CommandSpec::new("docker")
|
|
.args(["buildx", "bake", "-f", "fluxer_app_proxy/docker-bake.hcl"])
|
|
.env("IMAGE_REPO", "ghcr.io/example/fluxer-app-proxy")
|
|
.env("BUILD_VERSION", "2026.520.1")
|
|
.env("PUBLIC_ASSET_BASE_URL", DEFAULT_PUBLIC_ASSET_BASE_URL)
|
|
.env(
|
|
"FLUXER_APP_PROXY_TIME_FREEZE_ENABLED",
|
|
DEFAULT_APP_PROXY_TIME_FREEZE_ENABLED,
|
|
);
|
|
|
|
assert_eq!(command.program, OsString::from("docker"));
|
|
assert_eq!(
|
|
command.args,
|
|
vec![
|
|
OsString::from("buildx"),
|
|
OsString::from("bake"),
|
|
OsString::from("-f"),
|
|
OsString::from("fluxer_app_proxy/docker-bake.hcl"),
|
|
]
|
|
);
|
|
assert!(command.env.contains(&(
|
|
OsString::from("BUILD_VERSION"),
|
|
OsString::from("2026.520.1")
|
|
)));
|
|
assert!(command.env.contains(&(
|
|
OsString::from("FLUXER_APP_PROXY_TIME_FREEZE_ENABLED"),
|
|
OsString::from(DEFAULT_APP_PROXY_TIME_FREEZE_ENABLED)
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn hosted_bake_trims_the_local_asset_tree_by_default() {
|
|
assert_eq!(DEFAULT_APP_PROXY_BUNDLE_LOCAL_ASSETS, "false");
|
|
}
|
|
|
|
#[test]
|
|
fn dockerfile_guards_the_trim_on_an_absolute_asset_base_url() {
|
|
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
|
let trim = dockerfile
|
|
.split("FROM alpine:3.21 AS app-assets")
|
|
.nth(1)
|
|
.expect("app-assets stage");
|
|
assert!(
|
|
trim.contains("ARG PUBLIC_ASSET_BASE_URL"),
|
|
"the app-assets stage must redeclare PUBLIC_ASSET_BASE_URL to be able to check it"
|
|
);
|
|
assert!(
|
|
trim.contains("http://* | https://*"),
|
|
"the trim must assert an absolute PUBLIC_ASSET_BASE_URL before deleting anything"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dockerfile_workspace_manifest_keeps_the_release_profile() {
|
|
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
|
let manifest = dockerfile
|
|
.split("'[workspace]'")
|
|
.nth(1)
|
|
.expect("synthesized workspace manifest")
|
|
.split("> Cargo.toml")
|
|
.next()
|
|
.expect("synthesized workspace manifest body");
|
|
for entry in [
|
|
"'[profile.release]'",
|
|
"'lto = \"fat\"'",
|
|
"'codegen-units = 1'",
|
|
"'strip = \"symbols\"'",
|
|
] {
|
|
assert!(
|
|
manifest.contains(entry),
|
|
"the rust-builder workspace manifest replaces the repository root one, so it must carry {entry}"
|
|
);
|
|
}
|
|
assert!(
|
|
!manifest.contains("panic"),
|
|
"fluxer_svc runs every request on its own tokio task, so a panicking handler must unwind instead of aborting the pod"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_manifest_entries_are_sorted_and_relative_to_dist() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let dist = temp.path().join("dist");
|
|
write_file(&dist.join("assets/z.js"), "z");
|
|
write_file(&dist.join("assets/z.js.map"), "{}");
|
|
write_file(&dist.join("assets/chunks/a.js"), "a");
|
|
write_file(&dist.join("assets/chunks/a.js.map"), "{}");
|
|
write_file(&dist.join("index.html"), "ignored");
|
|
|
|
assert_eq!(
|
|
asset_manifest_entries(&dist).unwrap(),
|
|
vec![
|
|
"assets/chunks/a.js",
|
|
"assets/chunks/a.js.map",
|
|
"assets/z.js",
|
|
"assets/z.js.map"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_manifest_entries_require_assets_directory() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let dist = temp.path().join("dist");
|
|
fs::create_dir_all(&dist).unwrap();
|
|
|
|
assert!(
|
|
asset_manifest_entries(&dist)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("App proxy assets directory is missing")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_reader_trims_blank_lines_and_keeps_order() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let manifest = temp.path().join("assets-manifest.txt");
|
|
fs::write(&manifest, "\n assets/b.js \n\nassets/a.js\n").unwrap();
|
|
|
|
assert_eq!(
|
|
read_asset_manifest(&manifest).unwrap(),
|
|
vec!["assets/b.js", "assets/a.js"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_upload_plan_preserves_manifest_keys() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let dist = temp.path().join("dist");
|
|
write_file(&dist.join("assets/a.js"), "a");
|
|
write_file(&dist.join("assets/chunks/b.js"), "b");
|
|
let assets = vec!["assets/a.js".to_string(), "assets/chunks/b.js".to_string()];
|
|
|
|
let plan = asset_upload_plan(&dist, &assets).unwrap();
|
|
|
|
assert_eq!(
|
|
plan.iter()
|
|
.map(|item| item.key.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["assets/a.js", "assets/chunks/b.js"]
|
|
);
|
|
assert_eq!(plan[0].path, dist.join("assets/a.js"));
|
|
assert_eq!(plan[1].path, dist.join("assets/chunks/b.js"));
|
|
assert_eq!(
|
|
plan[0].content_type.as_deref(),
|
|
Some("application/javascript; charset=utf-8")
|
|
);
|
|
assert_eq!(
|
|
plan[0].cache_control.as_deref(),
|
|
Some(IMMUTABLE_ASSET_CACHE_CONTROL)
|
|
);
|
|
assert!(plan[0].repair_existing_metadata);
|
|
}
|
|
|
|
#[test]
|
|
fn uploaded_assets_carry_the_same_policy_the_app_proxy_serves() {
|
|
assert_eq!(
|
|
IMMUTABLE_ASSET_CACHE_CONTROL,
|
|
"public, max-age=31536000, immutable"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_upload_plan_rejects_manifest_entries_missing_on_disk() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let dist = temp.path().join("dist");
|
|
fs::create_dir_all(&dist).unwrap();
|
|
let assets = vec!["assets/missing.js".to_string()];
|
|
|
|
assert!(
|
|
asset_upload_plan(&dist, &assets)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Manifest asset is missing")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_reader_rejects_paths_outside_assets() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let manifest = temp.path().join("assets-manifest.txt");
|
|
fs::write(&manifest, "assets/a.js\n../secret\n").unwrap();
|
|
|
|
assert!(read_asset_manifest(&manifest).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_reader_rejects_absolute_parent_and_backslash_paths() {
|
|
for asset in ["/assets/a.js", "assets/../secret", r"assets\app.js"] {
|
|
assert!(validate_manifest_asset(asset).is_err(), "{asset}");
|
|
}
|
|
}
|
|
|
|
#[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!(
|
|
path_to_s3_key(Path::new("assets").join("chunks").join("a.js").as_path()),
|
|
"assets/chunks/a.js"
|
|
);
|
|
}
|
|
}
|