//! Depositing finished artifacts at one address, whichever host built them. //! //! Design + rationale: maintainer wiki. //! //! //! Bento's `pull_root` is per build host and fences what may be pulled *off* //! that box. Nothing said where the result belonged, so a release's bytes ended //! up in `dist_root` on whichever machine bentod happened to run on, and "where //! is the AppImage for goingson 1.4.0" had a different answer per target. //! //! This is the other half: after a target's `collect` lands its files locally, //! the daemon pushes that directory to `////` on the //! archive host. The layout matches `dist_root`'s exactly, so the local copy and //! the archived one are the same tree at two addresses rather than two shapes. //! //! Unconfigured, none of this runs. Configured, a failed deposit fails the //! `collect` step: the point of the archive is that the path IS the answer to //! where a version's artifact is, and a deposit that is quietly skipped makes //! that answer wrong for exactly the release nobody watched. use crate::config::{Archive, Config}; use crate::domain::{AppId, Target, Version}; use ops_exec::{CapabilitySet, Executor, LocalExec, SshExec, SyncOpts}; use std::path::{Path, PathBuf}; use std::sync::Arc; /// One target's directory under a root: `////`. /// /// Shared by `dist_root` (locally) and the archive (remotely) so the two trees /// are identical, and a human who knows one path knows the other. /// /// The target is a slug because `/` is a path separator here and /// `macos/aarch64` would silently become two components. pub fn target_dir(root: &Path, app: &AppId, version: &Version, target: Target) -> PathBuf { root.join(app.as_str()) .join(version.to_string()) .join(target_slug(target)) } /// `macos/aarch64` -> `macos-aarch64`: one path component, not two. pub fn target_slug(target: Target) -> String { target.to_string().replace('/', "-") } /// The transport that writes to the archive host. /// /// Granted nothing. Every capability in `ops_exec` gates either running a step /// or pulling a file, and this executor does neither — it only ever calls /// `push_dir`. So the archive host cannot be turned into a build host by /// anything holding this handle, and it is not in the topology, so no recipe can /// name it. Same shape as `state::build_deploy_executor`, one grant narrower. fn transport(archive: &Archive) -> Arc { const NONE: [&str; 0] = []; let caps = CapabilitySet::from_tokens(NONE, NONE); if archive.host == "local" || archive.host.is_empty() { return Arc::new(LocalExec::new(caps)); } Arc::new(SshExec::new(archive.host.clone(), caps)) } /// Deposit `local_dir` (a target's collected files) at this target's archive /// path. A no-op when no archive is configured. /// /// Idempotent: rsync without `--delete`, so a re-collect or a retried build /// re-deposits the same bytes rather than emptying the directory first. Nothing /// is pruned there either — retention runs against `dist_root` and `logs_root` /// on the daemon box, and the archive is the copy that is meant to outlive them. pub async fn deposit( cfg: &Config, local_dir: &Path, app: &AppId, version: &Version, target: Target, ) -> anyhow::Result<()> { let Some(archive) = &cfg.archive else { return Ok(()); }; let dest = target_dir(&archive.root, app, version, target); transport(archive) .push_dir(local_dir, &dest, &SyncOpts::archive_deposit()) .await .map_err(|e| { anyhow::anyhow!( "depositing {} at {}:{}: {e}", local_dir.display(), archive.host, dest.display() ) })?; tracing::info!( %app, %target, %version, host = %archive.host, dest = %dest.display(), "deposited artifacts in the archive" ); Ok(()) } #[cfg(test)] mod tests { use super::*; fn app() -> AppId { AppId::new("goingson") } fn version() -> Version { "1.4.0".parse().unwrap() } #[test] fn the_archive_path_is_per_app_version_and_target() { let dir = target_dir( Path::new("/var/lib/bento/artifacts"), &app(), &version(), "macos/aarch64".parse().unwrap(), ); assert_eq!( dir, Path::new("/var/lib/bento/artifacts/goingson/1.4.0/macos-aarch64") ); } /// The target has to be one component. A `/` left in would put the aarch64 /// build under a `macos` directory shared with every other mac target, which /// is the collision the slug exists to prevent. #[test] fn a_target_slug_carries_no_path_separator() { let slug = target_slug("macos/aarch64".parse().unwrap()); assert!(!slug.contains('/'), "{slug} must be a single component"); assert_eq!(slug, "macos-aarch64"); } /// Two targets of one version are siblings, not overwrites. This is the /// property that lets the archive hold a whole release rather than whichever /// host finished last. #[test] fn sibling_targets_do_not_share_a_directory() { let root = Path::new("/a"); let linux = target_dir(root, &app(), &version(), "linux/x86_64".parse().unwrap()); let macos = target_dir(root, &app(), &version(), "macos/aarch64".parse().unwrap()); assert_ne!(linux, macos); assert_eq!(linux.parent(), macos.parent()); } /// Unconfigured is a no-op rather than an error: an operator who has not /// named an archive host still gets releases, they just stay local. #[tokio::test] async fn no_archive_configured_deposits_nothing() { let tmp = tempfile::tempdir().unwrap(); let cfg = Config::for_tests(tmp.path()); assert!(cfg.archive.is_none()); deposit( &cfg, tmp.path(), &app(), &version(), "linux/x86_64".parse().unwrap(), ) .await .expect("a no-op cannot fail"); } /// The whole path, against a `local` archive host: files land at /// `////`, and the destination tree is created /// (the first build of a version is what makes its directory exist). #[tokio::test] async fn a_local_deposit_lands_at_the_versioned_path() { let tmp = tempfile::tempdir().unwrap(); let src = tmp.path().join("collected"); std::fs::create_dir_all(&src).unwrap(); std::fs::write(src.join("goingson_1.4.0.AppImage"), b"bytes").unwrap(); let mut cfg = Config::for_tests(tmp.path()); cfg.archive = Some(Archive { host: "local".into(), root: tmp.path().join("archive"), }); let target: Target = "linux/x86_64".parse().unwrap(); deposit(&cfg, &src, &app(), &version(), target) .await .unwrap(); let landed = tmp .path() .join("archive/goingson/1.4.0/linux-x86_64/goingson_1.4.0.AppImage"); assert!(landed.exists(), "{} must exist", landed.display()); assert_eq!(std::fs::read(&landed).unwrap(), b"bytes"); } /// A second target of the same version deposits beside the first rather than /// replacing it — the deposit does not `--delete`. #[tokio::test] async fn a_second_target_leaves_the_first_alone() { let tmp = tempfile::tempdir().unwrap(); let mut cfg = Config::for_tests(tmp.path()); cfg.archive = Some(Archive { host: "local".into(), root: tmp.path().join("archive"), }); for (target, file) in [ ("linux/x86_64", "goingson_1.4.0.AppImage"), ("macos/aarch64", "goingson_1.4.0.dmg"), ] { let src = tmp.path().join(format!("collected-{file}")); std::fs::create_dir_all(&src).unwrap(); std::fs::write(src.join(file), b"bytes").unwrap(); deposit(&cfg, &src, &app(), &version(), target.parse().unwrap()) .await .unwrap(); } let root = tmp.path().join("archive/goingson/1.4.0"); assert!(root.join("linux-x86_64/goingson_1.4.0.AppImage").exists()); assert!(root.join("macos-aarch64/goingson_1.4.0.dmg").exists()); } }