Skip to main content

max / makenotwork

8.3 KB · 217 lines History Blame Raw
1 //! Depositing finished artifacts at one address, whichever host built them.
2 //!
3 //! Design + rationale: maintainer wiki.
4 //! <!-- wiki: bento-overview -->
5 //!
6 //! Bento's `pull_root` is per build host and fences what may be pulled *off*
7 //! that box. Nothing said where the result belonged, so a release's bytes ended
8 //! up in `dist_root` on whichever machine bentod happened to run on, and "where
9 //! is the AppImage for goingson 1.4.0" had a different answer per target.
10 //!
11 //! This is the other half: after a target's `collect` lands its files locally,
12 //! the daemon pushes that directory to `<root>/<app>/<version>/<target>/` on the
13 //! archive host. The layout matches `dist_root`'s exactly, so the local copy and
14 //! the archived one are the same tree at two addresses rather than two shapes.
15 //!
16 //! Unconfigured, none of this runs. Configured, a failed deposit fails the
17 //! `collect` step: the point of the archive is that the path IS the answer to
18 //! where a version's artifact is, and a deposit that is quietly skipped makes
19 //! that answer wrong for exactly the release nobody watched.
20
21 use crate::config::{Archive, Config};
22 use crate::domain::{AppId, Target, Version};
23 use ops_exec::{CapabilitySet, Executor, LocalExec, SshExec, SyncOpts};
24 use std::path::{Path, PathBuf};
25 use std::sync::Arc;
26
27 /// One target's directory under a root: `<root>/<app>/<version>/<target>/`.
28 ///
29 /// Shared by `dist_root` (locally) and the archive (remotely) so the two trees
30 /// are identical, and a human who knows one path knows the other.
31 ///
32 /// The target is a slug because `/` is a path separator here and
33 /// `macos/aarch64` would silently become two components.
34 pub fn target_dir(root: &Path, app: &AppId, version: &Version, target: Target) -> PathBuf {
35 root.join(app.as_str())
36 .join(version.to_string())
37 .join(target_slug(target))
38 }
39
40 /// `macos/aarch64` -> `macos-aarch64`: one path component, not two.
41 pub fn target_slug(target: Target) -> String {
42 target.to_string().replace('/', "-")
43 }
44
45 /// The transport that writes to the archive host.
46 ///
47 /// Granted nothing. Every capability in `ops_exec` gates either running a step
48 /// or pulling a file, and this executor does neither — it only ever calls
49 /// `push_dir`. So the archive host cannot be turned into a build host by
50 /// anything holding this handle, and it is not in the topology, so no recipe can
51 /// name it. Same shape as `state::build_deploy_executor`, one grant narrower.
52 fn transport(archive: &Archive) -> Arc<dyn Executor> {
53 const NONE: [&str; 0] = [];
54 let caps = CapabilitySet::from_tokens(NONE, NONE);
55 if archive.host == "local" || archive.host.is_empty() {
56 return Arc::new(LocalExec::new(caps));
57 }
58 Arc::new(SshExec::new(archive.host.clone(), caps))
59 }
60
61 /// Deposit `local_dir` (a target's collected files) at this target's archive
62 /// path. A no-op when no archive is configured.
63 ///
64 /// Idempotent: rsync without `--delete`, so a re-collect or a retried build
65 /// re-deposits the same bytes rather than emptying the directory first. Nothing
66 /// is pruned there either — retention runs against `dist_root` and `logs_root`
67 /// on the daemon box, and the archive is the copy that is meant to outlive them.
68 pub async fn deposit(
69 cfg: &Config,
70 local_dir: &Path,
71 app: &AppId,
72 version: &Version,
73 target: Target,
74 ) -> anyhow::Result<()> {
75 let Some(archive) = &cfg.archive else {
76 return Ok(());
77 };
78 let dest = target_dir(&archive.root, app, version, target);
79 transport(archive)
80 .push_dir(local_dir, &dest, &SyncOpts::archive_deposit())
81 .await
82 .map_err(|e| {
83 anyhow::anyhow!(
84 "depositing {} at {}:{}: {e}",
85 local_dir.display(),
86 archive.host,
87 dest.display()
88 )
89 })?;
90 tracing::info!(
91 %app, %target, %version, host = %archive.host, dest = %dest.display(),
92 "deposited artifacts in the archive"
93 );
94 Ok(())
95 }
96
97 #[cfg(test)]
98 mod tests {
99 use super::*;
100
101 fn app() -> AppId {
102 AppId::new("goingson")
103 }
104 fn version() -> Version {
105 "1.4.0".parse().unwrap()
106 }
107
108 #[test]
109 fn the_archive_path_is_per_app_version_and_target() {
110 let dir = target_dir(
111 Path::new("/var/lib/bento/artifacts"),
112 &app(),
113 &version(),
114 "macos/aarch64".parse().unwrap(),
115 );
116 assert_eq!(
117 dir,
118 Path::new("/var/lib/bento/artifacts/goingson/1.4.0/macos-aarch64")
119 );
120 }
121
122 /// The target has to be one component. A `/` left in would put the aarch64
123 /// build under a `macos` directory shared with every other mac target, which
124 /// is the collision the slug exists to prevent.
125 #[test]
126 fn a_target_slug_carries_no_path_separator() {
127 let slug = target_slug("macos/aarch64".parse().unwrap());
128 assert!(!slug.contains('/'), "{slug} must be a single component");
129 assert_eq!(slug, "macos-aarch64");
130 }
131
132 /// Two targets of one version are siblings, not overwrites. This is the
133 /// property that lets the archive hold a whole release rather than whichever
134 /// host finished last.
135 #[test]
136 fn sibling_targets_do_not_share_a_directory() {
137 let root = Path::new("/a");
138 let linux = target_dir(root, &app(), &version(), "linux/x86_64".parse().unwrap());
139 let macos = target_dir(root, &app(), &version(), "macos/aarch64".parse().unwrap());
140 assert_ne!(linux, macos);
141 assert_eq!(linux.parent(), macos.parent());
142 }
143
144 /// Unconfigured is a no-op rather than an error: an operator who has not
145 /// named an archive host still gets releases, they just stay local.
146 #[tokio::test]
147 async fn no_archive_configured_deposits_nothing() {
148 let tmp = tempfile::tempdir().unwrap();
149 let cfg = Config::for_tests(tmp.path());
150 assert!(cfg.archive.is_none());
151 deposit(
152 &cfg,
153 tmp.path(),
154 &app(),
155 &version(),
156 "linux/x86_64".parse().unwrap(),
157 )
158 .await
159 .expect("a no-op cannot fail");
160 }
161
162 /// The whole path, against a `local` archive host: files land at
163 /// `<root>/<app>/<version>/<target>/`, and the destination tree is created
164 /// (the first build of a version is what makes its directory exist).
165 #[tokio::test]
166 async fn a_local_deposit_lands_at_the_versioned_path() {
167 let tmp = tempfile::tempdir().unwrap();
168 let src = tmp.path().join("collected");
169 std::fs::create_dir_all(&src).unwrap();
170 std::fs::write(src.join("goingson_1.4.0.AppImage"), b"bytes").unwrap();
171
172 let mut cfg = Config::for_tests(tmp.path());
173 cfg.archive = Some(Archive {
174 host: "local".into(),
175 root: tmp.path().join("archive"),
176 });
177 let target: Target = "linux/x86_64".parse().unwrap();
178 deposit(&cfg, &src, &app(), &version(), target)
179 .await
180 .unwrap();
181
182 let landed = tmp
183 .path()
184 .join("archive/goingson/1.4.0/linux-x86_64/goingson_1.4.0.AppImage");
185 assert!(landed.exists(), "{} must exist", landed.display());
186 assert_eq!(std::fs::read(&landed).unwrap(), b"bytes");
187 }
188
189 /// A second target of the same version deposits beside the first rather than
190 /// replacing it — the deposit does not `--delete`.
191 #[tokio::test]
192 async fn a_second_target_leaves_the_first_alone() {
193 let tmp = tempfile::tempdir().unwrap();
194 let mut cfg = Config::for_tests(tmp.path());
195 cfg.archive = Some(Archive {
196 host: "local".into(),
197 root: tmp.path().join("archive"),
198 });
199
200 for (target, file) in [
201 ("linux/x86_64", "goingson_1.4.0.AppImage"),
202 ("macos/aarch64", "goingson_1.4.0.dmg"),
203 ] {
204 let src = tmp.path().join(format!("collected-{file}"));
205 std::fs::create_dir_all(&src).unwrap();
206 std::fs::write(src.join(file), b"bytes").unwrap();
207 deposit(&cfg, &src, &app(), &version(), target.parse().unwrap())
208 .await
209 .unwrap();
210 }
211
212 let root = tmp.path().join("archive/goingson/1.4.0");
213 assert!(root.join("linux-x86_64/goingson_1.4.0.AppImage").exists());
214 assert!(root.join("macos-aarch64/goingson_1.4.0.dmg").exists());
215 }
216 }
217