diff --git a/src/commands/release/cleanup_drafts.rs b/src/commands/release/cleanup_drafts.rs new file mode 100644 index 000000000..751945e95 --- /dev/null +++ b/src/commands/release/cleanup_drafts.rs @@ -0,0 +1,180 @@ +//! One-off cleanup of the draft-release backlog the legacy tag trigger left +//! behind: every publish-*-* tag push used to open a draft GitHub Release +//! for something the bundle workflow never built (123 drafts of 775 releases +//! at the time of writing, almost all asset-free). +//! +//! Dry-run by default. Deletes ONLY drafts, ONLY asset-free ones, ONLY older +//! than the explicit cutoff, and only with --delete after the dry-run output +//! has been reviewed. Deletion is irreversible. + +use std::fmt::{Display, Formatter}; + +use anyhow::Context; +use chrono::{DateTime, SecondsFormat, Utc}; +use clap::Parser; +use serde::Serialize; + +use crate::PrettyPrintable; + +#[derive(Debug, Parser, Clone)] +#[command(about = "Delete asset-free draft releases left by the legacy tag trigger")] +pub struct Options { + /// Only drafts created strictly before this date (YYYY-MM-DD). + #[arg(long)] + pub before: String, + /// Actually delete; without this the command only lists. + #[arg(long, default_value_t = false)] + pub delete: bool, + /// owner/name; defaults to GITHUB_REPOSITORY. + #[arg(long, env = "GITHUB_REPOSITORY")] + pub repo: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct DraftRecord { + pub id: u64, + pub created_at: String, + pub tag: String, + pub name: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct CleanupDraftsResult { + pub candidates: Vec, + pub deleted: bool, +} + +impl Display for CleanupDraftsResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + for d in &self.candidates { + writeln!( + f, + "{} draft {} ({}) tag={} name={}", + if self.deleted { + "deleted" + } else { + "would delete" + }, + d.id, + d.created_at, + d.tag, + d.name + )?; + } + write!( + f, + "{} draft release(s){}", + self.candidates.len(), + if self.deleted { + " deleted" + } else { + "; re-run with --delete after review" + } + ) + } +} + +impl PrettyPrintable for CleanupDraftsResult { + fn pretty_print(&self) -> String { + self.to_string() + } +} + +/// Parse the `--before` date into the strict cutoff instant: midnight UTC at +/// the START of that day, so a draft created ON the date is kept. +fn cutoff_from_date(before: &str) -> anyhow::Result> { + let date = chrono::NaiveDate::parse_from_str(before, "%Y-%m-%d") + .with_context(|| format!("--before must be YYYY-MM-DD, got '{before}'"))?; + Ok(date + .and_hms_opt(0, 0, 0) + .expect("midnight exists on every date") + .and_utc()) +} + +pub async fn run(options: &Options) -> anyhow::Result { + let cutoff = cutoff_from_date(&options.before)?; + let token = std::env::var("GH_TOKEN") + .or_else(|_| std::env::var("GITHUB_TOKEN")) + .context("GH_TOKEN/GITHUB_TOKEN is not set; the cleanup needs a token")?; + let (owner, name) = options + .repo + .split_once('/') + .with_context(|| format!("--repo must be owner/name, got '{}'", options.repo))?; + let octocrab = octocrab::OctocrabBuilder::new() + .personal_token(token) + .build()?; + + let first_page = octocrab + .repos(owner, name) + .releases() + .list() + .per_page(100) + .send() + .await + .with_context(|| format!("cannot list releases of {}", options.repo))?; + let releases = octocrab + .all_pages::(first_page) + .await + .with_context(|| format!("cannot paginate releases of {}", options.repo))?; + + let candidates: Vec = releases + .into_iter() + .filter(|r| r.draft && r.assets.is_empty()) + .filter(|r| r.created_at.is_some_and(|created| created < cutoff)) + .map(|r| DraftRecord { + id: *r.id, + created_at: r + .created_at + .expect("filtered on presence") + .to_rfc3339_opts(SecondsFormat::Secs, true), + tag: if r.tag_name.is_empty() { + "".to_string() + } else { + r.tag_name + }, + name: match r.name { + Some(name) if !name.is_empty() => name, + _ => "".to_string(), + }, + }) + .collect(); + + if options.delete { + for candidate in &candidates { + octocrab + .repos(owner, name) + .releases() + .delete(candidate.id) + .await + .with_context(|| format!("failed to delete draft release {}", candidate.id))?; + } + } + + Ok(CleanupDraftsResult { + candidates, + deleted: options.delete, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cutoff_is_midnight_utc_and_strictly_before() { + let cutoff = cutoff_from_date("2026-08-01").unwrap(); + let last_instant_before = "2026-07-31T23:59:59Z".parse::>().unwrap(); + let on_the_date = "2026-08-01T00:00:00Z".parse::>().unwrap(); + assert!(last_instant_before < cutoff); + // Created ON the cutoff date: kept. + assert!(on_the_date >= cutoff); + } + + #[test] + fn malformed_dates_are_rejected() { + assert!(cutoff_from_date("01-08-2026").is_err()); + assert!(cutoff_from_date("2026-13-01").is_err()); + assert!(cutoff_from_date("yesterday").is_err()); + assert!(cutoff_from_date("").is_err()); + } +} diff --git a/src/commands/release/healthcheck.rs b/src/commands/release/healthcheck.rs new file mode 100644 index 000000000..181330400 --- /dev/null +++ b/src/commands/release/healthcheck.rs @@ -0,0 +1,240 @@ +//! Standing verification that the published release surface still resolves, +//! credential-less, exactly as a client would: fetch channels.json, +//! deserialize it against the contract types (stronger than schema +//! validation - these ARE the schema), follow every channel/target pointer +//! to its manifest, confirm the index lists the version, HEAD every artifact +//! and detached signature, and download + digest-verify the artifacts +//! pointers actually expose (pointed versions only; full-history sweeps +//! would move hundreds of megabytes per run). +//! +//! A missing channels.json is "nothing promoted yet", which is healthy. An +//! index gap is reported as repairable. Problems make the command exit +//! nonzero with the report; the workflow maintains the single tracking +//! issue. + +use std::collections::BTreeSet; +use std::fmt::{Display, Formatter}; + +use anyhow::anyhow; +use clap::Parser; +use serde::Serialize; + +use super::store::sha256_hex; +use super::types::{ArtifactSignature, Channels, Index, Manifest, SCHEMA_VERSION}; +use crate::PrettyPrintable; + +#[derive(Debug, Parser, Clone)] +#[command(about = "Verify the published release surface end to end")] +pub struct Options { + /// Applications to check. + #[arg(long, value_delimiter = ',', default_value = "spatial_engine")] + pub apps: Vec, + #[arg( + long, + env = "RELEASE_PUBLIC_BASE_URL", + default_value = "https://api.s3.fsl.dev" + )] + pub base_url: String, + #[arg(long, default_value = "fsl-releases-channels")] + pub channels_bucket: String, + #[arg(long, default_value = "fsl-releases")] + pub prod_bucket: String, + /// HEAD artifacts but skip downloading them (fast mode). + #[arg(long, default_value_t = false)] + pub skip_digest_verification: bool, +} + +#[derive(Debug, Serialize, Clone)] +pub struct HealthcheckResult { + /// "app version: N artifact(s)" lines for everything checked. + pub checked: Vec, + pub problems: Vec, +} + +impl Display for HealthcheckResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + for c in &self.checked { + writeln!(f, "checked {c}")?; + } + for p in &self.problems { + writeln!(f, "PROBLEM: {p}")?; + } + write!( + f, + "{}", + if self.problems.is_empty() { + "release surface healthy".to_string() + } else { + format!("{} problem(s) found", self.problems.len()) + } + ) + } +} + +impl PrettyPrintable for HealthcheckResult { + fn pretty_print(&self) -> String { + self.to_string() + } +} + +use super::http::{Client, client as https_client, head_present}; + +/// GET collapsing every failure to `None`: a non-2xx status, an unreachable +/// host, or an unreadable body are all the same answer a client would +/// experience. +async fn get_bytes(client: &Client, url: &str) -> Option> { + super::http::get_bytes(client, url).await.ok() +} + +async fn head_ok(client: &Client, url: &str) -> bool { + head_present(client, url).await +} + +pub async fn run(options: &Options) -> anyhow::Result { + let client = https_client()?; + let base = options.base_url.trim_end_matches('/'); + let mut checked = Vec::new(); + let mut problems = Vec::new(); + + for app in &options.apps { + let channels_url = format!("{base}/{}/{app}/channels.json", options.channels_bucket); + let Some(channels_bytes) = get_bytes(&client, &channels_url).await else { + // Healthy: nothing has been promoted for this app yet. + checked.push(format!("{app}: no channels.json yet; nothing promoted")); + continue; + }; + let channels: Channels = match serde_json::from_slice(&channels_bytes) { + Ok(channels) => channels, + Err(e) => { + problems.push(format!( + "{app} channels.json does not deserialize against the contract: {e}" + )); + continue; + } + }; + if channels.schema_version != SCHEMA_VERSION { + problems.push(format!( + "{app} channels.json has schema_version {}, expected {SCHEMA_VERSION}", + channels.schema_version + )); + } + + let index_url = format!("{base}/{}/{app}/index.json", options.prod_bucket); + let index: Option = match get_bytes(&client, &index_url).await { + None => { + problems.push(format!( + "{app}: channels exist but index.json is missing at {index_url}" + )); + None + } + Some(bytes) => match serde_json::from_slice::(&bytes) { + Ok(index) => { + if index.schema_version != SCHEMA_VERSION { + problems.push(format!( + "{app} index.json has schema_version {}, expected {SCHEMA_VERSION}", + index.schema_version + )); + } + Some(index) + } + Err(e) => { + problems.push(format!( + "{app} index.json does not deserialize against the contract: {e}" + )); + None + } + }, + }; + + // Every distinct pointed version, once. + let versions: BTreeSet<&String> = channels + .channels + .latest + .values() + .chain(channels.channels.stable.values()) + .collect(); + if versions.is_empty() { + checked.push(format!("{app}: channels.json exists but holds no pointers")); + } + + for version in versions { + let manifest_url = format!( + "{base}/{}/{app}/{version}/manifest.json", + options.prod_bucket + ); + let Some(manifest_bytes) = get_bytes(&client, &manifest_url).await else { + problems.push(format!( + "{app}: a channel points at {version} but {manifest_url} is missing" + )); + continue; + }; + let manifest: Manifest = match serde_json::from_slice(&manifest_bytes) { + Ok(manifest) => manifest, + Err(e) => { + problems.push(format!( + "{app} {version} manifest does not deserialize against the contract: {e}" + )); + continue; + } + }; + if manifest.schema_version != SCHEMA_VERSION { + problems.push(format!( + "{app} {version} manifest has schema_version {}, expected {SCHEMA_VERSION}", + manifest.schema_version + )); + } + + if let Some(index) = &index + && !index.versions.iter().any(|e| e.version == *version) + { + problems.push(format!( + "{app}: pointed version {version} is absent from index.json (repairable: re-add the entry)" + )); + } + + for artifact in &manifest.artifacts { + if !head_ok(&client, &artifact.url).await { + problems.push(format!( + "{app} {version}: artifact missing: {}", + artifact.url + )); + continue; + } + if !options.skip_digest_verification { + // Pointed versions are what clients download; verify the bytes. + let Some(bytes) = get_bytes(&client, &artifact.url).await else { + problems.push(format!("{app} {version}: cannot download {}", artifact.url)); + continue; + }; + let got = sha256_hex(&bytes); + if got != artifact.sha256 { + problems.push(format!( + "{app} {version}: digest mismatch for {}: manifest {}, object {got}", + artifact.filename, artifact.sha256 + )); + } + } + if let ArtifactSignature::OpenpgpDetached { url: sig_url, .. } = &artifact.signature + && !head_ok(&client, sig_url).await + { + problems.push(format!( + "{app} {version}: detached signature missing: {sig_url}" + )); + } + } + checked.push(format!( + "{app} {version}: {} artifact(s)", + manifest.artifacts.len() + )); + } + } + + let result = HealthcheckResult { checked, problems }; + if result.problems.is_empty() { + Ok(result) + } else { + // Nonzero exit with the FULL report, so the workflow's tracking + // issue carries every finding, never just the first. + Err(anyhow!("{result}")) + } +} diff --git a/src/commands/release/http.rs b/src/commands/release/http.rs index bfa4fb80a..cce2bd085 100644 --- a/src/commands/release/http.rs +++ b/src/commands/release/http.rs @@ -5,14 +5,18 @@ //! calls). One module means one TLS, redirect, and error behaviour to //! review. +use std::io::Write; +use std::path::Path; + use anyhow::{Context, bail}; use http_body_util::{BodyExt, Full}; use hyper::body::Bytes; -use hyper::{Method, Request}; +use hyper::{Method, Request, StatusCode}; use hyper_rustls::{ConfigBuilderExt, HttpsConnector}; use hyper_util::client::legacy::Client as HyperClient; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::rt::TokioExecutor; +use sha2::{Digest, Sha256}; pub(crate) type Client = HyperClient, Full>; @@ -32,6 +36,35 @@ pub(crate) fn client() -> anyhow::Result { Ok(HyperClient::builder(TokioExecutor::new()).build(https)) } +/// One request, no redirect handling: record's API calls, where a redirect +/// would be a misconfiguration, and callers that inspect the status +/// themselves. +pub(crate) async fn request( + client: &Client, + method: Method, + url: &str, + headers: &[(&str, String)], + body: Vec, +) -> anyhow::Result<(StatusCode, Bytes)> { + let mut builder = Request::builder().method(method).uri(url); + for (name, value) in headers { + builder = builder.header(*name, value); + } + let req = builder.body(Full::new(Bytes::from(body)))?; + let res = client + .request(req) + .await + .with_context(|| format!("request to {url} failed"))?; + let status = res.status(); + let bytes = res + .into_body() + .collect() + .await + .with_context(|| format!("could not read the response body from {url}"))? + .to_bytes(); + Ok((status, bytes)) +} + /// GET following up to [`MAX_REDIRECTS`] redirects (GitHub release downloads /// 302 to object storage), succeeding only on a 2xx. async fn get_response( @@ -71,3 +104,38 @@ pub(crate) async fn get_bytes(client: &Client, url: &str) -> anyhow::Result anyhow::Result { + let response = get_response(client, url).await?; + let mut body = response.into_body(); + let mut file = + std::fs::File::create(path).with_context(|| format!("cannot create {}", path.display()))?; + let mut hasher = Sha256::new(); + while let Some(frame) = body.frame().await { + let frame = frame.with_context(|| format!("GET {url}: body stream failed"))?; + if let Ok(data) = frame.into_data() { + file.write_all(&data)?; + hasher.update(&data); + } + } + file.flush()?; + Ok(format!("{:x}", hasher.finalize())) +} + +/// HEAD, where a redirect counts as present: the semantics of +/// `curl -fsSI` without `-L` (only >= 400 fails). +pub(crate) async fn head_present(client: &Client, url: &str) -> bool { + let Ok(req) = Request::builder() + .method(Method::HEAD) + .uri(url) + .body(Full::new(Bytes::new())) + else { + return false; + }; + match client.request(req).await { + Ok(res) => res.status().is_success() || res.status().is_redirection(), + Err(_) => false, + } +} diff --git a/src/commands/release/keyring.rs b/src/commands/release/keyring.rs index f82933339..d6fb158f2 100644 --- a/src/commands/release/keyring.rs +++ b/src/commands/release/keyring.rs @@ -51,6 +51,27 @@ impl TempKeyring { command } + /// Every key fingerprint in the keyring (`fpr` records of + /// `gpg --list-keys --with-colons`). + pub(crate) fn fingerprints(&self) -> anyhow::Result> { + let output = self + .gpg() + .args(["--list-keys", "--with-colons"]) + .output() + .context("cannot run gpg")?; + if !output.status.success() { + bail!( + "gpg --list-keys failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| line.starts_with("fpr:")) + .filter_map(|line| line.split(':').nth(9).map(str::to_string)) + .collect()) + } + pub(crate) fn verify_detached(&self, signature: &Path, file: &Path) -> anyhow::Result<()> { let output = self .gpg() diff --git a/src/commands/release/mod.rs b/src/commands/release/mod.rs index ff7ea4a3d..84ecc7707 100644 --- a/src/commands/release/mod.rs +++ b/src/commands/release/mod.rs @@ -22,11 +22,15 @@ use crate::PrettyPrintable; pub mod bundle_linux; pub mod classify; +pub mod cleanup_drafts; +pub mod healthcheck; pub(crate) mod http; pub(crate) mod keyring; pub mod probe_store; pub mod promote; pub mod publish; +pub mod record; +pub mod resolve; pub mod sign_linux; pub mod store; pub mod types; @@ -51,6 +55,14 @@ enum ReleaseCommands { Publish(Box), /// Move a channel pointer in channels.json (the only writer) Promote(Box), + /// Resolve a download exactly as an installed client would + Resolve(Box), + /// Record a published release in fsl_sling by reference (best effort) + Record(Box), + /// Verify the published release surface end to end + Healthcheck(Box), + /// Delete asset-free draft releases left by the legacy tag trigger + CleanupDrafts(Box), /// Build the Linux .deb and AppImage in the pinned floor container BundleLinux(Box), /// Detach-sign Linux artifacts with the org OpenPGP key @@ -65,6 +77,10 @@ pub enum ReleaseResult { ProbeStore(probe_store::ProbeStoreResult), Publish(publish::PublishResult), Promote(promote::PromoteResult), + Resolve(resolve::ResolveResult), + Record(record::RecordResult), + Healthcheck(healthcheck::HealthcheckResult), + CleanupDrafts(cleanup_drafts::CleanupDraftsResult), BundleLinux(bundle_linux::BundleLinuxResult), SignLinux(sign_linux::SignLinuxResult), } @@ -77,6 +93,10 @@ impl Display for ReleaseResult { ReleaseResult::ProbeStore(r) => r.fmt(f), ReleaseResult::Publish(r) => r.fmt(f), ReleaseResult::Promote(r) => r.fmt(f), + ReleaseResult::Resolve(r) => r.fmt(f), + ReleaseResult::Record(r) => r.fmt(f), + ReleaseResult::Healthcheck(r) => r.fmt(f), + ReleaseResult::CleanupDrafts(r) => r.fmt(f), ReleaseResult::BundleLinux(r) => r.fmt(f), ReleaseResult::SignLinux(r) => r.fmt(f), } @@ -91,6 +111,10 @@ impl PrettyPrintable for ReleaseResult { ReleaseResult::ProbeStore(r) => r.pretty_print(), ReleaseResult::Publish(r) => r.pretty_print(), ReleaseResult::Promote(r) => r.pretty_print(), + ReleaseResult::Resolve(r) => r.pretty_print(), + ReleaseResult::Record(r) => r.pretty_print(), + ReleaseResult::Healthcheck(r) => r.pretty_print(), + ReleaseResult::CleanupDrafts(r) => r.pretty_print(), ReleaseResult::BundleLinux(r) => r.pretty_print(), ReleaseResult::SignLinux(r) => r.pretty_print(), } @@ -112,6 +136,14 @@ pub async fn release( ReleaseCommands::ProbeStore(o) => probe_store::run(&o).await.map(ReleaseResult::ProbeStore), ReleaseCommands::Publish(o) => publish::run(&o).await.map(ReleaseResult::Publish), ReleaseCommands::Promote(o) => promote::run(&o).await.map(ReleaseResult::Promote), + ReleaseCommands::Resolve(o) => resolve::run(&o).await.map(ReleaseResult::Resolve), + ReleaseCommands::Record(o) => record::run(&o).await.map(ReleaseResult::Record), + ReleaseCommands::Healthcheck(o) => { + healthcheck::run(&o).await.map(ReleaseResult::Healthcheck) + } + ReleaseCommands::CleanupDrafts(o) => cleanup_drafts::run(&o) + .await + .map(ReleaseResult::CleanupDrafts), ReleaseCommands::BundleLinux(o) => bundle_linux::run(&o, working_directory) .await .map(ReleaseResult::BundleLinux), diff --git a/src/commands/release/record.rs b/src/commands/release/record.rs new file mode 100644 index 000000000..c60dd18b0 --- /dev/null +++ b/src/commands/release/record.rs @@ -0,0 +1,324 @@ +//! Best-effort recorder: registers a published release and its artifacts in +//! fsl_sling BY REFERENCE, for the human-facing portal. The bucket is +//! authoritative for bytes, digests and channel pointers; fsl_sling is never +//! in a client's download path, and this command never touches fsl_sling's +//! channel routes (its alpha/beta/prod set-membership model cannot express a +//! per-target pointer and must not appear to). +//! +//! The workflow runs this under continue-on-error: a recorder outage must +//! not fail a publication that already succeeded. +//! +//! Flow: mint a client-credentials token, find or create the release record +//! by version, POST each manifest artifact to +//! `/releases/{id}/assets/by-reference` (filename, bucket key as file_path, +//! size, sha256), then PUT `{"is_draft": false}` so the portal lists it. +//! Every route nests under `/api`, which the pre-fslabscli implementation +//! missed; the path is normalised here. + +use std::fmt::{Display, Formatter}; +use std::path::PathBuf; + +use anyhow::{Context, bail}; +use base64::Engine as _; +use clap::Parser; +use hyper::Method; +use serde::Serialize; + +use super::types::Manifest; +use crate::PrettyPrintable; + +#[derive(Debug, Parser, Clone)] +#[command(about = "Record a published release in fsl_sling by reference (best effort)")] +pub struct Options { + /// Path to the published manifest.json. + #[arg(long)] + pub manifest: PathBuf, + /// OAuth2 issuer, e.g. https://auth.fslabs.ca + #[arg(long, env = "FSL_RELEASES_ISSUER")] + pub issuer: String, + #[arg(long, env = "FSL_RELEASES_CLIENT_ID")] + pub client_id: String, + #[arg(long, env = "FSL_RELEASES_CLIENT_SECRET", hide_env_values = true)] + pub client_secret: String, + /// Service origin; /api is appended when absent. + #[arg(long, env = "FSL_RELEASES_API_URL")] + pub api_url: String, + /// The fsl_sling app UUID this release belongs to. + #[arg(long, env = "FSL_RELEASES_APP_ID")] + pub app_id: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct RecordResult { + pub release_id: String, + pub recorded_assets: Vec, + pub draft_cleared: bool, +} + +impl Display for RecordResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "recorded release {} with {} asset(s){}", + self.release_id, + self.recorded_assets.len(), + if self.draft_cleared { + "" + } else { + " (draft flag NOT cleared)" + } + ) + } +} + +impl PrettyPrintable for RecordResult { + fn pretty_print(&self) -> String { + self.to_string() + } +} + +/// Normalise the service origin into the API base: strip trailing slashes +/// and append `/api` unless it is already there. fsl_sling nests every route +/// under `/api`, which the pre-fslabscli implementation missed. +fn normalise_api_url(origin: &str) -> String { + let trimmed = origin.trim_end_matches('/'); + if trimmed.ends_with("/api") { + trimmed.to_string() + } else { + format!("{trimmed}/api") + } +} + +/// The bucket key fsl_sling records as `file_path`: the artifact URL with +/// the scheme and host stripped (the path after the host, no leading +/// slash). A string that is not an http(s) URL passes through unchanged. +fn file_path_from_url(url: &str) -> String { + for scheme in ["https://", "http://"] { + if let Some(rest) = url.strip_prefix(scheme) { + return match rest.split_once('/') { + Some((_host, path)) => path.to_string(), + None => url.to_string(), + }; + } + } + url.to_string() +} + +use super::http::{client as https_client, request}; + +/// jq's `.id` for either a UUID string or a numeric id. +fn extract_id(body: &[u8]) -> Option { + match serde_json::from_slice::(body) + .ok()? + .get("id")? + { + serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +pub async fn run(options: &Options) -> anyhow::Result { + let manifest: Manifest = serde_json::from_slice( + &std::fs::read(&options.manifest) + .with_context(|| format!("no manifest at '{}'", options.manifest.display()))?, + ) + .with_context(|| format!("unparseable manifest {}", options.manifest.display()))?; + let version = &manifest.version; + + let api = normalise_api_url(&options.api_url); + let client = https_client()?; + + // Mint a client-credentials token. + let basic = base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", options.client_id, options.client_secret)); + let (status, body) = request( + &client, + Method::POST, + &format!("{}/oauth2/token", options.issuer.trim_end_matches('/')), + &[ + ("Authorization", format!("Basic {basic}")), + ( + "Content-Type", + "application/x-www-form-urlencoded".to_string(), + ), + ], + b"grant_type=client_credentials".to_vec(), + ) + .await?; + if !status.is_success() { + bail!("could not mint a token (HTTP {status})"); + } + let token = serde_json::from_slice::(&body) + .ok() + .and_then(|v| { + v.get("access_token") + .and_then(|t| t.as_str().map(str::to_string)) + }) + .filter(|t| !t.is_empty()) + .context("could not mint a token (no access_token in the response)")?; + let auth = ("Authorization", format!("Bearer {token}")); + + // Find the release record by version, or create it. + let existing = request( + &client, + Method::GET, + &format!( + "{api}/apps/{}/releases/by-version/{version}", + options.app_id + ), + std::slice::from_ref(&auth), + Vec::new(), + ) + .await + .ok() + .filter(|(status, _)| status.is_success()) + .and_then(|(_, body)| extract_id(&body)); + + let release_id = match existing { + Some(id) => { + tracing::info!("release record {id} already exists for {version}"); + id + } + None => { + let (status, body) = request( + &client, + Method::POST, + &format!("{api}/apps/{}/releases", options.app_id), + &[ + auth.clone(), + ("Content-Type", "application/json".to_string()), + ], + serde_json::to_vec(&serde_json::json!({ + "version": version, + "release_notes": null, + "documentation_url": null, + }))?, + ) + .await?; + if !status.is_success() { + bail!("could not create the release record for {version} (HTTP {status})"); + } + let id = extract_id(&body).context("no release id in the creation response")?; + tracing::info!("created release record {id} for {version}"); + id + } + }; + + // Record every artifact by reference; a single failure is logged and + // skipped, because a partially-recorded portal entry beats none. + let mut recorded_assets = Vec::new(); + for artifact in &manifest.artifacts { + let body = serde_json::to_vec(&serde_json::json!({ + "filename": artifact.filename, + "file_path": file_path_from_url(&artifact.url), + "file_size": artifact.size_bytes, + "checksum": artifact.sha256, + "content_type": "application/octet-stream", + }))?; + let outcome = request( + &client, + Method::POST, + &format!("{api}/releases/{release_id}/assets/by-reference"), + &[ + auth.clone(), + ("Content-Type", "application/json".to_string()), + ], + body, + ) + .await; + match outcome { + Ok((status, _)) if status.is_success() => { + recorded_assets.push(artifact.filename.clone()); + } + Ok((status, _)) => tracing::warn!( + "failed to record artifact {} (HTTP {status}), continuing", + artifact.filename + ), + Err(e) => tracing::warn!( + "failed to record artifact {} ({e:#}), continuing", + artifact.filename + ), + } + } + + // Visible in the portal; CreateReleaseRequest defaults is_draft = true + // and a draft cannot be listed as released. + let (status, _) = request( + &client, + Method::PUT, + &format!("{api}/releases/{release_id}"), + &[auth, ("Content-Type", "application/json".to_string())], + serde_json::to_vec(&serde_json::json!({"is_draft": false}))?, + ) + .await?; + if !status.is_success() { + bail!("could not clear the draft flag on release {release_id} (HTTP {status})"); + } + + Ok(RecordResult { + release_id, + recorded_assets, + draft_cleared: true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_url_gains_the_api_prefix_the_old_workflow_missed() { + assert_eq!( + normalise_api_url("https://releases.fslabs.ca"), + "https://releases.fslabs.ca/api" + ); + assert_eq!( + normalise_api_url("https://releases.fslabs.ca/"), + "https://releases.fslabs.ca/api" + ); + } + + #[test] + fn api_url_already_ending_in_api_is_untouched() { + assert_eq!( + normalise_api_url("https://releases.fslabs.ca/api"), + "https://releases.fslabs.ca/api" + ); + assert_eq!( + normalise_api_url("https://releases.fslabs.ca/api/"), + "https://releases.fslabs.ca/api" + ); + } + + #[test] + fn file_path_strips_scheme_and_host_only() { + assert_eq!( + file_path_from_url("https://api.s3.fsl.dev/fsl-releases/spatial_engine/1.0.0/a.deb"), + "fsl-releases/spatial_engine/1.0.0/a.deb" + ); + assert_eq!(file_path_from_url("http://host/x/y"), "x/y"); + } + + #[test] + fn file_path_passes_non_urls_through_unchanged() { + assert_eq!( + file_path_from_url("fsl-releases/a.deb"), + "fsl-releases/a.deb" + ); + // No path after the host: nothing to strip. + assert_eq!(file_path_from_url("https://host"), "https://host"); + } + + #[test] + fn id_extraction_accepts_string_and_number() { + assert_eq!( + extract_id(br#"{"id": "abc-123"}"#), + Some("abc-123".to_string()) + ); + assert_eq!(extract_id(br#"{"id": 42}"#), Some("42".to_string())); + assert_eq!(extract_id(br#"{"id": null}"#), None); + assert_eq!(extract_id(br#"{}"#), None); + assert_eq!(extract_id(b"not json"), None); + } +} diff --git a/src/commands/release/resolve.rs b/src/commands/release/resolve.rs new file mode 100644 index 000000000..94c4f6dee --- /dev/null +++ b/src/commands/release/resolve.rs @@ -0,0 +1,416 @@ +//! Resolve a download exactly as an installed client must: this command is +//! the client resolution contract in executable form +//! (`software_guide/content/docs/releases/client_resolution.md` is the same +//! contract in prose). +//! +//! Two credential-less reads: channels.json for the pointer at +//! `.channels..` (an ABSENT key means "no pointer" and the +//! client must not fall back to another target, channel, or the index), then +//! that version's manifest for the URL, sha256 and (Linux) the detached +//! signature record. Both documents carry `schema_version`; an unknown major +//! is refused, never guessed at. With `--download`, the artifact is fetched, +//! digest-verified, and on Linux signature-verified against the published +//! key, whose fingerprint must equal the manifest's. + +use std::fmt::{Display, Formatter}; +use std::path::PathBuf; + +use anyhow::{Context, bail}; +use clap::Parser; +use serde::Serialize; + +use super::types::{ + ArtifactFormat, ArtifactSignature, Channel, Channels, Manifest, ManifestArtifact, + SCHEMA_VERSION, +}; +use crate::PrettyPrintable; + +#[derive(Debug, Parser, Clone)] +#[command(about = "Resolve a download exactly as an installed client would")] +pub struct Options { + #[arg(long)] + pub app: String, + #[arg(long, value_enum)] + pub channel: Channel, + /// Target triple, e.g. x86_64-unknown-linux-gnu. + #[arg(long)] + pub target: String, + /// Required when the target ships more than one format (Linux: deb or + /// appimage). + #[arg(long, value_enum)] + pub format: Option, + /// Download, digest-verify, and (Linux) signature-verify. + #[arg(long, default_value_t = false)] + pub download: bool, + /// Directory downloads land in. + #[arg(long, default_value = ".")] + pub output_dir: PathBuf, + #[arg( + long, + env = "RELEASE_PUBLIC_BASE_URL", + default_value = "https://api.s3.fsl.dev" + )] + pub base_url: String, + #[arg(long, default_value = "fsl-releases-channels")] + pub channels_bucket: String, + #[arg(long, default_value = "fsl-releases")] + pub prod_bucket: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ResolveResult { + pub version: String, + pub url: String, + pub sha256: String, + pub signature_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub downloaded: Option, + pub digest_verified: bool, + pub signature_verified: bool, +} + +impl Display for ResolveResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!(f, "version={}", self.version)?; + writeln!(f, "url={}", self.url)?; + writeln!(f, "sha256={}", self.sha256)?; + write!(f, "signature={}", self.signature_kind)?; + if let Some(path) = &self.downloaded { + write!( + f, + "\ndownloaded {} (digest {}, signature {})", + path.display(), + if self.digest_verified { + "verified" + } else { + "NOT VERIFIED" + }, + if self.signature_verified { + "verified" + } else { + "n/a" + }, + )?; + } + Ok(()) + } +} + +impl PrettyPrintable for ResolveResult { + fn pretty_print(&self) -> String { + self.to_string() + } +} + +/// Thin shims over the shared client so call sites stay one line. +pub(crate) async fn http_get(url: &str) -> anyhow::Result> { + super::http::get_bytes(&super::http::client()?, url).await +} + +async fn http_get_to_file(url: &str, path: &std::path::Path) -> anyhow::Result { + super::http::get_to_file(&super::http::client()?, url, path).await +} + +/// Gate on `schema_version` BEFORE deserializing the full shape: an unknown +/// major may not even parse into our types, and the contract is to refuse, +/// never to guess. +fn gate_schema(bytes: &[u8], what: &str) -> anyhow::Result { + let value: serde_json::Value = serde_json::from_slice(bytes) + .with_context(|| format!("{what} document is not valid JSON"))?; + if value + .get("schema_version") + .and_then(serde_json::Value::as_u64) + != Some(SCHEMA_VERSION) + { + bail!( + "unknown {what} schema_version {}; refusing to guess", + value + .get("schema_version") + .map(|v| v.to_string()) + .unwrap_or_else(|| "null".into()) + ); + } + Ok(value) +} + +/// Select the single artifact for a target (and format, where a target ships +/// more than one, e.g. deb vs appimage on Linux). +fn select_artifact<'a>( + manifest: &'a Manifest, + target: &str, + format: Option, +) -> anyhow::Result<&'a ManifestArtifact> { + let matches: Vec<&ManifestArtifact> = manifest + .artifacts + .iter() + .filter(|a| a.target == target) + .filter(|a| format.is_none_or(|f| a.format == f)) + .collect(); + match matches.len() { + 0 => bail!( + "manifest for {} {} has no artifact for {target}{}", + manifest.app, + manifest.version, + format + .map(|f| format!(" ({})", format_name(f))) + .unwrap_or_default() + ), + 1 => Ok(matches[0]), + n => bail!( + "ambiguous: {n} artifacts for {target}; pass a format ({})", + matches + .iter() + .map(|a| format_name(a.format)) + .collect::>() + .join(", ") + ), + } +} + +fn format_name(format: ArtifactFormat) -> &'static str { + match format { + ArtifactFormat::Msi => "msi", + ArtifactFormat::Dmg => "dmg", + ArtifactFormat::Deb => "deb", + ArtifactFormat::Appimage => "appimage", + } +} + +/// The serde `kind` string of a signature record, verbatim from the contract. +fn signature_kind(signature: &ArtifactSignature) -> &'static str { + match signature { + ArtifactSignature::Authenticode => "authenticode", + ArtifactSignature::AppleNotarized => "apple-notarized", + ArtifactSignature::OpenpgpDetached { .. } => "openpgp-detached", + } +} + +use super::keyring::TempKeyring; + +pub async fn run(options: &Options) -> anyhow::Result { + // Step 1: the channel pointer. + let channels_url = format!( + "{}/{}/{}/channels.json", + options.base_url, options.channels_bucket, options.app + ); + let bytes = http_get(&channels_url) + .await + .with_context(|| format!("cannot fetch {channels_url}"))?; + let mut value = gate_schema(&bytes, "channels")?; + // The reference resolver defaults an absent manifest_base to the + // production bucket; mirror that before deserializing the required field. + if value.get("manifest_base").is_none() { + value["manifest_base"] = + serde_json::Value::String(format!("{}/{}", options.base_url, options.prod_bucket)); + } + let channels: Channels = serde_json::from_value(value) + .with_context(|| format!("{channels_url} does not deserialize as a channels document"))?; + let Some(version) = channels + .channels + .get(options.channel) + .get(&options.target) + .cloned() + else { + bail!( + "no pointer for target {} on channel {}; a client must not fall back to another target or channel", + options.target, + options.channel + ); + }; + + // Step 2: the manifest, from the base the channels document names. + let manifest_url = format!( + "{}/{}/{}/manifest.json", + channels.manifest_base, options.app, version + ); + let bytes = http_get(&manifest_url) + .await + .with_context(|| format!("cannot fetch {manifest_url}"))?; + let value = gate_schema(&bytes, "manifest")?; + let manifest: Manifest = serde_json::from_value(value) + .with_context(|| format!("{manifest_url} does not deserialize as a manifest"))?; + + let artifact = select_artifact(&manifest, &options.target, options.format)?; + let mut result = ResolveResult { + version: version.clone(), + url: artifact.url.clone(), + sha256: artifact.sha256.clone(), + signature_kind: signature_kind(&artifact.signature).to_string(), + downloaded: None, + digest_verified: false, + signature_verified: false, + }; + if !options.download { + return Ok(result); + } + + std::fs::create_dir_all(&options.output_dir) + .with_context(|| format!("cannot create {}", options.output_dir.display()))?; + let filename = artifact + .url + .rsplit('/') + .next() + .expect("rsplit yields at least one segment"); + let path = options.output_dir.join(filename); + let got = http_get_to_file(&artifact.url, &path) + .await + .with_context(|| format!("download failed: {}", artifact.url))?; + if got != artifact.sha256 { + bail!( + "digest mismatch: manifest {}, downloaded {got}; do not run this file", + artifact.sha256 + ); + } + result.digest_verified = true; + + if let ArtifactSignature::OpenpgpDetached { + url: sig_url, + key_fingerprint, + } = &artifact.signature + { + let sig_path = options.output_dir.join(format!("{filename}.asc")); + let sig_bytes = http_get(sig_url) + .await + .with_context(|| format!("cannot fetch detached signature {sig_url}"))?; + std::fs::write(&sig_path, sig_bytes) + .with_context(|| format!("cannot write {}", sig_path.display()))?; + let key_url = format!( + "{}/{}/keys/fsl-release-linux.asc", + options.base_url, options.prod_bucket + ); + let key = http_get(&key_url) + .await + .with_context(|| format!("cannot import the published signing key from {key_url}"))?; + let keyring = TempKeyring::import(&key) + .with_context(|| format!("cannot import the published signing key from {key_url}"))?; + if !keyring.fingerprints()?.iter().any(|f| f == key_fingerprint) { + bail!("published key fingerprint does not match the manifest's {key_fingerprint}"); + } + keyring + .verify_detached(&sig_path, &path) + .context("detached signature verification failed; do not run this file")?; + result.signature_verified = true; + } + result.downloaded = Some(path); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::super::types::{TARGET_LINUX, TARGET_MACOS, TARGET_WINDOWS}; + use super::*; + + fn artifact( + target: &str, + format: ArtifactFormat, + signature: ArtifactSignature, + ) -> ManifestArtifact { + ManifestArtifact { + target: target.to_string(), + format, + filename: format!("app.{}", format_name(format)), + url: format!("https://x/b/app/1.0.0/{target}/app.{}", format_name(format)), + size_bytes: 1, + sha256: "0".repeat(64), + signature, + glibc_floor: None, + } + } + + fn manifest() -> Manifest { + Manifest { + schema_version: SCHEMA_VERSION, + app: "app".into(), + version: "1.0.0".into(), + prerelease: false, + source_revision: "0123456789abcdef0123456789abcdef01234567".into(), + release_name: "app-1.0.0".into(), + release_id: None, + workflow_run: None, + published_at: "2026-08-18T00:00:00Z".into(), + artifacts: vec![ + artifact( + TARGET_WINDOWS, + ArtifactFormat::Msi, + ArtifactSignature::Authenticode, + ), + artifact( + TARGET_LINUX, + ArtifactFormat::Deb, + ArtifactSignature::OpenpgpDetached { + url: "https://x/b/a.asc".into(), + key_fingerprint: "F".into(), + }, + ), + artifact( + TARGET_LINUX, + ArtifactFormat::Appimage, + ArtifactSignature::OpenpgpDetached { + url: "https://x/b/b.asc".into(), + key_fingerprint: "F".into(), + }, + ), + ], + } + } + + #[test] + fn schema_gate_accepts_ours_and_refuses_everything_else() { + assert!(gate_schema(br#"{"schema_version": 1}"#, "channels").is_ok()); + let err = gate_schema(br#"{"schema_version": 2}"#, "channels") + .unwrap_err() + .to_string(); + assert!(err.contains("refusing to guess"), "{err}"); + assert!(err.contains("channels schema_version 2"), "{err}"); + assert!(gate_schema(br#"{}"#, "manifest").is_err()); + assert!(gate_schema(b"not json", "manifest").is_err()); + } + + #[test] + fn single_artifact_target_needs_no_format() { + let m = manifest(); + let a = select_artifact(&m, TARGET_WINDOWS, None).unwrap(); + assert_eq!(a.format, ArtifactFormat::Msi); + } + + #[test] + fn multi_artifact_target_without_format_is_ambiguous_and_lists_formats() { + let m = manifest(); + let err = select_artifact(&m, TARGET_LINUX, None) + .unwrap_err() + .to_string(); + assert!(err.contains("ambiguous"), "{err}"); + assert!(err.contains("deb, appimage"), "{err}"); + } + + #[test] + fn format_filters_to_exactly_one() { + let m = manifest(); + let a = select_artifact(&m, TARGET_LINUX, Some(ArtifactFormat::Appimage)).unwrap(); + assert_eq!(a.format, ArtifactFormat::Appimage); + } + + #[test] + fn absent_target_or_format_is_an_error() { + let m = manifest(); + assert!(select_artifact(&m, TARGET_MACOS, None).is_err()); + assert!(select_artifact(&m, TARGET_LINUX, Some(ArtifactFormat::Msi)).is_err()); + } + + #[test] + fn signature_kind_strings_match_the_serde_contract() { + // The strings a client sees must be byte-identical to the serialized + // `kind` field. + for signature in [ + ArtifactSignature::Authenticode, + ArtifactSignature::AppleNotarized, + ArtifactSignature::OpenpgpDetached { + url: "u".into(), + key_fingerprint: "f".into(), + }, + ] { + let serialized = serde_json::to_value(&signature).unwrap(); + assert_eq!(serialized["kind"], signature_kind(&signature)); + } + } +}