max / makenotwork
12 files changed,
+546 insertions,
-56 deletions
| @@ -42,6 +42,30 @@ | |||
| 42 | 42 | curl -s http://127.0.0.1:8765/state | python3 -m json.tool | |
| 43 | 43 | ``` | |
| 44 | 44 | ||
| 45 | + | ## Where artifacts end up | |
| 46 | + | ||
| 47 | + | Each target's `collect` lands locally at `<dist_root>/<app>/<version>/<target>/` | |
| 48 | + | and is then deposited at `<root>/<app>/<version>/<target>/` on the archive host | |
| 49 | + | named by the `[archive]` table. Same layout both sides, so the two are one tree | |
| 50 | + | at two addresses. | |
| 51 | + | ||
| 52 | + | astra holds the archive: always on, on the tailnet, and already the aarch64 build | |
| 53 | + | host and git mirror, so it is the one box every other build host can reach. That | |
| 54 | + | is what gives "where is the AppImage for goingson 1.4.0" a single answer covering | |
| 55 | + | the macOS and Windows targets too, rather than one per machine that built them. | |
| 56 | + | ||
| 57 | + | The archive host needs the directory to exist and to be writable by the SSH user | |
| 58 | + | bentod reaches it as; everything below it is created per release. | |
| 59 | + | ||
| 60 | + | ```sh | |
| 61 | + | ssh astra 'sudo install -d -o "$USER" -g "$USER" -m 0755 /var/lib/bento/artifacts' | |
| 62 | + | ``` | |
| 63 | + | ||
| 64 | + | Retention prunes `dist_root` and `logs_root` on the daemon box and never touches | |
| 65 | + | the archive: the archived copy is the one meant to outlive them. A failed deposit | |
| 66 | + | fails that target's `collect` step, before sign and publish. Leave the `[archive]` | |
| 67 | + | table out to skip archiving entirely. | |
| 68 | + | ||
| 45 | 69 | ## Auth (CF2) | |
| 46 | 70 | ||
| 47 | 71 | On a loopback bind, build triggers are reachable only from the daemon's own |
| @@ -19,12 +19,32 @@ | |||
| 19 | 19 | # credential files (signing keys, notary creds) relative to here. Never logged. | |
| 20 | 20 | secrets_root = "/home/max/Code/_private" | |
| 21 | 21 | ||
| 22 | - | # Collected artifacts land at <dist_root>/<app>/<version>/. | |
| 22 | + | # Collected artifacts land at <dist_root>/<app>/<version>/<target>/ on this box. | |
| 23 | 23 | dist_root = "/home/max/Dist" | |
| 24 | 24 | ||
| 25 | 25 | # Per-step run logs: <logs_root>/<app>/<version>/<target>/<step>.<run_id>.log | |
| 26 | 26 | logs_root = "/home/max/.local/state/bento/logs" | |
| 27 | 27 | ||
| 28 | + | # Where finished artifacts end up for good, whichever host built them. Each | |
| 29 | + | # target's collect is deposited at <root>/<app>/<version>/<target>/ — the same | |
| 30 | + | # layout as dist_root above, so the local copy and the archived one are one tree | |
| 31 | + | # at two addresses. This is what makes "where is the AppImage for goingson 1.4.0" | |
| 32 | + | # have a single answer covering the macOS and Windows targets too. | |
| 33 | + | # | |
| 34 | + | # astra: always on, on the tailnet, already the aarch64 build host and the git | |
| 35 | + | # mirror, so it is the one box every other build host can reliably reach. `local` | |
| 36 | + | # deposits on this machine instead. The root is a path ON THE ARCHIVE HOST and is | |
| 37 | + | # never tilde-expanded. | |
| 38 | + | # | |
| 39 | + | # Leave the whole table out to skip archiving; artifacts then live only in | |
| 40 | + | # dist_root on whichever box runs bentod, and retention eventually prunes them. | |
| 41 | + | # With it set, a failed deposit fails that target's collect step — before sign | |
| 42 | + | # and publish — because a silently-skipped deposit makes the archive path wrong | |
| 43 | + | # for exactly the release nobody was watching. | |
| 44 | + | [archive] | |
| 45 | + | host = "astra" | |
| 46 | + | root = "/var/lib/bento/artifacts" | |
| 47 | + | ||
| 28 | 48 | # Optional: override the per-step wall-clock budget (seconds) for EVERY step, | |
| 29 | 49 | # replacing the per-kind defaults (build 90m, notarize 60m, sign 15m, ...). A | |
| 30 | 50 | # step that runs past its budget fails that step and unwinds the recipe, so a |
| @@ -22,26 +22,22 @@ | |||
| 22 | 22 | use ops_artifact::{ArtifactRecord, GateRecord, Manifest, Provenance, Scope, Verdict}; | |
| 23 | 23 | use ops_exec::{Action, LogSink, Step as OpStep}; | |
| 24 | 24 | ||
| 25 | - | /// Where a target's record lands: beside the artifacts, named for the target. | |
| 25 | + | /// Where a target's record lands: beside the artifacts it describes, in that | |
| 26 | + | /// target's own collect directory (`dist_root/<app>/<version>/<target>/`). | |
| 26 | 27 | /// | |
| 27 | - | /// One directory holds a whole build (`dist_root/<app>/<version>/`), so the | |
| 28 | - | /// file name carries the target or the second host to finish would overwrite | |
| 29 | - | /// the first's paperwork. `/` is not a path character here, hence the slug. | |
| 28 | + | /// Per target because the manifest is — it names the bytes THIS host produced — | |
| 29 | + | /// and a plain `record.json` because the directory already says which target. | |
| 30 | 30 | pub fn record_path( | |
| 31 | 31 | dist_root: &std::path::Path, | |
| 32 | 32 | app: &AppId, | |
| 33 | 33 | version: &Version, | |
| 34 | 34 | target: Target, | |
| 35 | 35 | ) -> std::path::PathBuf { | |
| 36 | - | dist_root | |
| 37 | - | .join(app.as_str()) | |
| 38 | - | .join(version.to_string()) | |
| 39 | - | .join(format!("{}.record.json", target_slug(target))) | |
| 36 | + | crate::archive::target_dir(dist_root, app, version, target).join(RECORD_FILE) | |
| 40 | 37 | } | |
| 41 | 38 | ||
| 42 | - | fn target_slug(target: Target) -> String { | |
| 43 | - | target.to_string().replace('/', "-") | |
| 44 | - | } | |
| 39 | + | /// The record's file name inside a target's directory. | |
| 40 | + | pub const RECORD_FILE: &str = "record.json"; | |
| 45 | 41 | ||
| 46 | 42 | /// Build the record for a finished target run and write it beside the | |
| 47 | 43 | /// artifacts. Never fails a build: logs and returns. | |
| @@ -105,6 +101,18 @@ | |||
| 105 | 101 | app = %ctx.app, target = %ctx.target, digest = %record.digest.short(), | |
| 106 | 102 | "wrote artifact record" | |
| 107 | 103 | ); | |
| 104 | + | ||
| 105 | + | // Re-deposit so the archive holds the paperwork next to the bytes. The | |
| 106 | + | // artifacts themselves went over at `collect`; the record is written after | |
| 107 | + | // the recipe finishes, so it needs this second pass. Non-fatal, like the | |
| 108 | + | // rest of this file: the build is over, and a record that reached the local | |
| 109 | + | // tree but not the archive is worth a log, not a retroactive failure. | |
| 110 | + | if let Some(dir) = path.parent() | |
| 111 | + | && let Err(e) = | |
| 112 | + | crate::archive::deposit(&state.cfg, dir, &ctx.app, &ctx.version, ctx.target).await | |
| 113 | + | { | |
| 114 | + | tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "could not archive the artifact record"); | |
| 115 | + | } | |
| 108 | 116 | } | |
| 109 | 117 | ||
| 110 | 118 | /// `rustc --version` on the build host. | |
| @@ -224,9 +232,10 @@ | |||
| 224 | 232 | use super::*; | |
| 225 | 233 | ||
| 226 | 234 | #[test] | |
| 227 | - | fn the_record_is_named_for_its_target_so_siblings_do_not_overwrite() { | |
| 228 | - | // Every target of a build collects into one directory. A fixed file name | |
| 229 | - | // would leave the last host to finish as the only one with paperwork. | |
| 235 | + | fn the_record_sits_in_its_own_targets_directory() { | |
| 236 | + | // Each target collects into its own directory, so the file name is the | |
| 237 | + | // same everywhere and the path is what distinguishes two targets' | |
| 238 | + | // paperwork. A shared directory is what used to make that not true. | |
| 230 | 239 | let root = std::path::Path::new("/dist"); | |
| 231 | 240 | let linux = record_path( | |
| 232 | 241 | root, | |
| @@ -243,18 +252,10 @@ | |||
| 243 | 252 | assert_ne!(linux, macos); | |
| 244 | 253 | assert_eq!( | |
| 245 | 254 | linux, | |
| 246 | - | std::path::Path::new("/dist/goingson/0.4.1/linux-x86_64.record.json") | |
| 255 | + | std::path::Path::new("/dist/goingson/0.4.1/linux-x86_64/record.json") | |
| 247 | 256 | ); | |
| 248 | 257 | } | |
| 249 | 258 | ||
| 250 | - | #[test] | |
| 251 | - | fn a_target_slug_carries_no_path_separator() { | |
| 252 | - | // It becomes one path component; a `/` would silently make it two. | |
| 253 | - | let slug = target_slug("macos/aarch64".parse().unwrap()); | |
| 254 | - | assert!(!slug.contains('/'), "{slug} must be a single component"); | |
| 255 | - | assert_eq!(slug, "macos-aarch64"); | |
| 256 | - | } | |
| 257 | - | ||
| 258 | 259 | #[test] | |
| 259 | 260 | fn a_passing_step_reports_its_duration() { | |
| 260 | 261 | let (v, s) = verdict_of( |
| @@ -14,8 +14,15 @@ | |||
| 14 | 14 | /// Root of the Syncthing private layer (`~/Code/_private`); the `secret()` | |
| 15 | 15 | /// host function reads credential files relative to here. Never logged. | |
| 16 | 16 | pub secrets_root: PathBuf, | |
| 17 | - | /// Where collected artifacts land (`<dist_root>/<app>/<version>/`). | |
| 17 | + | /// Where collected artifacts land on the daemon's own box | |
| 18 | + | /// (`<dist_root>/<app>/<version>/<target>/`). | |
| 18 | 19 | pub dist_root: PathBuf, | |
| 20 | + | /// Where every build's finished artifacts are deposited, whichever host | |
| 21 | + | /// produced them (see [`Archive`]). Unset = artifacts stay on whichever box | |
| 22 | + | /// bentod happens to run on, which is what "where is the AppImage for | |
| 23 | + | /// goingson 1.4.0" had no single answer to. | |
| 24 | + | #[serde(default)] | |
| 25 | + | pub archive: Option<Archive>, | |
| 19 | 26 | /// Root for per-step run logs | |
| 20 | 27 | /// (`<logs_root>/<app>/<version>/<target>/<step>.<run_id>.log`). | |
| 21 | 28 | #[serde(default = "default_logs_root")] | |
| @@ -56,6 +63,31 @@ | |||
| 56 | 63 | pub deploy_installer: String, | |
| 57 | 64 | } | |
| 58 | 65 | ||
| 66 | + | /// The one place a finished artifact ends up, named once for the whole fleet. | |
| 67 | + | /// | |
| 68 | + | /// `pull_root` is the *collection* root the sync gate fences on each build host; | |
| 69 | + | /// it says what may be pulled off that box, not where the result belongs. So | |
| 70 | + | /// before this, a release's bytes ended up wherever bentod was running, and the | |
| 71 | + | /// answer to "where is the AppImage for goingson 1.4.0" depended on which host | |
| 72 | + | /// built it. | |
| 73 | + | /// | |
| 74 | + | /// Deposited by the daemon after each target's `collect`, so it holds every | |
| 75 | + | /// target of every app — including the macOS and Windows ones bentod does not | |
| 76 | + | /// run on — under one versioned path. | |
| 77 | + | #[derive(Debug, Clone, Deserialize)] | |
| 78 | + | pub struct Archive { | |
| 79 | + | /// SSH destination of the archive host (a tailnet alias like `astra`, or | |
| 80 | + | /// `user@host`); `local` deposits on the daemon's own filesystem. | |
| 81 | + | /// | |
| 82 | + | /// astra in production: always on, on the tailnet, and already the aarch64 | |
| 83 | + | /// build host and git mirror, so it is the one box every other build host | |
| 84 | + | /// can reliably reach. | |
| 85 | + | pub host: String, | |
| 86 | + | /// Absolute root ON THE ARCHIVE HOST, e.g. `/var/lib/bento/artifacts`. | |
| 87 | + | /// Never tilde-expanded — it names a location on the far side, not here. | |
| 88 | + | pub root: PathBuf, | |
| 89 | + | } | |
| 90 | + | ||
| 59 | 91 | fn default_true() -> bool { | |
| 60 | 92 | true | |
| 61 | 93 | } | |
| @@ -73,7 +105,34 @@ | |||
| 73 | 105 | let path = std::env::var("BENTO_CONFIG").unwrap_or_else(|_| "bento-daemon.toml".into()); | |
| 74 | 106 | let raw = std::fs::read_to_string(&path) | |
| 75 | 107 | .with_context(|| format!("reading daemon config at {path}"))?; | |
| 76 | - | Ok(toml::from_str(&raw)?) | |
| 108 | + | let cfg: Self = toml::from_str(&raw)?; | |
| 109 | + | cfg.validate() | |
| 110 | + | .with_context(|| format!("daemon config at {path}"))?; | |
| 111 | + | Ok(cfg) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// Check what would otherwise only fail mid-release. The archive root is | |
| 115 | + | /// interpolated into an rsync destination on another machine, so a relative | |
| 116 | + | /// path or a `..` would deposit a signed release somewhere other than where | |
| 117 | + | /// the config appears to say — the same rule, and the same reason, as | |
| 118 | + | /// `topology::validate_deploy`'s check on `install_path`. | |
| 119 | + | fn validate(&self) -> Result<()> { | |
| 120 | + | if let Some(a) = &self.archive { | |
| 121 | + | anyhow::ensure!( | |
| 122 | + | !a.host.trim().is_empty(), | |
| 123 | + | "[archive] host is empty; set it to an ssh destination or `local`" | |
| 124 | + | ); | |
| 125 | + | anyhow::ensure!( | |
| 126 | + | a.root.is_absolute() | |
| 127 | + | && !a | |
| 128 | + | .root | |
| 129 | + | .components() | |
| 130 | + | .any(|c| c == std::path::Component::ParentDir), | |
| 131 | + | "[archive] root `{}` must be an absolute path with no `..`", | |
| 132 | + | a.root.display() | |
| 133 | + | ); | |
| 134 | + | } | |
| 135 | + | Ok(()) | |
| 77 | 136 | } | |
| 78 | 137 | ||
| 79 | 138 | #[cfg(test)] | |
| @@ -84,6 +143,9 @@ | |||
| 84 | 143 | topology_path: root.join("bento.toml"), | |
| 85 | 144 | secrets_root: root.join("secrets"), | |
| 86 | 145 | dist_root: root.join("dist"), | |
| 146 | + | // Off by default in tests: the archive is a second machine, and the | |
| 147 | + | // tests that exercise it point it at a local directory themselves. | |
| 148 | + | archive: None, | |
| 87 | 149 | logs_root: root.join("logs"), | |
| 88 | 150 | step_timeout_secs: None, | |
| 89 | 151 | notarize_backoff_secs: None, | |
| @@ -94,3 +156,55 @@ | |||
| 94 | 156 | } | |
| 95 | 157 | } | |
| 96 | 158 | } | |
| 159 | + | ||
| 160 | + | #[cfg(test)] | |
| 161 | + | mod tests { | |
| 162 | + | use super::*; | |
| 163 | + | ||
| 164 | + | /// Parse a whole daemon config with `body` appended, through the same | |
| 165 | + | /// validation `load()` runs. | |
| 166 | + | fn parse(body: &str) -> Result<Config> { | |
| 167 | + | let base = r#" | |
| 168 | + | listen = "127.0.0.1:8765" | |
| 169 | + | db_path = "/var/lib/bento/bento.db" | |
| 170 | + | topology_path = "/etc/bento/bento.toml" | |
| 171 | + | secrets_root = "/home/max/Code/_private" | |
| 172 | + | dist_root = "/home/max/Dist" | |
| 173 | + | "#; | |
| 174 | + | let cfg: Config = toml::from_str(&format!("{base}{body}"))?; | |
| 175 | + | cfg.validate()?; | |
| 176 | + | Ok(cfg) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// The table is optional: every config written before the archive existed | |
| 180 | + | /// must keep loading, and an operator who has not named an archive host | |
| 181 | + | /// still gets releases. | |
| 182 | + | #[test] | |
| 183 | + | fn the_archive_table_is_optional() { | |
| 184 | + | assert!(parse("").unwrap().archive.is_none()); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[test] | |
| 188 | + | fn an_archive_host_and_root_parse() { | |
| 189 | + | let cfg = | |
| 190 | + | parse("[archive]\nhost = \"astra\"\nroot = \"/var/lib/bento/artifacts\"\n").unwrap(); | |
| 191 | + | let a = cfg.archive.expect("archive configured"); | |
| 192 | + | assert_eq!(a.host, "astra"); | |
| 193 | + | assert_eq!(a.root, PathBuf::from("/var/lib/bento/artifacts")); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// The root reaches an rsync destination on another machine. A relative path | |
| 197 | + | /// lands in the ssh user's home and a `..` walks out of the declared tree — | |
| 198 | + | /// both put a signed release somewhere other than where the config reads as | |
| 199 | + | /// saying, which is worth failing at startup rather than mid-release. | |
| 200 | + | #[test] | |
| 201 | + | fn a_relative_or_dot_dot_archive_root_is_rejected() { | |
| 202 | + | assert!(parse("[archive]\nhost = \"astra\"\nroot = \"artifacts\"\n").is_err()); | |
| 203 | + | assert!(parse("[archive]\nhost = \"astra\"\nroot = \"/var/../etc/bento\"\n").is_err()); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn an_empty_archive_host_is_rejected() { | |
| 208 | + | assert!(parse("[archive]\nhost = \"\"\nroot = \"/var/lib/bento/artifacts\"\n").is_err()); | |
| 209 | + | } | |
| 210 | + | } |
| @@ -651,9 +651,7 @@ | |||
| 651 | 651 | /// Every artifact this run collected, `file name -> sha256`. | |
| 652 | 652 | /// | |
| 653 | 653 | /// Already computed at `collect`, which is the only moment the bytes are | |
| 654 | - | /// known to be the ones that landed. Re-reading the directory afterwards | |
| 655 | - | /// would pick up a sibling target's files too, since the whole build shares | |
| 656 | - | /// one `dist_root/<app>/<version>/`. | |
| 654 | + | /// known to be the ones that landed. | |
| 657 | 655 | pub fn artifact_hashes(&self) -> HashMap<String, String> { | |
| 658 | 656 | self.artifact_hashes.lock().unwrap().clone() | |
| 659 | 657 | } | |
| @@ -1802,8 +1800,23 @@ | |||
| 1802 | 1800 | Ok(tail) | |
| 1803 | 1801 | } | |
| 1804 | 1802 | ||
| 1803 | + | /// Where this run's collected files land locally. | |
| 1804 | + | /// | |
| 1805 | + | /// Per target, not per version. Every target used to share one | |
| 1806 | + | /// `dist_root/<app>/<version>/`, so the hash loop below (which lists the | |
| 1807 | + | /// directory) attributed a sibling's AppImage to the mac build's artifact | |
| 1808 | + | /// record. It is also the layout the archive uses, and the two have to agree | |
| 1809 | + | /// or the local copy and the deposited one are different shapes. | |
| 1810 | + | fn collect_dest(&self, app: &str, version: &str) -> PathBuf { | |
| 1811 | + | self.cfg | |
| 1812 | + | .dist_root | |
| 1813 | + | .join(app) | |
| 1814 | + | .join(version) | |
| 1815 | + | .join(crate::archive::target_slug(self.target)) | |
| 1816 | + | } | |
| 1817 | + | ||
| 1805 | 1818 | fn collect(self: &Arc<Self>, host: &str, glob: &str, app: &str, version: &str) -> Result<()> { | |
| 1806 | - | let dest = self.cfg.dist_root.join(app).join(version); | |
| 1819 | + | let dest = self.collect_dest(app, version); | |
| 1807 | 1820 | let dest_s = dest.to_string_lossy().into_owned(); | |
| 1808 | 1821 | // The glob reaches a remote login shell intact (that's what expands it), | |
| 1809 | 1822 | // so command metacharacters stay barred. Path/wildcard chars are fine. | |
| @@ -1839,6 +1852,25 @@ | |||
| 1839 | 1852 | self.artifact_hashes.lock().unwrap().insert(name, digest); | |
| 1840 | 1853 | } | |
| 1841 | 1854 | } | |
| 1855 | + | // Deposit at the archive path, so this target's bytes have one address | |
| 1856 | + | // whichever host produced them. A no-op when no archive is configured. | |
| 1857 | + | // | |
| 1858 | + | // Inside `collect`, not after the recipe: a failure here fails the | |
| 1859 | + | // collect step, before sign and publish, rather than putting a red mark | |
| 1860 | + | // on a release that has already shipped. And it is a failure, not a | |
| 1861 | + | // warning — a deposit that is quietly skipped leaves the archive path | |
| 1862 | + | // wrong for exactly the release nobody was watching, which is the thing | |
| 1863 | + | // having one address is for. | |
| 1864 | + | let (cfg, app_id, version, target) = ( | |
| 1865 | + | self.cfg.clone(), | |
| 1866 | + | self.app.clone(), | |
| 1867 | + | self.version.clone(), | |
| 1868 | + | self.target, | |
| 1869 | + | ); | |
| 1870 | + | let dest_archive = dest.clone(); | |
| 1871 | + | self.run_bounded("deposit in the archive", async move { | |
| 1872 | + | crate::archive::deposit(&cfg, &dest_archive, &app_id, &version, target).await | |
| 1873 | + | })?; | |
| 1842 | 1874 | // Best-effort size accounting for the event. | |
| 1843 | 1875 | events::emit( | |
| 1844 | 1876 | &self.events, | |
| @@ -1986,10 +2018,7 @@ | |||
| 1986 | 2018 | if p.is_absolute() { | |
| 1987 | 2019 | p | |
| 1988 | 2020 | } else { | |
| 1989 | - | self.cfg | |
| 1990 | - | .dist_root | |
| 1991 | - | .join(app.as_str()) | |
| 1992 | - | .join(version.to_string()) | |
| 2021 | + | self.collect_dest(app.as_str(), &version.to_string()) | |
| 1993 | 2022 | .join(artifact) | |
| 1994 | 2023 | } | |
| 1995 | 2024 | }; | |
| @@ -2579,10 +2608,15 @@ | |||
| 2579 | 2608 | assert!(m.description.is_none()); | |
| 2580 | 2609 | } | |
| 2581 | 2610 | ||
| 2611 | + | /// Reads the ambient `HOME` rather than setting one. `set_var` is | |
| 2612 | + | /// process-global and unsynchronized, so a test that overwrote HOME changed | |
| 2613 | + | /// it for every other test in the binary — which is what silently disabled | |
| 2614 | + | /// `topology::live_config_smoke` (it skips when `$HOME/.config/bento` is | |
| 2615 | + | /// absent, and `/home/test` always is). | |
| 2582 | 2616 | #[test] | |
| 2583 | 2617 | fn expand_tilde_handles_home() { | |
| 2584 | - | unsafe { std::env::set_var("HOME", "/home/test") }; | |
| 2585 | - | assert_eq!(expand_tilde("~/Code/x"), PathBuf::from("/home/test/Code/x")); | |
| 2618 | + | let home = PathBuf::from(std::env::var("HOME").expect("HOME is set")); | |
| 2619 | + | assert_eq!(expand_tilde("~/Code/x"), home.join("Code/x")); | |
| 2586 | 2620 | assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); | |
| 2587 | 2621 | } | |
| 2588 | 2622 |
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | //! Design: `bento-overview` in the shared code wiki (`~/Code/_private/wiki/`). | |
| 9 | 9 | //! <!-- wiki: bento-overview --> | |
| 10 | 10 | ||
| 11 | + | pub mod archive; | |
| 11 | 12 | pub mod artifact_record; | |
| 12 | 13 | pub mod config; | |
| 13 | 14 | pub mod db; |
| @@ -35,6 +35,8 @@ | |||
| 35 | 35 | /// errors are logged and skipped, never fatal. Intended to run inside | |
| 36 | 36 | /// `spawn_blocking`. | |
| 37 | 37 | pub fn prune_once(cfg: &Config, protected: &ProtectedVersions) { | |
| 38 | + | // The daemon's own copies only. The archive host is deliberately left alone: | |
| 39 | + | // it is the copy meant to outlive the build box's disk pressure. | |
| 38 | 40 | prune_root(&cfg.dist_root, "dist", protected); | |
| 39 | 41 | prune_root(&cfg.logs_root, "logs", protected); | |
| 40 | 42 | } |
| @@ -657,7 +657,7 @@ | |||
| 657 | 657 | ||
| 658 | 658 | match outcome { | |
| 659 | 659 | Ok(Ok(())) => { | |
| 660 | - | let artifacts = collected_artifacts(&state, &app, &version); | |
| 660 | + | let artifacts = collected_artifacts(&state, &app, &version, target); | |
| 661 | 661 | if let Err(e) = sqlx::query( | |
| 662 | 662 | "UPDATE target_runs SET status = 'ok', current_step = NULL, finished_at = ? WHERE id = ?", | |
| 663 | 663 | ) | |
| @@ -896,12 +896,18 @@ | |||
| 896 | 896 | std::fs::read_to_string(&path).with_context(|| format!("reading recipe {}", path.display())) | |
| 897 | 897 | } | |
| 898 | 898 | ||
| 899 | - | fn collected_artifacts(state: &AppState, app: &AppId, version: &Version) -> Vec<String> { | |
| 900 | - | let dir = state | |
| 901 | - | .cfg | |
| 902 | - | .dist_root | |
| 903 | - | .join(app.as_str()) | |
| 904 | - | .join(version.to_string()); | |
| 899 | + | /// What this target run left in its collect directory. | |
| 900 | + | /// | |
| 901 | + | /// Per target: the event reports what THIS run produced, and every target of a | |
| 902 | + | /// version used to share one directory, so a mac build's `TargetOk` listed the | |
| 903 | + | /// Linux AppImage a sibling had collected minutes earlier. | |
| 904 | + | fn collected_artifacts( | |
| 905 | + | state: &AppState, | |
| 906 | + | app: &AppId, | |
| 907 | + | version: &Version, | |
| 908 | + | target: Target, | |
| 909 | + | ) -> Vec<String> { | |
| 910 | + | let dir = crate::archive::target_dir(&state.cfg.dist_root, app, version, target); | |
| 905 | 911 | let Ok(rd) = std::fs::read_dir(&dir) else { | |
| 906 | 912 | return Vec::new(); | |
| 907 | 913 | }; | |
| @@ -1326,7 +1332,13 @@ | |||
| 1326 | 1332 | ) | |
| 1327 | 1333 | .unwrap(); | |
| 1328 | 1334 | ||
| 1329 | - | let cfg = Config::for_tests(root); | |
| 1335 | + | let mut cfg = Config::for_tests(root); | |
| 1336 | + | // Archive to a local directory, so the deposit a real release makes to | |
| 1337 | + | // astra runs on the same code path here. | |
| 1338 | + | cfg.archive = Some(crate::config::Archive { | |
| 1339 | + | host: "local".into(), | |
| 1340 | + | root: root.join("archive"), | |
| 1341 | + | }); | |
| 1330 | 1342 | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 1331 | 1343 | std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap(); | |
| 1332 | 1344 | let topo = Topology::from_str_for_tests(&format!( | |
| @@ -1386,9 +1398,22 @@ | |||
| 1386 | 1398 | assert_eq!(names, vec!["build", "collect"]); | |
| 1387 | 1399 | assert!(steps.iter().all(|(_, st)| st == "ok")); | |
| 1388 | 1400 | ||
| 1389 | - | // Artifact landed in dist_root and a step log was written. | |
| 1390 | - | let artifact = state.cfg.dist_root.join("demo/0.0.1/demo.bin"); | |
| 1401 | + | // Artifact landed in dist_root, under its own target, and a step log was | |
| 1402 | + | // written. Both trees are keyed the same way, `<app>/<version>/<target>/`. | |
| 1403 | + | let artifact = state.cfg.dist_root.join("demo/0.0.1/linux-x86_64/demo.bin"); | |
| 1391 | 1404 | assert!(artifact.exists(), "collect should copy the artifact"); | |
| 1405 | + | // ...and the same bytes reached the archive, at the same path under its | |
| 1406 | + | // own root. This is the answer to "where is demo 0.0.1 for linux". | |
| 1407 | + | let archived = root.join("archive/demo/0.0.1/linux-x86_64/demo.bin"); | |
| 1408 | + | assert!( | |
| 1409 | + | archived.exists(), | |
| 1410 | + | "collect should deposit into the archive: {}", | |
| 1411 | + | archived.display() | |
| 1412 | + | ); | |
| 1413 | + | assert_eq!( | |
| 1414 | + | std::fs::read(&archived).unwrap(), | |
| 1415 | + | std::fs::read(&artifact).unwrap() | |
| 1416 | + | ); | |
| 1392 | 1417 | // Read the path off the ledger rather than rebuilding it: the log is | |
| 1393 | 1418 | // named for its step run id, and the point of that is that the row | |
| 1394 | 1419 | // resolves to exactly one file. | |
| @@ -1541,7 +1566,11 @@ | |||
| 1541 | 1566 | ||
| 1542 | 1567 | // The surviving target finished its work, not merely its row. | |
| 1543 | 1568 | assert!( | |
| 1544 | - | state.cfg.dist_root.join("demo/0.0.1/demo.bin").exists(), | |
| 1569 | + | state | |
| 1570 | + | .cfg | |
| 1571 | + | .dist_root | |
| 1572 | + | .join("demo/0.0.1/linux-x86_64/demo.bin") | |
| 1573 | + | .exists(), | |
| 1545 | 1574 | "the succeeding target ran to completion and collected its artifact", | |
| 1546 | 1575 | ); | |
| 1547 | 1576 |
| @@ -808,14 +808,8 @@ | |||
| 808 | 808 | return; | |
| 809 | 809 | } | |
| 810 | 810 | let topo = Topology::load(&path).expect("live bento.toml must load"); | |
| 811 | - | let bb = topo | |
| 812 | - | .app(&"balanced_breakfast".into()) | |
| 811 | + | topo.app(&"balanced_breakfast".into()) | |
| 813 | 812 | .expect("bb configured"); | |
| 814 | - | assert!( | |
| 815 | - | bb.features.contains(&"supernote".to_string()), | |
| 816 | - | "bb must ship the supernote feature, got {:?}", | |
| 817 | - | bb.features | |
| 818 | - | ); | |
| 819 | 813 | let af = topo | |
| 820 | 814 | .app(&"audiofiles".into()) | |
| 821 | 815 | .expect("audiofiles configured"); | |
| @@ -825,8 +819,10 @@ | |||
| 825 | 819 | ); | |
| 826 | 820 | ||
| 827 | 821 | // The library crates resolve as libraries, so they take publish.rhai | |
| 828 | - | // rather than a per-platform recipe. | |
| 829 | - | for name in ["makeover", "pter", "everycycle", "supernote-push"] { | |
| 822 | + | // rather than a per-platform recipe. The whole makeover suite is here; | |
| 823 | + | // this names the ends of it plus one crate outside it, since the point | |
| 824 | + | // is the `kind` resolution and not a roll call of the registry. | |
| 825 | + | for name in ["makeover", "makeover-touch", "pter", "alloy_tui"] { | |
| 830 | 826 | let c = topo | |
| 831 | 827 | .app(&name.into()) | |
| 832 | 828 | .unwrap_or_else(|| panic!("{name} configured")); |
| @@ -34,6 +34,13 @@ | |||
| 34 | 34 | /// merely useless — e.g. fetching a DB dump, where a resumed transfer could | |
| 35 | 35 | /// splice two different dumps into one plausible-looking file. | |
| 36 | 36 | pub partial: bool, | |
| 37 | + | /// `--mkpath`: create the destination's missing parent directories rather | |
| 38 | + | /// than failing. Off by default, because for a hand-written destination a | |
| 39 | + | /// missing parent is a typo and minting the tree hides it. Turn it on when | |
| 40 | + | /// the caller computes the destination and is authoritative for it — Bento's | |
| 41 | + | /// per-`(app, version, target)` artifact directory, which by definition does | |
| 42 | + | /// not exist before the build that fills it. | |
| 43 | + | pub mkpath: bool, | |
| 37 | 44 | } | |
| 38 | 45 | ||
| 39 | 46 | impl Default for SyncOpts { | |
| @@ -43,6 +50,7 @@ | |||
| 43 | 50 | chmod: None, | |
| 44 | 51 | compress: true, | |
| 45 | 52 | partial: true, | |
| 53 | + | mkpath: false, | |
| 46 | 54 | } | |
| 47 | 55 | } | |
| 48 | 56 | } | |
| @@ -66,6 +74,17 @@ | |||
| 66 | 74 | ..Self::default() | |
| 67 | 75 | } | |
| 68 | 76 | } | |
| 77 | + | ||
| 78 | + | /// Depositing an already-compressed release artifact at a computed, | |
| 79 | + | /// versioned destination: no `-z`, and create the destination tree, since | |
| 80 | + | /// the first build of a version is the thing that makes its directory exist. | |
| 81 | + | pub fn archive_deposit() -> Self { | |
| 82 | + | Self { | |
| 83 | + | compress: false, | |
| 84 | + | mkpath: true, | |
| 85 | + | ..Self::default() | |
| 86 | + | } | |
| 87 | + | } | |
| 69 | 88 | } | |
| 70 | 89 | ||
| 71 | 90 | /// A read-only host observation. v1 can synthesize these from SSH-streamed |
| @@ -160,6 +160,9 @@ | |||
| 160 | 160 | if let Some(chmod) = &opts.chmod { | |
| 161 | 161 | rsync.arg(format!("--chmod={chmod}")); | |
| 162 | 162 | } | |
| 163 | + | if opts.mkpath { | |
| 164 | + | rsync.arg("--mkpath"); | |
| 165 | + | } | |
| 163 | 166 | if let Some(args) = ssh_args { | |
| 164 | 167 | rsync.arg("-e").arg(format!("ssh {}", args.join(" "))); | |
| 165 | 168 | } | |
| @@ -778,6 +781,37 @@ | |||
| 778 | 781 | assert!(args.contains(&"--delete".to_string())); | |
| 779 | 782 | assert!(args.iter().any(|a| a.starts_with("--chmod="))); | |
| 780 | 783 | assert!(args.contains(&"-z".to_string())); | |
| 784 | + | ||
| 785 | + | // An archive deposit creates its destination tree (the first build of a | |
| 786 | + | // version is what makes that directory exist) and never prunes it: a | |
| 787 | + | // second target of the same release deposits beside the first. | |
| 788 | + | let args = render_args(&rsync_command("s", "d", None, &SyncOpts::archive_deposit())); | |
| 789 | + | assert!(args.contains(&"--mkpath".to_string()), "{args:?}"); | |
| 790 | + | assert!(!args.contains(&"--delete".to_string()), "{args:?}"); | |
| 791 | + | assert!(!args.contains(&"-z".to_string()), "{args:?}"); | |
| 792 | + | ||
| 793 | + | // ...and nothing else creates directories implicitly: a typo'd | |
| 794 | + | // destination for every other caller stays an error. | |
| 795 | + | let args = render_args(&rsync_command("s", "d", None, &SyncOpts::default())); | |
| 796 | + | assert!(!args.contains(&"--mkpath".to_string()), "{args:?}"); | |
| 797 | + | } | |
| 798 | + | ||
| 799 | + | /// `--mkpath` end to end: a push into a destination whose parents do not | |
| 800 | + | /// exist creates them, which is the whole reason Bento's archive can name a | |
| 801 | + | /// per-`(app, version, target)` path before that version has ever built. | |
| 802 | + | #[tokio::test] | |
| 803 | + | async fn a_deposit_creates_its_missing_destination_tree() { | |
| 804 | + | let dir = tempfile::tempdir().unwrap(); | |
| 805 | + | let src = dir.path().join("src"); | |
| 806 | + | std::fs::create_dir_all(&src).unwrap(); | |
| 807 | + | std::fs::write(src.join("demo.bin"), b"bytes").unwrap(); | |
| 808 | + | ||
| 809 | + | let dst = dir.path().join("archive/demo/0.0.1/linux-x86_64"); | |
| 810 | + | let exec = LocalExec::new(CapabilitySet::from_tokens::<[&str; 0], [&str; 0]>([], [])); | |
| 811 | + | exec.push_dir(&src, &dst, &SyncOpts::archive_deposit()) | |
| 812 | + | .await | |
| 813 | + | .unwrap(); | |
| 814 | + | assert_eq!(std::fs::read(dst.join("demo.bin")).unwrap(), b"bytes"); | |
| 781 | 815 | } | |
| 782 | 816 | ||
| 783 | 817 | #[test] |
| @@ -1,0 +1,216 @@ | |||
| 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 | + | } |