Files
fluxer/tools/ci/src/image_set.rs
T

1164 lines
38 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::common::{
CommandSpec, append_github_output, env_string, output_text, parse_version_instant, run_command,
};
use crate::release::{RELEASE_REPOSITORY, release_tag, resolve_commit_sha, validate_full_sha};
use anyhow::{Context, Result, bail, ensure};
use clap::{Args, Subcommand};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
const SCHEMA_VERSION: u8 = 1;
const DEFAULT_REGISTRY: &str = "ghcr.io/fluxerapp";
const DEFAULT_MOVING_TAGS: &str = "v1,latest";
const DEFAULT_FROM_TAG: &str = "v1";
const DEFAULT_OUT_DIR: &str = "release-out";
const RELEASE_COMPONENT: &str = "fluxer-release";
const COMPOSE_IMAGE_PREFIX: &str =
"${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}";
const OCI_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
const DOCKER_MANIFEST_LIST_MEDIA_TYPE: &str =
"application/vnd.docker.distribution.manifest.list.v2+json";
const REQUIRED_PLATFORMS: &[(&str, &str)] = &[("linux", "amd64"), ("linux", "arm64")];
const ARCH_TAG_SUFFIXES: &[&str] = &["-amd64", "-arm64"];
struct Component {
image: &'static str,
services: &'static [&'static str],
}
const COMPONENTS: &[Component] = &[
Component {
image: "fluxer-admin",
services: &["admin"],
},
Component {
image: "fluxer-api",
services: &["api", "worker"],
},
Component {
image: "fluxer-app-proxy",
services: &[],
},
Component {
image: "fluxer-app-proxy-self-hosted",
services: &["app-proxy"],
},
Component {
image: "fluxer-docs",
services: &[],
},
Component {
image: "fluxer-gateway",
services: &["gateway"],
},
Component {
image: "fluxer-gifs",
services: &["gifs", "gifs-shard"],
},
Component {
image: "fluxer-media-proxy",
services: &["media-proxy"],
},
Component {
image: "fluxer-messages",
services: &["messages", "messages-shard"],
},
Component {
image: "fluxer-push",
services: &["push"],
},
Component {
image: "fluxer-snowflakes",
services: &["snowflakes", "snowflakes-shard"],
},
Component {
image: "fluxer-static",
services: &["static-proxy"],
},
Component {
image: "fluxer-unfurl",
services: &["unfurl", "unfurl-shard"],
},
Component {
image: "fluxer-users",
services: &["users", "users-shard"],
},
];
#[derive(Debug, Args, Clone)]
pub struct ImageSetArgs {
#[command(subcommand)]
command: ImageSetCommand,
}
#[derive(Debug, Subcommand, Clone)]
#[clap(rename_all = "kebab_case")]
enum ImageSetCommand {
Resolve(ResolveArgs),
Verify(VerifyArgs),
Promote(PromoteArgs),
}
#[derive(Debug, Args, Clone)]
struct ResolveArgs {
#[arg(long)]
version: String,
#[arg(long, default_value = DEFAULT_FROM_TAG)]
from_tag: String,
#[arg(long)]
component_version: Vec<String>,
#[arg(long, default_value = DEFAULT_REGISTRY)]
registry: String,
#[arg(long, default_value = DEFAULT_OUT_DIR)]
out_dir: PathBuf,
#[arg(long)]
allow_unreleased: bool,
#[arg(long)]
github_output: bool,
}
#[derive(Debug, Args, Clone)]
struct VerifyArgs {
#[arg(long)]
manifest: PathBuf,
#[arg(long)]
allow_unreleased: bool,
}
#[derive(Debug, Args, Clone)]
struct PromoteArgs {
#[arg(long)]
component: String,
#[arg(long)]
build_version: String,
#[arg(long, default_value = DEFAULT_REGISTRY)]
registry: String,
#[arg(long, default_value = DEFAULT_MOVING_TAGS)]
moving_tags: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub(crate) struct ImageSetManifest {
pub(crate) schema_version: u8,
pub(crate) version: String,
pub(crate) release_tag: String,
pub(crate) registry: String,
pub(crate) components: Vec<ImageSetComponent>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub(crate) struct ImageSetComponent {
pub(crate) component: String,
pub(crate) image: String,
pub(crate) tag: String,
pub(crate) digest: String,
pub(crate) build_version: String,
pub(crate) source_sha: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommitCompare {
Identical,
Ahead,
Behind,
Diverged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedComponent {
component: String,
tag: String,
digest: String,
source_sha: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RegistryManifest {
#[serde(rename = "mediaType")]
media_type: String,
digest: String,
#[serde(default)]
manifests: Vec<RegistryManifestEntry>,
}
#[derive(Debug, Deserialize)]
struct RegistryManifestEntry {
#[serde(default)]
platform: Option<RegistryPlatform>,
}
#[derive(Debug, Deserialize)]
struct RegistryPlatform {
os: String,
architecture: String,
}
#[derive(Debug, Deserialize)]
struct PackageVersion {
name: String,
#[serde(default)]
metadata: Option<PackageVersionMetadata>,
}
#[derive(Debug, Deserialize)]
struct PackageVersionMetadata {
#[serde(default)]
container: Option<PackageContainerMetadata>,
}
#[derive(Debug, Deserialize)]
struct PackageContainerMetadata {
#[serde(default)]
tags: Vec<String>,
}
pub fn run(args: ImageSetArgs) -> Result<()> {
match args.command {
ImageSetCommand::Resolve(args) => run_resolve(args),
ImageSetCommand::Verify(args) => run_verify(args),
ImageSetCommand::Promote(args) => run_promote(args),
}
}
fn run_resolve(args: ResolveArgs) -> Result<()> {
parse_version_instant(&args.version)?;
validate_registry(&args.registry)?;
let pins = parse_component_versions(&args.component_version)?;
let from_tag_is_calver = parse_version_instant(&args.from_tag).is_ok();
let mut resolved = Vec::with_capacity(COMPONENTS.len());
for component in COMPONENTS {
let image = format!("{}/{}", args.registry, component.image);
let tag = match pins.get(component.image) {
Some(pinned) => pinned.clone(),
None if from_tag_is_calver => args.from_tag.clone(),
None => {
let snapshot =
inspect_digest(&format!("{image}:{}", args.from_tag)).with_context(|| {
format!(
"Component {} has no published {} image",
component.image, args.from_tag
)
})?;
let tags = package_tags(&args.registry, component.image, &snapshot)?;
calver_tag_for_digest(&tags).with_context(|| {
format!(
"Component {} tag {} does not resolve to a single CalVer tag",
component.image, args.from_tag
)
})?
}
};
let digest = inspect_digest(&format!("{image}:{tag}")).with_context(|| {
format!("Component {} has no published {tag} image", component.image)
})?;
let source_sha = component_source_sha(component.image, &tag);
if source_sha.is_none() {
println!(
"warning: {} has no {} release in {RELEASE_REPOSITORY}",
component.image,
release_tag(component.image, &tag)
);
}
resolved.push(ResolvedComponent {
component: component.image.to_string(),
tag,
digest,
source_sha,
});
}
let manifest = build_manifest(
&args.version,
&args.registry,
&resolved,
args.allow_unreleased,
)?;
let bundle_commit = resolve_bundle_commit(&resolved)?;
write_release_files(&args.out_dir, &manifest)?;
if args.github_output {
append_github_output(&[("bundle_commit", bundle_commit.as_str())])?;
}
Ok(())
}
fn resolve_bundle_commit(resolved: &[ResolvedComponent]) -> Result<String> {
let unreleased: Vec<&str> = resolved
.iter()
.filter(|entry| entry.source_sha.is_none())
.map(|entry| entry.component.as_str())
.collect();
if !unreleased.is_empty() {
println!(
"warning: no source commit for {}, so the bundle commit is not proven to be in every image",
unreleased.join(", ")
);
}
let mut seen = BTreeSet::new();
let mut candidates: Vec<(&str, &str)> = Vec::new();
for entry in resolved {
let Some(sha) = entry.source_sha.as_deref() else {
continue;
};
if seen.insert(sha) {
candidates.push((entry.component.as_str(), sha));
}
}
ensure!(
!candidates.is_empty(),
"Refusing to release: no component records a source commit, so no bundle commit can be proven"
);
let (component, commit) = oldest_common_commit(&candidates, compare_commits)?;
println!("bundle commit {commit} pinned by {component}");
if let Some(head) = env_string("GITHUB_SHA")
&& head != commit
{
println!(
"warning: workflow ref {head} is not the bundle commit, so the release tag points at {commit}"
);
}
Ok(commit.to_string())
}
fn oldest_common_commit<'a, F>(
candidates: &[(&'a str, &'a str)],
mut compare: F,
) -> Result<(&'a str, &'a str)>
where
F: FnMut(&str, &str) -> Result<CommitCompare>,
{
let mut oldest = *candidates
.first()
.context("The image set records no source commits")?;
for &(component, commit) in candidates.iter().skip(1) {
match compare(oldest.1, commit)? {
CommitCompare::Identical | CommitCompare::Ahead => {}
CommitCompare::Behind => oldest = (component, commit),
CommitCompare::Diverged => bail!(
"Refusing to release: {component} was built from {commit} and {} was built from {}, which are on divergent branches",
oldest.0,
oldest.1
),
}
}
Ok(oldest)
}
fn compare_commits(base: &str, head: &str) -> Result<CommitCompare> {
let status = output_text(
CommandSpec::new("gh")
.arg("api")
.arg(format!(
"repos/{RELEASE_REPOSITORY}/compare/{base}...{head}"
))
.args(["--jq", ".status"]),
)?;
parse_commit_compare(&status)
}
fn parse_commit_compare(status: &str) -> Result<CommitCompare> {
match status.trim() {
"identical" => Ok(CommitCompare::Identical),
"ahead" => Ok(CommitCompare::Ahead),
"behind" => Ok(CommitCompare::Behind),
"diverged" => Ok(CommitCompare::Diverged),
other => bail!("Unexpected GitHub compare status {other:?}"),
}
}
fn run_verify(args: VerifyArgs) -> Result<()> {
let raw = fs::read_to_string(&args.manifest)
.with_context(|| format!("Failed to read {}", args.manifest.display()))?;
let manifest: ImageSetManifest = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse {}", args.manifest.display()))?;
ensure!(
manifest.schema_version == SCHEMA_VERSION,
"Image set {} has schema version {}, expected {SCHEMA_VERSION}",
args.manifest.display(),
manifest.schema_version
);
let recorded: Vec<ResolvedComponent> = manifest
.components
.iter()
.map(|entry| ResolvedComponent {
component: entry.component.clone(),
tag: entry.tag.clone(),
digest: entry.digest.clone(),
source_sha: entry.source_sha.clone(),
})
.collect();
let rebuilt = build_manifest(
&manifest.version,
&manifest.registry,
&recorded,
args.allow_unreleased,
)?;
ensure!(
rebuilt == manifest,
"Image set {} does not match the manifest rebuilt from its own contents",
args.manifest.display()
);
let mut observed = Vec::with_capacity(manifest.components.len());
for entry in &manifest.components {
let digest = inspect_digest(&format!("{}:{}", entry.image, entry.tag))?;
inspect_digest(&format!("{}@{}", entry.image, entry.digest)).with_context(|| {
format!(
"Component {} digest {} is no longer published",
entry.component, entry.digest
)
})?;
observed.push(ResolvedComponent {
component: entry.component.clone(),
tag: entry.tag.clone(),
digest,
source_sha: entry.source_sha.clone(),
});
}
verify_resolved(&manifest, &observed)?;
println!(
"{} verified: {} components pinned by digest",
manifest.release_tag,
manifest.components.len()
);
Ok(())
}
fn run_promote(args: PromoteArgs) -> Result<()> {
let component = component(&args.component)?;
parse_version_instant(&args.build_version)?;
validate_registry(&args.registry)?;
let tags = moving_tags(&args.moving_tags)?;
let image = format!("{}/{}", args.registry, component.image);
let digest = inspect_digest(&format!("{image}:{}", args.build_version))?;
if tags.is_empty() {
println!("No moving tags requested for {image}.");
return Ok(());
}
for warning in unpublished_moving_tags(&args.registry, component.image, &tags, |reference| {
inspect_digest(reference).is_ok()
}) {
println!("warning: {warning}");
}
run_command(promote_command(&image, &digest, &tags))
}
fn unpublished_moving_tags<F>(
registry: &str,
component: &str,
tags: &[String],
mut published: F,
) -> Vec<String>
where
F: FnMut(&str) -> bool,
{
let mut warnings = Vec::new();
for other in COMPONENTS.iter().filter(|other| other.image != component) {
for tag in tags {
if !published(&format!("{registry}/{}:{tag}", other.image)) {
warnings.push(format!(
"{} has no published {tag} image, so {tag} stays an incomplete set until the build-{} workflow runs",
other.image,
workflow_suffix(other.image)
));
}
}
}
warnings
}
fn write_release_files(out_dir: &Path, manifest: &ImageSetManifest) -> Result<()> {
fs::create_dir_all(out_dir)
.with_context(|| format!("Failed to create {}", out_dir.display()))?;
let manifest_path = out_dir.join(manifest_filename(&manifest.version));
let compose_path = out_dir.join(compose_filename(&manifest.version));
let json = format!(
"{}\n",
serde_json::to_string_pretty(manifest).context("Failed to serialise the image set")?
);
fs::write(&manifest_path, &json)
.with_context(|| format!("Failed to write {}", manifest_path.display()))?;
fs::write(&compose_path, compose_override(manifest))
.with_context(|| format!("Failed to write {}", compose_path.display()))?;
println!("{}", manifest_path.display());
println!("{}", compose_path.display());
println!("sha256:{}", hex::encode(Sha256::digest(json.as_bytes())));
Ok(())
}
fn build_manifest(
version: &str,
registry: &str,
resolved: &[ResolvedComponent],
allow_unreleased: bool,
) -> Result<ImageSetManifest> {
let version_instant = parse_version_instant(version)?;
validate_registry(registry)?;
let mut seen = BTreeSet::new();
for entry in resolved {
component(&entry.component)?;
ensure!(
seen.insert(entry.component.as_str()),
"Component {} appears more than once in the release set",
entry.component
);
}
let missing: Vec<&str> = COMPONENTS
.iter()
.map(|component| component.image)
.filter(|image| !seen.contains(image))
.collect();
ensure!(
missing.is_empty(),
"Refusing to build the release set: missing components {missing:?}"
);
let mut components = Vec::with_capacity(resolved.len());
for entry in resolved {
let digest = validate_digest(&entry.digest).with_context(|| {
format!("Component {} has an unusable image digest", entry.component)
})?;
let tag_instant = parse_version_instant(&entry.tag)
.with_context(|| format!("Component {} has an unusable tag", entry.component))?;
ensure!(
tag_instant <= version_instant,
"Component {} tag {} is newer than release version {version}",
entry.component,
entry.tag
);
let source_sha = match entry.source_sha.as_deref() {
Some(sha) => Some(validate_full_sha(
&format!("{} source SHA", entry.component),
sha,
)?),
None => None,
};
ensure!(
allow_unreleased || source_sha.is_some(),
"Component {} has no {} release in {RELEASE_REPOSITORY}; rebuild it or pass --allow-unreleased",
entry.component,
release_tag(&entry.component, &entry.tag)
);
components.push(ImageSetComponent {
component: entry.component.clone(),
image: format!("{registry}/{}", entry.component),
tag: entry.tag.clone(),
digest,
build_version: entry.tag.clone(),
source_sha,
});
}
components.sort_by(|left, right| left.component.cmp(&right.component));
Ok(ImageSetManifest {
schema_version: SCHEMA_VERSION,
version: version.to_string(),
release_tag: release_tag(RELEASE_COMPONENT, version),
registry: registry.to_string(),
components,
})
}
fn compose_override(manifest: &ImageSetManifest) -> String {
let mut services: BTreeMap<&str, String> = BTreeMap::new();
for entry in &manifest.components {
let Ok(component) = component(&entry.component) else {
continue;
};
for service in component.services {
services.insert(
service,
format!(
"{COMPOSE_IMAGE_PREFIX}/{}@${{{}:-{}}}",
entry.component,
digest_env_name(&entry.component),
entry.digest
),
);
}
}
let mut rendered = String::new();
rendered.push_str(&format!("# {} image set\n", manifest.release_tag));
rendered.push_str(&format!(
"# docker compose -f docker-compose.yml -f {} up -d\n",
compose_filename(&manifest.version)
));
rendered.push_str("services:\n");
for (service, image) in &services {
rendered.push_str(&format!(" {service}:\n image: {image}\n"));
}
rendered
}
fn verify_resolved(manifest: &ImageSetManifest, resolved: &[ResolvedComponent]) -> Result<()> {
let observed: BTreeMap<&str, &ResolvedComponent> = resolved
.iter()
.map(|entry| (entry.component.as_str(), entry))
.collect();
for entry in &manifest.components {
let found = observed
.get(entry.component.as_str())
.with_context(|| format!("Component {} was not resolved", entry.component))?;
ensure!(
found.tag == entry.tag,
"Component {} records tag {} but {} resolved {}",
entry.component,
entry.tag,
entry.image,
found.tag
);
ensure!(
found.digest == entry.digest,
"Component {} drifted: {}:{} records {} but now resolves to {}",
entry.component,
entry.image,
entry.tag,
entry.digest,
found.digest
);
}
Ok(())
}
fn promote_command(image: &str, digest: &str, tags: &[String]) -> CommandSpec {
let mut spec = CommandSpec::new("docker").args(["buildx", "imagetools", "create"]);
for tag in tags {
spec = spec.arg("-t").arg(format!("{image}:{tag}"));
}
spec.arg(format!("{image}@{digest}"))
}
fn component(image: &str) -> Result<&'static Component> {
COMPONENTS
.iter()
.find(|candidate| candidate.image == image)
.with_context(|| format!("Unknown release component {image:?}"))
}
fn workflow_suffix(image: &str) -> &str {
image.strip_prefix("fluxer-").unwrap_or(image)
}
fn digest_env_name(component: &str) -> String {
format!(
"{}_IMAGE_DIGEST",
component.to_ascii_uppercase().replace('-', "_")
)
}
fn manifest_filename(version: &str) -> String {
format!("{RELEASE_COMPONENT}-{version}.json")
}
fn compose_filename(version: &str) -> String {
format!("{RELEASE_COMPONENT}-{version}.yml")
}
fn validate_digest(digest: &str) -> Result<String> {
let value = digest.trim();
let hex = value
.strip_prefix("sha256:")
.with_context(|| format!("Invalid image digest {value:?}: expected sha256:<64 hex>"))?;
ensure!(
hex.len() == 64
&& hex
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
"Invalid image digest {value:?}: expected sha256:<64 hex>"
);
Ok(value.to_string())
}
fn validate_registry(registry: &str) -> Result<()> {
ensure!(!registry.is_empty(), "Registry must not be empty");
ensure!(
!registry.chars().any(char::is_whitespace),
"Invalid registry {registry:?}: whitespace is not allowed"
);
ensure!(
!registry.ends_with('/'),
"Invalid registry {registry:?}: a trailing slash is not allowed"
);
let namespace = registry.rsplit('/').next().unwrap_or_default();
ensure!(
!namespace.is_empty() && !namespace.contains(':'),
"Invalid registry {registry:?}: expected a namespace, not an image reference"
);
Ok(())
}
fn moving_tags(raw: &str) -> Result<Vec<String>> {
let mut tags = Vec::new();
for entry in raw.split(',') {
let tag = entry.trim();
if tag.is_empty() {
continue;
}
ensure!(
tag.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')),
"Invalid moving tag {tag:?}: expected letters, digits, dots, underscores and hyphens"
);
tags.push(tag.to_string());
}
Ok(tags)
}
fn calver_tag_for_digest(tags: &[String]) -> Result<String> {
let mut calver: Vec<&str> = tags
.iter()
.map(String::as_str)
.filter(|tag| !ARCH_TAG_SUFFIXES.iter().any(|suffix| tag.ends_with(suffix)))
.filter(|tag| parse_version_instant(tag).is_ok())
.collect();
calver.sort_unstable();
calver.dedup();
match calver.as_slice() {
[tag] => Ok((*tag).to_string()),
found => bail!(
"Expected exactly one CalVer tag but found {}: {tags:?}",
found.len()
),
}
}
fn parse_component_versions(entries: &[String]) -> Result<BTreeMap<String, String>> {
let mut pins = BTreeMap::new();
for raw in entries {
let entry = raw.trim();
let (image, version) = entry.split_once('=').with_context(|| {
format!("Invalid component version {entry:?}: expected <image>=<version>")
})?;
let image = image.trim();
let version = version.trim();
component(image)?;
parse_version_instant(version)
.with_context(|| format!("Component {image} is pinned to an unusable version"))?;
ensure!(
pins.insert(image.to_string(), version.to_string())
.is_none(),
"Component {image} is pinned more than once"
);
}
Ok(pins)
}
fn inspect_digest(reference: &str) -> Result<String> {
let output = output_text(
CommandSpec::new("docker")
.args(["buildx", "imagetools", "inspect", reference])
.args(["--format", "{{json .Manifest}}"]),
)?;
let manifest: RegistryManifest = serde_json::from_str(&output)
.with_context(|| format!("Failed to parse the registry manifest for {reference}"))?;
ensure!(
manifest.media_type == OCI_INDEX_MEDIA_TYPE
|| manifest.media_type == DOCKER_MANIFEST_LIST_MEDIA_TYPE,
"{reference} is {} and not a multi-architecture index",
manifest.media_type
);
let platforms: BTreeSet<(&str, &str)> = manifest
.manifests
.iter()
.filter_map(|entry| entry.platform.as_ref())
.map(|platform| (platform.os.as_str(), platform.architecture.as_str()))
.collect();
for (os, architecture) in REQUIRED_PLATFORMS {
ensure!(
platforms.contains(&(*os, *architecture)),
"{reference} does not publish the {os}/{architecture} platform"
);
}
validate_digest(&manifest.digest)
}
fn package_tags(registry: &str, image: &str, digest: &str) -> Result<Vec<String>> {
let owner = registry
.rsplit('/')
.next()
.filter(|owner| !owner.is_empty())
.with_context(|| format!("Cannot derive a package owner from registry {registry:?}"))?;
let output = output_text(
CommandSpec::new("gh")
.args(["api", "--paginate", "--slurp"])
.arg(format!(
"/orgs/{owner}/packages/container/{image}/versions?per_page=100"
)),
)?;
let pages: Vec<Vec<PackageVersion>> = serde_json::from_str(&output)
.with_context(|| format!("Failed to parse the package versions of {image}"))?;
pages
.into_iter()
.flatten()
.find(|version| version.name == digest)
.map(|version| {
version
.metadata
.and_then(|metadata| metadata.container)
.map(|container| container.tags)
.unwrap_or_default()
})
.with_context(|| format!("No {image} package version matches {digest}"))
}
fn component_source_sha(image: &str, version: &str) -> Option<String> {
resolve_commit_sha(&release_tag(image, version)).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cmp::Ordering;
const COMPOSE_IMAGE_TAG: &str = "${FLUXER_IMAGE_TAG:-v1}";
const RELEASE_VERSION: &str = "2026.901.120000";
const COMPONENT_TAG: &str = "2026.830.191141";
fn resolved_set() -> Vec<ResolvedComponent> {
COMPONENTS
.iter()
.enumerate()
.map(|(index, component)| ResolvedComponent {
component: component.image.to_string(),
tag: COMPONENT_TAG.to_string(),
digest: format!("sha256:{}", format!("{index:02x}").repeat(32)),
source_sha: Some("a".repeat(40)),
})
.collect()
}
fn manifest() -> ImageSetManifest {
build_manifest(RELEASE_VERSION, DEFAULT_REGISTRY, &resolved_set(), true).unwrap()
}
fn compose_component(reference: &str) -> Option<&str> {
reference
.strip_prefix(COMPOSE_IMAGE_PREFIX)?
.strip_prefix('/')?
.strip_suffix(COMPOSE_IMAGE_TAG)?
.strip_suffix(':')
}
#[test]
fn components_cover_every_self_hosting_service() {
let compose = include_str!("../../../deploy/self-hosting/docker-compose.yml");
let expected: BTreeSet<(&str, &str)> = COMPONENTS
.iter()
.flat_map(|component| {
component
.services
.iter()
.map(move |service| (component.image, *service))
})
.collect();
let mut observed: BTreeSet<(&str, &str)> = BTreeSet::new();
let mut in_services = false;
let mut service = None;
for line in compose.lines() {
if !line.is_empty() && !line.starts_with(' ') {
in_services = line == "services:";
service = None;
continue;
}
if !in_services {
continue;
}
if let Some(name) = line
.strip_prefix(" ")
.and_then(|rest| rest.strip_suffix(':'))
&& !name.starts_with(' ')
{
service = Some(name);
continue;
}
let trimmed = line.trim();
let Some(reference) = trimmed.strip_prefix("image: ") else {
continue;
};
if !reference.starts_with("${FLUXER_REGISTRY") {
continue;
}
let image = compose_component(reference)
.unwrap_or_else(|| panic!("Unexpected Fluxer image line: {trimmed}"));
let service = service.expect("a Fluxer image line must follow a service header");
observed.insert((image, service));
}
assert_eq!(observed, expected);
}
#[test]
fn manifest_requires_every_component() {
let mut resolved = resolved_set();
resolved.retain(|entry| entry.component != "fluxer-api");
let error = build_manifest(RELEASE_VERSION, DEFAULT_REGISTRY, &resolved, true)
.unwrap_err()
.to_string();
assert!(error.contains("missing components"), "{error}");
assert!(error.contains("fluxer-api"), "{error}");
}
#[test]
fn manifest_rejects_unknown_and_duplicate_components() {
let mut unknown = resolved_set();
unknown[0].component = "fluxer-unknown".to_string();
let error = build_manifest(RELEASE_VERSION, DEFAULT_REGISTRY, &unknown, true)
.unwrap_err()
.to_string();
assert!(error.contains("Unknown release component"), "{error}");
let mut duplicate = resolved_set();
duplicate.push(duplicate[0].clone());
let error = build_manifest(RELEASE_VERSION, DEFAULT_REGISTRY, &duplicate, true)
.unwrap_err()
.to_string();
assert!(error.contains("appears more than once"), "{error}");
}
#[test]
fn manifest_rejects_bad_digest() {
let mut resolved = resolved_set();
resolved[0].digest = "sha256:notahexdigest".to_string();
let error = format!(
"{:?}",
build_manifest(RELEASE_VERSION, DEFAULT_REGISTRY, &resolved, true).unwrap_err()
);
assert!(error.contains("fluxer-admin"), "{error}");
assert!(error.contains("Invalid image digest"), "{error}");
}
#[test]
fn manifest_rejects_component_tag_newer_than_release() {
let mut resolved = resolved_set();
resolved[0].tag = "2026.902.0".to_string();
let error = build_manifest(RELEASE_VERSION, DEFAULT_REGISTRY, &resolved, true)
.unwrap_err()
.to_string();
assert!(
error.contains("is newer than release version 2026.901.120000"),
"{error}"
);
}
#[test]
fn manifest_requires_source_sha_unless_allowed() {
let mut resolved = resolved_set();
resolved[0].source_sha = None;
let error = build_manifest(RELEASE_VERSION, DEFAULT_REGISTRY, &resolved, false)
.unwrap_err()
.to_string();
assert!(error.contains("[email protected]"), "{error}");
assert!(error.contains("--allow-unreleased"), "{error}");
let manifest = build_manifest(RELEASE_VERSION, DEFAULT_REGISTRY, &resolved, true).unwrap();
assert_eq!(manifest.components[0].source_sha, None);
assert_eq!(manifest.release_tag, "[email protected]");
assert_eq!(manifest.components.len(), COMPONENTS.len());
}
#[test]
fn compose_override_pins_every_service_by_digest() {
let manifest = manifest();
let rendered = compose_override(&manifest);
assert_eq!(
rendered
.lines()
.filter(|line| line.starts_with(" image: "))
.count(),
18
);
let api = manifest
.components
.iter()
.find(|entry| entry.component == "fluxer-api")
.unwrap();
for service in ["api", "worker"] {
assert!(
rendered.contains(&format!(
" {service}:\n image: {COMPOSE_IMAGE_PREFIX}/fluxer-api@${{FLUXER_API_IMAGE_DIGEST:-{}}}\n",
api.digest
)),
"{rendered}"
);
}
let app_proxy = manifest
.components
.iter()
.find(|entry| entry.component == "fluxer-app-proxy-self-hosted")
.unwrap();
assert!(
rendered.contains(&format!(
" app-proxy:\n image: {COMPOSE_IMAGE_PREFIX}/fluxer-app-proxy-self-hosted@${{FLUXER_APP_PROXY_SELF_HOSTED_IMAGE_DIGEST:-{}}}\n",
app_proxy.digest
)),
"{rendered}"
);
assert!(!rendered.contains("fluxer-docs"), "{rendered}");
assert!(
rendered.starts_with(
"# [email protected] image set\n# docker compose -f docker-compose.yml -f fluxer-release-2026.901.120000.yml up -d\nservices:\n"
),
"{rendered}"
);
}
#[test]
fn promote_command_targets_the_digest() {
let digest = format!("sha256:{}", "0".repeat(64));
let spec = promote_command(
"ghcr.io/fluxerapp/fluxer-api",
&digest,
&["v1".to_string(), "latest".to_string()],
);
assert_eq!(spec.program, "docker");
assert_eq!(
spec.args,
[
"buildx",
"imagetools",
"create",
"-t",
"ghcr.io/fluxerapp/fluxer-api:v1",
"-t",
"ghcr.io/fluxerapp/fluxer-api:latest",
&format!("ghcr.io/fluxerapp/fluxer-api@{digest}"),
]
);
}
#[test]
fn calver_tag_for_digest_picks_the_single_calver_tag() {
let tags = ["2026.630.20736", "latest", "v1"].map(str::to_string);
assert_eq!(calver_tag_for_digest(&tags).unwrap(), "2026.630.20736");
let arch = [
"2026.630.20736",
"2026.630.20736-amd64",
"2026.630.20736-arm64",
]
.map(str::to_string);
assert_eq!(calver_tag_for_digest(&arch).unwrap(), "2026.630.20736");
let none = ["v1", "latest"].map(str::to_string);
assert!(calver_tag_for_digest(&none).is_err());
let many = ["2026.630.20736", "2026.830.191141"].map(str::to_string);
assert!(calver_tag_for_digest(&many).is_err());
}
#[test]
fn verify_resolved_detects_digest_drift() {
let manifest = manifest();
let mut resolved = resolved_set();
verify_resolved(&manifest, &resolved).unwrap();
resolved[1].digest = format!("sha256:{}", "f".repeat(64));
let error = verify_resolved(&manifest, &resolved)
.unwrap_err()
.to_string();
assert!(error.contains("fluxer-api"), "{error}");
assert!(error.contains("drifted"), "{error}");
let mut retagged = resolved_set();
retagged[1].tag = "2026.830.191140".to_string();
let error = verify_resolved(&manifest, &retagged)
.unwrap_err()
.to_string();
assert!(error.contains("records tag"), "{error}");
}
fn linear_compare<'a>(
history: &'a [&'a str; 3],
) -> impl FnMut(&str, &str) -> Result<CommitCompare> + 'a {
move |base: &str, head: &str| {
let position = |commit: &str| {
history
.iter()
.position(|entry| *entry == commit)
.with_context(|| format!("{commit} is not in the test history"))
};
Ok(match position(base)?.cmp(&position(head)?) {
Ordering::Equal => CommitCompare::Identical,
Ordering::Less => CommitCompare::Ahead,
Ordering::Greater => CommitCompare::Behind,
})
}
}
#[test]
fn oldest_common_commit_picks_the_commit_every_image_contains() {
let history = ["a".repeat(40), "b".repeat(40), "c".repeat(40)];
let history = [
history[0].as_str(),
history[1].as_str(),
history[2].as_str(),
];
let candidates = [
("fluxer-api", history[2]),
("fluxer-gateway", history[0]),
("fluxer-users", history[1]),
];
assert_eq!(
oldest_common_commit(&candidates, linear_compare(&history)).unwrap(),
("fluxer-gateway", history[0])
);
let single = [("fluxer-api", history[2])];
assert_eq!(
oldest_common_commit(&single, linear_compare(&history)).unwrap(),
("fluxer-api", history[2])
);
}
#[test]
fn oldest_common_commit_refuses_divergent_component_builds() {
let candidates = [("fluxer-api", "aa"), ("fluxer-gateway", "bb")];
let error =
oldest_common_commit(&candidates, |_: &str, _: &str| Ok(CommitCompare::Diverged))
.unwrap_err()
.to_string();
assert!(error.contains("fluxer-api"), "{error}");
assert!(error.contains("fluxer-gateway"), "{error}");
assert!(error.contains("divergent branches"), "{error}");
}
#[test]
fn resolve_bundle_commit_refuses_a_set_with_no_source_commits() {
let mut resolved = resolved_set();
for entry in &mut resolved {
entry.source_sha = None;
}
let error = resolve_bundle_commit(&resolved).unwrap_err().to_string();
assert!(error.contains("no bundle commit can be proven"), "{error}");
}
#[test]
fn release_filenames_are_stable() {
assert_eq!(
manifest_filename(RELEASE_VERSION),
"fluxer-release-2026.901.120000.json"
);
assert_eq!(
compose_filename(RELEASE_VERSION),
"fluxer-release-2026.901.120000.yml"
);
}
}